From 82725641dfe5bcddb5c73d1336b0473aea27dd93 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 16 Oct 2026 17:32:41 -0500 Subject: [PATCH 1/2] fix(tasks): approving a proposed task keeps it isolated (0.11.0) Approval rewrote authority to 'operator', so the local task runner ran approved work -- often from untrusted issue bodies -- in the operator's own checkout with no branch or PR. Approval now records approved_at and approved_by (migration 0002) and keeps 'proposed'; the queue, sandbox dispatch and sandbox claim run approved proposed tasks, and the runner keeps isolating every non-operator task. Awaiting-approval listings (briefing, digest, task summary, dashboard, contract invariant) exclude approved tasks. Shared SQL in schema-enums. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++ web/migrations/0002_cc_tasks_approval.sql | 13 ++ web/package.json | 2 +- web/schema.sql | 4 +- web/src/contracts/cc-task.contract.ts | 6 +- web/src/dashboard.ts | 4 +- web/src/factory/dispatch.ts | 3 +- web/src/factory/executor-do.ts | 3 +- web/src/kernel/briefing.ts | 3 +- web/src/kernel/scheduled/digest.ts | 3 +- web/src/mcp/handlers.ts | 18 +-- web/src/routes/cc-tasks.ts | 13 +- web/src/schema-enums.ts | 11 ++ web/src/version.ts | 2 +- web/tests/cc-task-approval.test.ts | 143 ++++++++++++++++++++++ web/tests/cc-tasks.test.ts | 7 +- web/tests/factory/dispatch.test.ts | 2 +- web/tests/mcp.test.ts | 2 +- 18 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 web/migrations/0002_cc_tasks_approval.sql create mode 100644 web/tests/cc-task-approval.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a83b9..ee8daab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.11.0 (2026-10-16) + +### Fixed +- **Approving a proposed task no longer makes it an operator task.** Approval (REST `POST /api/cc-tasks/:id/approve`, MCP `aegis_approve_cc_task` and `aegis_batch_approve`) rewrote `authority` to `operator`. The local task runner runs operator tasks in the operator's own checkout with no branch or PR, so approved work, often generated from untrusted issue bodies, ran there unisolated, and nothing afterwards showed it had started as proposed. Approval now keeps `authority = 'proposed'` and records `approved_at` and `approved_by` (`rest` or `mcp`); a task can be approved once. The task queue (`/next`), sandbox dispatch and the sandbox claim run proposed tasks once approved, and the runner keeps giving every non-operator task its own worktree and PR. +- Awaiting-approval listings (briefing, digest, task summary, dashboard) and the contract invariant `proposed_task_needs_approval` count only proposed tasks not yet approved. The shared conditions are `TASK_RUNNABLE_SQL` and `TASK_AWAITING_APPROVAL_SQL` in `schema-enums.ts`. + +### Upgrade +- Existing databases: apply `migrations/0002_cc_tasks_approval.sql` (adds `approved_at`, `approved_by`) before deploying. Fresh installs get the columns from `schema.sql`. Tasks approved on earlier versions were already rewritten to `operator`; their original authority wasn't kept, so they can't be restored. + ## 0.10.0 (2026-10-16) ### Breaking diff --git a/web/migrations/0002_cc_tasks_approval.sql b/web/migrations/0002_cc_tasks_approval.sql new file mode 100644 index 0000000..a6d8479 --- /dev/null +++ b/web/migrations/0002_cc_tasks_approval.sql @@ -0,0 +1,13 @@ +-- 0.11.0: approving a proposed task records who and when, and keeps the +-- authority 'proposed' so the task still runs isolated (worktree + PR, +-- never in the operator's checkout). +-- +-- Apply to a database created before 0.11.0: +-- npx wrangler d1 execute --remote --file=migrations/0002_cc_tasks_approval.sql +-- Fresh installs get the columns from schema.sql and do not need this file. +-- +-- Tasks approved before 0.11.0 were rewritten to authority 'operator'; that +-- can't be undone here because the original authority wasn't kept. + +ALTER TABLE cc_tasks ADD COLUMN approved_at TEXT; +ALTER TABLE cc_tasks ADD COLUMN approved_by TEXT; diff --git a/web/package.json b/web/package.json index ff3c98f..98c8e42 100755 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@stackbilt/aegis-core", - "version": "0.10.0", + "version": "0.11.0", "description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.", "license": "Apache-2.0", "publishConfig": { diff --git a/web/schema.sql b/web/schema.sql index af04f64..4932acb 100755 --- a/web/schema.sql +++ b/web/schema.sql @@ -334,7 +334,9 @@ CREATE TABLE IF NOT EXISTS cc_tasks ( github_issue_number INTEGER, -- source issue number (repo-scoped) business_unit TEXT NOT NULL DEFAULT 'stackbilt', -- partition key for multi-BU operators executor TEXT NOT NULL DEFAULT 'claude_code' -- where it runs: claude_code (local runner) or do_sandbox (sandbox executor) - CHECK (executor IN ('claude_code', 'do_sandbox')) + CHECK (executor IN ('claude_code', 'do_sandbox')), + approved_at TEXT, -- when an operator approved a proposed task (authority stays 'proposed') + approved_by TEXT -- who approved it (e.g. 'mcp', 'rest') ); CREATE INDEX IF NOT EXISTS idx_cc_tasks_status ON cc_tasks(status, priority); diff --git a/web/src/contracts/cc-task.contract.ts b/web/src/contracts/cc-task.contract.ts index d91a10d..bd55eab 100644 --- a/web/src/contracts/cc-task.contract.ts +++ b/web/src/contracts/cc-task.contract.ts @@ -42,6 +42,8 @@ export const CCTaskContract = defineContract({ githubIssueRepo: z.string().nullable(), githubIssueNumber: z.number().int().positive().nullable(), businessUnit: z.string().min(1).default('stackbilt'), + approvedAt: z.string().nullable().default(null), + approvedBy: z.string().nullable().default(null), }), operations: { @@ -180,8 +182,8 @@ export const CCTaskContract = defineContract({ name: 'proposed_task_needs_approval', description: 'Proposed tasks must be approved before they can run', check: (entity: unknown) => { - const t = entity as { authority?: string; status?: string }; - if (t.authority === 'proposed' && t.status === 'running') { + const t = entity as { authority?: string; status?: string; approvedAt?: string | null }; + if (t.authority === 'proposed' && !t.approvedAt && t.status === 'running') { return 'Proposed tasks require approval before execution'; } return true; diff --git a/web/src/dashboard.ts b/web/src/dashboard.ts index 3f92f0d..be9ac91 100644 --- a/web/src/dashboard.ts +++ b/web/src/dashboard.ts @@ -123,7 +123,7 @@ export async function getDashboardData(db: D1Database, memoryBinding?: MemorySer db.prepare("SELECT COALESCE(SUM(cost), 0) as total FROM episodic_memory WHERE created_at > datetime('now', '-7 days')").first<{ total: number }>(), db.prepare("SELECT summary, outcome, cost, latency_ms, created_at FROM episodic_memory ORDER BY created_at DESC LIMIT 20").all<{ summary: string; outcome: string; cost: number; latency_ms: number; created_at: string }>(), db.prepare("SELECT severity, summary, created_at FROM heartbeat_results ORDER BY created_at DESC LIMIT 1").first<{ severity: string; summary: string; created_at: string }>(), - db.prepare("SELECT status, authority, COUNT(*) as c FROM cc_tasks WHERE status IN ('pending', 'running') GROUP BY status, authority").all<{ status: string; authority: string; c: number }>(), + db.prepare("SELECT status, CASE WHEN authority = 'proposed' AND approved_at IS NULL THEN 'proposed' ELSE 'runnable' END AS authority, COUNT(*) as c FROM cc_tasks WHERE status IN ('pending', 'running') GROUP BY 1, 2").all<{ status: string; authority: string; c: number }>(), db.prepare("SELECT status, COUNT(*) as c FROM cc_tasks WHERE completed_at > datetime('now', '-24 hours') GROUP BY status").all<{ status: string; c: number }>(), db.prepare("SELECT id, title, repo, status, authority, category, exit_code, pr_url, completed_at FROM cc_tasks ORDER BY COALESCE(completed_at, started_at, created_at) DESC LIMIT 10").all<{ id: string; title: string; repo: string; status: string; authority: string; category: string; exit_code: number | null; pr_url: string | null; completed_at: string | null }>(), ]); @@ -216,7 +216,7 @@ export async function getDashboardData(db: D1Database, memoryBinding?: MemorySer } : null, tasks: { pending: taskStatusRows.results.filter(r => r.status === 'pending' && r.authority !== 'proposed').reduce((s, r) => s + r.c, 0), - proposed: taskStatusRows.results.filter(r => r.authority === 'proposed').reduce((s, r) => s + r.c, 0), + proposed: taskStatusRows.results.filter(r => r.status === 'pending' && r.authority === 'proposed').reduce((s, r) => s + r.c, 0), running: taskStatusRows.results.filter(r => r.status === 'running').reduce((s, r) => s + r.c, 0), completed24h: taskCompletedRows.results.find(r => r.status === 'completed')?.c ?? 0, failed24h: taskCompletedRows.results.find(r => r.status === 'failed')?.c ?? 0, diff --git a/web/src/factory/dispatch.ts b/web/src/factory/dispatch.ts index 864fcb1..d210d11 100644 --- a/web/src/factory/dispatch.ts +++ b/web/src/factory/dispatch.ts @@ -12,6 +12,7 @@ import type { ScheduledTaskPlugin } from '../core.js'; import type { EdgeEnv } from '../kernel/dispatch.js'; +import { TASK_RUNNABLE_SQL } from '../schema-enums.js'; interface RunResponse { ok?: boolean; @@ -25,7 +26,7 @@ export async function runTaskExecutorDispatch(env: EdgeEnv): Promise { SELECT id FROM cc_tasks WHERE status = 'pending' AND executor = 'do_sandbox' - AND authority IN ('auto_safe', 'operator') + AND ${TASK_RUNNABLE_SQL} LIMIT 1 `).first<{ id: string }>(); if (!eligible) return; diff --git a/web/src/factory/executor-do.ts b/web/src/factory/executor-do.ts index 2d4d033..36c3891 100644 --- a/web/src/factory/executor-do.ts +++ b/web/src/factory/executor-do.ts @@ -56,6 +56,7 @@ import { type ExternalRepoPolicy, type TaskRepoTarget, } from './repo.js'; +import { TASK_RUNNABLE_SQL } from '../schema-enums.js'; /** Bindings and secrets the executor reads. Declare them in your Worker's wrangler config. */ export interface TaskExecutorEnv { @@ -518,7 +519,7 @@ export function createTaskExecutorDO(config: TaskExecutorConfig) { SELECT id FROM cc_tasks WHERE status = 'pending' AND executor = 'do_sandbox' - AND authority IN ('auto_safe', 'operator') + AND ${TASK_RUNNABLE_SQL} ORDER BY priority ASC, created_at ASC LIMIT 1 ) diff --git a/web/src/kernel/briefing.ts b/web/src/kernel/briefing.ts index 1760dd7..734da43 100755 --- a/web/src/kernel/briefing.ts +++ b/web/src/kernel/briefing.ts @@ -4,6 +4,7 @@ // No fluff, no preamble. Answer before the question is asked. import type { EdgeEnv } from './dispatch.js'; +import { TASK_AWAITING_APPROVAL_SQL } from '../schema-enums.js'; export interface Briefing { // What happened while you were away @@ -47,7 +48,7 @@ export async function generateBriefing(env: EdgeEnv): Promise { // Proposed tasks awaiting approval env.db.prepare( - "SELECT title, repo FROM cc_tasks WHERE authority = 'proposed' AND status = 'pending' ORDER BY created_at ASC LIMIT 5" + `SELECT title, repo FROM cc_tasks WHERE ${TASK_AWAITING_APPROVAL_SQL} AND status = 'pending' ORDER BY created_at ASC LIMIT 5` ).all<{ title: string; repo: string }>(), // Currently running diff --git a/web/src/kernel/scheduled/digest.ts b/web/src/kernel/scheduled/digest.ts index 10d6c07..fa09baa 100644 --- a/web/src/kernel/scheduled/digest.ts +++ b/web/src/kernel/scheduled/digest.ts @@ -10,6 +10,7 @@ import { type DigestServiceAlert, type DigestSections, } from '../../email.js'; +import { TASK_AWAITING_APPROVAL_SQL } from '../../schema-enums.js'; // ─── Daily Digest ────────────────────────────────────────────── // Consolidates operator notifications into one daily email at 09:00 UTC (4:00 AM CT). @@ -168,7 +169,7 @@ export async function runDailyDigest(env: EdgeEnv): Promise { "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE status = 'failed' AND completed_at > ? ORDER BY completed_at DESC" ).bind(since).all(), env.db.prepare( - "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE authority = 'proposed' AND status = 'pending' ORDER BY created_at ASC" + `SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE ${TASK_AWAITING_APPROVAL_SQL} AND status = 'pending' ORDER BY created_at ASC` ).all(), ]); diff --git a/web/src/mcp/handlers.ts b/web/src/mcp/handlers.ts index 41fe211..fac8c26 100755 --- a/web/src/mcp/handlers.ts +++ b/web/src/mcp/handlers.ts @@ -6,7 +6,7 @@ import { generateDecisionDoc } from '../decision-docs.js'; import { createDynamicTool, getDynamicTool, listDynamicTools, executeDynamicTool, invalidateToolCache } from '../kernel/dynamic-tools.js'; import { buildEdgeEnv } from '../edge-env.js'; import type { MessageMetadata } from '../types.js'; -import { TASK_CATEGORIES, TASK_AUTHORITIES, TASK_EXECUTORS, validateEnum } from '../schema-enums.js'; +import { TASK_CATEGORIES, TASK_AUTHORITIES, TASK_EXECUTORS, TASK_RUNNABLE_SQL, TASK_AWAITING_APPROVAL_SQL, validateEnum } from '../schema-enums.js'; import { acceptanceAdmissionError, parseAcceptanceSpec } from '../factory/acceptance.js'; // ─── Tool Handler Types ────────────────────────────────────── @@ -388,13 +388,13 @@ export async function toolAegisApproveCcTask(args: Record, env: if (!id) return { content: [{ type: 'text', text: 'Error: id is required' }], isError: true }; const result = await env.db.prepare( - "UPDATE cc_tasks SET authority = 'operator' WHERE id = ? AND authority = 'proposed' AND status = 'pending'" + "UPDATE cc_tasks SET approved_at = datetime('now'), approved_by = 'mcp' WHERE id = ? AND authority = 'proposed' AND approved_at IS NULL AND status = 'pending'" ).bind(id).run(); if (result.meta.changes === 0) { - return { content: [{ type: 'text', text: `Task ${id} not found, not proposed, or not pending` }], isError: true }; + return { content: [{ type: 'text', text: `Task ${id} not found, not awaiting approval, or not pending` }], isError: true }; } - return { content: [{ type: 'text', text: `Approved task ${id} — now eligible for autonomous execution` }] }; + return { content: [{ type: 'text', text: `Approved task ${id} — now eligible to run, isolated on its own branch; it lands as a PR for review` }] }; } export async function toolAegisTaskSummary(env: EdgeEnv): Promise { @@ -409,12 +409,12 @@ export async function toolAegisTaskSummary(env: EdgeEnv): Promise { // Proposed tasks awaiting approval const proposed = await env.db.prepare( - "SELECT id, title, repo, category, priority, created_at FROM cc_tasks WHERE authority = 'proposed' AND status = 'pending' ORDER BY priority ASC, created_at ASC" + `SELECT id, title, repo, category, priority, created_at FROM cc_tasks WHERE ${TASK_AWAITING_APPROVAL_SQL} AND status = 'pending' ORDER BY priority ASC, created_at ASC` ).all(); // Pending tasks ready to run (approved, not proposed) const pending = await env.db.prepare( - "SELECT id, title, repo, authority, category, priority, preflight_json FROM cc_tasks WHERE status = 'pending' AND authority != 'proposed' ORDER BY priority ASC, created_at ASC" + `SELECT id, title, repo, authority, category, priority, preflight_json FROM cc_tasks WHERE status = 'pending' AND ${TASK_RUNNABLE_SQL} ORDER BY priority ASC, created_at ASC` ).all(); // Recent failures (last 3) @@ -475,7 +475,7 @@ export async function toolAegisBatchApprove(args: Record, env: if (ids.trim().toLowerCase() === 'all') { const result = await env.db.prepare( - "UPDATE cc_tasks SET authority = 'operator' WHERE authority = 'proposed' AND status = 'pending'" + "UPDATE cc_tasks SET approved_at = datetime('now'), approved_by = 'mcp' WHERE authority = 'proposed' AND approved_at IS NULL AND status = 'pending'" ).run(); approved = result.meta.changes ?? 0; } else { @@ -485,13 +485,13 @@ export async function toolAegisBatchApprove(args: Record, env: } for (const taskId of taskIds) { const result = await env.db.prepare( - "UPDATE cc_tasks SET authority = 'operator' WHERE id = ? AND authority = 'proposed' AND status = 'pending'" + "UPDATE cc_tasks SET approved_at = datetime('now'), approved_by = 'mcp' WHERE id = ? AND authority = 'proposed' AND approved_at IS NULL AND status = 'pending'" ).bind(taskId).run(); approved += result.meta.changes ?? 0; } } - return { content: [{ type: 'text', text: `Approved ${approved} task(s) — now eligible for autonomous execution` }] }; + return { content: [{ type: 'text', text: `Approved ${approved} task(s) — now eligible to run, each isolated on its own branch and landing as a PR for review` }] }; } export async function toolAegisPublishTechPost(args: Record, env: EdgeEnv): Promise { diff --git a/web/src/routes/cc-tasks.ts b/web/src/routes/cc-tasks.ts index 26c7b4d..1af71d7 100755 --- a/web/src/routes/cc-tasks.ts +++ b/web/src/routes/cc-tasks.ts @@ -3,7 +3,7 @@ import { bodyLimit } from 'hono/body-limit'; import type { Env } from '../types.js'; import { classifyTaskFailure, parseTaskPreflight, scoreTaskUtility } from '../task-intelligence.js'; import { moveBoardItemLocal, linkTaskToBoard } from '../kernel/board.js'; -import { TASK_AUTHORITIES, TASK_CATEGORIES, validateEnum } from '../schema-enums.js'; +import { TASK_AUTHORITIES, TASK_CATEGORIES, TASK_RUNNABLE_SQL, validateEnum } from '../schema-enums.js'; const CC_TASKS_BODY_LIMIT = 256 * 1024; @@ -14,7 +14,7 @@ ccTasks.get('/api/cc-tasks/next', async (c) => { const task = await c.env.DB.prepare(` SELECT * FROM cc_tasks WHERE status = 'pending' - AND authority != 'proposed' + AND ${TASK_RUNNABLE_SQL} AND (depends_on IS NULL OR depends_on IN ( SELECT id FROM cc_tasks WHERE status = 'completed' )) @@ -303,13 +303,14 @@ ccTasks.post('/api/cc-tasks/:id/cancel', bodyLimit({ maxSize: CC_TASKS_BODY_LIMI ccTasks.post('/api/cc-tasks/:id/approve', bodyLimit({ maxSize: CC_TASKS_BODY_LIMIT }), async (c) => { const taskId = c.req.param('id'); const result = await c.env.DB.prepare(` - UPDATE cc_tasks SET authority = 'operator' - WHERE id = ? AND authority = 'proposed' AND status = 'pending' + UPDATE cc_tasks SET approved_at = datetime('now'), approved_by = 'rest' + WHERE id = ? AND authority = 'proposed' AND approved_at IS NULL AND status = 'pending' `).bind(taskId).run(); if (!result.meta.changes) { - return c.json({ error: 'Task not found, not proposed, or not pending' }, 404); + return c.json({ error: 'Task not found, not awaiting approval, or not pending' }, 404); } - return c.json({ id: taskId, authority: 'operator', status: 'pending' }); + // Authority stays 'proposed': the task runs isolated and lands as a PR. + return c.json({ id: taskId, authority: 'proposed', approved: true, status: 'pending' }); }); // Reject a proposed task diff --git a/web/src/schema-enums.ts b/web/src/schema-enums.ts index 7d2381a..6b98bc1 100644 --- a/web/src/schema-enums.ts +++ b/web/src/schema-enums.ts @@ -71,6 +71,17 @@ export type TaskStatus = EnumValues; export const TASK_AUTHORITIES = ['proposed', 'auto_safe', 'operator'] as const; export type TaskAuthority = EnumValues; +/** + * Approving a `proposed` task records `approved_at`/`approved_by` and keeps + * the authority. It used to rewrite authority to `operator`, which made the + * local task runner run approved work -- often from untrusted issue bodies -- + * in the operator's own checkout with no branch or PR. A non-operator task + * always runs isolated and lands as a PR. + */ +export const TASK_RUNNABLE_SQL = "(authority != 'proposed' OR approved_at IS NOT NULL)"; +/** Proposed tasks still waiting for an operator decision. */ +export const TASK_AWAITING_APPROVAL_SQL = "(authority = 'proposed' AND approved_at IS NULL)"; + /** Where a cc_task runs: a local `claude -p` task runner, or the sandbox task executor. */ export const TASK_EXECUTORS = ['claude_code', 'do_sandbox'] as const; export type TaskExecutor = EnumValues; diff --git a/web/src/version.ts b/web/src/version.ts index f9532fa..788739c 100755 --- a/web/src/version.ts +++ b/web/src/version.ts @@ -1,3 +1,3 @@ // Semantic version - bump on each deploy // Used in /health endpoint and changelog tracking -export const VERSION = '0.10.0'; +export const VERSION = '0.11.0'; diff --git a/web/tests/cc-task-approval.test.ts b/web/tests/cc-task-approval.test.ts new file mode 100644 index 0000000..7a69c7b --- /dev/null +++ b/web/tests/cc-task-approval.test.ts @@ -0,0 +1,143 @@ +// Approving a proposed task keeps it isolated. +// Approval used to rewrite authority to 'operator', so the local task runner +// ran approved work -- often from untrusted issue bodies -- in the operator's +// own checkout with no branch or PR, and nothing marked it as untrusted +// afterwards. Approval now records approved_at/approved_by and keeps +// 'proposed'. Runs the real cc_tasks schema and migration on SQLite. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Hono } from 'hono'; +import { ccTasks } from '../src/routes/cc-tasks.js'; +import * as handlers from '../src/mcp/handlers.js'; +import { runTaskExecutorDispatch } from '../src/factory/dispatch.js'; +import { CCTaskContract } from '../src/contracts/cc-task.contract.js'; +import { TASK_RUNNABLE_SQL, TASK_AWAITING_APPROVAL_SQL } from '../src/schema-enums.js'; + +const read = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); +const SCHEMA = read('../schema.sql'); +const CC_TASKS_DDL = SCHEMA.slice(SCHEMA.indexOf('CREATE TABLE IF NOT EXISTS cc_tasks'), SCHEMA.indexOf(');', SCHEMA.indexOf('CREATE TABLE IF NOT EXISTS cc_tasks')) + 2); + +function d1(sqlite: DatabaseSync): D1Database { + return { + prepare(sql: string) { + let args: unknown[] = []; + const stmt = { + bind(...a: unknown[]) { args = a; return stmt; }, + first: async () => (sqlite.prepare(sql).get(...(args as never[])) ?? null), + all: async () => ({ results: sqlite.prepare(sql).all(...(args as never[])) }), + run: async () => { + const r = sqlite.prepare(sql).run(...(args as never[])); + return { success: true, meta: { changes: Number(r.changes) } }; + }, + }; + return stmt; + }, + } as unknown as D1Database; +} + +let sqlite: DatabaseSync; +let db: D1Database; +const insert = (id: string, authority: string, extra: Record = {}) => { + const row = { id, title: `task ${id}`, repo: 'demo', prompt: 'do it', authority, category: 'docs', ...extra }; + const cols = Object.keys(row); + sqlite.prepare(`INSERT INTO cc_tasks (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...(Object.values(row) as never[])); +}; +const task = (id: string) => sqlite.prepare('SELECT authority, approved_at, approved_by, status FROM cc_tasks WHERE id = ?').get(id) as Record; + +beforeEach(() => { + sqlite = new DatabaseSync(':memory:'); + sqlite.exec(CC_TASKS_DDL); + db = d1(sqlite); +}); + +function app() { + const a = new Hono(); + a.use('*', async (c, next) => { (c.env as any) = { DB: db, AEGIS_TOKEN: 't' }; await next(); }); + a.route('/', ccTasks); + return a; +} +const post = (path: string) => app().request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + +describe('migration 0002', () => { + it('adds approved_at/approved_by to a pre-0.11.0 cc_tasks table', () => { + const old = new DatabaseSync(':memory:'); + old.exec(CC_TASKS_DDL.replace(/,\s*approved_at TEXT[^\n]*\n\s*approved_by TEXT[^\n]*\n/, '\n')); + expect((old.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('cc_tasks') WHERE name LIKE 'approved_%'").get() as { n: number }).n).toBe(0); + old.exec(read('../migrations/0002_cc_tasks_approval.sql')); + expect((old.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('cc_tasks') WHERE name LIKE 'approved_%'").get() as { n: number }).n).toBe(2); + }); +}); + +describe('approval keeps the task proposed', () => { + it('REST approve records who and when; the task stays proposed; a second approve is refused', async () => { + insert('t1', 'proposed'); + expect((await post('/api/cc-tasks/t1/approve')).status).toBe(200); + expect(task('t1')).toMatchObject({ authority: 'proposed', approved_by: 'rest', status: 'pending' }); + expect(task('t1').approved_at).toBeTruthy(); + expect((await post('/api/cc-tasks/t1/approve')).status).toBe(404); + }); + + it('MCP approve and batch approve do the same, and never touch other authorities', async () => { + insert('m1', 'proposed'); insert('m2', 'proposed'); insert('m3', 'proposed'); insert('op', 'operator'); insert('safe', 'auto_safe'); + await handlers.toolAegisApproveCcTask({ id: 'm1' }, { db } as any); + await handlers.toolAegisBatchApprove({ ids: 'all' }, { db } as any); + for (const id of ['m1', 'm2', 'm3']) expect(task(id)).toMatchObject({ authority: 'proposed', approved_by: 'mcp' }); + expect(task('op')).toMatchObject({ authority: 'operator', approved_at: null }); + expect(task('safe')).toMatchObject({ authority: 'auto_safe', approved_at: null }); + }); + + it('reject still cancels a proposed task', async () => { + insert('r1', 'proposed'); + expect((await post('/api/cc-tasks/r1/reject')).status).toBe(200); + expect(task('r1').status).toBe('cancelled'); + }); +}); + +describe('what runs, and where', () => { + it('the task queue hands out an approved proposed task, never an unapproved one', async () => { + insert('p1', 'proposed'); + const none = await app().request('/api/cc-tasks/next'); + expect(await none.json()).toMatchObject({ id: null }); + await post('/api/cc-tasks/p1/approve'); + const next = await (await app().request('/api/cc-tasks/next')).json() as { id: string; authority: string }; + // authority 'proposed' reaches the runner, which isolates every non-operator task + expect(next).toMatchObject({ id: 'p1', authority: 'proposed' }); + }); + + it('the sandbox dispatcher picks up an approved proposed task, not an unapproved one', async () => { + const fetches: string[] = []; + const env = { db, taskExecutor: { idFromName: () => 'id', get: () => ({ fetch: async (r: Request) => { fetches.push(r.url); return Response.json({ dispatched: true, taskId: 's1' }); } }) } }; + insert('s1', 'proposed', { executor: 'do_sandbox' }); + await runTaskExecutorDispatch(env as any); + expect(fetches).toEqual([]); + await post('/api/cc-tasks/s1/approve'); + await runTaskExecutorDispatch(env as any); + expect(fetches).toEqual(['https://internal/run']); + }); + + it('the shared SQL splits tasks the same way everywhere', () => { + insert('a', 'proposed'); insert('b', 'proposed', { approved_at: '2026-10-16 00:00:00' }); insert('c', 'auto_safe'); insert('d', 'operator'); + const ids = (where: string) => (sqlite.prepare(`SELECT id FROM cc_tasks WHERE ${where} ORDER BY id`).all() as { id: string }[]).map((r) => r.id); + expect(ids(TASK_AWAITING_APPROVAL_SQL)).toEqual(['a']); + expect(ids(TASK_RUNNABLE_SQL)).toEqual(['b', 'c', 'd']); + }); + + it('the task summary lists only tasks still awaiting approval as proposed', async () => { + insert('w1', 'proposed'); insert('w2', 'proposed', { approved_at: '2026-10-16 00:00:00' }); + const out = await handlers.toolAegisTaskSummary({ db } as any); + const parsed = JSON.parse(out.content[0].text as string); + expect(parsed.proposed.tasks.map((t: { id: string }) => t.id)).toEqual(['w1']); + expect(parsed.pending.tasks.map((t: { id: string }) => t.id)).toEqual(['w2']); + }); +}); + +describe('contract invariant', () => { + const inv = CCTaskContract.invariants.find((i) => i.name === 'proposed_task_needs_approval')!; + it('flags a running proposed task only when it was never approved', () => { + expect(inv.check({ authority: 'proposed', status: 'running', approvedAt: null })).not.toBe(true); + expect(inv.check({ authority: 'proposed', status: 'running', approvedAt: '2026-10-16T00:00:00Z' })).toBe(true); + }); +}); diff --git a/web/tests/cc-tasks.test.ts b/web/tests/cc-tasks.test.ts index 4a4c47d..d719849 100755 --- a/web/tests/cc-tasks.test.ts +++ b/web/tests/cc-tasks.test.ts @@ -439,7 +439,12 @@ describe('cc-tasks routes', () => { expect(res.status).toBe(200); const json = await res.json() as any; - expect(json.authority).toBe('operator'); + // Approval keeps the authority: the task still runs isolated and lands as a PR. + expect(json.authority).toBe('proposed'); + expect(json.approved).toBe(true); + const sql = db._queries[0].sql; + expect(sql).toContain("approved_at = datetime('now')"); + expect(sql).not.toContain("authority = 'operator'"); }); it('returns 404 when task not found or not proposed', async () => { diff --git a/web/tests/factory/dispatch.test.ts b/web/tests/factory/dispatch.test.ts index 2c8ec28..575e9d1 100644 --- a/web/tests/factory/dispatch.test.ts +++ b/web/tests/factory/dispatch.test.ts @@ -35,7 +35,7 @@ describe('runTaskExecutorDispatch', () => { const sql = prepare.mock.calls[0][0] as string; expect(sql).toContain("executor = 'do_sandbox'"); expect(sql).toContain("status = 'pending'"); - expect(sql).toContain("authority IN ('auto_safe', 'operator')"); + expect(sql).toContain("(authority != 'proposed' OR approved_at IS NOT NULL)"); }); it('POSTs /run on the singleton executor when a task is eligible', async () => { diff --git a/web/tests/mcp.test.ts b/web/tests/mcp.test.ts index 3109d1c..f589444 100755 --- a/web/tests/mcp.test.ts +++ b/web/tests/mcp.test.ts @@ -975,7 +975,7 @@ describe('MCP Tool Handlers', () => { const env = { ...makeEnv(), db: db as unknown as D1Database }; const result = await handlers.toolAegisApproveCcTask({ id: 'task-999' }, env as any); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('not found, not proposed, or not pending'); + expect(result.content[0].text).toContain('not found, not awaiting approval, or not pending'); }); }); From a04e67ee3e8f52973b976e10e195c6d4c6aafd81 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 16 Oct 2026 20:00:52 -0500 Subject: [PATCH 2/2] fix(tasks): approved proposed tasks stay checked; tests that catch regressions Review of #91: - do_sandbox tasks need an acceptance block unless operator-queued; approved proposed tasks keep their authority and would otherwise run unchecked. - An approved proposed task on an outside repo is refused with a note to requeue it with operator authority, and the changelog says so. - The sandbox claim moves to factory/claim.ts and the digest's task queries to digestTasks(), so both are tested on the real schema; new tests fail when the claim, briefing, digest, dashboard or admission filters revert. - setup applies migrations/*.sql, skipping ones already applied. - Dashboard marks approved tasks; aegis_list_cc_tasks returns the approval fields; reject is tested on approved tasks. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++- web/scripts/setup-lib.mjs | 14 +++ web/scripts/setup.mjs | 21 ++++- web/src/dashboard.ts | 5 +- web/src/factory/acceptance.ts | 10 ++- web/src/factory/claim.ts | 35 ++++++++ web/src/factory/dispatch.ts | 4 +- web/src/factory/executor-do.ts | 36 +++----- web/src/kernel/scheduled/digest.ts | 38 +++++--- web/src/mcp/handlers.ts | 2 +- web/src/routes/cc-tasks.ts | 2 +- web/tests/cc-task-approval-views.test.ts | 109 +++++++++++++++++++++++ web/tests/cc-task-approval.test.ts | 10 +++ web/tests/setup-lib.test.ts | 21 ++++- 14 files changed, 260 insertions(+), 57 deletions(-) create mode 100644 web/src/factory/claim.ts create mode 100644 web/tests/cc-task-approval-views.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8daab..2f8e94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,16 @@ ### Fixed - **Approving a proposed task no longer makes it an operator task.** Approval (REST `POST /api/cc-tasks/:id/approve`, MCP `aegis_approve_cc_task` and `aegis_batch_approve`) rewrote `authority` to `operator`. The local task runner runs operator tasks in the operator's own checkout with no branch or PR, so approved work, often generated from untrusted issue bodies, ran there unisolated, and nothing afterwards showed it had started as proposed. Approval now keeps `authority = 'proposed'` and records `approved_at` and `approved_by` (`rest` or `mcp`); a task can be approved once. The task queue (`/next`), sandbox dispatch and the sandbox claim run proposed tasks once approved, and the runner keeps giving every non-operator task its own worktree and PR. -- Awaiting-approval listings (briefing, digest, task summary, dashboard) and the contract invariant `proposed_task_needs_approval` count only proposed tasks not yet approved. The shared conditions are `TASK_RUNNABLE_SQL` and `TASK_AWAITING_APPROVAL_SQL` in `schema-enums.ts`. +- Awaiting-approval listings (briefing, digest, task summary, dashboard) and the contract invariant `proposed_task_needs_approval` count only proposed tasks not yet approved. The shared conditions are `TASK_RUNNABLE_SQL` and `TASK_AWAITING_APPROVAL_SQL` in `schema-enums.ts`. The dashboard marks an approved task `[APPROVED]`, and `aegis_list_cc_tasks` returns `approved_at` and `approved_by`. +- `pnpm run setup` now applies `migrations/*.sql` after `schema.sql`, so re-running it on an existing database adds new columns. A migration whose columns already exist (a fresh database, or a re-run) is reported as already applied. + +### Changed +- **Sandbox tasks need an acceptance block unless the operator queued them.** `acceptanceAdmissionError` used to require one only for `auto_safe`. Approved `proposed` tasks now keep their authority, often carry a prompt from an untrusted issue, and would otherwise run with no machine check, so `proposed` needs one too. MCP refuses to create a `proposed` `do_sandbox` task without a block. A pending one already in the queue fails at claim as `acceptance_missing`. +- **Approved proposed tasks can't run on repositories outside the home org.** The default `externalRepoPolicy` requires `operator` authority there, which approval no longer grants. Such a task is claimed and then fails as `external_repo_refused`, with a note to queue it again with operator authority. (The policy and home org live in the executor config, so approval can't check this earlier.) +- The sandbox claim is `claimNextSandboxTask` in `factory/claim.ts`, so it can be tested without the Workers runtime. The digest's task queries are `digestTasks(db, since)`. ### Upgrade -- Existing databases: apply `migrations/0002_cc_tasks_approval.sql` (adds `approved_at`, `approved_by`) before deploying. Fresh installs get the columns from `schema.sql`. Tasks approved on earlier versions were already rewritten to `operator`; their original authority wasn't kept, so they can't be restored. +- Existing databases: apply `migrations/0002_cc_tasks_approval.sql` (adds `approved_at`, `approved_by`) before deploying. Until it is applied, the task queue, sandbox dispatch, briefing, digest, task summary and dashboard fail with `no such column: approved_at`. Fresh installs get the columns from `schema.sql`. The migration can't be run twice (SQLite has no `ADD COLUMN IF NOT EXISTS`). Tasks approved on earlier versions were already rewritten to `operator`; their original authority wasn't kept, so they can't be restored. ## 0.10.0 (2026-10-16) diff --git a/web/scripts/setup-lib.mjs b/web/scripts/setup-lib.mjs index 92092dd..9a27fd9 100644 --- a/web/scripts/setup-lib.mjs +++ b/web/scripts/setup-lib.mjs @@ -49,6 +49,20 @@ export function workerUrlFromDeployOutput(output) { return match ? match[0] : null; } +/** Migration files in apply order (`NNNN_name.sql`). */ +export function migrationFiles(names) { + return names.filter((name) => /^\d{4}_[\w-]+\.sql$/.test(name)).sort(); +} + +/** + * True when a migration failed only because its columns already exist: a + * fresh database gets them from schema.sql, and a re-run has applied them. + * SQLite has no ADD COLUMN IF NOT EXISTS. + */ +export function migrationAlreadyApplied(output) { + return /duplicate column name/i.test(output ?? ''); +} + /** A 256-bit bearer token for AEGIS_TOKEN. */ export function generateToken() { return randomBytes(32).toString('hex'); diff --git a/web/scripts/setup.mjs b/web/scripts/setup.mjs index 649ec0c..9035614 100644 --- a/web/scripts/setup.mjs +++ b/web/scripts/setup.mjs @@ -6,19 +6,22 @@ // pnpm run setup -- --dry-run # print every command; change nothing // // Steps: check the wrangler login → create (or reuse) the D1 database → write -// wrangler.toml → apply schema.sql → deploy → set a generated AEGIS_TOKEN → -// check /health. Safe to re-run: it reuses an existing database of the same -// name, the schema is idempotent, and it refuses to overwrite an existing +// wrangler.toml → apply schema.sql and migrations/ → deploy → set a generated +// AEGIS_TOKEN → check /health. Safe to re-run: it reuses an existing database +// of the same name, the schema is idempotent, a migration whose columns +// already exist is skipped, and it refuses to overwrite an existing // wrangler.toml unless you pass --force. import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { createInterface } from 'node:readline/promises'; import { fileURLToPath } from 'node:url'; import { findDatabaseId, generateToken, + migrationAlreadyApplied, + migrationFiles, renderWranglerToml, workerNameError, workerUrlFromDeployOutput, @@ -112,6 +115,16 @@ async function main() { const schema = run('npx', ['wrangler', 'd1', 'execute', name, '--remote', '--file=schema.sql', '--yes'], { capture: false }); if (schema.status !== 0) fail('Applying schema.sql failed (see the output above).'); + // schema.sql only creates missing tables, so a reused database gets new + // columns from the migrations. + step('Applying migrations'); + for (const file of migrationFiles(readdirSync(join(root, 'migrations')))) { + const migration = run('npx', ['wrangler', 'd1', 'execute', name, '--remote', `--file=migrations/${file}`, '--yes']); + if (migration.status === 0) console.log(` Applied ${file}`); + else if (migrationAlreadyApplied(migration.stdout)) console.log(` ${file}: already applied`); + else fail(`Applying migrations/${file} failed:\n${migration.stdout.slice(-2000)}`); + } + step('Building and deploying'); const deploy = run('npm', ['run', 'deploy'], { dryResult: { status: 0, stdout: `https://${name}.example.workers.dev` }, diff --git a/web/src/dashboard.ts b/web/src/dashboard.ts index be9ac91..6457510 100644 --- a/web/src/dashboard.ts +++ b/web/src/dashboard.ts @@ -86,6 +86,7 @@ interface DashboardData { repo: string; status: string; authority: string; + approved_at: string | null; category: string; exit_code: number | null; pr_url: string | null; @@ -125,7 +126,7 @@ export async function getDashboardData(db: D1Database, memoryBinding?: MemorySer db.prepare("SELECT severity, summary, created_at FROM heartbeat_results ORDER BY created_at DESC LIMIT 1").first<{ severity: string; summary: string; created_at: string }>(), db.prepare("SELECT status, CASE WHEN authority = 'proposed' AND approved_at IS NULL THEN 'proposed' ELSE 'runnable' END AS authority, COUNT(*) as c FROM cc_tasks WHERE status IN ('pending', 'running') GROUP BY 1, 2").all<{ status: string; authority: string; c: number }>(), db.prepare("SELECT status, COUNT(*) as c FROM cc_tasks WHERE completed_at > datetime('now', '-24 hours') GROUP BY status").all<{ status: string; c: number }>(), - db.prepare("SELECT id, title, repo, status, authority, category, exit_code, pr_url, completed_at FROM cc_tasks ORDER BY COALESCE(completed_at, started_at, created_at) DESC LIMIT 10").all<{ id: string; title: string; repo: string; status: string; authority: string; category: string; exit_code: number | null; pr_url: string | null; completed_at: string | null }>(), + db.prepare("SELECT id, title, repo, status, authority, approved_at, category, exit_code, pr_url, completed_at FROM cc_tasks ORDER BY COALESCE(completed_at, started_at, created_at) DESC LIMIT 10").all<{ id: string; title: string; repo: string; status: string; authority: string; approved_at: string | null; category: string; exit_code: number | null; pr_url: string | null; completed_at: string | null }>(), ]); // Build executor stats from episodes @@ -586,7 +587,7 @@ export function dashboardPage(data: DashboardData): string { ${statusBadge(t.status)} ${esc(t.repo)} ${esc(t.category)} - ${t.authority === 'proposed' ? '[PROPOSED]' : ''} + ${t.authority === 'proposed' ? (t.approved_at ? '[APPROVED]' : '[PROPOSED]') : ''} ${esc(t.title)} ${t.pr_url ? `PR` : ''} ${exitLabel} diff --git a/web/src/factory/acceptance.ts b/web/src/factory/acceptance.ts index 8e8d743..3dbeffd 100644 --- a/web/src/factory/acceptance.ts +++ b/web/src/factory/acceptance.ts @@ -93,13 +93,15 @@ export function parseAcceptanceSpec(prompt: string): ParsedAcceptance { /** * Admission rule shared by task creation and the executor: a malformed block is - * always rejected, and unattended (`auto_safe`) tasks must declare one. Returns - * the rejection reason, or null when the task may run. + * always rejected, and every task the operator didn't queue directly + * (`auto_safe`, and `proposed` even once approved -- its prompt often comes + * from an untrusted issue) must declare one. Returns the rejection reason, or + * null when the task may run. */ export function acceptanceAdmissionError(parsed: ParsedAcceptance, authority: string): string | null { if (parsed.kind === 'invalid') return `acceptance block rejected: ${parsed.error}`; - if (parsed.kind === 'absent' && authority === 'auto_safe') { - return 'auto_safe do_sandbox tasks must declare an ```acceptance block; use operator authority to run without one'; + if (parsed.kind === 'absent' && authority !== 'operator') { + return `${authority} do_sandbox tasks must declare an \`\`\`acceptance block; use operator authority to run without one`; } return null; } diff --git a/web/src/factory/claim.ts b/web/src/factory/claim.ts new file mode 100644 index 0000000..b7914b4 --- /dev/null +++ b/web/src/factory/claim.ts @@ -0,0 +1,35 @@ +// The sandbox executor's claim, kept apart from executor-do so it can be +// tested without the Workers runtime. + +import { TASK_RUNNABLE_SQL } from '../schema-enums.js'; + +/** + * Claims the next runnable do_sandbox task: pending, and not a proposed task + * still awaiting approval. Marks it running and returns it, or null. + */ +export async function claimNextSandboxTask(db: D1Database): Promise { + return db.prepare( + `UPDATE cc_tasks + SET status = 'running', started_at = datetime('now'), session_id = 'do-sandbox' + WHERE id = ( + SELECT id FROM cc_tasks + WHERE status = 'pending' + AND executor = 'do_sandbox' + AND ${TASK_RUNNABLE_SQL} + ORDER BY priority ASC, created_at ASC + LIMIT 1 + ) + RETURNING id, title, prompt, repo, category, authority, github_issue_repo, github_issue_number`, + ).first(); +} + +export interface CCTaskRow { + id: string; + title: string; + prompt: string; + repo: string; + category: string; + authority: string; + github_issue_repo: string | null; + github_issue_number: number | null; +} diff --git a/web/src/factory/dispatch.ts b/web/src/factory/dispatch.ts index d210d11..1ce048e 100644 --- a/web/src/factory/dispatch.ts +++ b/web/src/factory/dispatch.ts @@ -1,7 +1,7 @@ // Heartbeat dispatcher for the sandbox task executor. // -// The executor claims its own work (executor = 'do_sandbox', authority -// auto_safe | operator) when POSTed /run. This plugin does that on the hourly +// The executor claims its own work (executor = 'do_sandbox', and not a +// proposed task still awaiting approval) when POSTed /run. This plugin does that on the hourly // heartbeat, and checks for an eligible task first so an empty queue never // wakes the Durable Object. A Durable Object evicted mid-task re-attaches // through onFiberRecovered, not here. diff --git a/web/src/factory/executor-do.ts b/web/src/factory/executor-do.ts index 36c3891..e25273c 100644 --- a/web/src/factory/executor-do.ts +++ b/web/src/factory/executor-do.ts @@ -56,7 +56,7 @@ import { type ExternalRepoPolicy, type TaskRepoTarget, } from './repo.js'; -import { TASK_RUNNABLE_SQL } from '../schema-enums.js'; +import { claimNextSandboxTask, type CCTaskRow } from './claim.js'; /** Bindings and secrets the executor reads. Declare them in your Worker's wrangler config. */ export interface TaskExecutorEnv { @@ -105,17 +105,6 @@ const READABLE_ARTIFACTS = new Set([ 'acceptance.json', ]); -interface CCTaskRow { - id: string; - title: string; - prompt: string; - repo: string; - category: string; - authority: string; - github_issue_repo: string | null; - github_issue_number: number | null; -} - interface TaskPlan { branch: string; executor: 'workers_ai_tool_loop'; @@ -284,7 +273,14 @@ export function createTaskExecutorDO(config: TaskExecutorConfig) { homeOrg, config.externalRepoPolicy, ); - if (external) throw new TaskExecutorError('external_repo_refused', false, 1, external); + if (external) { + // Approval keeps a task 'proposed', so an approved issue-derived task + // can never meet a policy that asks for operator authority. + const hint = task.authority === 'proposed' + ? ' (an approved proposed task keeps proposed authority; queue it again with operator authority to run it here)' + : ''; + throw new TaskExecutorError('external_repo_refused', false, 1, external + hint); + } const target = parseTaskRepo(task.repo, homeOrg); // Recipes and the verification cwd key on the bare name for home-org // repos, so `/app` is configured the same as `app`. @@ -512,19 +508,7 @@ export function createTaskExecutorDO(config: TaskExecutorConfig) { } private async claimNextTask(): Promise { - return this.env.DB.prepare( - `UPDATE cc_tasks - SET status = 'running', started_at = datetime('now'), session_id = 'do-sandbox' - WHERE id = ( - SELECT id FROM cc_tasks - WHERE status = 'pending' - AND executor = 'do_sandbox' - AND ${TASK_RUNNABLE_SQL} - ORDER BY priority ASC, created_at ASC - LIMIT 1 - ) - RETURNING id, title, prompt, repo, category, authority, github_issue_repo, github_issue_number`, - ).first(); + return claimNextSandboxTask(this.env.DB); } private createPlan(task: CCTaskRow, target: TaskRepoTarget): TaskPlan { diff --git a/web/src/kernel/scheduled/digest.ts b/web/src/kernel/scheduled/digest.ts index fa09baa..98c76b2 100644 --- a/web/src/kernel/scheduled/digest.ts +++ b/web/src/kernel/scheduled/digest.ts @@ -17,6 +17,26 @@ import { TASK_AWAITING_APPROVAL_SQL } from '../../schema-enums.js'; // Reads accumulated digest_sections, cc_tasks, operator_log, and agenda. // Operator log runs at 08:00 UTC to generate content before this fires. +/** Tasks completed or failed since `since`, and proposed tasks still awaiting approval. */ +export async function digestTasks(db: D1Database, since: string): Promise<{ + completedTasks: DigestTask[]; + failedTasks: DigestTask[]; + proposedTasks: DigestTask[]; +}> { + const [completedResult, failedResult, proposedResult] = await Promise.all([ + db.prepare( + "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE status = 'completed' AND completed_at > ? ORDER BY completed_at DESC" + ).bind(since).all(), + db.prepare( + "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE status = 'failed' AND completed_at > ? ORDER BY completed_at DESC" + ).bind(since).all(), + db.prepare( + `SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE ${TASK_AWAITING_APPROVAL_SQL} AND status = 'pending' ORDER BY created_at ASC` + ).all(), + ]); + return { completedTasks: completedResult.results, failedTasks: failedResult.results, proposedTasks: proposedResult.results }; +} + export async function runDailyDigest(env: EdgeEnv): Promise { // Time gate: fire at or after 09:00 UTC (4:00 AM CT). // The 22h cooldown below prevents double-sends, while allowing same-day @@ -161,17 +181,7 @@ export async function runDailyDigest(env: EdgeEnv): Promise { // 2. Completed/failed/proposed tasks since last digest (24h) const since = lastDigest?.received_at ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - const [completedResult, failedResult, proposedResult] = await Promise.all([ - env.db.prepare( - "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE status = 'completed' AND completed_at > ? ORDER BY completed_at DESC" - ).bind(since).all(), - env.db.prepare( - "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE status = 'failed' AND completed_at > ? ORDER BY completed_at DESC" - ).bind(since).all(), - env.db.prepare( - `SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE ${TASK_AWAITING_APPROVAL_SQL} AND status = 'pending' ORDER BY created_at ASC` - ).all(), - ]); + const { completedTasks, failedTasks, proposedTasks } = await digestTasks(env.db, since); // 3. Latest operator log entry (generated at 08:00 UTC) const logEntry = await env.db.prepare( @@ -220,9 +230,9 @@ export async function runDailyDigest(env: EdgeEnv): Promise { } const sections: DigestSections = { - completedTasks: completedResult.results, - failedTasks: failedResult.results, - proposedTasks: proposedResult.results, + completedTasks, + failedTasks, + proposedTasks, operatorLog, healthChecks, eventNotifications, diff --git a/web/src/mcp/handlers.ts b/web/src/mcp/handlers.ts index fac8c26..132164c 100755 --- a/web/src/mcp/handlers.ts +++ b/web/src/mcp/handlers.ts @@ -349,7 +349,7 @@ export async function toolAegisListCcTasks(args: Record, env: E const businessUnit = typeof args.business_unit === 'string' ? args.business_unit : undefined; const limit = Math.min(Math.max(1, (args.limit as number) || 20), 50); - const cols = 'id, title, repo, status, priority, authority, category, business_unit, created_at, started_at, completed_at, exit_code, error, failure_kind, retryable'; + const cols = 'id, title, repo, status, priority, authority, approved_at, approved_by, category, business_unit, created_at, started_at, completed_at, exit_code, error, failure_kind, retryable'; const conditions: string[] = []; const bindings: unknown[] = []; if (status) { diff --git a/web/src/routes/cc-tasks.ts b/web/src/routes/cc-tasks.ts index 1af71d7..fe7b7c5 100755 --- a/web/src/routes/cc-tasks.ts +++ b/web/src/routes/cc-tasks.ts @@ -321,7 +321,7 @@ ccTasks.post('/api/cc-tasks/:id/reject', bodyLimit({ maxSize: CC_TASKS_BODY_LIMI WHERE id = ? AND authority = 'proposed' AND status = 'pending' `).bind(taskId).run(); if (!result.meta.changes) { - return c.json({ error: 'Task not found, not proposed, or not pending' }, 404); + return c.json({ error: 'Task not found, not proposed (approved or not), or not pending' }, 404); } return c.json({ id: taskId, status: 'cancelled' }); }); diff --git a/web/tests/cc-task-approval-views.test.ts b/web/tests/cc-task-approval-views.test.ts new file mode 100644 index 0000000..e22a3ce --- /dev/null +++ b/web/tests/cc-task-approval-views.test.ts @@ -0,0 +1,109 @@ +// Approved proposed tasks: the sandbox claim runs them, the views stop +// listing them as awaiting approval, and the admission rules still treat +// them as proposed. Runs the full schema.sql on SQLite. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { claimNextSandboxTask } from '../src/factory/claim.js'; +import { acceptanceAdmissionError, parseAcceptanceSpec } from '../src/factory/acceptance.js'; +import { generateBriefing } from '../src/kernel/briefing.js'; +import { digestTasks } from '../src/kernel/scheduled/digest.js'; +import { getDashboardData } from '../src/dashboard.js'; + +const SCHEMA = readFileSync(fileURLToPath(new URL('../schema.sql', import.meta.url)), 'utf8'); + +function d1(sqlite: DatabaseSync): D1Database { + return { + prepare(sql: string) { + let args: unknown[] = []; + const stmt = { + bind(...a: unknown[]) { args = a; return stmt; }, + first: async () => (sqlite.prepare(sql).get(...(args as never[])) ?? null), + all: async () => ({ results: sqlite.prepare(sql).all(...(args as never[])) }), + run: async () => { + const r = sqlite.prepare(sql).run(...(args as never[])); + return { success: true, meta: { changes: Number(r.changes) } }; + }, + }; + return stmt; + }, + } as unknown as D1Database; +} + +let sqlite: DatabaseSync; +let db: D1Database; +const insert = (id: string, authority: string, extra: Record = {}) => { + const row = { id, title: `task ${id}`, repo: 'demo', prompt: 'do it', authority, category: 'docs', ...extra }; + const cols = Object.keys(row); + sqlite.prepare(`INSERT INTO cc_tasks (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...(Object.values(row) as never[])); +}; +const APPROVED = { approved_at: '2026-10-16 00:00:00', approved_by: 'mcp' }; + +beforeEach(() => { + sqlite = new DatabaseSync(':memory:'); + sqlite.exec(SCHEMA); + db = d1(sqlite); +}); + +describe('sandbox claim', () => { + it('claims an approved proposed task and never one awaiting approval', async () => { + insert('waiting', 'proposed', { executor: 'do_sandbox', priority: 1 }); + insert('approved', 'proposed', { executor: 'do_sandbox', priority: 2, ...APPROVED }); + const claimed = await claimNextSandboxTask(db); + expect(claimed?.id).toBe('approved'); + expect(claimed?.authority).toBe('proposed'); + expect(await claimNextSandboxTask(db)).toBeNull(); + const status = (id: string) => (sqlite.prepare('SELECT status FROM cc_tasks WHERE id = ?').get(id) as { status: string }).status; + expect(status('waiting')).toBe('pending'); + expect(status('approved')).toBe('running'); + }); + + it('claims auto_safe and operator tasks but not other executors', async () => { + insert('local', 'operator', { executor: 'claude_code', priority: 1 }); + insert('safe', 'auto_safe', { executor: 'do_sandbox', priority: 2 }); + expect((await claimNextSandboxTask(db))?.id).toBe('safe'); + expect(await claimNextSandboxTask(db)).toBeNull(); + }); +}); + +describe('acceptance admission', () => { + const none = parseAcceptanceSpec('do it'); + it('requires a block for auto_safe and proposed tasks, approved or not', () => { + expect(acceptanceAdmissionError(none, 'auto_safe')).toMatch(/must declare/); + expect(acceptanceAdmissionError(none, 'proposed')).toMatch(/proposed do_sandbox tasks must declare/); + }); + it('lets operator tasks run without one', () => { + expect(acceptanceAdmissionError(none, 'operator')).toBeNull(); + }); +}); + +describe('awaiting-approval views', () => { + beforeEach(() => { + insert('waiting', 'proposed', { title: 'still waiting' }); + insert('approved', 'proposed', { title: 'already approved', ...APPROVED }); + }); + + it('the briefing asks only about the task still awaiting approval', async () => { + // The briefing also reads two tables schema.sql doesn't create. + sqlite.exec('CREATE TABLE events (ts TEXT); CREATE TABLE digest_sections (section TEXT, payload TEXT, created_at TEXT)'); + const briefing = await generateBriefing({ db } as any); + const ask = briefing.decisions.find((d) => d.includes('awaiting approval')); + expect(ask).toContain('still waiting'); + expect(ask).not.toContain('already approved'); + expect(ask).toMatch(/^1 proposed task /); + }); + + it('the digest lists only the task still awaiting approval', async () => { + const { proposedTasks } = await digestTasks(db, '2000-01-01T00:00:00Z'); + expect(proposedTasks.map((t) => t.id)).toEqual(['waiting']); + }); + + it('the dashboard counts only the task still awaiting approval and marks the approved one', async () => { + const data = await getDashboardData(db); + expect(data.tasks.proposed).toBe(1); + const approved = data.tasks.recent.find((t) => t.id === 'approved'); + expect(approved?.approved_at).toBe(APPROVED.approved_at); + }); +}); diff --git a/web/tests/cc-task-approval.test.ts b/web/tests/cc-task-approval.test.ts index 7a69c7b..0a13dca 100644 --- a/web/tests/cc-task-approval.test.ts +++ b/web/tests/cc-task-approval.test.ts @@ -141,3 +141,13 @@ describe('contract invariant', () => { expect(inv.check({ authority: 'proposed', status: 'running', approvedAt: '2026-10-16T00:00:00Z' })).toBe(true); }); }); + +describe('reject', () => { + it('cancels an approved task that has not started, and refuses a running one', async () => { + insert('r1', 'proposed', { approved_at: '2026-10-16 00:00:00', approved_by: 'rest' }); + expect((await post('/api/cc-tasks/r1/reject')).status).toBe(200); + expect(task('r1').status).toBe('cancelled'); + insert('r2', 'proposed', { status: 'running', approved_at: '2026-10-16 00:00:00' }); + expect((await post('/api/cc-tasks/r2/reject')).status).toBe(404); + }); +}); diff --git a/web/tests/setup-lib.test.ts b/web/tests/setup-lib.test.ts index db065e4..1a51a36 100644 --- a/web/tests/setup-lib.test.ts +++ b/web/tests/setup-lib.test.ts @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; // @ts-expect-error — plain .mjs helper without type declarations -import { findDatabaseId, generateToken, renderWranglerToml, workerNameError, workerUrlFromDeployOutput } from '../scripts/setup-lib.mjs'; +import { findDatabaseId, generateToken, migrationAlreadyApplied, migrationFiles, renderWranglerToml, workerNameError, workerUrlFromDeployOutput } from '../scripts/setup-lib.mjs'; +import { readdirSync } from 'node:fs'; const example = readFileSync(join(__dirname, '..', 'wrangler.toml.example'), 'utf8'); @@ -65,3 +66,21 @@ describe('generateToken', () => { expect(generateToken()).not.toBe(token); }); }); + +describe('migrations', () => { + it('orders migration files and ignores everything else', () => { + expect(migrationFiles(['0002_b.sql', 'README.md', '0001_a.sql', 'x.sql'])).toEqual(['0001_a.sql', '0002_b.sql']); + }); + + it('includes every migration in the repo', () => { + const files = readdirSync(join(__dirname, '..', 'migrations')); + expect(migrationFiles(files)).toEqual(files.filter((f) => f.endsWith('.sql')).sort()); + expect(migrationFiles(files)).toContain('0002_cc_tasks_approval.sql'); + }); + + it('treats only an existing column as already applied', () => { + expect(migrationAlreadyApplied('✘ [ERROR] duplicate column name: approved_at: SQLITE_ERROR')).toBe(true); + expect(migrationAlreadyApplied('no such table: cc_tasks')).toBe(false); + expect(migrationAlreadyApplied(undefined)).toBe(false); + }); +});