diff --git a/AgentCompany-New/src/main/database/migrations/030-approval-source-refs.ts b/AgentCompany-New/src/main/database/migrations/030-approval-source-refs.ts new file mode 100644 index 00000000..cfa26f9b --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/030-approval-source-refs.ts @@ -0,0 +1,49 @@ +export const approvalSourceRefsSchema = ` + alter table approvals add column source_entity_type text; + alter table approvals add column source_entity_id text; + + update approvals + set + source_entity_type = 'task', + source_entity_id = ( + select tasks.id + from tasks + where tasks.org_id = approvals.org_id + and tasks.title = approvals.title + and tasks.description = approvals.details + order by tasks.created_at desc + limit 1 + ) + where kind = 'task_risk' + and source_entity_id is null + and exists ( + select 1 + from tasks + where tasks.org_id = approvals.org_id + and tasks.title = approvals.title + and tasks.description = approvals.details + ); + + update approvals + set + source_entity_type = 'scheduled_post', + source_entity_id = ( + select scheduled_posts.id + from scheduled_posts + where scheduled_posts.approval_id = approvals.id + limit 1 + ) + where kind = 'social_publish' + and source_entity_id is null + and exists ( + select 1 + from scheduled_posts + where scheduled_posts.approval_id = approvals.id + ); + + create index if not exists idx_approvals_source + on approvals(org_id, source_entity_type, source_entity_id, kind, state); + + insert into schema_migrations(version, name, applied_at) + values (30, 'approval_source_refs', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index ff2c2f9f..9d6beb01 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -28,6 +28,7 @@ import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platfo import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records"; import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutover-journal"; import { fileHandoffRecordsSchema } from "./migrations/029-file-handoff-records"; +import { approvalSourceRefsSchema } from "./migrations/030-approval-source-refs"; interface Migration { version: number; @@ -65,6 +66,7 @@ const MIGRATIONS: Migration[] = [ { version: 27, name: "social_audit_records", sql: socialAuditRecordsSchema }, { version: 28, name: "migration_cutover_journal", sql: migrationCutoverJournalSchema }, { version: 29, name: "file_handoff_records", sql: fileHandoffRecordsSchema }, + { version: 30, name: "approval_source_refs", sql: approvalSourceRefsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/approval-service.ts b/AgentCompany-New/src/main/services/approval-service.ts index 86921919..89bad10a 100644 --- a/AgentCompany-New/src/main/services/approval-service.ts +++ b/AgentCompany-New/src/main/services/approval-service.ts @@ -9,6 +9,8 @@ import { OrgGovernanceService } from "./org-governance-service"; interface ApprovalAuditContext { sourceMessageId?: string; sourceThreadId?: string; + sourceEntityType?: string; + sourceEntityId?: string; } export class ApprovalService { @@ -35,8 +37,8 @@ export class ApprovalService { this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, requested_by_id, - state, decided_by_employee_id, created_at, decided_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, input.orgId, @@ -46,6 +48,8 @@ export class ApprovalService { input.risk, input.requestedByType, input.requestedById, + context.sourceEntityType ?? null, + context.sourceEntityId ?? null, APPROVAL_STATE.PENDING, null, now, diff --git a/AgentCompany-New/src/main/services/board-intervention-service.ts b/AgentCompany-New/src/main/services/board-intervention-service.ts index 3f701f65..4a78b7cc 100644 --- a/AgentCompany-New/src/main/services/board-intervention-service.ts +++ b/AgentCompany-New/src/main/services/board-intervention-service.ts @@ -113,7 +113,12 @@ export class BoardInterventionService { risk: RISK_LEVEL.HIGH, requestedByType: requester.type, requestedById: requester.id, - }, { sourceMessageId: input.messageId, sourceThreadId: input.threadId }); + }, { + sourceMessageId: input.messageId, + sourceThreadId: input.threadId, + sourceEntityType: "chat_message", + sourceEntityId: input.messageId, + }); } private applyBoardThreadState(input: RecordBoardInterventionInput, command: BoardCommand, target: BoardTarget): void { diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 5abd1ca5..737364af 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -229,7 +229,7 @@ export class ChatService { risk: RISK_LEVEL.HIGH, requestedByType: requester.type, requestedById: requester.id, - }, { sourceMessageId: messageId, sourceThreadId: threadId }); + }, { sourceMessageId: messageId, sourceThreadId: threadId, sourceEntityType: "chat_message", sourceEntityId: messageId }); } private recordSlashInvocation(messageId: string, slash: SlashInvocation, resultRef: string | null, now: string): void { diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index 73fceda1..69d64f5b 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -190,8 +190,8 @@ export class OrganizationService { this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, - requested_by_id, state, decided_by_employee_id, created_at, decided_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( approvalId, input.orgId, @@ -201,6 +201,8 @@ export class OrganizationService { RISK_LEVEL.MEDIUM, "system", null, + "agent", + id, APPROVAL_STATE.PENDING, null, now, @@ -242,28 +244,32 @@ export class OrganizationService { now, ); const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk); - if (policy.approvalRequired) { + const approvalRequired = policy.approvalRequired || input.requiresFilesystem; + if (approvalRequired) { const approvalId = createId("app"); + const approvalKind = policy.approvalRequired ? "task_risk" : "task_execution"; this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, - requested_by_id, state, decided_by_employee_id, created_at, decided_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( approvalId, input.orgId, - "task_risk", + approvalKind, input.title.trim(), input.description.trim(), RISK_LEVEL.HIGH, "system", null, + "task", + id, APPROVAL_STATE.PENDING, null, now, null, ); - this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: "task_risk", risk: RISK_LEVEL.HIGH }); + this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: approvalKind, risk: RISK_LEVEL.HIGH }); } this.audit.record({ orgId: input.orgId, diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index 15dfcd64..00d87023 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -262,7 +262,7 @@ export class SdlcService { { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, { key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry }, { key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady }, - { key: "schema_version", passed: schemaVersion >= 29, value: schemaVersion }, + { key: "schema_version", passed: schemaVersion >= 30, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/src/main/services/social-service.ts b/AgentCompany-New/src/main/services/social-service.ts index f6e08bb8..0bb3e91e 100644 --- a/AgentCompany-New/src/main/services/social-service.ts +++ b/AgentCompany-New/src/main/services/social-service.ts @@ -352,7 +352,7 @@ export class SocialService { risk: policy.risk_level, requestedByType: "employee", requestedById: employeeId, - }); + }, { sourceEntityType: "scheduled_post", sourceEntityId: post.id }); this.assignSocialApprovalRoute(orgId, approvalId, route); return approvalId; } diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index 2ee6567f..c177f26a 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import type { RunTaskInput } from "../../shared/commands"; -import { PROVIDER_EXECUTION_MODE } from "../../shared/domain"; +import { APPROVAL_STATE, PROVIDER_EXECUTION_MODE, RISK_LEVEL } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; import { ProviderService } from "./provider-service"; @@ -61,6 +61,11 @@ interface WorkspaceRow { repo_ref: string; } +interface TaskApprovalRow { + id: string; + state: string; +} + export class TaskExecutionService { private readonly audit: AuditService; @@ -73,6 +78,7 @@ export class TaskExecutionService { async runTask(input: RunTaskInput): Promise { const task = this.requireTask(input.orgId, input.taskId); + this.assertExecutionApproval(input.orgId, task); const agent = this.requireAssignedAgent(input.orgId, task); const provider = this.requireAgentProvider(input.orgId, agent); const runId = this.createRun(input.orgId, task.id, agent.id, provider.id); @@ -141,6 +147,23 @@ export class TaskExecutionService { return agent; } + private assertExecutionApproval(orgId: string, task: TaskExecutionTaskRow): void { + if (task.risk !== RISK_LEVEL.HIGH && task.requires_filesystem !== 1) return; + const approval = this.db.prepare(` + select id, state + from approvals + where org_id = ? + and source_entity_type = 'task' + and source_entity_id = ? + and kind in ('task_risk', 'task_execution') + order by created_at desc + limit 1 + `).get(orgId, task.id) as TaskApprovalRow | undefined; + if (!approval || approval.state !== APPROVAL_STATE.APPROVED) { + throw new Error("Approved task execution approval is required."); + } + } + private requireAgentProvider(orgId: string, agent: TaskExecutionAgentRow): TaskExecutionProviderRow { if (!agent.model_provider_id) throw new Error("Task assignee requires a model provider."); const provider = this.db.prepare(` diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 0cfaab51..c3b7a096 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -159,6 +159,8 @@ export interface ApprovalRecord { risk: RiskLevel; requestedByType: "employee" | "agent" | "system"; requestedById: string | null; + sourceEntityType: string | null; + sourceEntityId: string | null; state: ApprovalState; decidedByEmployeeId: string | null; createdAt: string; diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 0e5237df..54b511ae 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]); connection.close(); }); @@ -93,6 +93,7 @@ describe("domain services", () => { expect(state.approvals).toHaveLength(1); expect(state.approvalAssignments).toHaveLength(1); expect(state.approvals[0]?.state).toBe("pending"); + expect(state.approvals[0]).toMatchObject({ sourceEntityType: "task", sourceEntityId: state.tasks[0]?.id }); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/task-execution-service.test.ts b/AgentCompany-New/tests/unit/task-execution-service.test.ts index eaefb173..107b6244 100644 --- a/AgentCompany-New/tests/unit/task-execution-service.test.ts +++ b/AgentCompany-New/tests/unit/task-execution-service.test.ts @@ -3,12 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; +import { ApprovalService } from "../../src/main/services/approval-service"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ProviderService } from "../../src/main/services/provider-service"; import { providerAdapters, type ProviderAdapterRegistry } from "../../src/main/services/provider-adapters"; import { SnapshotService } from "../../src/main/services/snapshot-service"; import { TaskExecutionService } from "../../src/main/services/task-execution-service"; -import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; +import { APPROVAL_STATE, RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; function createConnection(): DatabaseConnection { return new DatabaseConnection(join(tmpdir(), `agentcompany-new-task-execution-${randomUUID()}.db`)); @@ -97,6 +98,80 @@ describe("task execution service", () => { connection.close(); }); + test("requires approved execution approval for high risk tasks", async () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const approvals = new ApprovalService(connection.db); + const snapshot = new SnapshotService(connection.db); + const adapters: ProviderAdapterRegistry = { + ...providerAdapters, + openai: { + ...providerAdapters.openai, + createResponse: async ({ model }) => ({ + text: `executed:${model}`, + rawResponseJson: JSON.stringify({ id: "resp-task-approved" }), + rateLimitState: null, + }), + }, + }; + const providers = new ProviderService(connection.db, adapters, testVault); + const execution = new TaskExecutionService(connection.db, providers); + let orgId = ""; + let employeeId = ""; + let taskId = ""; + let approvalId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + const providerId = openAiProviderId(connection, orgId); + providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" }); + const agentId = organization.createAgent({ + orgId, + name: "Ada", + role: "Execution lead", + department: "Engineering", + modelProviderId: providerId, + modelId: "gpt-5.4", + budgetCents: 10000, + managerAgentId: null, + }); + taskId = organization.createTask({ + orgId, + title: "Production deploy", + description: "Deploy a high-risk release.", + status: TASK_STATUS.IN_PROGRESS, + assigneeAgentId: agentId, + risk: RISK_LEVEL.HIGH, + projectId: null, + goalId: null, + workspaceId: null, + priority: TASK_PRIORITY.HIGH, + requiresFilesystem: true, + }); + approvalId = (connection.db.prepare(` + select id from approvals + where org_id = ? and source_entity_type = 'task' and source_entity_id = ? + `).get(orgId, taskId) as { id: string }).id; + }); + + await expect(execution.runTask({ orgId, taskId })).rejects.toThrow("Approved task execution approval is required."); + expect(connection.db.prepare("select count(*) as count from task_runs").get()).toEqual({ count: 0 }); + + connection.transaction(() => approvals.decide({ orgId, approvalId, state: APPROVAL_STATE.APPROVED, decidedByEmployeeId: employeeId })); + const runId = await execution.runTask({ orgId, taskId }); + const state = snapshot.load(); + + expect(state.approvals.find((approval) => approval.id === approvalId)).toMatchObject({ state: APPROVAL_STATE.APPROVED }); + expect(state.taskRuns.find((run) => run.id === runId)).toMatchObject({ taskId, state: "completed" }); + connection.close(); + }); + test("marks task runs failed when provider execution fails", async () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);