Gate task execution on approval source refs
This commit is contained in:
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(`
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user