From 237db05e37b11596f516ede76f2ada76c712ae79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 11 Apr 2026 02:11:07 -0400 Subject: [PATCH] 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 --- .gitignore | 1 + eslint.config.mjs | 1 + package.json | 2 +- .../adapter-utils/src/server-utils-process.ts | 26 ++- src/main/agent-permissions.ts | 156 ++++++++++++++++- src/main/api-server/routes-agents.ts | 64 +++++-- src/main/database/budget.ts | 34 +++- src/main/database/entity-crud.ts | 1 + src/main/execution-mode-reconciler.ts | 160 ++++++++++++++++++ src/main/ipc-core-admin-handlers.ts | 17 +- src/main/ipc-core-agent-graph-handlers.ts | 41 ++++- src/main/orchestration/heartbeat.ts | 18 +- src/main/prompt-builder/api-section.ts | 2 +- .../operating-instructions-ceo.ts | 6 +- src/main/prompt-builder/sop-section.ts | 2 +- .../execution/ApprovalInspector.tsx | 2 +- .../components/execution/PolicyFormModal.tsx | 22 +-- .../components/organization/AgentCreator.tsx | 25 ++- .../components/organization/AgentEditor.tsx | 50 ++++-- src/renderer/lib/budget-policy-input.ts | 45 +++++ src/shared/contracts/schemas-agents.ts | 16 +- .../api-recursive-org-execution.spec.ts | 100 ++++++++++- tests/unit/agent-budget-policy-sync.test.ts | 112 ++++++++++++ tests/unit/agent-hire-routing.test.ts | 98 +++++++++++ tests/unit/agent-schema-validation.test.ts | 59 +++++++ tests/unit/budget-policy-input.test.ts | 41 +++++ tests/unit/command-paths.test.ts | 25 ++- tests/unit/company-mode-switch.test.ts | 151 +++++++++++++++++ tests/unit/ipc-core-admin-handlers.test.ts | 1 + 29 files changed, 1193 insertions(+), 85 deletions(-) create mode 100644 src/main/execution-mode-reconciler.ts create mode 100644 src/renderer/lib/budget-policy-input.ts create mode 100644 tests/unit/agent-budget-policy-sync.test.ts create mode 100644 tests/unit/agent-hire-routing.test.ts create mode 100644 tests/unit/agent-schema-validation.test.ts create mode 100644 tests/unit/budget-policy-input.test.ts create mode 100644 tests/unit/company-mode-switch.test.ts diff --git a/.gitignore b/.gitignore index dc216ea0..7077044d 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ pnpm-debug.log* yarn-debug.log* yarn-error.log* local-release/ +.omx/ diff --git a/eslint.config.mjs b/eslint.config.mjs index d136bede..ca52ac50 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default tseslint.config( "dist/**", "oldreference/**", "Reference/**", + "claude-code-source-reference/**", "boss-build/**", "local-release/**", "builtin-skills/**", diff --git a/package.json b/package.json index 338660a9..d71b3242 100644 --- a/package.json +++ b/package.json @@ -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.", diff --git a/packages/adapter-utils/src/server-utils-process.ts b/packages/adapter-utils/src/server-utils-process.ts index 215d4542..f00b365f 100644 --- a/packages/adapter-utils/src/server-utils-process.ts +++ b/packages/adapter-utils/src/server-utils-process.ts @@ -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 { diff --git a/src/main/agent-permissions.ts b/src/main/agent-permissions.ts index dd70d934..65e9bb59 100644 --- a/src/main/agent-permissions.ts +++ b/src/main/agent-permissions.ts @@ -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; diff --git a/src/main/api-server/routes-agents.ts b/src/main/api-server/routes-agents.ts index 4fd5cbb2..c4d6f656 100644 --- a/src/main/api-server/routes-agents.ts +++ b/src/main/api-server/routes-agents.ts @@ -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, }, diff --git a/src/main/database/budget.ts b/src/main/database/budget.ts index ae04b13e..97747dba 100644 --- a/src/main/database/budget.ts +++ b/src/main/database/budget.ts @@ -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; }, diff --git a/src/main/database/entity-crud.ts b/src/main/database/entity-crud.ts index e1ac751a..455e48d7 100644 --- a/src/main/database/entity-crud.ts +++ b/src/main/database/entity-crud.ts @@ -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; }, diff --git a/src/main/execution-mode-reconciler.ts b/src/main/execution-mode-reconciler.ts new file mode 100644 index 00000000..c064c533 --- /dev/null +++ b/src/main/execution-mode-reconciler.ts @@ -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 { + const agentIds = new Set(); + 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 { + const agentIds = new Set(); + 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): 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([ + ...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); +} diff --git a/src/main/ipc-core-admin-handlers.ts b/src/main/ipc-core-admin-handlers.ts index 57309521..5cbb01f7 100644 --- a/src/main/ipc-core-admin-handlers.ts +++ b/src/main/ipc-core-admin-handlers.ts @@ -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", diff --git a/src/main/ipc-core-agent-graph-handlers.ts b/src/main/ipc-core-agent-graph-handlers.ts index 6fc32b1f..24e2924c 100644 --- a/src/main/ipc-core-agent-graph-handlers.ts +++ b/src/main/ipc-core-agent-graph-handlers.ts @@ -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, }, diff --git a/src/main/orchestration/heartbeat.ts b/src/main/orchestration/heartbeat.ts index 40c74aa2..d9057cb4 100644 --- a/src/main/orchestration/heartbeat.ts +++ b/src/main/orchestration/heartbeat.ts @@ -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; } diff --git a/src/main/prompt-builder/api-section.ts b/src/main/prompt-builder/api-section.ts index 8bce5194..58052dd1 100644 --- a/src/main/prompt-builder/api-section.ts +++ b/src/main/prompt-builder/api-section.ts @@ -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", diff --git a/src/main/prompt-builder/operating-instructions-ceo.ts b/src/main/prompt-builder/operating-instructions-ceo.ts index f6299c6f..3eb0f546 100644 --- a/src/main/prompt-builder/operating-instructions-ceo.ts +++ b/src/main/prompt-builder/operating-instructions-ceo.ts @@ -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 diff --git a/src/main/prompt-builder/sop-section.ts b/src/main/prompt-builder/sop-section.ts index d8ed318e..b2a1142e 100644 --- a/src/main/prompt-builder/sop-section.ts +++ b/src/main/prompt-builder/sop-section.ts @@ -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 diff --git a/src/renderer/components/execution/ApprovalInspector.tsx b/src/renderer/components/execution/ApprovalInspector.tsx index 021441b0..713ce2fc 100644 --- a/src/renderer/components/execution/ApprovalInspector.tsx +++ b/src/renderer/components/execution/ApprovalInspector.tsx @@ -215,7 +215,7 @@ export function ApprovalInspector({ approval, agents, tasks, documents, companyI {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 : 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 (
Proposed Hire
-
Name: {proposedAgent?.name ?? approval.payloadSummary.replace(/^Hire\s+/i, "").split(/\s+as\s+/i)[0] ?? "Pending hire"}
+
Name: {proposedAgent?.name ?? (typeof reqSnap?.name === "string" ? reqSnap.name : "Unspecified")}
Role: {proposedAgent?.role ?? (typeof reqSnap?.role === "string" ? reqSnap.role : "Unspecified")}
{(proposedAgent?.title || typeof reqSnap?.title === "string") ?
Title: {proposedAgent?.title || reqSnap?.title as string}
: null} {(proposedAgent?.capabilities || typeof reqSnap?.capabilities === "string") ?
Capabilities: {proposedAgent?.capabilities || reqSnap?.capabilities as string}
: null} diff --git a/src/renderer/components/execution/PolicyFormModal.tsx b/src/renderer/components/execution/PolicyFormModal.tsx index ea278eb9..dab551e2 100644 --- a/src/renderer/components/execution/PolicyFormModal.tsx +++ b/src/renderer/components/execution/PolicyFormModal.tsx @@ -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(); diff --git a/src/renderer/components/organization/AgentCreator.tsx b/src/renderer/components/organization/AgentCreator.tsx index 5590e31a..05dddc3f 100644 --- a/src/renderer/components/organization/AgentCreator.tsx +++ b/src/renderer/components/organization/AgentCreator.tsx @@ -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(""); const [connectorId, setConnectorId] = useState(executionReadyConnectors[0]?.id ?? ""); const [model, setModel] = useState(""); const [availableModels, setAvailableModels] = useState>([]); @@ -69,6 +71,7 @@ export function AgentCreator({ const t = useT(); const [showAdvanced, setShowAdvanced] = useState(false); + const selectedDepartment: DepartmentId | null = department === "" ? null : department; return (
@@ -114,6 +117,15 @@ export function AgentCreator({ {showAdvanced ? ( + (agent.department ?? ""); const [status, setStatus] = useState(agent.status); const [connectorId, setConnectorId] = useState(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({ + 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); +} diff --git a/src/shared/contracts/schemas-agents.ts b/src/shared/contracts/schemas-agents.ts index 4ddc6037..976b55a3 100644 --- a/src/shared/contracts/schemas-agents.ts +++ b/src/shared/contracts/schemas-agents.ts @@ -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, diff --git a/tests/integration/api-recursive-org-execution.spec.ts b/tests/integration/api-recursive-org-execution.spec.ts index 6ad96cba..10a48c23 100644 --- a/tests/integration/api-recursive-org-execution.spec.ts +++ b/tests/integration/api-recursive-org-execution.spec.ts @@ -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(); diff --git a/tests/unit/agent-budget-policy-sync.test.ts b/tests/unit/agent-budget-policy-sync.test.ts new file mode 100644 index 00000000..b4c36e21 --- /dev/null +++ b/tests/unit/agent-budget-policy-sync.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/agent-hire-routing.test.ts b/tests/unit/agent-hire-routing.test.ts new file mode 100644 index 00000000..2a615dd1 --- /dev/null +++ b/tests/unit/agent-hire-routing.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/agent-schema-validation.test.ts b/tests/unit/agent-schema-validation.test.ts new file mode 100644 index 00000000..08ff2bea --- /dev/null +++ b/tests/unit/agent-schema-validation.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/budget-policy-input.test.ts b/tests/unit/budget-policy-input.test.ts new file mode 100644 index 00000000..9ead8123 --- /dev/null +++ b/tests/unit/budget-policy-input.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/command-paths.test.ts b/tests/unit/command-paths.test.ts index 5a034338..6d710842 100644 --- a/tests/unit/command-paths.test.ts +++ b/tests/unit/command-paths.test.ts @@ -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"); + }); }); diff --git a/tests/unit/company-mode-switch.test.ts b/tests/unit/company-mode-switch.test.ts new file mode 100644 index 00000000..cc913654 --- /dev/null +++ b/tests/unit/company-mode-switch.test.ts @@ -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 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(); + }); +}); diff --git a/tests/unit/ipc-core-admin-handlers.test.ts b/tests/unit/ipc-core-admin-handlers.test.ts index 64e4635e..392eb0b2 100644 --- a/tests/unit/ipc-core-admin-handlers.test.ts +++ b/tests/unit/ipc-core-admin-handlers.test.ts @@ -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(),