refactor(approvals): move execution policy into database
This commit is contained in:
1 parent
f8eb57ad8c
commit
9ee1425a71
12 files changed
+433
-101
No files matched your search
@@ -3,11 +3,15 @@ import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { ApprovalRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { normalizeOptionalId, nowIso, parseJson } from "./helpers";
|
||||
import {
|
||||
COFOUNDER_MANDATORY_APPROVAL_TYPES,
|
||||
OPERATIONAL_APPROVAL_TYPES,
|
||||
} from "../execution-policy";
|
||||
import { normalizeOptionalId, nowIso, parseJson, toBoolean } from "./helpers";
|
||||
|
||||
export interface ApprovalPolicyRecord {
|
||||
approvalType: string;
|
||||
scopeType: string;
|
||||
blocksExecution: boolean;
|
||||
requiredMode: "any" | "ai_ceo" | "ai_cofounder";
|
||||
autoApprovable: boolean;
|
||||
}
|
||||
|
||||
export function buildApprovalMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
@@ -239,36 +243,65 @@ export function buildApprovalMethods(
|
||||
return readAll(sql, ...args);
|
||||
},
|
||||
|
||||
getApprovalPolicy(approvalType: string): ApprovalPolicyRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select approval_type as approvalType, scope_type as scopeType, blocks_execution as blocksExecution, required_mode as requiredMode, auto_approvable as autoApprovable from approval_policies where approval_type = ?",
|
||||
)
|
||||
.get(approvalType) as {
|
||||
approvalType: string;
|
||||
scopeType: string;
|
||||
blocksExecution: number;
|
||||
requiredMode: "any" | "ai_ceo" | "ai_cofounder";
|
||||
autoApprovable: number;
|
||||
} | undefined;
|
||||
return row
|
||||
? {
|
||||
approvalType: row.approvalType,
|
||||
scopeType: row.scopeType,
|
||||
blocksExecution: toBoolean(row.blocksExecution),
|
||||
requiredMode: row.requiredMode,
|
||||
autoApprovable: toBoolean(row.autoApprovable),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
|
||||
getBlockingApproval(companyId: string, agentId: string, taskId: string | null): ApprovalRecord | null {
|
||||
const taskCondition = taskId != null
|
||||
? "(related_task_id = ? or requested_by_agent_id = ?)"
|
||||
: "requested_by_agent_id = ?";
|
||||
const taskParams: SQLInputValue[] = taskId != null ? [taskId, agentId] : [agentId];
|
||||
const operationalApprovalPlaceholders = OPERATIONAL_APPROVAL_TYPES.map(() => "?").join(", ");
|
||||
const cofounderApprovalPlaceholders = COFOUNDER_MANDATORY_APPROVAL_TYPES.map(() => "?").join(", ");
|
||||
const company = getDb()
|
||||
.prepare("select execution_mode as executionMode from companies where id = ?")
|
||||
.get(companyId) as { executionMode: "ai_ceo" | "ai_cofounder" } | undefined;
|
||||
if (!company) {
|
||||
throw new Error(`Company not found for approval gate: ${companyId}`);
|
||||
}
|
||||
const rows = readAll<ApprovalRecord>(
|
||||
`select id, company_id as companyId, related_task_id as relatedTaskId,
|
||||
requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId,
|
||||
type, payload_summary as payloadSummary, impact_summary as impactSummary,
|
||||
a.type, payload_summary as payloadSummary, impact_summary as impactSummary,
|
||||
payload_json as payloadJson, decision_note as decisionNote, state,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
from approvals
|
||||
where company_id = ? and state = 'pending'
|
||||
a.created_at as createdAt, a.updated_at as updatedAt
|
||||
from approvals a
|
||||
inner join approval_policies p on p.approval_type = a.type
|
||||
where a.company_id = ?
|
||||
and a.state = 'pending'
|
||||
and p.blocks_execution = 1
|
||||
and (p.required_mode = 'any' or p.required_mode = ?)
|
||||
and (
|
||||
(type = 'hire_agent' and related_agent_id = ?)
|
||||
or (type = 'dangerous_command' and ${taskCondition})
|
||||
or (type = 'secret_access' and ${taskCondition})
|
||||
or (type = 'approve_ceo_strategy' and ${taskCondition})
|
||||
or (type = 'deliverable_review' and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition})))
|
||||
or (type in (${operationalApprovalPlaceholders}) and ${taskCondition})
|
||||
or (type in (${cofounderApprovalPlaceholders}) and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition})))
|
||||
(p.scope_type = 'related_agent' and a.related_agent_id = ?)
|
||||
or (
|
||||
p.scope_type = 'requester_or_task'
|
||||
and (
|
||||
a.requested_by_agent_id = ?
|
||||
or (? is not null and a.related_task_id = ?)
|
||||
)
|
||||
)
|
||||
)
|
||||
order by created_at asc limit 1`,
|
||||
companyId, agentId,
|
||||
...taskParams, ...taskParams, ...taskParams,
|
||||
agentId, ...taskParams,
|
||||
...OPERATIONAL_APPROVAL_TYPES, ...taskParams,
|
||||
...COFOUNDER_MANDATORY_APPROVAL_TYPES, agentId, ...taskParams,
|
||||
order by a.created_at asc limit 1`,
|
||||
companyId,
|
||||
company.executionMode,
|
||||
agentId,
|
||||
agentId,
|
||||
taskId,
|
||||
taskId,
|
||||
);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { APPROVAL_POLICY_SEED } from "../../approval-policy-definitions";
|
||||
import { nowIso, tableExists } from "./types";
|
||||
|
||||
export const APPROVAL_POLICY_SCHEMA_VERSION = 58;
|
||||
export const APPROVAL_POLICY_SCHEMA_NAME = "approval_policy_table";
|
||||
|
||||
export function migrateApprovalPoliciesV58(db: DatabaseSync): void {
|
||||
installApprovalPoliciesTable(db);
|
||||
seedApprovalPolicies(db);
|
||||
db
|
||||
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(APPROVAL_POLICY_SCHEMA_VERSION, APPROVAL_POLICY_SCHEMA_NAME, nowIso());
|
||||
assertApprovalPoliciesV58(db);
|
||||
}
|
||||
|
||||
export function assertApprovalPoliciesV58(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "approval_policies")) {
|
||||
throw new Error("Schema v58 drift: approval_policies table is missing.");
|
||||
}
|
||||
|
||||
const columns = db
|
||||
.prepare("pragma table_info(approval_policies)")
|
||||
.all()
|
||||
.map((row) => row as { name: string; type: string; notnull: number; pk: number });
|
||||
const actualColumns = columns.map((column) => [
|
||||
column.name,
|
||||
column.type.toUpperCase(),
|
||||
column.notnull,
|
||||
column.pk,
|
||||
]);
|
||||
const expectedColumns = [
|
||||
["approval_type", "TEXT", 0, 1],
|
||||
["scope_type", "TEXT", 1, 0],
|
||||
["blocks_execution", "INTEGER", 1, 0],
|
||||
["required_mode", "TEXT", 1, 0],
|
||||
["auto_approvable", "INTEGER", 1, 0],
|
||||
["created_at", "TEXT", 1, 0],
|
||||
["updated_at", "TEXT", 1, 0],
|
||||
];
|
||||
if (JSON.stringify(actualColumns) !== JSON.stringify(expectedColumns)) {
|
||||
throw new Error("Schema v58 drift: approval_policies columns do not match the canonical schema.");
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
"select approval_type as approvalType, scope_type as scopeType, blocks_execution as blocksExecution, required_mode as requiredMode, auto_approvable as autoApprovable from approval_policies order by approval_type",
|
||||
)
|
||||
.all() as Array<{
|
||||
approvalType: string;
|
||||
scopeType: string;
|
||||
blocksExecution: number;
|
||||
requiredMode: string;
|
||||
autoApprovable: number;
|
||||
}>;
|
||||
const actual = rows.map((row) => ({
|
||||
approvalType: row.approvalType,
|
||||
scopeType: row.scopeType,
|
||||
blocksExecution: row.blocksExecution,
|
||||
requiredMode: row.requiredMode,
|
||||
autoApprovable: row.autoApprovable,
|
||||
}));
|
||||
const expected = [...APPROVAL_POLICY_SEED]
|
||||
.sort((a, b) => a.approvalType.localeCompare(b.approvalType))
|
||||
.map((policy) => ({
|
||||
approvalType: policy.approvalType,
|
||||
scopeType: policy.scopeType,
|
||||
blocksExecution: policy.blocksExecution ? 1 : 0,
|
||||
requiredMode: policy.requiredMode,
|
||||
autoApprovable: policy.autoApprovable ? 1 : 0,
|
||||
}));
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error("Schema v58 drift: approval_policies seed rows do not match the canonical policy.");
|
||||
}
|
||||
}
|
||||
|
||||
function installApprovalPoliciesTable(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
create table if not exists approval_policies (
|
||||
approval_type text primary key,
|
||||
scope_type text not null check (scope_type in ('requester_or_task', 'related_agent')),
|
||||
blocks_execution integer not null check (blocks_execution in (0, 1)),
|
||||
required_mode text not null check (required_mode in ('any', 'ai_ceo', 'ai_cofounder')),
|
||||
auto_approvable integer not null check (auto_approvable in (0, 1)),
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function seedApprovalPolicies(db: DatabaseSync): void {
|
||||
const ts = nowIso();
|
||||
const stmt = db.prepare(
|
||||
`insert or replace into approval_policies (
|
||||
approval_type, scope_type, blocks_execution, required_mode, auto_approvable, created_at, updated_at
|
||||
) values (?, ?, ?, ?, ?, coalesce((select created_at from approval_policies where approval_type = ?), ?), ?)`,
|
||||
);
|
||||
for (const policy of APPROVAL_POLICY_SEED) {
|
||||
stmt.run(
|
||||
policy.approvalType,
|
||||
policy.scopeType,
|
||||
policy.blocksExecution ? 1 : 0,
|
||||
policy.requiredMode,
|
||||
policy.autoApprovable ? 1 : 0,
|
||||
policy.approvalType,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,17 @@ import {
|
||||
assertTaskLeasesV57,
|
||||
migrateTaskLeasesV57,
|
||||
} from "./migrations/v57-task-leases";
|
||||
import {
|
||||
APPROVAL_POLICY_SCHEMA_VERSION,
|
||||
assertApprovalPoliciesV58,
|
||||
migrateApprovalPoliciesV58,
|
||||
} from "./migrations/v58-approval-policies";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
} from "./migrations/types";
|
||||
|
||||
const LATEST_SCHEMA_VERSION = TASK_LEASE_SCHEMA_VERSION;
|
||||
const LATEST_SCHEMA_VERSION = APPROVAL_POLICY_SCHEMA_VERSION;
|
||||
|
||||
export function initializeDatabase(
|
||||
db: DatabaseSync,
|
||||
@@ -38,6 +43,16 @@ export function initializeDatabase(
|
||||
tx(() => {
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
assertApprovalPoliciesV58(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentVersion === TASK_LEASE_SCHEMA_VERSION) {
|
||||
tx(() => {
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -49,6 +64,7 @@ export function initializeDatabase(
|
||||
seedSettings(db);
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -61,6 +77,7 @@ export function initializeDatabase(
|
||||
seedSettings(db);
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -71,6 +88,7 @@ export function initializeDatabase(
|
||||
seedSettings(db);
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user