muse-crew 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1101 @@
1
+ #!/usr/bin/env node
2
+ // crew-api.js — the Muse Crew's own task-service API.
3
+ //
4
+ // The crew owns its state. This CLI implements the Crew API contract
5
+ // (API.md) against a SQLite database at $CREW_HOME/crew-state.db. Workflows
6
+ // call it through their agents' shell; the dashboard delegates to it.
7
+ // Nobody calls the dashboard for crew state anymore.
8
+ //
9
+ // Usage:
10
+ // crew-api.js --crew-home <path> <command> [--json '<json>' | --flag value ...]
11
+ // crew-api.js --crew-home <path> <command> < /tmp/args.json
12
+ //
13
+ // Commands (kebab-case) map to API.md actions:
14
+ // get-dispatch-state, get-state, create-task, update-task, claim-task,
15
+ // park-task, recover-task, upsert-session, log-event, get-events,
16
+ // create-project, update-project, delete-project, list-projects,
17
+ // get-project, kill-switch, get-config, update-config,
18
+ // set-provenance, get-provenance, acknowledge-poll,
19
+ // record-phase (composite), migrate (one-time import)
20
+ //
21
+ // Output is JSON on stdout. Errors are JSON on stderr.
22
+ // Exit codes: 0 ok · 2 usage/validation · 3 not found · 4 conflict/guard.
23
+
24
+ import { readFileSync, existsSync, statSync } from "node:fs";
25
+ import { join, resolve, sep } from "node:path";
26
+ import { randomUUID } from "node:crypto";
27
+ import { DatabaseSync } from "node:sqlite";
28
+ import { homedir } from "node:os";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Errors
32
+ // ---------------------------------------------------------------------------
33
+
34
+ class CrewError extends Error {
35
+ constructor(code, message, exitCode) {
36
+ super(message);
37
+ this.code = code;
38
+ this.exitCode = exitCode;
39
+ }
40
+ }
41
+ const usageError = (m) => new CrewError("usage", m, 2);
42
+ const notFound = (m) => new CrewError("not_found", m, 3);
43
+ const conflict = (m) => new CrewError("conflict", m, 4);
44
+
45
+ // Tri-state visual_protocol validation: true | false | null. undefined means
46
+ // "not provided" (create: defaults to null; update: leaves the field alone).
47
+ // null is the explicit clear operation — it returns the project to inherit.
48
+ // Anything else ("yes", 1, 0, {}, "") is a usage error.
49
+ function validateVisualProtocol(value) {
50
+ if (value === undefined || value === null) return null;
51
+ if (value === true || value === false) return value;
52
+ throw usageError("visual_protocol must be true, false, or null.");
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Crew home resolution (fail closed)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function resolveCrewHome(flagValue) {
60
+ const raw = flagValue || process.env.CREW_HOME;
61
+ if (!raw) throw usageError("crew home not set: pass --crew-home or set CREW_HOME.");
62
+ const abs = resolve(raw);
63
+ // Gate 0: the crew only lives under $HOME/workspace (workflow_launch
64
+ // confinement). Refuse anything else rather than writing state elsewhere.
65
+ const allowed = homedir() + sep + "workspace" + sep;
66
+ if (!abs.startsWith(allowed)) {
67
+ throw usageError(`crew home must be under ${allowed}; got ${abs}.`);
68
+ }
69
+ return abs;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Database
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const SCHEMA_PATH = join(import.meta.dirname, "schema.sql");
77
+
78
+ function openDb(crewHome) {
79
+ const dbPath = join(crewHome, "crew-state.db");
80
+ const fresh = !existsSync(dbPath);
81
+ const db = new DatabaseSync(dbPath);
82
+ db.exec("PRAGMA journal_mode = WAL;");
83
+ db.exec("PRAGMA foreign_keys = ON;");
84
+ if (fresh) {
85
+ const schema = readFileSync(SCHEMA_PATH, "utf8");
86
+ db.exec(schema);
87
+ } else {
88
+ // Idempotent: CREATE TABLE IF NOT EXISTS covers upgrades that only add.
89
+ const schema = readFileSync(SCHEMA_PATH, "utf8");
90
+ db.exec(schema);
91
+ // Migration: add last_heartbeat column if missing (duplicate-resume fix, 2026-09-11).
92
+ // SQLite has no IF NOT EXISTS for ADD COLUMN, so check first. The try/catch
93
+ // handles the theoretical race where two processes both pass the check.
94
+ const cols = db.prepare("PRAGMA table_info(agent_sessions)").all();
95
+ if (!cols.some((c) => c.name === "last_heartbeat")) {
96
+ try {
97
+ db.exec("ALTER TABLE agent_sessions ADD COLUMN last_heartbeat TEXT");
98
+ } catch (e) {
99
+ // Another process may have added it concurrently; ignore duplicate-column errors.
100
+ if (!/duplicate column name/i.test(e.message)) throw e;
101
+ }
102
+ }
103
+ // Migration: add visual_protocol column if missing (per-project visual
104
+ // protocol toggle, 2026-09-12). Same idempotent PRAGMA-check pattern;
105
+ // ADD COLUMN without a default leaves existing rows NULL, which is the
106
+ // inherit state by design (no backfill step).
107
+ const projectCols = db.prepare("PRAGMA table_info(projects)").all();
108
+ if (!projectCols.some((c) => c.name === "visual_protocol")) {
109
+ try {
110
+ db.exec("ALTER TABLE projects ADD COLUMN visual_protocol INTEGER CHECK (visual_protocol IN (0, 1))");
111
+ } catch (e) {
112
+ // Another process may have added it concurrently; ignore duplicate-column errors.
113
+ if (!/duplicate column name/i.test(e.message)) throw e;
114
+ }
115
+ }
116
+ }
117
+ return db;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Small helpers (ported from the dashboard's actions.ts — same shapes)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const now = () => new Date().toISOString();
125
+ const uuid = () => randomUUID();
126
+
127
+ function parseDeps(value) {
128
+ try {
129
+ const parsed = JSON.parse(value ?? "[]");
130
+ return Array.isArray(parsed) ? parsed.filter((i) => typeof i === "string") : [];
131
+ } catch { return []; }
132
+ }
133
+
134
+ function parseCaveats(value) {
135
+ try {
136
+ const parsed = JSON.parse(value ?? "[]");
137
+ return Array.isArray(parsed) ? parsed : [];
138
+ } catch { return []; }
139
+ }
140
+
141
+ function mapProject(row) {
142
+ return {
143
+ id: row.id,
144
+ display_name: row.display_name,
145
+ repo_path: row.repo_path,
146
+ deploy_type: row.deploy_type,
147
+ deploy_slug: row.deploy_slug,
148
+ description: row.description,
149
+ simultaneity: row.simultaneity,
150
+ quiesced: !!row.quiesced,
151
+ // Tri-state: null (inherit) stays null, never coerced to false.
152
+ visual_protocol: row.visual_protocol == null ? null : !!row.visual_protocol,
153
+ created_at: row.created_at,
154
+ updated_at: row.updated_at,
155
+ };
156
+ }
157
+
158
+ function mapTask(row, taskStates = new Map()) {
159
+ const deps = parseDeps(row.deps);
160
+ return {
161
+ id: row.id,
162
+ title: row.title,
163
+ description: row.description,
164
+ state: row.state,
165
+ priority: row.priority,
166
+ project: row.project,
167
+ workflow: row.workflow,
168
+ next_phase: row.next_phase,
169
+ deps,
170
+ filed_by: row.filed_by,
171
+ retry_reset_at: row.retry_reset_at,
172
+ blocked: deps.some((id) => taskStates.get(id) !== "done"),
173
+ created_at: row.created_at,
174
+ updated_at: row.updated_at,
175
+ };
176
+ }
177
+
178
+ function mapSession(row) {
179
+ return {
180
+ id: row.id,
181
+ task_id: row.task_id,
182
+ identity: row.identity,
183
+ step: row.step,
184
+ status: row.status,
185
+ started_at: row.started_at,
186
+ ended_at: row.ended_at,
187
+ notes: row.notes,
188
+ caveats: parseCaveats(row.caveats),
189
+ failure_reason: row.failure_reason,
190
+ last_heartbeat: row.last_heartbeat ?? null,
191
+ };
192
+ }
193
+
194
+ function mapEvent(row) {
195
+ return {
196
+ id: row.id,
197
+ type: row.type,
198
+ task_id: row.task_id,
199
+ identity: row.identity,
200
+ message: row.message,
201
+ timestamp: row.timestamp,
202
+ };
203
+ }
204
+
205
+ // Retry counting, ported verbatim from the dashboard's retry.ts.
206
+ function countConsecutiveFailures(sessions, retryResetAt) {
207
+ const latest = sessions[0];
208
+ if (!latest) return 0;
209
+ let n = 0;
210
+ for (const s of sessions) {
211
+ if (retryResetAt && s.started_at <= retryResetAt) break;
212
+ if (s.step !== latest.step) break;
213
+ if (s.status !== "failed" && s.status !== "timed_out" && s.status !== "stalled") break;
214
+ n += 1;
215
+ }
216
+ return n;
217
+ }
218
+
219
+ function countRejectionsSinceReset(sessions, retryResetAt) {
220
+ return sessions.filter((s) =>
221
+ s.status === "rejected" && (!retryResetAt || s.started_at > retryResetAt)).length;
222
+ }
223
+
224
+ function setRetryResetAtForTransition(patch, currentState, requestedState, timestamp) {
225
+ if (currentState === "parked" && requestedState === "todo") {
226
+ patch.retry_reset_at = timestamp;
227
+ }
228
+ }
229
+
230
+ // Active runs: status=running AND started within the last hour.
231
+ function findActiveRuns(db, taskIds) {
232
+ if (taskIds.length === 0) return [];
233
+ const placeholders = taskIds.map(() => "?").join(",");
234
+ return db.prepare(
235
+ `SELECT id, task_id, identity, step, started_at FROM agent_sessions
236
+ WHERE task_id IN (${placeholders}) AND status = 'running'
237
+ AND datetime(started_at) > datetime('now', '-1 hour')
238
+ ORDER BY started_at DESC`
239
+ ).all(...taskIds);
240
+ }
241
+
242
+ function requireTask(db, id) {
243
+ const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
244
+ if (!row) throw notFound("Task not found.");
245
+ return row;
246
+ }
247
+
248
+ function requireProject(db, id) {
249
+ const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(id);
250
+ if (!row) throw notFound("Project not found.");
251
+ return row;
252
+ }
253
+
254
+ function workflowExists(crewHome, slug) {
255
+ if (!slug) return false;
256
+ return (
257
+ existsSync(join(crewHome, "current", "workflows", `${slug}.js`)) ||
258
+ existsSync(join(crewHome, "workflows", `${slug}.js`))
259
+ );
260
+ }
261
+
262
+ // Minimal git-repo validation (ported from the dashboard's validateRepoPath):
263
+ // accepts a repo root (.git dir) or a linked worktree (.git file).
264
+ function isGitRepoPath(p) {
265
+ try {
266
+ const git = join(p, ".git");
267
+ if (!existsSync(git)) return false;
268
+ const st = statSync(git);
269
+ return st.isDirectory() || st.isFile();
270
+ } catch { return false; }
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Commands
275
+ // ---------------------------------------------------------------------------
276
+
277
+ const commands = {};
278
+
279
+ commands["get-dispatch-state"] = (db) => {
280
+ // Sweep zombie sessions: running with no heartbeat for over an hour is timed out.
281
+ // Uses last_heartbeat (updated on every record-phase) falling back to started_at
282
+ // for old sessions. A live workflow heartbeats via record-phase at each phase
283
+ // boundary, so the sweep no longer kills sessions for slow-but-alive workflows
284
+ // (duplicate-resume fix, 2026-09-11).
285
+ db.prepare(
286
+ `UPDATE agent_sessions SET status = 'timed_out', ended_at = datetime('now')
287
+ WHERE status = 'running' AND datetime(COALESCE(last_heartbeat, started_at)) < datetime('now', '-1 hour')`
288
+ ).run();
289
+
290
+ const taskRows = db.prepare("SELECT * FROM tasks").all();
291
+ const projectRows = db.prepare("SELECT * FROM projects ORDER BY display_name").all();
292
+ const configRows = db.prepare("SELECT key, value FROM config WHERE key <> 'simultaneity'").all();
293
+ const sessionCount = db.prepare("SELECT COUNT(*) AS n FROM agent_sessions").get().n;
294
+ const eventCount = db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
295
+
296
+ const taskStates = new Map(taskRows.map((t) => [t.id, t.state]));
297
+ const projectConfigs = new Map(projectRows.map((p) => [p.id, p]));
298
+ const priorityOrder = { high: 0, normal: 1, low: 2 };
299
+ const readyTaskRows = taskRows
300
+ .filter((task) => {
301
+ const project = projectConfigs.get(task.project);
302
+ if (!project || project.quiesced) return false;
303
+ if (task.state === "in_progress") return true;
304
+ if (task.state !== "todo") return false;
305
+ return !parseDeps(task.deps).some((id) => taskStates.get(id) !== "done");
306
+ })
307
+ .sort((a, b) => {
308
+ const d = priorityOrder[a.priority] - priorityOrder[b.priority];
309
+ return d !== 0 ? d : a.created_at.localeCompare(b.created_at);
310
+ });
311
+
312
+ const readyIds = readyTaskRows.map((t) => t.id);
313
+ let sessionsByTask = new Map();
314
+ if (readyIds.length > 0) {
315
+ const ph = readyIds.map(() => "?").join(",");
316
+ const rows = db.prepare(
317
+ `SELECT * FROM agent_sessions WHERE task_id IN (${ph})
318
+ ORDER BY task_id, started_at DESC`
319
+ ).all(...readyIds);
320
+ for (const s of rows) {
321
+ const list = sessionsByTask.get(s.task_id) ?? [];
322
+ list.push(s);
323
+ sessionsByTask.set(s.task_id, list);
324
+ }
325
+ }
326
+
327
+ return {
328
+ ready_tasks: readyTaskRows.map((task) => {
329
+ const deps = parseDeps(task.deps);
330
+ const taskSessions = sessionsByTask.get(task.id) ?? [];
331
+ const latest = taskSessions[0] ?? null;
332
+ const project = projectConfigs.get(task.project);
333
+ return {
334
+ id: task.id,
335
+ title: task.title,
336
+ description: task.description,
337
+ state: task.state,
338
+ project: task.project,
339
+ project_config: {
340
+ simultaneity: project?.simultaneity ?? 0,
341
+ quiesced: !!project?.quiesced,
342
+ },
343
+ workflow: task.workflow,
344
+ blocked: deps.some((id) => taskStates.get(id) !== "done"),
345
+ deps,
346
+ priority: task.priority,
347
+ latest_session: latest ? {
348
+ id: latest.id,
349
+ identity: latest.identity,
350
+ step: latest.step,
351
+ status: latest.status,
352
+ notes: latest.notes,
353
+ started_at: latest.started_at,
354
+ ended_at: latest.ended_at,
355
+ caveats: parseCaveats(latest.caveats),
356
+ } : null,
357
+ retry: {
358
+ consecutive_failures: countConsecutiveFailures(
359
+ taskSessions.map(mapSession), task.retry_reset_at),
360
+ rejections_since_reset: countRejectionsSinceReset(
361
+ taskSessions.map(mapSession), task.retry_reset_at),
362
+ },
363
+ };
364
+ }),
365
+ projects: projectRows.map(mapProject),
366
+ config: Object.fromEntries(configRows.map((r) => [r.key, r.value])),
367
+ counts: {
368
+ total_tasks: taskRows.length,
369
+ active_tasks: taskRows.filter((t) => t.state === "todo" || t.state === "in_progress").length,
370
+ done_tasks: taskRows.filter((t) => t.state === "done").length,
371
+ total_sessions: sessionCount,
372
+ total_events: eventCount,
373
+ },
374
+ };
375
+ };
376
+
377
+ commands["get-state"] = (db, args) => {
378
+ const dayStart = args.day_start ?? new Date(new Date().setUTCHours(0, 0, 0, 0)).toISOString();
379
+ const dayEnd = args.day_end ?? new Date(new Date().setUTCHours(24, 0, 0, 0)).toISOString();
380
+ const limit = Math.min(Math.max(args.events_limit ?? 20, 1), 100);
381
+ const offset = Math.max(args.events_offset ?? 0, 0);
382
+ const projectRows = db.prepare("SELECT * FROM projects ORDER BY display_name").all();
383
+ const taskRows = db.prepare("SELECT * FROM tasks ORDER BY updated_at DESC").all();
384
+ const sessionRows = db.prepare("SELECT * FROM agent_sessions ORDER BY started_at DESC").all();
385
+ const recentEvents = db.prepare(
386
+ "SELECT * FROM events ORDER BY timestamp DESC LIMIT ? OFFSET ?").all(limit, offset);
387
+ const todayEvents = db.prepare(
388
+ "SELECT id FROM events WHERE timestamp >= ? AND timestamp < ?").all(dayStart, dayEnd);
389
+ const eventCount = db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
390
+ const pollRow = db.prepare("SELECT last_poll_at FROM poll_state WHERE id = 1").get();
391
+ const configRows = db.prepare("SELECT key, value FROM config WHERE key <> 'simultaneity'").all();
392
+ const taskStates = new Map(taskRows.map((t) => [t.id, t.state]));
393
+ return {
394
+ projects: projectRows.map(mapProject),
395
+ tasks: taskRows.map((t) => mapTask(t, taskStates)),
396
+ sessions: sessionRows.map(mapSession),
397
+ events: recentEvents.map(mapEvent),
398
+ events_total: eventCount,
399
+ events_offset: offset,
400
+ events_limit: limit,
401
+ events_has_more: offset + recentEvents.length < eventCount,
402
+ config: Object.fromEntries(configRows.map((r) => [r.key, r.value])),
403
+ summary: {
404
+ active: sessionRows.filter((s) => s.status === "running").length,
405
+ total_tasks: taskRows.length,
406
+ events_today: todayEvents.length,
407
+ total_events: eventCount,
408
+ },
409
+ observed_at: now(),
410
+ last_poll_at: pollRow?.last_poll_at ?? null,
411
+ };
412
+ };
413
+
414
+ commands["create-task"] = (db, args, ctx) => {
415
+ const title = (args.title ?? "").trim();
416
+ if (title.length < 1 || title.length > 160) throw usageError("title must be 1-160 chars.");
417
+ const description = (args.description ?? "").trim();
418
+ if (description.length > 5000) throw usageError("description must be <= 5000 chars.");
419
+ const state = args.state ?? "todo";
420
+ if (!["todo", "in_progress", "parked", "done"].includes(state)) throw usageError("bad state.");
421
+ const priority = args.priority ?? "normal";
422
+ if (!["high", "normal", "low"].includes(priority)) throw usageError("bad priority.");
423
+ const filedBy = (args.filed_by ?? "").trim();
424
+ if (filedBy.length < 1 || filedBy.length > 80) throw usageError("filed_by is required (1-80 chars).");
425
+ if (args.workflow && !workflowExists(ctx.crewHome, args.workflow)) throw notFound("Workflow not found.");
426
+ const projectId = args.project || "orchestra-dashboard";
427
+ requireProject(db, projectId);
428
+ const deps = [...new Set(args.deps ?? [])];
429
+ const existingIds = new Set(db.prepare("SELECT id FROM tasks").all().map((r) => r.id));
430
+ const missing = deps.find((id) => !existingIds.has(id));
431
+ if (missing) throw usageError(`Unknown task dependency: ${missing}.`);
432
+
433
+ const timestamp = now();
434
+ const task = {
435
+ id: uuid(), title, description, state, priority, project: projectId,
436
+ workflow: args.workflow ?? null, next_phase: null, deps: JSON.stringify(deps),
437
+ filed_by: filedBy, retry_reset_at: null, created_at: timestamp, updated_at: timestamp,
438
+ };
439
+ const event = {
440
+ id: uuid(), type: "created", task_id: task.id, identity: null,
441
+ message: `Created \u201c${title}\u201d${task.workflow ? ` with the ${task.workflow} workflow` : " for triage"}.`,
442
+ timestamp,
443
+ };
444
+ const insertTask = db.prepare(
445
+ `INSERT INTO tasks (id, title, description, state, priority, project, workflow,
446
+ next_phase, deps, filed_by, retry_reset_at, created_at, updated_at)
447
+ VALUES (@id, @title, @description, @state, @priority, @project, @workflow,
448
+ @next_phase, @deps, @filed_by, @retry_reset_at, @created_at, @updated_at)`);
449
+ const insertEvent = db.prepare(
450
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
451
+ VALUES (@id, @type, @task_id, @identity, @message, @timestamp)`);
452
+ db.exec("BEGIN");
453
+ try {
454
+ insertTask.run(task);
455
+ insertEvent.run(event);
456
+ db.exec("COMMIT");
457
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
458
+ const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
459
+ return { task: mapTask({ ...task }, taskStates) };
460
+ };
461
+
462
+ commands["update-task"] = (db, args, ctx) => {
463
+ if (!args.id) throw usageError("id is required.");
464
+ const current = requireTask(db, args.id);
465
+ if (args.workflow && !workflowExists(ctx.crewHome, args.workflow)) throw notFound("Workflow not found.");
466
+ if (args.project !== undefined) requireProject(db, args.project);
467
+ if (args.project !== undefined && args.project !== current.project) {
468
+ const active = findActiveRuns(db, [args.id]);
469
+ if (active[0]) {
470
+ throw conflict(
471
+ `Cannot move task "${current.title}" from project "${current.project}" to "${args.project}": ` +
472
+ `an active run is in progress (${active[0].identity} ${active[0].step ?? "unknown step"}). ` +
473
+ `Wait for the run to finish, or recover/park the task first.`);
474
+ }
475
+ }
476
+ const deps = args.deps === undefined ? undefined : [...new Set(args.deps)];
477
+ if (deps !== undefined) {
478
+ const existingIds = new Set(db.prepare("SELECT id FROM tasks").all().map((r) => r.id));
479
+ const missing = deps.find((id) => !existingIds.has(id));
480
+ if (missing) throw usageError(`Unknown task dependency: ${missing}.`);
481
+ }
482
+ const timestamp = now();
483
+ const patch = {};
484
+ setRetryResetAtForTransition(patch, current.state, args.state, timestamp);
485
+ if (args.title !== undefined) {
486
+ const t = args.title.trim();
487
+ if (t.length < 1 || t.length > 200) throw usageError("title must be 1-200 chars.");
488
+ patch.title = t;
489
+ }
490
+ if (args.description !== undefined) patch.description = args.description.trim().slice(0, 3000);
491
+ if (args.state !== undefined) {
492
+ if (!["todo", "in_progress", "parked", "done"].includes(args.state)) throw usageError("bad state.");
493
+ patch.state = args.state;
494
+ }
495
+ if (args.priority !== undefined) {
496
+ if (!["high", "normal", "low"].includes(args.priority)) throw usageError("bad priority.");
497
+ patch.priority = args.priority;
498
+ }
499
+ if (args.project !== undefined) patch.project = args.project;
500
+ if (args.workflow !== undefined) patch.workflow = args.workflow;
501
+ if (deps !== undefined) patch.deps = JSON.stringify(deps);
502
+
503
+ db.exec("BEGIN");
504
+ try {
505
+ if (Object.keys(patch).length > 0) {
506
+ patch.updated_at = timestamp;
507
+ const sets = Object.keys(patch).map((k) => `${k} = @${k}`).join(", ");
508
+ db.prepare(`UPDATE tasks SET ${sets} WHERE id = @id`).run({ ...patch, id: args.id });
509
+ }
510
+ if (args.state && args.state !== current.state) {
511
+ const type = args.state === "done" ? "completed"
512
+ : args.state === "in_progress" ? "dispatched" : "note";
513
+ db.prepare(
514
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
515
+ VALUES (?, ?, ?, NULL, ?, ?)`
516
+ ).run(uuid(), type, args.id, `Moved \u201c${patch.title ?? current.title}\u201d to ${args.state.replace("_", " ")}.`, timestamp);
517
+ }
518
+ db.exec("COMMIT");
519
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
520
+ const updated = { ...current, ...patch };
521
+ const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
522
+ return { task: mapTask(updated, taskStates) };
523
+ };
524
+
525
+ commands["claim-task"] = (db, args) => {
526
+ if (!args.task_id) throw usageError("task_id is required.");
527
+ const identity = (args.identity ?? "").trim();
528
+ if (identity.length < 1 || identity.length > 80) throw usageError("identity is required (1-80 chars).");
529
+ const task = requireTask(db, args.task_id);
530
+ const project = requireProject(db, task.project);
531
+ if (project.quiesced) throw conflict("This project is paused. Resume it before claiming tasks.");
532
+
533
+ const row = {
534
+ id: uuid(), task_id: args.task_id, identity,
535
+ step: args.step ?? null, status: "running",
536
+ started_at: now(), ended_at: null,
537
+ notes: (args.notes ?? "").trim().slice(0, 3000), caveats: "[]",
538
+ last_heartbeat: now(),
539
+ };
540
+ // Atomic: the partial unique index on (task_id) WHERE status='running'
541
+ // turns a duplicate claim into a no-op insert.
542
+ const info = db.prepare(
543
+ `INSERT INTO agent_sessions (id, task_id, identity, step, status, started_at,
544
+ ended_at, notes, caveats, last_heartbeat)
545
+ VALUES (@id, @task_id, @identity, @step, @status, @started_at,
546
+ @ended_at, @notes, @caveats, @last_heartbeat)
547
+ ON CONFLICT DO NOTHING`).run(row);
548
+ if (info.changes > 0) {
549
+ return { ok: true, claimed: true, session_id: row.id, session: mapSession({ ...row, failure_reason: null }) };
550
+ }
551
+ const existing = db.prepare(
552
+ `SELECT id FROM agent_sessions
553
+ WHERE task_id = ? AND status = 'running'
554
+ ORDER BY started_at DESC LIMIT 1`).get(args.task_id);
555
+ if (!existing) throw new CrewError("claim_unresolved", "Task claim could not be resolved. Please retry.", 4);
556
+ return { ok: true, claimed: false, reason: "already_claimed", existing_session_id: existing.id };
557
+ };
558
+
559
+ commands["park-task"] = (db, args) => {
560
+ if (!args.task_id) throw usageError("task_id is required.");
561
+ const message = (args.message ?? "").trim();
562
+ if (message.length < 1 || message.length > 1000) throw usageError("message is required (1-1000 chars).");
563
+ const task = requireTask(db, args.task_id);
564
+ const timestamp = now();
565
+ db.exec("BEGIN");
566
+ try {
567
+ db.prepare("UPDATE tasks SET state = 'parked', updated_at = ? WHERE id = ?").run(timestamp, task.id);
568
+ db.prepare(
569
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
570
+ VALUES (?, 'note', ?, NULL, ?, ?)`
571
+ ).run(uuid(), task.id, message, timestamp);
572
+ // Settle every running session: a parked task never keeps a ghost run.
573
+ // Settled sessions move to 'failed' so recover-task accepts them.
574
+ const settled = db.prepare(
575
+ `UPDATE agent_sessions
576
+ SET status = 'failed', ended_at = ?,
577
+ notes = notes || ' Parked: ' || ?
578
+ WHERE task_id = ? AND status = 'running'`
579
+ ).run(timestamp, message.slice(0, 500), task.id);
580
+ db.exec("COMMIT");
581
+ const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
582
+ return {
583
+ ok: true,
584
+ task: mapTask({ ...task, state: "parked", updated_at: timestamp }, taskStates),
585
+ settled_sessions: Number(settled.changes),
586
+ };
587
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
588
+ };
589
+
590
+ commands["recover-task"] = (db, args, ctx) => {
591
+ if (!args.task_id) throw usageError("task_id is required.");
592
+ if (args.action !== "send_to") throw usageError('action must be "send_to".');
593
+ const targetPhase = (args.target_phase ?? "").trim();
594
+ if (targetPhase.length < 1 || targetPhase.length > 120) throw usageError("target_phase is required (1-120 chars).");
595
+ const task = requireTask(db, args.task_id);
596
+ const latest = db.prepare(
597
+ `SELECT * FROM agent_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1`
598
+ ).get(args.task_id);
599
+ if (!latest || !["failed", "timed_out"].includes(latest.status)) {
600
+ throw conflict("The latest session is not failed or timed out.");
601
+ }
602
+ if (!task.workflow) throw conflict("This task does not have a workflow with selectable phases.");
603
+ if (!workflowExists(ctx.crewHome, task.workflow)) throw notFound("Workflow not found.");
604
+ const timestamp = now();
605
+ const queued = {
606
+ id: uuid(), task_id: task.id,
607
+ identity: latest.identity, step: targetPhase, status: "failed",
608
+ started_at: timestamp, ended_at: timestamp,
609
+ notes: (args.updated_description ?? "").trim().slice(0, 5000),
610
+ caveats: "[]", failure_reason: null, last_heartbeat: timestamp,
611
+ };
612
+ db.exec("BEGIN");
613
+ try {
614
+ db.prepare("UPDATE tasks SET next_phase = ?, updated_at = ? WHERE id = ?")
615
+ .run(targetPhase, timestamp, task.id);
616
+ db.prepare("UPDATE agent_sessions SET ended_at = ? WHERE id = ?").run(timestamp, latest.id);
617
+ db.prepare(
618
+ `INSERT INTO agent_sessions (id, task_id, identity, step, status, started_at,
619
+ ended_at, notes, caveats, failure_reason, last_heartbeat)
620
+ VALUES (@id, @task_id, @identity, @step, @status, @started_at,
621
+ @ended_at, @notes, @caveats, @failure_reason, @last_heartbeat)`).run(queued);
622
+ db.exec("COMMIT");
623
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
624
+ const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
625
+ return {
626
+ task: mapTask({ ...task, next_phase: targetPhase, updated_at: timestamp }, taskStates),
627
+ session: mapSession(queued),
628
+ };
629
+ };
630
+
631
+ commands["upsert-session"] = (db, args) => {
632
+ if (!args.task_id) throw usageError("task_id is required.");
633
+ requireTask(db, args.task_id);
634
+ const identity = (args.identity ?? "").trim();
635
+ if (identity.length < 1) throw usageError("identity is required.");
636
+ const status = args.status;
637
+ const valid = ["running", "completed", "failed", "rejected", "timed_out", "stalled", "passed", "superseded"];
638
+ if (!valid.includes(status)) throw usageError(`bad status: ${status}.`);
639
+ const id = args.id ?? uuid();
640
+ const current = db.prepare("SELECT * FROM agent_sessions WHERE id = ?").get(id);
641
+ const row = {
642
+ id,
643
+ task_id: args.task_id,
644
+ identity,
645
+ step: args.step === undefined ? (current?.step ?? null) : args.step,
646
+ status,
647
+ started_at: args.started_at ?? current?.started_at ?? now(),
648
+ ended_at: args.ended_at ?? (status === "running" ? null : now()),
649
+ notes: (args.notes ?? "").trim().slice(0, 3000),
650
+ caveats: args.caveats === undefined ? (current?.caveats ?? "[]") : JSON.stringify(args.caveats),
651
+ failure_reason: args.failure_reason === undefined ? (current?.failure_reason ?? null) : args.failure_reason,
652
+ last_heartbeat: now(),
653
+ };
654
+ if (current) {
655
+ db.prepare(
656
+ `UPDATE agent_sessions SET task_id=@task_id, identity=@identity, step=@step,
657
+ status=@status, started_at=@started_at, ended_at=@ended_at, notes=@notes,
658
+ caveats=@caveats, failure_reason=@failure_reason, last_heartbeat=@last_heartbeat WHERE id=@id`).run(row);
659
+ } else {
660
+ db.prepare(
661
+ `INSERT INTO agent_sessions (id, task_id, identity, step, status, started_at,
662
+ ended_at, notes, caveats, failure_reason, last_heartbeat)
663
+ VALUES (@id, @task_id, @identity, @step, @status, @started_at,
664
+ @ended_at, @notes, @caveats, @failure_reason, @last_heartbeat)
665
+ ON CONFLICT DO NOTHING`).run(row);
666
+ }
667
+ return { session: mapSession(row) };
668
+ };
669
+
670
+ commands["heartbeat-session"] = (db, args) => {
671
+ // Update the last_heartbeat for a running session. Workflows call this
672
+ // periodically during long phases to prevent the zombie sweep from
673
+ // marking a live session as timed_out (duplicate-resume fix, 2026-09-11).
674
+ if (!args.id) throw usageError("id is required.");
675
+ const session = db.prepare("SELECT * FROM agent_sessions WHERE id = ?").get(args.id);
676
+ if (!session) throw notFound("Session not found.");
677
+ if (session.status !== "running") throw conflict("Only running sessions can heartbeat.");
678
+ const timestamp = now();
679
+ db.prepare("UPDATE agent_sessions SET last_heartbeat = ? WHERE id = ?").run(timestamp, args.id);
680
+ return { session: mapSession({ ...session, last_heartbeat: timestamp }) };
681
+ };
682
+
683
+ commands["log-event"] = (db, args) => {
684
+ const valid = ["dispatched", "completed", "failed", "blocked", "created",
685
+ "note", "release_activated", "deployed", "rejected"];
686
+ if (!valid.includes(args.type)) throw usageError(`bad event type: ${args.type}.`);
687
+ const message = (args.message ?? "").trim();
688
+ if (message.length < 1 || message.length > 1000) throw usageError("message is required (1-1000 chars).");
689
+ if (args.task_id) requireTask(db, args.task_id);
690
+ const event = {
691
+ id: uuid(), type: args.type, task_id: args.task_id ?? null,
692
+ identity: args.identity ?? null, message,
693
+ timestamp: args.timestamp ?? now(),
694
+ };
695
+ db.prepare(
696
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
697
+ VALUES (@id, @type, @task_id, @identity, @message, @timestamp)`).run(event);
698
+ return { event: mapEvent(event) };
699
+ };
700
+
701
+ commands["get-events"] = (db, args) => {
702
+ const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
703
+ const rows = args.task_id
704
+ ? db.prepare("SELECT * FROM events WHERE task_id = ? ORDER BY timestamp DESC LIMIT ?").all(args.task_id, limit)
705
+ : db.prepare("SELECT * FROM events ORDER BY timestamp DESC LIMIT ?").all(limit);
706
+ return { events: rows.map(mapEvent) };
707
+ };
708
+
709
+ // Composite: record a phase's session verdict AND its event in one
710
+ // transaction. This is the operation that used to crash the workflow when the
711
+ // two writes went to different contracts: the session stored, the event
712
+ // rejected, the run dead. One transaction, one vocabulary, no partial state.
713
+ commands["record-phase"] = (db, args) => {
714
+ if (!args.task_id) throw usageError("task_id is required.");
715
+ requireTask(db, args.task_id);
716
+ const session = args.session ?? {};
717
+ const event = args.event ?? {};
718
+ const status = session.status;
719
+ const validStatuses = ["running", "completed", "failed", "rejected", "timed_out", "stalled", "passed", "superseded"];
720
+ if (!validStatuses.includes(status)) throw usageError(`bad session status: ${status}.`);
721
+ const validTypes = ["dispatched", "completed", "failed", "blocked", "created",
722
+ "note", "release_activated", "deployed", "rejected"];
723
+ if (!validTypes.includes(event.type)) throw usageError(`bad event type: ${event.type}.`);
724
+ const message = (event.message ?? "").trim();
725
+ if (message.length < 1 || message.length > 1000) throw usageError("event message is required (1-1000 chars).");
726
+ const timestamp = now();
727
+ const sessionRow = {
728
+ id: session.id ?? uuid(),
729
+ task_id: args.task_id,
730
+ identity: (session.identity ?? "").trim() || "crew",
731
+ step: session.step ?? null,
732
+ status,
733
+ started_at: session.started_at ?? timestamp,
734
+ ended_at: session.ended_at ?? (status === "running" ? null : timestamp),
735
+ notes: (session.notes ?? "").trim().slice(0, 3000),
736
+ caveats: session.caveats === undefined ? "[]" : JSON.stringify(session.caveats),
737
+ failure_reason: session.failure_reason ?? null,
738
+ last_heartbeat: timestamp,
739
+ };
740
+ const eventRow = {
741
+ id: uuid(), type: event.type, task_id: args.task_id,
742
+ identity: event.identity ?? sessionRow.identity, message, timestamp,
743
+ };
744
+ db.exec("BEGIN");
745
+ try {
746
+ db.prepare(
747
+ `INSERT INTO agent_sessions (id, task_id, identity, step, status, started_at,
748
+ ended_at, notes, caveats, failure_reason, last_heartbeat)
749
+ VALUES (@id, @task_id, @identity, @step, @status, @started_at,
750
+ @ended_at, @notes, @caveats, @failure_reason, @last_heartbeat)
751
+ ON CONFLICT(id) DO UPDATE SET status=@status, ended_at=@ended_at,
752
+ notes=@notes, caveats=@caveats, failure_reason=@failure_reason,
753
+ last_heartbeat=@last_heartbeat`).run(sessionRow);
754
+ db.prepare(
755
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
756
+ VALUES (@id, @type, @task_id, @identity, @message, @timestamp)`).run(eventRow);
757
+ db.exec("COMMIT");
758
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
759
+ return { session: mapSession(sessionRow), event: mapEvent(eventRow) };
760
+ };
761
+
762
+ commands["create-project"] = (db, args) => {
763
+ const id = (args.id ?? "").trim();
764
+ if (!/^[a-z0-9-]+$/.test(id)) throw usageError("id must be a slug.");
765
+ const displayName = (args.display_name ?? "").trim();
766
+ if (displayName.length < 1 || displayName.length > 120) throw usageError("display_name must be 1-120 chars.");
767
+ const repoPath = (args.repo_path ?? "").trim();
768
+ if (!repoPath) throw usageError("repo_path is required.");
769
+ if (!isGitRepoPath(repoPath)) throw usageError("repo_path must be a valid Git repository path.");
770
+ const deployType = args.deploy_type ?? "";
771
+ if (!["npm", "artifact", "vercel", ""].includes(deployType)) throw usageError("bad deploy_type.");
772
+ if (db.prepare("SELECT id FROM projects WHERE id = ?").get(id)) {
773
+ throw conflict("A project with this ID already exists.");
774
+ }
775
+ const timestamp = now();
776
+ const visualProtocol = validateVisualProtocol(args.visual_protocol);
777
+ const row = {
778
+ id, display_name: displayName, repo_path: repoPath, deploy_type: deployType,
779
+ deploy_slug: args.deploy_slug ?? null,
780
+ description: (args.description ?? "").trim().slice(0, 3000),
781
+ simultaneity: args.simultaneity ?? 2,
782
+ quiesced: args.quiesced ? 1 : 0,
783
+ visual_protocol: visualProtocol == null ? null : (visualProtocol ? 1 : 0),
784
+ created_at: timestamp, updated_at: timestamp,
785
+ };
786
+ if (!Number.isInteger(row.simultaneity) || row.simultaneity < 1 || row.simultaneity > 100) {
787
+ throw usageError("simultaneity must be 1-100.");
788
+ }
789
+ db.prepare(
790
+ `INSERT INTO projects (id, display_name, repo_path, deploy_type, deploy_slug,
791
+ description, simultaneity, quiesced, visual_protocol, created_at, updated_at)
792
+ VALUES (@id, @display_name, @repo_path, @deploy_type, @deploy_slug,
793
+ @description, @simultaneity, @quiesced, @visual_protocol, @created_at, @updated_at)`).run(row);
794
+ return { project: mapProject(row) };
795
+ };
796
+
797
+ commands["get-project"] = (db, args) => {
798
+ if (!args.id) throw usageError("id is required.");
799
+ return { project: mapProject(requireProject(db, args.id)) };
800
+ };
801
+
802
+ commands["list-projects"] = (db) => {
803
+ const rows = db.prepare("SELECT * FROM projects ORDER BY display_name").all();
804
+ return { projects: rows.map(mapProject) };
805
+ };
806
+
807
+ commands["update-project"] = (db, args) => {
808
+ if (!args.id) throw usageError("id is required.");
809
+ const current = requireProject(db, args.id);
810
+ if (args.repo_path !== undefined && !isGitRepoPath(args.repo_path)) {
811
+ throw usageError("repo_path must be a valid Git repository path.");
812
+ }
813
+ const patch = { updated_at: now() };
814
+ if (args.display_name !== undefined) patch.display_name = args.display_name.trim();
815
+ if (args.repo_path !== undefined) patch.repo_path = args.repo_path;
816
+ if (args.deploy_type !== undefined) {
817
+ if (!["npm", "artifact", "vercel", ""].includes(args.deploy_type)) throw usageError("bad deploy_type.");
818
+ patch.deploy_type = args.deploy_type;
819
+ }
820
+ if (args.deploy_slug !== undefined) patch.deploy_slug = args.deploy_slug;
821
+ if (args.description !== undefined) patch.description = args.description.trim().slice(0, 3000);
822
+ if (args.simultaneity !== undefined) {
823
+ if (!Number.isInteger(args.simultaneity) || args.simultaneity < 0 || args.simultaneity > 100) {
824
+ throw usageError("simultaneity must be 0-100.");
825
+ }
826
+ const changesDispatchState = (current.simultaneity === 0) !== (args.simultaneity === 0);
827
+ if (changesDispatchState) throw usageError("Use kill-switch to pause or resume dispatch.");
828
+ patch.simultaneity = args.simultaneity;
829
+ }
830
+ if (args.quiesced !== undefined) patch.quiesced = args.quiesced ? 1 : 0;
831
+ // NOT covered by the context-change guard below: visual_protocol is read
832
+ // once from launch args at dispatch time, so a mid-run change only affects
833
+ // future launches.
834
+ if (args.visual_protocol !== undefined) {
835
+ const vp = validateVisualProtocol(args.visual_protocol);
836
+ patch.visual_protocol = vp == null ? null : (vp ? 1 : 0);
837
+ }
838
+
839
+ const contextChanged =
840
+ (args.repo_path !== undefined && args.repo_path !== current.repo_path) ||
841
+ (args.deploy_type !== undefined && args.deploy_type !== current.deploy_type) ||
842
+ (args.deploy_slug !== undefined && args.deploy_slug !== current.deploy_slug);
843
+ if (contextChanged) {
844
+ const taskIds = db.prepare("SELECT id FROM tasks WHERE project = ?").all(args.id).map((r) => r.id);
845
+ const active = findActiveRuns(db, taskIds);
846
+ if (active.length > 0) {
847
+ throw conflict(
848
+ `Cannot change repo_path/deploy_type/deploy_slug for project "${current.display_name}": ` +
849
+ `${active.length} active run(s) in progress. Wait for the runs to finish, or recover/park those tasks first.`);
850
+ }
851
+ }
852
+ const sets = Object.keys(patch).map((k) => `${k} = @${k}`).join(", ");
853
+ db.prepare(`UPDATE projects SET ${sets} WHERE id = @id`).run({ ...patch, id: args.id });
854
+ return { project: mapProject({ ...current, ...patch }) };
855
+ };
856
+
857
+ commands["delete-project"] = (db, args) => {
858
+ if (!args.id) throw usageError("id is required.");
859
+ requireProject(db, args.id);
860
+ const taskIds = db.prepare("SELECT id FROM tasks WHERE project = ?").all(args.id).map((r) => r.id);
861
+ db.exec("BEGIN");
862
+ try {
863
+ if (taskIds.length > 0) {
864
+ const ph = taskIds.map(() => "?").join(",");
865
+ db.prepare(`DELETE FROM events WHERE task_id IN (${ph})`).run(...taskIds);
866
+ db.prepare(`DELETE FROM agent_sessions WHERE task_id IN (${ph})`).run(...taskIds);
867
+ }
868
+ db.prepare("DELETE FROM tasks WHERE project = ?").run(args.id);
869
+ db.prepare("DELETE FROM config WHERE key = ?").run(`prev_simultaneity_${args.id}`);
870
+ db.prepare("DELETE FROM projects WHERE id = ?").run(args.id);
871
+ db.exec("COMMIT");
872
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
873
+ return { ok: true };
874
+ };
875
+
876
+ commands["kill-switch"] = (db, args) => {
877
+ if (!args.project_id) throw usageError("project_id is required.");
878
+ if (!["pause", "resume"].includes(args.action)) throw usageError('action must be "pause" or "resume".');
879
+ const reason = (args.reason ?? "").trim();
880
+ if (reason.length < 1 || reason.length > 1000) throw usageError("reason is required (1-1000 chars).");
881
+ const actor = (args.actor ?? "agent").trim().slice(0, 120) || "agent";
882
+ const project = requireProject(db, args.project_id);
883
+ const isPaused = project.simultaneity === 0;
884
+ if (args.action === "pause" && isPaused) throw conflict("This project is already paused.");
885
+ if (args.action === "resume" && !isPaused) throw conflict("This project is already running.");
886
+ const timestamp = now();
887
+ const storedKey = `prev_simultaneity_${project.id}`;
888
+ let newSimultaneity;
889
+ db.exec("BEGIN");
890
+ try {
891
+ if (args.action === "pause") {
892
+ newSimultaneity = 0;
893
+ db.prepare("INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
894
+ .run(storedKey, String(project.simultaneity));
895
+ db.prepare("UPDATE projects SET simultaneity = 0, updated_at = ? WHERE id = ?").run(timestamp, project.id);
896
+ } else {
897
+ const stored = db.prepare("SELECT value FROM config WHERE key = ?").get(storedKey);
898
+ const parsed = Number.parseInt(stored?.value ?? "2", 10);
899
+ newSimultaneity = Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, 100) : 2;
900
+ db.prepare("UPDATE projects SET simultaneity = ?, updated_at = ? WHERE id = ?")
901
+ .run(newSimultaneity, timestamp, project.id);
902
+ db.prepare("DELETE FROM config WHERE key = ?").run(storedKey);
903
+ }
904
+ const verb = args.action === "pause" ? "Paused" : "Resumed";
905
+ db.prepare(
906
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
907
+ VALUES (?, 'note', NULL, NULL, ?, ?)`
908
+ ).run(uuid(),
909
+ `[KILL SWITCH] ${verb} project "${project.display_name}" — reason: ${reason} | previous_sim=${project.simultaneity} new_sim=${newSimultaneity} actor=${actor}`,
910
+ timestamp);
911
+ db.exec("COMMIT");
912
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
913
+ return {
914
+ ok: true, project_id: project.id,
915
+ previous_simultaneity: project.simultaneity, new_simultaneity: newSimultaneity,
916
+ };
917
+ };
918
+
919
+ commands["get-config"] = (db) => {
920
+ const rows = db.prepare("SELECT key, value FROM config").all();
921
+ return { config: Object.fromEntries(rows.map((r) => [r.key, r.value])) };
922
+ };
923
+
924
+ commands["update-config"] = (db, args) => {
925
+ const key = (args.key ?? "").trim();
926
+ if (key.length < 1 || key.length > 80) throw usageError("key must be 1-80 chars.");
927
+ if (key === "simultaneity") throw usageError("Simultaneity is configured per project.");
928
+ const value = String(args.value ?? "").slice(0, 10000);
929
+ db.prepare("INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
930
+ .run(key, value);
931
+ return { ok: true };
932
+ };
933
+
934
+ commands["set-provenance"] = (db, args) => {
935
+ if (!/^[0-9a-f]{40}$/.test(args.source_commit ?? "")) throw usageError("source_commit must be 40-char hex.");
936
+ if (!/^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/.test(args.crew_release ?? "")) {
937
+ throw usageError("crew_release must be 40-char hex or pkg-<semver>.");
938
+ }
939
+ // task_id is optional; when present it must be 1-80 chars (matches dashboard contract).
940
+ const taskId = args.task_id ?? null;
941
+ if (taskId !== null && (typeof taskId !== "string" || taskId.trim().length < 1 || taskId.length > 80)) {
942
+ throw usageError("task_id must be 1-80 chars when provided.");
943
+ }
944
+ const upsert = db.prepare(
945
+ "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value");
946
+ upsert.run("provenance.source_commit", args.source_commit);
947
+ upsert.run("provenance.crew_release", args.crew_release);
948
+ upsert.run("provenance.published_at", args.published_at ?? now());
949
+ if (taskId !== null) upsert.run("provenance.task_id", taskId);
950
+ return { ok: true };
951
+ };
952
+
953
+ commands["get-provenance"] = (db) => {
954
+ const rows = db.prepare("SELECT key, value FROM config WHERE key LIKE 'provenance.%'").all();
955
+ const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]));
956
+ const sc = byKey["provenance.source_commit"];
957
+ const cr = byKey["provenance.crew_release"];
958
+ const pa = byKey["provenance.published_at"];
959
+ if (!sc || !cr || !pa) return { provenance: null };
960
+ return { provenance: {
961
+ source_commit: sc, crew_release: cr, published_at: pa,
962
+ task_id: byKey["provenance.task_id"] ?? null,
963
+ } };
964
+ };
965
+
966
+ commands["acknowledge-poll"] = (db) => {
967
+ const timestamp = now();
968
+ const existing = db.prepare("SELECT min_poll_interval_seconds FROM poll_state WHERE id = 1").get();
969
+ if (existing) {
970
+ db.prepare("UPDATE poll_state SET poll_requested = 0, last_poll_at = ? WHERE id = 1").run(timestamp);
971
+ } else {
972
+ db.prepare("INSERT INTO poll_state (id, last_poll_at, poll_requested, min_poll_interval_seconds) VALUES (1, ?, 0, 300)")
973
+ .run(timestamp);
974
+ }
975
+ return {
976
+ last_poll_at: timestamp, poll_requested: false,
977
+ min_poll_interval_seconds: existing?.min_poll_interval_seconds ?? 300,
978
+ };
979
+ };
980
+
981
+ // One-time import from a dashboard app.db. Copies the six crew tables
982
+ // verbatim (same column names). The dashboard DB is never modified.
983
+ commands["migrate"] = (db, args) => {
984
+ const dashboardDb = args.dashboard_db;
985
+ if (!dashboardDb || !existsSync(dashboardDb)) throw usageError("dashboard_db must be an existing SQLite file.");
986
+ const src = new DatabaseSync(dashboardDb, { readOnly: true });
987
+ try {
988
+ const tables = ["projects", "tasks", "poll_state", "config", "agent_sessions", "events"];
989
+ const counts = {};
990
+ db.exec("BEGIN");
991
+ try {
992
+ for (const table of tables) {
993
+ const hasTable = src.prepare(
994
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
995
+ if (!hasTable) { counts[table] = 0; continue; }
996
+ const rows = src.prepare(`SELECT * FROM "${table}"`).all();
997
+ if (rows.length === 0) { counts[table] = 0; continue; }
998
+ const cols = Object.keys(rows[0]);
999
+ const placeholders = cols.map((c) => `@${c}`).join(", ");
1000
+ const stmt = db.prepare(
1001
+ `INSERT OR REPLACE INTO "${table}" (${cols.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})`);
1002
+ for (const row of rows) stmt.run(row);
1003
+ counts[table] = rows.length;
1004
+ }
1005
+ db.exec("COMMIT");
1006
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
1007
+ return { ok: true, counts };
1008
+ } finally {
1009
+ src.close();
1010
+ }
1011
+ };
1012
+
1013
+ // import-json: one-time import from a dashboard exportCrewState JSON payload.
1014
+ // Used for the live migration (the artifact's app.db is never opened directly).
1015
+ // Args: { data: { projects: [...], tasks: [...], agent_sessions: [...], events: [...], config: [...], poll_state: [...] } }
1016
+ // Or pipe the JSON via stdin.
1017
+ commands["import-json"] = (db, args) => {
1018
+ const data = args.data;
1019
+ if (!data || typeof data !== "object") throw usageError("data must be the exportCrewState JSON object.");
1020
+ const tables = ["projects", "tasks", "poll_state", "config", "agent_sessions", "events"];
1021
+ const counts = {};
1022
+ db.exec("BEGIN");
1023
+ try {
1024
+ for (const table of tables) {
1025
+ const rows = data[table];
1026
+ if (!Array.isArray(rows) || rows.length === 0) { counts[table] = 0; continue; }
1027
+ const cols = Object.keys(rows[0]);
1028
+ const placeholders = cols.map((c) => `@${c}`).join(", ");
1029
+ const stmt = db.prepare(
1030
+ `INSERT OR REPLACE INTO "${table}" (${cols.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})`);
1031
+ for (const row of rows) stmt.run(row);
1032
+ counts[table] = rows.length;
1033
+ }
1034
+ db.exec("COMMIT");
1035
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
1036
+ return { ok: true, counts };
1037
+ };
1038
+
1039
+ // ---------------------------------------------------------------------------
1040
+ // CLI plumbing
1041
+ // ---------------------------------------------------------------------------
1042
+
1043
+ function parseArgs(argv) {
1044
+ const out = { flags: {}, json: null, command: null, crewHome: null };
1045
+ let i = 0;
1046
+ const rest = [];
1047
+ while (i < argv.length) {
1048
+ const a = argv[i];
1049
+ if (a === "--crew-home") { out.crewHome = argv[++i]; }
1050
+ else if (a === "--json") { out.json = argv[++i]; }
1051
+ else if (a.startsWith("--")) {
1052
+ const key = a.slice(2).replace(/-/g, "_");
1053
+ const next = argv[i + 1];
1054
+ if (next === undefined || next.startsWith("--")) { out.flags[key] = true; }
1055
+ else { out.flags[key] = next; i++; }
1056
+ } else rest.push(a);
1057
+ i++;
1058
+ }
1059
+ if (rest.length > 0) out.command = rest[0].replace(/_/g, "-");
1060
+ return out;
1061
+ }
1062
+
1063
+ function readStdin() {
1064
+ if (process.stdin.isTTY) return null;
1065
+ try {
1066
+ const data = readFileSync(0, "utf8").trim();
1067
+ return data ? data : null;
1068
+ } catch { return null; }
1069
+ }
1070
+
1071
+ function main() {
1072
+ const parsed = parseArgs(process.argv.slice(2));
1073
+ const cmd = commands[parsed.command];
1074
+ if (!cmd) {
1075
+ const names = Object.keys(commands).sort().join(", ");
1076
+ throw usageError(`unknown command: ${parsed.command ?? "(none)"}. Known: ${names}.`);
1077
+ }
1078
+ let args = { ...parsed.flags };
1079
+ const raw = parsed.json ?? readStdin();
1080
+ if (raw) {
1081
+ try { args = { ...args, ...JSON.parse(raw) }; }
1082
+ catch { throw usageError("--json must be valid JSON."); }
1083
+ }
1084
+ const crewHome = resolveCrewHome(parsed.crewHome);
1085
+ const db = openDb(crewHome);
1086
+ try {
1087
+ const result = cmd(db, args, { crewHome });
1088
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1089
+ } finally {
1090
+ db.close();
1091
+ }
1092
+ }
1093
+
1094
+ try {
1095
+ main();
1096
+ } catch (e) {
1097
+ const code = e instanceof CrewError ? e.code : "internal";
1098
+ const exitCode = e instanceof CrewError ? e.exitCode : 1;
1099
+ process.stderr.write(JSON.stringify({ ok: false, error: code, message: e.message }) + "\n");
1100
+ process.exit(exitCode);
1101
+ }