Gate task execution on approval source refs

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 21:52:52 -04:00
1 parent 4dbd8997c0
commit c59be97b79
12 files changed
+183 -16

No files matched your search

@@ -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'));
`;
@@ -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;
@@ -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,
@@ -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 {
@@ -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 {
@@ -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,
@@ -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 },
];
}
@@ -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;
}
@@ -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<string> {
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(`
+2
View File
@@ -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;
@@ -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();
});
@@ -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);