Package release 0.4.167 from the current workspace snapshot
Constraint: Windows and Linux artifacts are built on native GitHub runners from a temporary snapshot branch Rejected: Build Windows/Linux locally on Apple Silicon | docker/wine packaging is not stable on this host Confidence: medium Scope-risk: moderate Directive: This snapshot branch is transport-only and should be deleted after the release completes Tested: local verify/build stages before snapshot creation Not-tested: remote packaging before workflow execution
This commit is contained in:
1 parent
9779c4d1ca
commit
237db05e37
29 files changed
+1193
-85
No files matched your search
@@ -71,3 +71,4 @@ pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
local-release/
|
||||
.omx/
|
||||
@@ -13,6 +13,7 @@ export default tseslint.config(
|
||||
"dist/**",
|
||||
"oldreference/**",
|
||||
"Reference/**",
|
||||
"claude-code-source-reference/**",
|
||||
"boss-build/**",
|
||||
"local-release/**",
|
||||
"builtin-skills/**",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-company",
|
||||
"version": "0.4.165",
|
||||
"version": "0.4.167",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||
|
||||
@@ -35,6 +35,8 @@ interface SpawnTarget {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
const WINDOWS_CMD_SCRIPT_EXTENSIONS = new Set([".bat", ".cmd"]);
|
||||
|
||||
type ChildProcessWithEvents = ChildProcess & {
|
||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||
on(
|
||||
@@ -105,7 +107,23 @@ function quoteForCmd(arg: string) {
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
async function resolveSpawnTarget(
|
||||
export function buildWindowsSpawnTarget(
|
||||
executable: string,
|
||||
args: string[],
|
||||
shell: string,
|
||||
): SpawnTarget {
|
||||
const extension = path.extname(executable).toLowerCase();
|
||||
if (!WINDOWS_CMD_SCRIPT_EXTENSIONS.has(extension)) {
|
||||
return { command: executable, args };
|
||||
}
|
||||
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
||||
return {
|
||||
command: shell,
|
||||
args: ["/d", "/c", `chcp 65001 >nul && ${commandLine}`],
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSpawnTarget(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
@@ -123,11 +141,7 @@ async function resolveSpawnTarget(
|
||||
// Chinese/CJK character corruption. The default Windows code page (e.g. 936/GBK)
|
||||
// corrupts multi-byte UTF-8 characters passed through cmd.exe, curl, etc.
|
||||
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
|
||||
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
||||
return {
|
||||
command: shell,
|
||||
args: ["/d", "/s", "/c", `chcp 65001 >nul && ${commandLine}`],
|
||||
};
|
||||
return buildWindowsSpawnTarget(executable, args, shell);
|
||||
}
|
||||
|
||||
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { TaskRecord } from "@shared/types";
|
||||
|
||||
function safeGetAgent(db: AppDatabase, agentId: string) {
|
||||
function safeGetAgent(db: AppDatabase, agentId: string | null | undefined) {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return db.getAgent(agentId);
|
||||
} catch {
|
||||
@@ -23,7 +26,7 @@ const MANAGEMENT_KEYWORDS = [
|
||||
"lead",
|
||||
];
|
||||
|
||||
function hasManagementAuthority(agent: { role: string; title: string; department: string | null }) {
|
||||
export function hasManagementAuthority(agent: { role: string; title: string; department: string | null }) {
|
||||
if (agent.department === "hr") {
|
||||
return true;
|
||||
}
|
||||
@@ -89,6 +92,155 @@ export function canRequestHire(db: AppDatabase, companyId: string, agentId: stri
|
||||
return hasManagementAuthority(agent);
|
||||
}
|
||||
|
||||
function getDepartmentLeadCandidateId(
|
||||
db: AppDatabase,
|
||||
companyId: string,
|
||||
department: string,
|
||||
excludeAgentId: string | null,
|
||||
): string | null {
|
||||
const candidates = db.queryAll<{
|
||||
id: string;
|
||||
role: string;
|
||||
title: string;
|
||||
department: string | null;
|
||||
createdAt: string;
|
||||
}>(
|
||||
`select id, role, title, department, created_at as createdAt
|
||||
from agents
|
||||
where company_id = ?
|
||||
and department = ?
|
||||
and status not in ('terminated', 'pending_approval')`,
|
||||
companyId,
|
||||
department,
|
||||
);
|
||||
|
||||
const ranked = candidates
|
||||
.filter((candidate) => candidate.id !== excludeAgentId)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
hasReports: db.getDirectReports(candidate.id, companyId).length > 0,
|
||||
leadershipScore: hasManagementAuthority(candidate) ? 1 : 0,
|
||||
chainDepth: db.getChainOfCommand(candidate.id).length,
|
||||
createdAt: candidate.createdAt,
|
||||
}))
|
||||
.filter((candidate) => candidate.hasReports || candidate.leadershipScore > 0)
|
||||
.sort((left, right) => {
|
||||
if (left.hasReports !== right.hasReports) return left.hasReports ? -1 : 1;
|
||||
if (left.leadershipScore !== right.leadershipScore) return right.leadershipScore - left.leadershipScore;
|
||||
if (left.chainDepth !== right.chainDepth) return left.chainDepth - right.chainDepth;
|
||||
return left.createdAt.localeCompare(right.createdAt);
|
||||
});
|
||||
|
||||
return ranked[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export function resolveHireReportsTo(
|
||||
db: AppDatabase,
|
||||
companyId: string,
|
||||
requestedByAgentId: string | null | undefined,
|
||||
explicitReportsTo: string | null | undefined,
|
||||
department: string | null | undefined,
|
||||
): string | null {
|
||||
if (explicitReportsTo !== undefined) {
|
||||
return explicitReportsTo;
|
||||
}
|
||||
|
||||
const requester = requestedByAgentId ? safeGetAgent(db, requestedByAgentId) : null;
|
||||
if (requester && requester.companyId !== companyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!department) {
|
||||
return requester?.id ?? db.getCeoForCompany(companyId)?.id ?? null;
|
||||
}
|
||||
|
||||
if (requester && (requester.department === department || !requester.reportsTo)) {
|
||||
return requester.id;
|
||||
}
|
||||
|
||||
const departmentLeadId = getDepartmentLeadCandidateId(db, companyId, department, requester?.id ?? null);
|
||||
if (departmentLeadId) {
|
||||
return departmentLeadId;
|
||||
}
|
||||
|
||||
if (requester) {
|
||||
return requester.id;
|
||||
}
|
||||
|
||||
return db.getCeoForCompany(companyId)?.id ?? null;
|
||||
}
|
||||
|
||||
function normalizeComparableText(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_\-()[\]{}]+/g, " ")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function isAllowedRoleAcronym(value: string): boolean {
|
||||
return /^[A-Z]{2,5}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export function getHireNameValidationError(input: {
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
}): string | null {
|
||||
const normalizedName = normalizeComparableText(input.name);
|
||||
if (!normalizedName) {
|
||||
return "Hire requests require a real display name.";
|
||||
}
|
||||
const normalizedRole = normalizeComparableText(input.role);
|
||||
const normalizedTitle = normalizeComparableText(input.title);
|
||||
if (normalizedName === normalizedRole && !isAllowedRoleAcronym(input.name)) {
|
||||
return "Hire name must be a real display name, not just the role.";
|
||||
}
|
||||
if (
|
||||
normalizedTitle.length > 0 &&
|
||||
normalizedName === normalizedTitle &&
|
||||
!isAllowedRoleAcronym(input.name)
|
||||
) {
|
||||
return "Hire name must be a real display name, not just the title.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getHireRoutingValidationError(
|
||||
db: AppDatabase,
|
||||
companyId: string,
|
||||
requestedByAgentId: string | null | undefined,
|
||||
reportsTo: string | null | undefined,
|
||||
department: string | null | undefined,
|
||||
): string | null {
|
||||
const requester = requestedByAgentId ? safeGetAgent(db, requestedByAgentId) : null;
|
||||
const manager = reportsTo ? safeGetAgent(db, reportsTo) : null;
|
||||
const requestedDepartment = department ?? null;
|
||||
|
||||
if (requester && requester.companyId !== companyId) {
|
||||
return "Requesting agent does not belong to the current company.";
|
||||
}
|
||||
if (!requestedDepartment) {
|
||||
return null;
|
||||
}
|
||||
if (reportsTo != null && !manager) {
|
||||
return "Selected manager not found.";
|
||||
}
|
||||
if (manager && manager.companyId !== companyId) {
|
||||
return "Selected manager does not belong to the current company.";
|
||||
}
|
||||
|
||||
if (
|
||||
manager &&
|
||||
manager.reportsTo != null &&
|
||||
manager.department !== requestedDepartment
|
||||
) {
|
||||
return "Selected manager must belong to the same department as the new hire, unless they are a top-level executive.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean {
|
||||
if (!targetAgentId || targetAgentId === actorAgentId) {
|
||||
return true;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import log from "electron-log/main.js";
|
||||
import { hireRequestSchema } from "@shared/contracts";
|
||||
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
|
||||
import { canManageOrganization, canRequestHire } from "../agent-permissions";
|
||||
import {
|
||||
canManageOrganization,
|
||||
canRequestHire,
|
||||
getHireNameValidationError,
|
||||
getHireRoutingValidationError,
|
||||
resolveHireReportsTo,
|
||||
} from "../agent-permissions";
|
||||
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
|
||||
import { requireParam, type AgentApiServer } from "./server-core";
|
||||
|
||||
@@ -236,32 +242,59 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
server.json(res, 403, { error: "Current agent is not allowed to request hires" });
|
||||
return;
|
||||
}
|
||||
const reportsToProvided =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
Object.prototype.hasOwnProperty.call(body, "reportsTo");
|
||||
const parsed = hireRequestSchema.safeParse({
|
||||
companyId,
|
||||
requestedByAgentId: token.agentId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
reportsTo:
|
||||
typeof body === "object" && body && "reportsTo" in body
|
||||
? (body as { reportsTo?: string | null }).reportsTo
|
||||
: token.agentId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
server.json(res, 400, { error: "Invalid hire payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
const resolvedReportsTo = resolveHireReportsTo(
|
||||
db,
|
||||
companyId,
|
||||
token.agentId,
|
||||
reportsToProvided ? input.reportsTo : undefined,
|
||||
input.department ?? null,
|
||||
);
|
||||
const nameError = getHireNameValidationError({
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
});
|
||||
if (nameError) {
|
||||
server.json(res, 422, { error: nameError });
|
||||
return;
|
||||
}
|
||||
const routingError = getHireRoutingValidationError(
|
||||
db,
|
||||
companyId,
|
||||
token.agentId,
|
||||
resolvedReportsTo,
|
||||
input.department ?? null,
|
||||
);
|
||||
if (routingError) {
|
||||
server.json(res, 422, { error: routingError });
|
||||
return;
|
||||
}
|
||||
if (server.companyRefError(res, companyId, [
|
||||
{ id: input.requestedByAgentId, table: "agents", label: "requesting agent" },
|
||||
{ id: input.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: resolvedReportsTo, table: "agents", label: "manager" },
|
||||
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
input.reportsTo &&
|
||||
input.reportsTo !== token.agentId &&
|
||||
resolvedReportsTo &&
|
||||
resolvedReportsTo !== token.agentId &&
|
||||
!canManageOrganization(db, companyId, token.agentId) &&
|
||||
!db.getChainOfCommand(input.reportsTo).some((agent) => agent.id === token.agentId)
|
||||
!db.getChainOfCommand(resolvedReportsTo).some((agent) => agent.id === token.agentId)
|
||||
) {
|
||||
server.json(res, 403, { error: "Requested hire must report to you or a manager in your reporting chain." });
|
||||
return;
|
||||
@@ -310,12 +343,12 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
} catch { /* invalid payloadJson, skip */ }
|
||||
}
|
||||
|
||||
const managerLabel = input.reportsTo
|
||||
const managerLabel = resolvedReportsTo
|
||||
? (() => {
|
||||
try {
|
||||
return db.getAgent(input.reportsTo).name;
|
||||
return db.getAgent(resolvedReportsTo).name;
|
||||
} catch {
|
||||
return input.reportsTo;
|
||||
return resolvedReportsTo;
|
||||
}
|
||||
})()
|
||||
: "the company board";
|
||||
@@ -329,7 +362,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
department: input.department ?? null,
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
reportsTo: resolvedReportsTo,
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
@@ -339,6 +372,9 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
payloadSummary: `Hire ${input.name} as ${input.role}`,
|
||||
impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`,
|
||||
payloadJson: JSON.stringify({
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
|
||||
requestedConfigurationSnapshot: {
|
||||
connectorId: input.connectorId,
|
||||
@@ -347,7 +383,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
metadataJson: input.metadataJson ?? "{}",
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
reportsTo: resolvedReportsTo,
|
||||
department: input.department ?? null,
|
||||
title: input.title,
|
||||
},
|
||||
|
||||
@@ -50,8 +50,30 @@ export function buildBudgetMethods(
|
||||
notifyEnabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}): string {
|
||||
const id = input.id || randomUUID();
|
||||
const ts = nowIso();
|
||||
const metric = input.metric ?? "billed_cents";
|
||||
const windowKind = input.windowKind ?? "calendar_month_utc";
|
||||
const scopedExisting = input.id
|
||||
? []
|
||||
: readAll<{ id: string; createdAt: string }>(
|
||||
`SELECT id, created_at as createdAt
|
||||
FROM budget_policies
|
||||
WHERE company_id = ? AND scope_type = ? AND scope_id = ? AND metric = ? AND window_kind = ?
|
||||
ORDER BY created_at ASC`,
|
||||
input.companyId,
|
||||
input.scopeType,
|
||||
input.scopeId,
|
||||
metric,
|
||||
windowKind,
|
||||
);
|
||||
const existing = input.id
|
||||
? getDb()
|
||||
.prepare("SELECT id, created_at as createdAt FROM budget_policies WHERE id = ?")
|
||||
.get(input.id) as { id: string; createdAt: string } | undefined
|
||||
: scopedExisting[0];
|
||||
const staleIds = input.id ? [] : scopedExisting.slice(1).map((policy) => policy.id);
|
||||
const id = existing?.id ?? input.id ?? randomUUID();
|
||||
const createdAt = existing?.createdAt ?? ts;
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO budget_policies
|
||||
@@ -61,12 +83,18 @@ export function buildBudgetMethods(
|
||||
)
|
||||
.run(
|
||||
id, input.companyId, input.scopeType, input.scopeId,
|
||||
input.metric ?? "billed_cents", input.windowKind ?? "calendar_month_utc",
|
||||
metric, windowKind,
|
||||
input.amount, input.warnPercent ?? 80,
|
||||
(input.hardStopEnabled ?? true) ? 1 : 0,
|
||||
(input.notifyEnabled ?? true) ? 1 : 0,
|
||||
(input.isActive ?? true) ? 1 : 0, ts, ts,
|
||||
(input.isActive ?? true) ? 1 : 0, createdAt, ts,
|
||||
);
|
||||
if (staleIds.length > 0) {
|
||||
const placeholders = staleIds.map(() => "?").join(", ");
|
||||
getDb()
|
||||
.prepare(`UPDATE budget_policies SET is_active = 0, updated_at = ? WHERE id IN (${placeholders})`)
|
||||
.run(ts, ...staleIds);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export function buildEntityCrudMethods(
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) throw new Error(`Agent not found: ${id}`);
|
||||
row.heartbeatEnabled = Boolean(row.heartbeatEnabled);
|
||||
return row;
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { resolveApprovalDecision } from "./approval-policy";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
type ReconcileExecutionModeChangeInput = {
|
||||
db: AppDatabase;
|
||||
companyId: string;
|
||||
previousExecutionMode: string;
|
||||
nextExecutionMode: string;
|
||||
previousReviewDeliverables: boolean;
|
||||
nextReviewDeliverables: boolean;
|
||||
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
};
|
||||
|
||||
const COFOUNDER_TRANSITION_APPROVAL_TYPES = [
|
||||
"deliverable_review",
|
||||
"document_review",
|
||||
"plan_review",
|
||||
"scope_review",
|
||||
"draft_review",
|
||||
"strategy_review",
|
||||
"exception_review",
|
||||
"approve_ceo_strategy",
|
||||
] as const;
|
||||
|
||||
const REVIEW_WAIT_STATE_TYPES = new Set(["user_reply", "user_review", "user_approval"]);
|
||||
|
||||
function collectImpactedAgentIdsFromWaitStates(db: AppDatabase, companyId: string): Set<string> {
|
||||
const agentIds = new Set<string>();
|
||||
const waitStates = db.listThreadWaitStates(companyId);
|
||||
for (const waitState of waitStates) {
|
||||
const thread = db.getThread(waitState.threadId, companyId);
|
||||
if (!thread || thread.relatedEntityType !== "task" || !thread.relatedEntityId) continue;
|
||||
try {
|
||||
const task = db.getTask(thread.relatedEntityId);
|
||||
if (task.assigneeAgentId) {
|
||||
agentIds.add(task.assigneeAgentId);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return agentIds;
|
||||
}
|
||||
|
||||
function collectImpactedAgentIdsFromReviewState(db: AppDatabase, companyId: string): Set<string> {
|
||||
const agentIds = new Set<string>();
|
||||
const flaggedTasks = db.queryAll<{ assigneeAgentId: string | null }>(
|
||||
"select assignee_agent_id as assigneeAgentId from tasks where company_id = ? and requires_user_review = 1",
|
||||
companyId,
|
||||
);
|
||||
for (const task of flaggedTasks) {
|
||||
if (task.assigneeAgentId) {
|
||||
agentIds.add(task.assigneeAgentId);
|
||||
}
|
||||
}
|
||||
const pendingApprovals = db.queryAll<{ requestedByAgentId: string | null; relatedTaskId: string | null }>(
|
||||
`select requested_by_agent_id as requestedByAgentId, related_task_id as relatedTaskId
|
||||
from approvals
|
||||
where company_id = ?
|
||||
and state = 'pending'
|
||||
and type = 'deliverable_review'`,
|
||||
companyId,
|
||||
);
|
||||
for (const approval of pendingApprovals) {
|
||||
if (approval.requestedByAgentId) {
|
||||
agentIds.add(approval.requestedByAgentId);
|
||||
}
|
||||
if (!approval.relatedTaskId) continue;
|
||||
try {
|
||||
const task = db.getTask(approval.relatedTaskId);
|
||||
if (task.assigneeAgentId) {
|
||||
agentIds.add(task.assigneeAgentId);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return agentIds;
|
||||
}
|
||||
|
||||
function resumeAgents(db: AppDatabase, companyId: string, agentIds: Iterable<string>): void {
|
||||
for (const agentId of agentIds) {
|
||||
try {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.companyId !== companyId) continue;
|
||||
if (["terminated", "pending_approval"].includes(agent.status)) continue;
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agent.id, true, agent.heartbeatIntervalSec || 120);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileExecutionModeChange({
|
||||
db,
|
||||
companyId,
|
||||
previousExecutionMode,
|
||||
nextExecutionMode,
|
||||
previousReviewDeliverables,
|
||||
nextReviewDeliverables,
|
||||
handleApprovalResolved,
|
||||
}: ReconcileExecutionModeChangeInput): void {
|
||||
const leavingCofounderMode =
|
||||
previousExecutionMode === "ai_cofounder" && nextExecutionMode === "ai_ceo";
|
||||
const disablingDeliverableReviews =
|
||||
previousReviewDeliverables && !nextReviewDeliverables;
|
||||
|
||||
if (!leavingCofounderMode && !disablingDeliverableReviews) {
|
||||
return;
|
||||
}
|
||||
|
||||
const impactedAgents = new Set<string>([
|
||||
...collectImpactedAgentIdsFromWaitStates(db, companyId),
|
||||
...collectImpactedAgentIdsFromReviewState(db, companyId),
|
||||
]);
|
||||
|
||||
if (leavingCofounderMode) {
|
||||
const pendingApprovals = db.queryAll<{ id: string; type: string }>(
|
||||
`select id, type
|
||||
from approvals
|
||||
where company_id = ?
|
||||
and state = 'pending'
|
||||
and type in (${COFOUNDER_TRANSITION_APPROVAL_TYPES.map(() => "?").join(", ")})`,
|
||||
companyId,
|
||||
...COFOUNDER_TRANSITION_APPROVAL_TYPES,
|
||||
);
|
||||
for (const approval of pendingApprovals) {
|
||||
resolveApprovalDecision({
|
||||
db,
|
||||
handleApprovalResolved,
|
||||
companyId,
|
||||
approvalId: approval.id,
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved because the company switched to AI CEO mode.",
|
||||
detail: `Mode transition auto-approved ${approval.type}.`,
|
||||
actor: "system",
|
||||
});
|
||||
}
|
||||
|
||||
const waitStates = db.listThreadWaitStates(companyId);
|
||||
for (const waitState of waitStates) {
|
||||
if (!REVIEW_WAIT_STATE_TYPES.has(waitState.waitingFor)) continue;
|
||||
db.resolveThreadWaitState(waitState.id, companyId);
|
||||
}
|
||||
}
|
||||
|
||||
if (disablingDeliverableReviews || leavingCofounderMode) {
|
||||
const tasks = db.queryAll<{ id: string }>(
|
||||
"select id from tasks where company_id = ? and requires_user_review = 1",
|
||||
companyId,
|
||||
);
|
||||
for (const task of tasks) {
|
||||
db.setTaskRequiresUserReview(task.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
resumeAgents(db, companyId, impactedAgents);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
secretInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { validateConnectorConfigurationModel, validateConnectorModelSelection } from "./connector-model-policy";
|
||||
import { reconcileExecutionModeChange } from "./execution-mode-reconciler";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreAdminHandlers({
|
||||
@@ -15,16 +16,30 @@ export function registerCoreAdminHandlers({
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
handleApprovalResolved,
|
||||
checkConnector,
|
||||
openConnectorAuthTerminal,
|
||||
openGenericTerminal,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal" | "openGenericTerminal"
|
||||
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "handleApprovalResolved" | "checkConnector" | "openConnectorAuthTerminal" | "openGenericTerminal"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveCompany, async (_event, payload) => {
|
||||
const parsed = companyInputSchema.parse(payload);
|
||||
const previousMode = parsed.id ? db.getCompanyExecutionMode(parsed.id) : parsed.executionMode;
|
||||
const previousApprovalSettings = parsed.id
|
||||
? db.getCompanyApprovalSettings(parsed.id)
|
||||
: { autoApproveHires: parsed.autoApproveHires, reviewDeliverables: parsed.reviewDeliverables };
|
||||
const id = db.saveCompany(parsed);
|
||||
reconcileExecutionModeChange({
|
||||
db,
|
||||
companyId: id,
|
||||
previousExecutionMode: previousMode,
|
||||
nextExecutionMode: parsed.executionMode,
|
||||
previousReviewDeliverables: previousApprovalSettings?.reviewDeliverables ?? false,
|
||||
nextReviewDeliverables: parsed.reviewDeliverables,
|
||||
handleApprovalResolved,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId: id,
|
||||
actor: "board",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@shared/contracts";
|
||||
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
|
||||
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
|
||||
import { getHireNameValidationError, getHireRoutingValidationError, resolveHireReportsTo } from "./agent-permissions";
|
||||
import { validateConnectorModelSelection } from "./connector-model-policy";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
@@ -146,9 +147,16 @@ export function registerCoreAgentGraphHandlers({
|
||||
const parsed = hireRequestSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const resolvedReportsTo = resolveHireReportsTo(
|
||||
db,
|
||||
parsed.companyId,
|
||||
parsed.requestedByAgentId ?? null,
|
||||
parsed.reportsTo,
|
||||
parsed.department ?? null,
|
||||
);
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.requestedByAgentId, table: "agents", label: "requesting agent" },
|
||||
{ id: parsed.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: resolvedReportsTo, table: "agents", label: "manager" },
|
||||
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
@@ -168,16 +176,34 @@ export function registerCoreAgentGraphHandlers({
|
||||
if (connectorIssue) {
|
||||
return fail("CONNECTOR_NOT_READY", connectorIssue);
|
||||
}
|
||||
const nameError = getHireNameValidationError({
|
||||
name: parsed.name,
|
||||
role: parsed.role,
|
||||
title: parsed.title,
|
||||
});
|
||||
if (nameError) {
|
||||
return fail("INVALID_HIRE_NAME", nameError);
|
||||
}
|
||||
const routingError = getHireRoutingValidationError(
|
||||
db,
|
||||
parsed.companyId,
|
||||
parsed.requestedByAgentId ?? null,
|
||||
parsed.reportsTo,
|
||||
parsed.department ?? null,
|
||||
);
|
||||
if (routingError) {
|
||||
return fail("INVALID_HIRE_ROUTING", routingError);
|
||||
}
|
||||
const modelSelection = validateConnectorModelSelection(db, parsed.connectorId, parsed.model ?? null);
|
||||
if (!modelSelection.ok) {
|
||||
return fail("CONNECTOR_MODEL_NOT_ALLOWED", modelSelection.error);
|
||||
}
|
||||
const managerLabel = parsed.reportsTo
|
||||
const managerLabel = resolvedReportsTo
|
||||
? (() => {
|
||||
try {
|
||||
return db.getAgent(parsed.reportsTo).name;
|
||||
return db.getAgent(resolvedReportsTo).name;
|
||||
} catch {
|
||||
return parsed.reportsTo;
|
||||
return resolvedReportsTo;
|
||||
}
|
||||
})()
|
||||
: "the company board";
|
||||
@@ -192,7 +218,7 @@ export function registerCoreAgentGraphHandlers({
|
||||
role: parsed.role,
|
||||
title: parsed.title,
|
||||
department: parsed.department ?? null,
|
||||
reportsTo: parsed.reportsTo ?? null,
|
||||
reportsTo: resolvedReportsTo,
|
||||
connectorId: parsed.connectorId,
|
||||
workspaceId: parsed.workspaceId ?? null,
|
||||
model: parsed.model ?? null,
|
||||
@@ -202,6 +228,9 @@ export function registerCoreAgentGraphHandlers({
|
||||
payloadSummary,
|
||||
impactSummary,
|
||||
payloadJson: JSON.stringify({
|
||||
name: parsed.name,
|
||||
role: parsed.role,
|
||||
title: parsed.title,
|
||||
requestedByAgentId: parsed.requestedByAgentId ?? null,
|
||||
requestedConfigurationSnapshot: {
|
||||
connectorId: parsed.connectorId,
|
||||
@@ -210,7 +239,7 @@ export function registerCoreAgentGraphHandlers({
|
||||
metadataJson: parsed.metadataJson ?? "{}",
|
||||
capabilities: parsed.capabilities,
|
||||
budgetMonthlyUsd: parsed.budgetMonthlyUsd,
|
||||
reportsTo: parsed.reportsTo ?? null,
|
||||
reportsTo: resolvedReportsTo,
|
||||
department: parsed.department ?? null,
|
||||
title: parsed.title,
|
||||
},
|
||||
|
||||
@@ -80,14 +80,28 @@ export function createHeartbeat(
|
||||
return null;
|
||||
}
|
||||
|
||||
const companyMode = db.getCompanyExecutionMode(companyId);
|
||||
const approvalSettings = db.getCompanyApprovalSettings(companyId);
|
||||
const shouldEnforceHumanReview =
|
||||
companyMode === "ai_cofounder" || approvalSettings?.reviewDeliverables === true;
|
||||
|
||||
// Block heartbeat when agent has tasks awaiting user deliverable review
|
||||
if (!isMessageWake && (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId))) {
|
||||
if (
|
||||
shouldEnforceHumanReview &&
|
||||
!isMessageWake &&
|
||||
(db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId))
|
||||
) {
|
||||
logger.debug(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block CEO heartbeat when company has any pending deliverable reviews
|
||||
if (!isMessageWake && !agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) {
|
||||
if (
|
||||
shouldEnforceHumanReview &&
|
||||
!isMessageWake &&
|
||||
!agent.reportsTo &&
|
||||
db.hasPendingDeliverableReviews(companyId)
|
||||
) {
|
||||
logger.debug(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ Body: {
|
||||
### Propose a Hire
|
||||
POST /api/companies/${ctx.agent.companyId}/agents
|
||||
Body: {
|
||||
"name": "New hire name",
|
||||
"name": "New hire display name (must not repeat role/title)",
|
||||
"role": "role_title",
|
||||
"title": "Descriptive title",
|
||||
"department": "engineering|product|marketing|sales|hr|finance|legal|operations|customer_support|research|design|executive",
|
||||
|
||||
@@ -43,10 +43,10 @@ Start with C-suite and VPs who report to you (3-5 max), then let them hire their
|
||||
|
||||
## Step 3: Submit Hire Requests
|
||||
For EACH role, use POST /api/companies/{companyId}/agents with:
|
||||
- A clear, professional name
|
||||
- A clear, professional name that is not just the role or title repeated back
|
||||
- Specific role and title
|
||||
- Detailed capabilities describing what they should own
|
||||
- reportsTo: YOUR agent ID (they report to you)
|
||||
- reportsTo: an explicit manager agent ID (do not omit this field)
|
||||
- connectorId: Use the connector recommended in the "Available Connectors" section above. If no section is shown, use claude_local.
|
||||
- budgetMonthlyUsd: Appropriate budget (recommend $50-250/agent)
|
||||
- model: Best model for the role's complexity
|
||||
@@ -159,7 +159,7 @@ When workload exceeds capacity or new capabilities are needed:
|
||||
- Assess: count open tasks vs active agents per department
|
||||
- If a department is overloaded: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents
|
||||
- Consider hiring specialists under your VPs (reportsTo = VP, not you)
|
||||
- Each hire needs: clear role, specific capabilities, budget justification, department assignment
|
||||
- Each hire needs: a distinct agent name, clear role/title, specific capabilities, budget justification, department assignment, and the correct manager (reportsTo)
|
||||
- After hire approval, create onboarding tasks for the new agent
|
||||
|
||||
## Priority 7: Strategic Communication & Reporting
|
||||
|
||||
@@ -41,7 +41,7 @@ When reviewing work from reports:
|
||||
## SOP-005: Hiring Protocol
|
||||
When the team needs new members:
|
||||
1. Identify the capability gap (what work can't be done with current team?)
|
||||
2. Define the role clearly: name, title, capabilities, which manager they report to
|
||||
2. Define the role clearly: a distinct agent name, title, capabilities, and which manager they report to
|
||||
3. Choose the best connector/model for the role
|
||||
4. Submit via POST /api/companies/{companyId}/agents
|
||||
5. The hire enters pending_approval — the Board must approve
|
||||
|
||||
@@ -215,7 +215,7 @@ export function ApprovalInspector({ approval, agents, tasks, documents, companyI
|
||||
</div>
|
||||
{approval.type === "hire_agent" ? (() => { const payload = parseApprovalPayloadJson(approval); const payloadAgentId = typeof payload?.agentId === "string" ? payload.agentId : null; const reqSnap = payload?.requestedConfigurationSnapshot && typeof payload.requestedConfigurationSnapshot === "object" && !Array.isArray(payload.requestedConfigurationSnapshot) ? payload.requestedConfigurationSnapshot as Record<string, unknown> : null; const proposedAgent = (approval.relatedAgentId ? (agents.find((a) => a.id === approval.relatedAgentId) ?? null) : null) ?? (payloadAgentId ? (agents.find((a) => a.id === payloadAgentId) ?? null) : null); const mgrId = proposedAgent?.reportsTo ?? (typeof reqSnap?.reportsTo === "string" ? reqSnap.reportsTo : null); const mgr = mgrId ? (agents.find((a) => a.id === mgrId) ?? null) : null; if (!proposedAgent && !reqSnap) return null; return (
|
||||
<div className="rounded-lg border border-[color:var(--primary)] border-opacity-30 bg-accent px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Proposed Hire</div><div className="grid gap-1.5 text-[12px]">
|
||||
<div><span className="text-muted-foreground">Name:</span> <span className="font-medium text-foreground">{proposedAgent?.name ?? approval.payloadSummary.replace(/^Hire\s+/i, "").split(/\s+as\s+/i)[0] ?? "Pending hire"}</span></div>
|
||||
<div><span className="text-muted-foreground">Name:</span> <span className="font-medium text-foreground">{proposedAgent?.name ?? (typeof reqSnap?.name === "string" ? reqSnap.name : "Unspecified")}</span></div>
|
||||
<div><span className="text-muted-foreground">Role:</span> <span className="text-foreground">{proposedAgent?.role ?? (typeof reqSnap?.role === "string" ? reqSnap.role : "Unspecified")}</span></div>
|
||||
{(proposedAgent?.title || typeof reqSnap?.title === "string") ? <div><span className="text-muted-foreground">Title:</span> <span className="text-foreground">{proposedAgent?.title || reqSnap?.title as string}</span></div> : null}
|
||||
{(proposedAgent?.capabilities || typeof reqSnap?.capabilities === "string") ? <div><span className="text-muted-foreground">Capabilities:</span> <span className="text-foreground">{proposedAgent?.capabilities || reqSnap?.capabilities as string}</span></div> : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BudgetPolicyRecord } from "@shared/types";
|
||||
import { ActionButton, Input, Modal, Select } from "../ui";
|
||||
import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../lib/budget-policy-input";
|
||||
|
||||
interface PolicyFormModalProps {
|
||||
open: boolean;
|
||||
@@ -25,7 +26,7 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
|
||||
if (editingPolicy) {
|
||||
setScopeType(editingPolicy.scopeType);
|
||||
setScopeId(editingPolicy.scopeId);
|
||||
setAmountDollars((editingPolicy.amount / 100).toFixed(2));
|
||||
setAmountDollars(getBudgetPolicyAmountDollars(editingPolicy));
|
||||
setWarnPercent(String(editingPolicy.warnPercent));
|
||||
setHardStopEnabled(editingPolicy.hardStopEnabled);
|
||||
setNotifyEnabled(editingPolicy.notifyEnabled);
|
||||
@@ -52,24 +53,19 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const amountCents = Math.round(parseFloat(amountDollars) * 100);
|
||||
if (Number.isNaN(amountCents) || amountCents <= 0) return;
|
||||
|
||||
const wp = parseInt(warnPercent, 10);
|
||||
if (Number.isNaN(wp) || wp < 1 || wp > 100) return;
|
||||
|
||||
const result = await window.agentCompany.saveBudgetPolicy({
|
||||
const payload = buildBudgetPolicyInput({
|
||||
policyId: editingPolicy?.id ?? null,
|
||||
companyId,
|
||||
scopeType,
|
||||
scopeId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: amountCents,
|
||||
warnPercent: wp,
|
||||
amountDollars,
|
||||
warnPercent,
|
||||
hardStopEnabled,
|
||||
notifyEnabled,
|
||||
isActive: true,
|
||||
});
|
||||
if (!payload) return;
|
||||
|
||||
const result = await window.agentCompany.saveBudgetPolicy(payload);
|
||||
if (result.ok) {
|
||||
onSaved();
|
||||
onClose();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Robot } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { DEPARTMENT_OPTIONS } from "@shared/constants";
|
||||
import type { AgentRecord, ConnectorRecord, DepartmentId, WorkspaceRecord } from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
@@ -27,6 +28,7 @@ export function AgentCreator({
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [department, setDepartment] = useState<DepartmentId | "">("");
|
||||
const [connectorId, setConnectorId] = useState(executionReadyConnectors[0]?.id ?? "");
|
||||
const [model, setModel] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
|
||||
@@ -69,6 +71,7 @@ export function AgentCreator({
|
||||
|
||||
const t = useT();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const selectedDepartment: DepartmentId | null = department === "" ? null : department;
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4">
|
||||
@@ -114,6 +117,15 @@ export function AgentCreator({
|
||||
<AnimatePresence>
|
||||
{showAdvanced ? (
|
||||
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden">
|
||||
<Select
|
||||
label={t("comm.department")}
|
||||
value={department}
|
||||
onChange={(value) => setDepartment(value as DepartmentId | "")}
|
||||
options={DEPARTMENT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.value === "" ? t("comm.selectDepartment") : option.label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label={t("agent.workspace")}
|
||||
value={workspaceId}
|
||||
@@ -162,21 +174,22 @@ export function AgentCreator({
|
||||
unwrap(await window.agentCompany.requestHire({
|
||||
companyId,
|
||||
requestedByAgentId: null,
|
||||
name,
|
||||
role,
|
||||
title,
|
||||
department: null,
|
||||
name: name.trim(),
|
||||
role: role.trim(),
|
||||
title: title.trim(),
|
||||
department: selectedDepartment,
|
||||
connectorId: connectorId as ConnectorRecord["id"],
|
||||
workspaceId: workspaceId || null,
|
||||
reportsTo: reportsTo || null,
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
capabilities,
|
||||
capabilities: capabilities.trim(),
|
||||
budgetMonthlyUsd: Number(budget || 0),
|
||||
}));
|
||||
setName("");
|
||||
setRole("");
|
||||
setTitle("");
|
||||
setDepartment("");
|
||||
setCapabilities("");
|
||||
setReportsTo("");
|
||||
setRequestError(null);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Trash } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { DEPARTMENT_OPTIONS } from "@shared/constants";
|
||||
import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, DepartmentId, WorkspaceRecord } from "@shared/types";
|
||||
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../lib/budget-policy-input";
|
||||
import { useAppStore } from "../../state/store";
|
||||
import { useT } from "../../i18n";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
@@ -30,6 +32,7 @@ export function AgentEditor({
|
||||
const [name, setName] = useState(agent.name);
|
||||
const [role, setRole] = useState(agent.role);
|
||||
const [title, setTitle] = useState(agent.title);
|
||||
const [department, setDepartment] = useState<DepartmentId | "">(agent.department ?? "");
|
||||
const [status, setStatus] = useState<AgentRecord["status"]>(agent.status);
|
||||
const [connectorId, setConnectorId] = useState<ConnectorRecord["id"]>(agent.connectorId);
|
||||
const [model, setModel] = useState(agent.model ?? "");
|
||||
@@ -108,18 +111,22 @@ export function AgentEditor({
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listBudgetPolicies(companyId);
|
||||
if (result.ok) {
|
||||
const policy = result.data.find(
|
||||
(p) => p.scopeType === "agent" && p.scopeId === agent.id
|
||||
);
|
||||
const policy = result.data
|
||||
.filter((p) => p.scopeType === "agent" && p.scopeId === agent.id)
|
||||
.sort((left, right) => {
|
||||
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
|
||||
return right.updatedAt.localeCompare(left.updatedAt);
|
||||
})[0];
|
||||
if (policy) {
|
||||
setAgentPolicy(policy);
|
||||
setBudget(String((policy.amount / 100).toFixed(2)));
|
||||
setBudget(getBudgetPolicyAmountDollars(policy));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [companyId, agent.id]);
|
||||
|
||||
const managerCandidates = agents.filter((a) => a.id !== agent.id);
|
||||
const selectedDepartment: DepartmentId | null = department === "" ? null : department;
|
||||
|
||||
async function persistAgentDraft() {
|
||||
const meta = { ...existingMeta };
|
||||
@@ -130,9 +137,10 @@ export function AgentEditor({
|
||||
unwrap(await window.agentCompany.saveAgent({
|
||||
id: agent.id,
|
||||
companyId,
|
||||
name,
|
||||
role,
|
||||
title,
|
||||
name: name.trim(),
|
||||
role: role.trim(),
|
||||
title: title.trim(),
|
||||
department: selectedDepartment,
|
||||
status,
|
||||
connectorId,
|
||||
workspaceId: workspaceId || null,
|
||||
@@ -140,22 +148,24 @@ export function AgentEditor({
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
budgetMonthlyUsd: budgetDollars,
|
||||
capabilities,
|
||||
capabilities: capabilities.trim(),
|
||||
}));
|
||||
|
||||
if (budgetDollars > 0 && agent.id) {
|
||||
await window.agentCompany.saveBudgetPolicy({
|
||||
const payload = buildBudgetPolicyInput({
|
||||
policyId: agentPolicy?.id ?? null,
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agent.id,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: Math.round(budgetDollars * 100),
|
||||
warnPercent: agentPolicy?.warnPercent ?? 80,
|
||||
amountDollars: String(budgetDollars),
|
||||
warnPercent: String(agentPolicy?.warnPercent ?? 80),
|
||||
hardStopEnabled: agentPolicy?.hardStopEnabled ?? true,
|
||||
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
|
||||
isActive: true,
|
||||
});
|
||||
if (!payload) {
|
||||
throw new Error("Agent budget policy is invalid.");
|
||||
}
|
||||
unwrap(await window.agentCompany.saveBudgetPolicy(payload));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +185,16 @@ export function AgentEditor({
|
||||
<Input label={t("agent.name")} value={name} onChange={setName} placeholder={t("agent.name")} readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Input label={t("agent.role")} value={role} onChange={setRole} placeholder={t("agent.role")} readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Input label={t("agent.title")} value={title} onChange={setTitle} placeholder={t("agent.title")} readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Select
|
||||
label={t("comm.department")}
|
||||
value={department}
|
||||
onChange={(value) => setDepartment(value as DepartmentId | "")}
|
||||
disabled={pendingApproval}
|
||||
options={DEPARTMENT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.value === "" ? t("comm.selectDepartment") : option.label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label={t("agent.status")}
|
||||
value={status}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { BudgetPolicyRecord } from "@shared/types";
|
||||
|
||||
interface BuildBudgetPolicyInputArgs {
|
||||
policyId?: string | null;
|
||||
companyId: string;
|
||||
scopeType: "company" | "agent" | "project";
|
||||
scopeId: string;
|
||||
amountDollars: string;
|
||||
warnPercent: string;
|
||||
hardStopEnabled: boolean;
|
||||
notifyEnabled: boolean;
|
||||
}
|
||||
|
||||
export function buildBudgetPolicyInput(args: BuildBudgetPolicyInputArgs) {
|
||||
const amount = Math.round(Number.parseFloat(args.amountDollars) * 100);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const warnPercent = Number.parseInt(args.warnPercent, 10);
|
||||
if (!Number.isFinite(warnPercent) || warnPercent < 1 || warnPercent > 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: args.policyId ?? null,
|
||||
companyId: args.companyId,
|
||||
scopeType: args.scopeType,
|
||||
scopeId: args.scopeId,
|
||||
metric: "billed_cents" as const,
|
||||
windowKind: "calendar_month_utc" as const,
|
||||
amount,
|
||||
warnPercent,
|
||||
hardStopEnabled: args.hardStopEnabled,
|
||||
notifyEnabled: args.notifyEnabled,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBudgetPolicyAmountDollars(policy: BudgetPolicyRecord | null): string {
|
||||
if (!policy) {
|
||||
return "";
|
||||
}
|
||||
return (policy.amount / 100).toFixed(2);
|
||||
}
|
||||
@@ -2,12 +2,16 @@ import { z } from "zod";
|
||||
import { DEPARTMENT_IDS, optionalId } from "./helpers";
|
||||
import { connectorInputSchema } from "./schemas-company";
|
||||
|
||||
const agentNameSchema = z.string().trim().min(2).max(80);
|
||||
const agentRoleSchema = z.string().trim().min(2).max(80);
|
||||
const agentTitleSchema = z.string().trim().max(80).default("");
|
||||
|
||||
export const agentInputSchema = z.object({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
name: z.string().min(2).max(80),
|
||||
role: z.string().min(2).max(80),
|
||||
title: z.string().default(""),
|
||||
name: agentNameSchema,
|
||||
role: agentRoleSchema,
|
||||
title: agentTitleSchema,
|
||||
department: DEPARTMENT_IDS.nullable().optional(),
|
||||
reportsTo: optionalId,
|
||||
connectorId: connectorInputSchema.shape.id,
|
||||
@@ -22,9 +26,9 @@ export const agentInputSchema = z.object({
|
||||
export const hireRequestSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
requestedByAgentId: optionalId,
|
||||
name: z.string().min(2).max(80),
|
||||
role: z.string().min(2).max(80),
|
||||
title: z.string().default(""),
|
||||
name: agentNameSchema,
|
||||
role: agentRoleSchema,
|
||||
title: agentTitleSchema,
|
||||
department: DEPARTMENT_IDS.nullable().optional(),
|
||||
reportsTo: optionalId,
|
||||
connectorId: connectorInputSchema.shape.id,
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("recursive org execution", () => {
|
||||
method: "POST",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
name: "Engineering IC",
|
||||
name: "Avery Chen",
|
||||
role: "Engineer",
|
||||
title: "Frontend Engineer",
|
||||
department: "engineering",
|
||||
@@ -166,6 +166,100 @@ describe("recursive org execution", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("routes cross-department hires to the relevant department lead when reportsTo is omitted", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const hrId = harness.db.saveAgent({
|
||||
companyId: harness.companyId,
|
||||
name: "HR Director",
|
||||
role: "HR Director",
|
||||
title: "Director of People",
|
||||
status: "active",
|
||||
reportsTo: harness.ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Coordinate staffing",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "hr",
|
||||
});
|
||||
const hrTaskId = harness.db.saveTask({
|
||||
companyId: harness.companyId,
|
||||
title: "Fill engineering gap",
|
||||
description: "Request a backend engineer for the engineering team.",
|
||||
assigneeAgentId: hrId,
|
||||
workspaceId: harness.workspaceId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
});
|
||||
const hrRun = harness.db.createRun({
|
||||
companyId: harness.companyId,
|
||||
taskId: hrTaskId,
|
||||
agentId: hrId,
|
||||
workspaceId: harness.workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
const hrToken = createAgentToken(hrId, harness.companyId, hrRun.id);
|
||||
|
||||
const response = await fetch(`${harness.baseUrl}/api/companies/${harness.companyId}/agents`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${hrToken}`,
|
||||
"X-Agent-Company-Run-Id": hrRun.id,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Avery Chen",
|
||||
role: "Backend Engineer",
|
||||
title: "Backend Engineer Golang",
|
||||
department: "engineering",
|
||||
connectorId: "codex_local",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Go, PostgreSQL, distributed systems",
|
||||
budgetMonthlyUsd: 180,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const body = await response.json() as { agentId: string };
|
||||
const pendingHire = harness.db.getAgent(body.agentId);
|
||||
expect(pendingHire.department).toBe("engineering");
|
||||
expect(pendingHire.reportsTo).toBe(harness.ctoId);
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects multi-word hire names that only repeat the job title", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const response = await fetch(`${harness.baseUrl}/api/companies/${harness.companyId}/agents`, {
|
||||
method: "POST",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
name: "Backend Engineer Golang",
|
||||
role: "Backend Engineer",
|
||||
title: "Backend Engineer Golang",
|
||||
department: "engineering",
|
||||
connectorId: "codex_local",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Go, APIs",
|
||||
budgetMonthlyUsd: 180,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
const body = await response.json() as { error: string };
|
||||
expect(body.error).toContain("Hire name");
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("manager cannot route a hire outside their reporting branch", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
@@ -201,9 +295,9 @@ describe("recursive org execution", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.status).toBe(422);
|
||||
const body = await response.json() as { error: string };
|
||||
expect(body.error).toContain("must report to you or a manager in your reporting chain");
|
||||
expect(body.error).toContain("same department");
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("agent budget policy synchronization", () => {
|
||||
it("updates the canonical agent-scope policy instead of creating duplicate active policies", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-budget-sync-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Budget Sync Co",
|
||||
description: "Budget sync regression coverage",
|
||||
status: "active",
|
||||
});
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Backend Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build backend services",
|
||||
budgetMonthlyUsd: 100,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
const firstPolicyId = db.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agentId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 10_000,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
db.saveAgent({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Backend Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build backend services",
|
||||
budgetMonthlyUsd: 250,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
const secondPolicyId = db.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agentId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 25_000,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const agentPolicies = db.listBudgetPolicies(companyId).filter(
|
||||
(policy) => policy.scopeType === "agent" && policy.scopeId === agentId,
|
||||
);
|
||||
expect(secondPolicyId).toBe(firstPolicyId);
|
||||
expect(agentPolicies).toHaveLength(1);
|
||||
expect(agentPolicies[0]?.amount).toBe(25_000);
|
||||
expect(db.getActivePoliciesForRun(companyId, agentId, null).map((policy) => policy.amount)).toEqual([25_000]);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { getHireRoutingValidationError, resolveHireReportsTo } from "../../src/main/agent-permissions";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("hire routing guards", () => {
|
||||
it("defaults same-department hires to the requester and infers department leads for cross-department hires", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-hire-manager-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Routing Co",
|
||||
description: "Hire routing regression coverage",
|
||||
status: "active",
|
||||
});
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
department: "executive",
|
||||
});
|
||||
const hrId = db.saveAgent({
|
||||
companyId,
|
||||
name: "HR Lead",
|
||||
role: "HR Lead",
|
||||
title: "Head of HR",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Run hiring operations",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "hr",
|
||||
});
|
||||
const ctoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
expect(resolveHireReportsTo(db, companyId, hrId, undefined, "hr")).toBe(hrId);
|
||||
expect(resolveHireReportsTo(db, companyId, hrId, undefined, "engineering")).toBe(ctoId);
|
||||
expect(getHireRoutingValidationError(db, companyId, hrId, hrId, "engineering")).toBe(
|
||||
"Selected manager must belong to the same department as the new hire, unless they are a top-level executive.",
|
||||
);
|
||||
expect(getHireRoutingValidationError(db, companyId, ctoId, ctoId, "engineering")).toBeNull();
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hireRequestSchema } from "../../src/shared/contracts/schemas-agents";
|
||||
import { getHireNameValidationError } from "../../src/main/agent-permissions";
|
||||
|
||||
const baseHireRequest = {
|
||||
companyId: "00000000-0000-0000-0000-000000000000",
|
||||
requestedByAgentId: null,
|
||||
name: "Alice",
|
||||
role: "Engineer",
|
||||
title: "Backend Engineer",
|
||||
department: "engineering" as const,
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local" as const,
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
metadataJson: "{}",
|
||||
capabilities: "Build backend services",
|
||||
budgetMonthlyUsd: 120,
|
||||
};
|
||||
|
||||
describe("agent input schemas", () => {
|
||||
it("rejects whitespace-only hire names", () => {
|
||||
expect(() =>
|
||||
hireRequestSchema.parse({
|
||||
...baseHireRequest,
|
||||
name: " ",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("trims semantic hire fields before persistence", () => {
|
||||
const parsed = hireRequestSchema.parse({
|
||||
...baseHireRequest,
|
||||
name: " Alice ",
|
||||
role: " Engineer ",
|
||||
title: " Backend Engineer ",
|
||||
});
|
||||
|
||||
expect(parsed.name).toBe("Alice");
|
||||
expect(parsed.role).toBe("Engineer");
|
||||
expect(parsed.title).toBe("Backend Engineer");
|
||||
});
|
||||
|
||||
it("rejects role-like names for non-executive hires", () => {
|
||||
expect(getHireNameValidationError({
|
||||
name: "Backend Engineer",
|
||||
role: "Backend Engineer",
|
||||
title: "Backend Engineer",
|
||||
})).toBe("Hire name must be a real display name, not just the role.");
|
||||
});
|
||||
|
||||
it("allows executive acronyms when they are the intended display name", () => {
|
||||
expect(getHireNameValidationError({
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../src/renderer/lib/budget-policy-input";
|
||||
|
||||
describe("budget-policy-input", () => {
|
||||
it("preserves the existing policy id on edit", () => {
|
||||
const payload = buildBudgetPolicyInput({
|
||||
policyId: "policy-123",
|
||||
companyId: "00000000-0000-0000-0000-000000000001",
|
||||
scopeType: "agent",
|
||||
scopeId: "00000000-0000-0000-0000-000000000002",
|
||||
amountDollars: "250.00",
|
||||
warnPercent: "85",
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
});
|
||||
|
||||
expect(payload?.id).toBe("policy-123");
|
||||
expect(payload?.amount).toBe(25000);
|
||||
expect(payload?.warnPercent).toBe(85);
|
||||
});
|
||||
|
||||
it("formats stored cents back to dollars for edit forms", () => {
|
||||
expect(
|
||||
getBudgetPolicyAmountDollars({
|
||||
id: "policy-123",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 12550,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
).toBe("125.50");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { buildWindowsSpawnTarget, normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { commandLooksLike } from "@agentcompany/adapter-utils/test-utils";
|
||||
|
||||
describe("command path normalization", () => {
|
||||
@@ -21,4 +21,27 @@ describe("command path normalization", () => {
|
||||
expect(commandLooksLike('"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe"', "claude")).toBe(true);
|
||||
expect(commandLooksLike("'C:\\Users\\First Last\\AppData\\Local\\bin\\codex.exe'", "codex")).toBe(true);
|
||||
});
|
||||
|
||||
it("spawns direct exe paths on Windows instead of wrapping them through cmd.exe", () => {
|
||||
const target = buildWindowsSpawnTarget(
|
||||
"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe",
|
||||
["--version"],
|
||||
"cmd.exe",
|
||||
);
|
||||
expect(target).toEqual({
|
||||
command: "C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe",
|
||||
args: ["--version"],
|
||||
});
|
||||
});
|
||||
|
||||
it("still wraps shell scripts through cmd.exe on Windows", () => {
|
||||
const target = buildWindowsSpawnTarget(
|
||||
"C:\\Tools\\agentcompany\\launch.cmd",
|
||||
["--version"],
|
||||
"cmd.exe",
|
||||
);
|
||||
expect(target.command).toBe("cmd.exe");
|
||||
expect(target.args[0]).toBe("/d");
|
||||
expect(target.args.at(-1)).toContain("launch.cmd");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { registerCoreAdminHandlers } from "../../src/main/ipc-core-admin-handlers";
|
||||
import { createThreadIntegration } from "../../src/main/thread-integration";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("company mode switching", () => {
|
||||
it("clears cofounder review artifacts when switching back to AI CEO", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-mode-switch-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Mode Switch Co",
|
||||
description: "Execution mode cleanup",
|
||||
status: "active",
|
||||
executionMode: "ai_cofounder",
|
||||
autoApproveHires: false,
|
||||
reviewDeliverables: true,
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const engineerId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Lead Engineer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Ship features",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "engineering",
|
||||
});
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Ship feature",
|
||||
description: "Waits for cofounder review.",
|
||||
assigneeAgentId: engineerId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const threadIntegration = createThreadIntegration({
|
||||
db,
|
||||
logger: { info: () => {}, error: () => {} },
|
||||
});
|
||||
|
||||
threadIntegration.onTaskCreated(taskId, companyId);
|
||||
threadIntegration.onRunFinished("run-1", taskId, engineerId, companyId, "succeeded", null);
|
||||
db.setTaskRequiresUserReview(taskId, true);
|
||||
const approvalId = db.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: engineerId,
|
||||
type: "deliverable_review",
|
||||
payloadSummary: "Review deliverable",
|
||||
impactSummary: "Needs review",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, "deliverable_review", "Review deliverable");
|
||||
|
||||
expect(db.listThreadWaitStates(companyId).length).toBeGreaterThan(0);
|
||||
expect(db.getTask(taskId).requiresUserReview).toBe(true);
|
||||
expect(db.getApprovalRecord(approvalId).state).toBe("pending");
|
||||
|
||||
const handlers = new Map<string, (...args: any[]) => any>();
|
||||
registerCoreAdminHandlers({
|
||||
registerHandle: (channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
db,
|
||||
ensureCompanyExists: (id) => (db.hasCompany(id) ? null : { ok: false, error: { code: "COMPANY_NOT_FOUND", message: "missing" } }),
|
||||
publishDomainChanged: vi.fn(),
|
||||
handleApprovalResolved: (resolvedApprovalId, resolvedCompanyId, decision) => {
|
||||
threadIntegration.onApprovalResolved(resolvedApprovalId, resolvedCompanyId, decision);
|
||||
},
|
||||
checkConnector: vi.fn(),
|
||||
openConnectorAuthTerminal: vi.fn(),
|
||||
openGenericTerminal: vi.fn(),
|
||||
});
|
||||
|
||||
const saveCompany = handlers.get(IPC_CHANNELS.saveCompany);
|
||||
if (!saveCompany) {
|
||||
throw new Error("saveCompany handler was not registered.");
|
||||
}
|
||||
|
||||
const result = await saveCompany(null, {
|
||||
id: companyId,
|
||||
name: "Mode Switch Co",
|
||||
description: "Execution mode cleanup",
|
||||
status: "active",
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
executionMode: "ai_ceo",
|
||||
}) as { ok: boolean };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(db.getCompanyExecutionMode(companyId)).toBe("ai_ceo");
|
||||
expect(db.listThreadWaitStates(companyId)).toHaveLength(0);
|
||||
expect(db.getTask(taskId).requiresUserReview).toBe(false);
|
||||
expect(db.getApprovalRecord(approvalId).state).toBe("approved");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ describe("ipc-core-admin-handlers connector saves", () => {
|
||||
db,
|
||||
ensureCompanyExists: () => null,
|
||||
publishDomainChanged: vi.fn(),
|
||||
handleApprovalResolved: vi.fn(),
|
||||
checkConnector: vi.fn(),
|
||||
openConnectorAuthTerminal: vi.fn(),
|
||||
openGenericTerminal: vi.fn(),
|
||||
|
||||
Reference in new issue
Block a user