@felixdotgo/aidlc-kit 0.0.1 → 0.1.0

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.
Files changed (92) hide show
  1. package/DEVELOPMENT.md +1 -1
  2. package/README.md +5 -4
  3. package/dist/assets/.agents/aidlc/conventions.md +8 -8
  4. package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
  5. package/dist/assets/.agents/aidlc/phase-build.md +7 -7
  6. package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
  7. package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
  8. package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
  9. package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
  10. package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
  12. package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +8 -1
  16. package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +215 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +314 -0
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +279 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +161 -0
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +254 -0
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +65 -0
  38. package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +39 -4
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
  42. package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
  43. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +309 -0
  44. package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
  45. package/dist/assets/.agents/aidlc/scripts/state.mjs +67 -11
  46. package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
  47. package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
  48. package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
  49. package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
  50. package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
  51. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
  52. package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
  53. package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
  54. package/dist/services/aidlc-coordination/compose.yaml +23 -2
  55. package/dist/services/aidlc-coordination/package-lock.json +1 -0
  56. package/dist/services/aidlc-coordination/package.json +2 -1
  57. package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
  58. package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
  59. package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
  60. package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
  61. package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +39 -4
  63. package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
  64. package/dist/services/aidlc-coordination/src/server.mjs +53 -9
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
  66. package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
  67. package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
  68. package/dist/src/adapters.js +25 -5
  69. package/dist/src/cli.js +66 -6
  70. package/dist/src/gate.js +3 -3
  71. package/dist/src/installer.d.ts +5 -1
  72. package/dist/src/installer.js +114 -30
  73. package/dist/src/managed-content.js +1 -1
  74. package/dist/src/model.d.ts +15 -0
  75. package/dist/src/model.js +1 -1
  76. package/dist/src/profiles.d.ts +3 -1
  77. package/dist/src/profiles.js +26 -3
  78. package/dist/src/state.d.ts +7 -0
  79. package/dist/src/state.js +54 -3
  80. package/dist/src/upgrade.d.ts +2 -2
  81. package/dist/src/upgrade.js +39 -4
  82. package/dist/src/workflow.d.ts +1 -0
  83. package/dist/src/workflow.js +2 -1
  84. package/docs/README.md +2 -2
  85. package/docs/architecture.md +6 -1
  86. package/docs/command-reference.md +63 -5
  87. package/docs/configuration.md +20 -1
  88. package/docs/coordination-service.md +54 -1
  89. package/docs/development.md +1 -1
  90. package/docs/operating-workflow.md +50 -14
  91. package/docs/testing-and-release.md +47 -4
  92. package/package.json +2 -2
@@ -1,13 +1,27 @@
1
1
  import Database from "better-sqlite3";
2
2
  import pg from "pg";
3
+ import { createHash } from "node:crypto";
3
4
  import { isAbsolute, resolve } from "node:path";
4
5
  import { isDeepStrictEqual } from "node:util";
5
- import { nextAction, stampLegacyG2Waits, transitionTask, validateState } from "./lifecycle-core.mjs";
6
+ import { hasCurrentBuildApproval, nextAction, stampLegacyG2Waits, transitionDiagnostics, transitionTask, validateState } from "./lifecycle-core.mjs";
6
7
 
7
8
  const emptyState = () => ({ schemaVersion: 4, tasks: {}, archive: {} });
8
9
  const emptyIndex = (revision) => ({ schemaVersion: 1, sourceRevision: revision, taskIds: [], updatedAt: new Date().toISOString() });
9
10
  const parse = (value) => JSON.parse(value);
10
11
  const encode = (value) => JSON.stringify(value);
12
+ const requestHash = (principal, operation, payload) => createHash("sha256").update(JSON.stringify([principal,operation,payload])).digest("hex");
13
+ const sqliteRequest = (db, workspace, key, principal, operation, payload) => {
14
+ if (!principal) return;
15
+ const digest=requestHash(principal,operation,payload), row=db.prepare("SELECT digest FROM state_request_fingerprints WHERE workspace=? AND idempotency_key=?").get(workspace,key);
16
+ if (row && row.digest!==digest) throw new Error("idempotency payload or principal mismatch");
17
+ if (!row) { if (db.prepare("SELECT 1 FROM state_idempotency WHERE workspace=? AND idempotency_key=?").get(workspace,key)) throw new Error("legacy idempotency key cannot be reused by authenticated fleet"); db.prepare("INSERT INTO state_request_fingerprints VALUES (?,?,?)").run(workspace,key,digest); }
18
+ };
19
+ const postgresRequest = async (client,workspace,key,principal,operation,payload) => {
20
+ if (!principal) return;
21
+ const digest=requestHash(principal,operation,payload), row=(await client.query("SELECT digest FROM state_request_fingerprints WHERE workspace=$1 AND idempotency_key=$2",[workspace,key])).rows[0];
22
+ if(row && row.digest!==digest) throw new Error("idempotency payload or principal mismatch");
23
+ if(!row){if((await client.query("SELECT 1 FROM state_idempotency WHERE workspace=$1 AND idempotency_key=$2",[workspace,key])).rowCount) throw new Error("legacy idempotency key cannot be reused by authenticated fleet"); await client.query("INSERT INTO state_request_fingerprints VALUES ($1,$2,$3)",[workspace,key,digest]);}
24
+ };
11
25
  // Rows persisted by a pre-v4 service are the only trusted source of legacy G2 stamps; the stamp
12
26
  // timestamp reuses task.updatedAt so repeated loads stay byte-identical until a write persists v4.
13
27
  const upgradeStoredState = (state) => {
@@ -64,6 +78,53 @@ const mutationResult = (before, state, index, touchedTaskIds = []) => {
64
78
  }
65
79
  return { state, index, touchedTaskIds };
66
80
  };
81
+ const rejectLeadApprovals = (before, after) => {
82
+ for(const [taskId, task] of Object.entries(after.tasks ?? {})) {
83
+ const prior=before.tasks?.[taskId]?.evidence ?? [];
84
+ const appended=(task.evidence ?? []).slice(prior.length).filter((item)=>item.kind==="approval");
85
+ if(appended.some((item)=>item.result==="pass")) throw new Error("lead credential cannot record human gate approval");
86
+ if(appended.length && !(appended.length===1 && appended[0].result==="fail" && appended[0].gate==="G1_review" && before.tasks?.[taskId]?.phase==="build" && before.tasks[taskId].status==="paused" && before.tasks[taskId].handoff && task.phase==="plan" && task.status==="active")) throw new Error("lead credential cannot record gate approval evidence");
87
+ }
88
+ };
89
+ const assertLeadTransitions = (before, after) => {
90
+ for(const [taskId, task] of Object.entries(after.tasks ?? {})) {
91
+ const previous=before.tasks?.[taskId];
92
+ if(!previous) { if(task.phase!=="clarify" || task.gate!=="G0_confirm" || task.status!=="active" || task.evidence.some((item)=>item.kind==="approval")) throw new Error("new fleet task must start active at G0 without approvals"); continue; }
93
+ if(task.phase===previous.phase) continue;
94
+ if(previous.phase==="wrap" && task.phase==="done" && previous.status==="active" && task.status==="done" && task.gate==="none" && hasCurrentBuildApproval(previous,"G2_codereview") && !transitionDiagnostics(previous,"done").some((item)=>item.level==="ERROR")) continue;
95
+ if(previous.phase==="build" && task.phase==="plan" && previous.status==="paused" && previous.handoff && task.status==="active" && task.gate==="G1_review" && task.evidence.slice(previous.evidence.length).some((item)=>item.kind==="approval" && item.gate==="G1_review" && item.result==="fail")) continue;
96
+ throw new Error(`lead credential cannot change phase ${previous.phase} to ${task.phase}; human gate approval is required`);
97
+ }
98
+ for(const [taskId, previous] of Object.entries(before.tasks ?? {})) if(!after.tasks?.[taskId] && !["done","closed","superseded"].includes(previous.status)) throw new Error("active fleet task cannot be removed");
99
+ };
100
+ const snapshotId = (workspace, taskId, revision, gate, reviewText, integrationCommit) => createHash("sha256").update(JSON.stringify([workspace, taskId, revision, gate, reviewText, integrationCommit ?? null])).digest("hex");
101
+ const taskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).digest("hex");
102
+ const reviewWithCommit = (gate, reviewText, integrationCommit) => gate === "G2_codereview" && typeof reviewText === "string" ? `${reviewText.trimEnd()}\n\nIntegration commit: ${integrationCommit}` : reviewText;
103
+ const validateGateSnapshot = (current, taskId, gate, reviewText, integrationCommit) => {
104
+ const task = current.state.tasks?.[taskId];
105
+ if (!task || task.gate !== gate || task.status !== "blocked_on_user" || nextAction(task).classification !== "await_user") throw new Error("task is not ready at requested gate");
106
+ if (typeof reviewText !== "string" || !reviewText.trim() || reviewText.length > 20000) throw new Error("bounded gate review text is required");
107
+ if ((integrationCommit !== undefined && integrationCommit !== null && !/^[a-f0-9]{40}$/.test(integrationCommit)) || (gate === "G2_codereview" && !/^[a-f0-9]{40}$/.test(integrationCommit ?? ""))) throw new Error("G2 review snapshot requires a 40-character integration commit SHA");
108
+ };
109
+ const approveSnapshot = (current, snapshot, taskId, source, integrationCommit) => {
110
+ if (typeof source !== "string" || !source.trim() || source.length > 2000) throw new Error("explicit approval source is required");
111
+ if (!snapshot || snapshot.task_id !== taskId || !current.state.tasks?.[taskId] || snapshot.task_digest !== taskDigest(current.state.tasks[taskId])) throw new Error("stale gate snapshot");
112
+ if(snapshot.integration_commit && snapshot.integration_commit!==integrationCommit) throw new Error("reviewed integration commit does not match approval request");
113
+ const task = current.state.tasks?.[taskId];
114
+ if (!task || task.gate !== snapshot.gate || task.status !== "blocked_on_user" || nextAction(task).classification !== "await_user") throw new Error("gate is no longer ready");
115
+ const state = structuredClone(current.state);
116
+ state.tasks[taskId].evidence.push({ kind: "approval", gate: snapshot.gate, source, result: "pass", recordedAt: new Date().toISOString() });
117
+ const target = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" }[snapshot.gate];
118
+ if (!target) throw new Error("invalid gate");
119
+ transitionTask(state, taskId, target);
120
+ return mutationResult(current.state, state, current.index, [taskId]);
121
+ };
122
+ const verifyFence = (fleet, taskId, epoch, ownerToken, time = Date.now()) => {
123
+ const lead = fleet?.leads?.[taskId];
124
+ if (!lead || !Number.isInteger(epoch) || lead.epoch !== epoch || Date.parse(lead.expiresAt) <= time || (lead.ownerTokenHash && (typeof ownerToken !== "string" || createHash("sha256").update(ownerToken).digest("hex") !== lead.ownerTokenHash))) throw new Error("stale or expired lead epoch");
125
+ const supervisor = fleet?.supervisors?.[taskId];
126
+ if (supervisor && (!lead.supervisorEpoch || lead.supervisorEpoch !== supervisor.epoch || lead.actor !== supervisor.actor || lead.supervisorOwnerTokenHash !== supervisor.ownerTokenHash || Date.parse(supervisor.expiresAt) <= time)) throw new Error("stale or expired supervisor epoch");
127
+ };
67
128
 
68
129
  export class RevisionConflict extends Error {
69
130
  constructor(current) { super(`State revision conflict: expected a newer snapshot than ${current.revision}`); this.name = "RevisionConflict"; this.current = current; }
@@ -119,7 +180,15 @@ export class SqliteStateRepository {
119
180
  CREATE TABLE IF NOT EXISTS state_idempotency (
120
181
  workspace TEXT NOT NULL, idempotency_key TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(workspace, idempotency_key)
121
182
  );
183
+ CREATE TABLE IF NOT EXISTS state_request_fingerprints (workspace TEXT NOT NULL, idempotency_key TEXT NOT NULL, digest TEXT NOT NULL, PRIMARY KEY(workspace,idempotency_key));
184
+ CREATE TABLE IF NOT EXISTS state_gate_snapshots (
185
+ id TEXT PRIMARY KEY, workspace TEXT NOT NULL, task_id TEXT NOT NULL, gate TEXT NOT NULL, state_revision INTEGER NOT NULL, task_digest TEXT NOT NULL, integration_commit TEXT, review_text TEXT NOT NULL, created_at TEXT NOT NULL
186
+ );
187
+ CREATE TABLE IF NOT EXISTS state_gate_checkpoints (workspace TEXT NOT NULL, task_id TEXT NOT NULL, snapshot_id TEXT NOT NULL, gate TEXT NOT NULL, task_digest TEXT NOT NULL, state_revision INTEGER NOT NULL, integration_commit TEXT, status TEXT NOT NULL, approval_revision INTEGER, PRIMARY KEY(workspace, task_id));
122
188
  `);
189
+ if(!this.db.prepare("PRAGMA table_info(state_gate_checkpoints)").all().some((column)=>column.name==="phase")) this.db.exec("ALTER TABLE state_gate_checkpoints ADD COLUMN phase TEXT");
190
+ if(!this.db.prepare("PRAGMA table_info(state_gate_snapshots)").all().some((column)=>column.name==="task_digest")) this.db.exec("ALTER TABLE state_gate_snapshots ADD COLUMN task_digest TEXT NOT NULL DEFAULT ''");
191
+ if(!this.db.prepare("PRAGMA table_info(state_gate_snapshots)").all().some((column)=>column.name==="integration_commit")) this.db.exec("ALTER TABLE state_gate_snapshots ADD COLUMN integration_commit TEXT");
123
192
  }
124
193
 
125
194
  #ensure(workspace) {
@@ -139,7 +208,7 @@ export class SqliteStateRepository {
139
208
  .map((row) => ({ cursor: row.id, revision: row.revision, command: parse(row.command), recordedAt: row.recorded_at }));
140
209
  }
141
210
 
142
- async apply(workspace, expectedRevision, idempotencyKey, command, clientProjectRoot) {
211
+ async apply(workspace, expectedRevision, idempotencyKey, command, clientProjectRoot, leadEpoch, leadOwnerToken, principal) {
143
212
  if (!Number.isInteger(expectedRevision) || expectedRevision < 0) throw new Error("expectedRevision must be a non-negative integer");
144
213
  if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) throw new Error("idempotencyKey is required");
145
214
  if (typeof clientProjectRoot !== "string" || !clientProjectRoot.trim() || !isAbsolute(clientProjectRoot)) throw new Error("clientProjectRoot must be an absolute path");
@@ -147,17 +216,32 @@ export class SqliteStateRepository {
147
216
  this.db.exec("BEGIN IMMEDIATE");
148
217
  try {
149
218
  this.#ensure(workspace);
219
+ if (leadEpoch !== undefined) {
220
+ const fleetRow=this.db.prepare("SELECT fleet FROM fleet_workspaces WHERE workspace=?").get(workspace);
221
+ verifyFence(fleetRow && parse(fleetRow.fleet),command.taskId,leadEpoch,leadOwnerToken);
222
+ if(this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='fleet_git_operations'").get() && this.db.prepare("SELECT 1 FROM fleet_git_operations WHERE workspace=? AND task_id=? AND status='pending' LIMIT 1").get(workspace,command.taskId)) throw new Error("pending Git operation requires reconciliation");
223
+ }
224
+ sqliteRequest(this.db,workspace,idempotencyKey,principal,"apply",{expectedRevision,command,clientProjectRoot,leadEpoch,leadOwnerToken});
150
225
  const stored = this.db.prepare("SELECT response FROM state_idempotency WHERE workspace = ? AND idempotency_key = ?").get(workspace, idempotencyKey);
151
226
  if (stored) { this.db.exec("COMMIT"); return parse(stored.response); }
152
227
  const current = snapshot(this.db.prepare("SELECT workspace, revision, state, task_index, updated_at FROM state_workspaces WHERE workspace = ?").get(workspace));
153
228
  if (current.revision !== expectedRevision) throw new RevisionConflict(current);
154
229
  const next = applyCommand(current, command);
230
+ if(leadEpoch!==undefined) { rejectLeadApprovals(current.state,next.state); assertLeadTransitions(current.state,next.state); }
155
231
  const revision = current.revision + 1; const updatedAt = new Date().toISOString();
156
232
  next.index = { ...next.index, sourceRevision: revision, updatedAt };
157
233
  const nextActions = Object.fromEntries(next.touchedTaskIds.flatMap((taskId) => next.state.tasks?.[taskId] ? [[taskId, nextAction(next.state.tasks[taskId], actionRoot)]] : []));
158
234
  const response = { workspace, revision, state: next.state, index: next.index, nextActions, updatedAt };
159
235
  this.db.prepare("UPDATE state_workspaces SET revision = ?, state = ?, task_index = ?, updated_at = ? WHERE workspace = ?")
160
236
  .run(revision, encode(next.state), encode(next.index), updatedAt, workspace);
237
+ if (leadEpoch !== undefined) {
238
+ const fleet = parse(this.db.prepare("SELECT fleet FROM fleet_workspaces WHERE workspace=?").get(workspace).fleet);
239
+ const checkpointId = fleet.leads?.[command.taskId]?.checkpointSnapshotId;
240
+ if (checkpointId) {
241
+ const changed = this.db.prepare("UPDATE state_gate_checkpoints SET status='consumed' WHERE workspace=? AND task_id=? AND snapshot_id=? AND status='approved'").run(workspace,command.taskId,checkpointId);
242
+ if (!changed.changes) { const cp=this.db.prepare("SELECT snapshot_id,status FROM state_gate_checkpoints WHERE workspace=? AND task_id=?").get(workspace,command.taskId); if (!cp || cp.snapshot_id !== checkpointId || cp.status !== "consumed") throw new Error("checkpoint changed before fenced side effect"); }
243
+ }
244
+ }
161
245
  this.db.prepare("INSERT INTO state_events (workspace, revision, idempotency_key, command, recorded_at) VALUES (?, ?, ?, ?, ?)")
162
246
  .run(workspace, revision, idempotencyKey, encode(command), updatedAt);
163
247
  this.db.prepare("INSERT INTO state_idempotency (workspace, idempotency_key, response) VALUES (?, ?, ?)")
@@ -166,6 +250,62 @@ export class SqliteStateRepository {
166
250
  } catch (error) { try { this.db.exec("ROLLBACK"); } catch {} throw error; }
167
251
  }
168
252
 
253
+ async gateInbox(workspace) {
254
+ const current = await this.get(workspace);
255
+ return this.db.prepare("SELECT id,task_id AS taskId,gate,state_revision AS stateRevision,task_digest AS taskDigest,integration_commit AS integrationCommit,review_text AS reviewText,created_at AS createdAt FROM state_gate_snapshots WHERE workspace=? ORDER BY created_at DESC,id DESC LIMIT 100").all(workspace).filter((item) => current.state.tasks[item.taskId]?.status === "blocked_on_user" && item.taskDigest === taskDigest(current.state.tasks[item.taskId])).map((item)=>({...item,currentRevision:current.revision}));
256
+ }
257
+
258
+ async putGateSnapshot(workspace, expectedRevision, idempotencyKey, taskId, gate, reviewText, leadEpoch, integrationCommit, leadOwnerToken, checkpoint = false, principal) {
259
+ if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) throw new Error("idempotencyKey is required");
260
+ this.db.exec("BEGIN IMMEDIATE");
261
+ try {
262
+ this.#ensure(workspace);
263
+ if (leadEpoch !== undefined) { const fleetRow=this.db.prepare("SELECT fleet FROM fleet_workspaces WHERE workspace=?").get(workspace); verifyFence(fleetRow && parse(fleetRow.fleet),taskId,leadEpoch,leadOwnerToken); }
264
+ sqliteRequest(this.db,workspace,idempotencyKey,principal,"gate_snapshot_put",{expectedRevision,taskId,gate,reviewText,leadEpoch,integrationCommit,leadOwnerToken,checkpoint});
265
+ const cached=this.db.prepare("SELECT response FROM state_idempotency WHERE workspace=? AND idempotency_key=?").get(workspace,idempotencyKey);
266
+ if(cached){this.db.exec("COMMIT");return parse(cached.response);}
267
+ const current=snapshot(this.db.prepare("SELECT * FROM state_workspaces WHERE workspace=?").get(workspace));
268
+ if(current.revision!==expectedRevision) throw new RevisionConflict(current);
269
+ if (checkpoint) { const prior=this.db.prepare("SELECT status FROM state_gate_checkpoints WHERE workspace=? AND task_id=?").get(workspace,taskId); if (prior?.status === "approved") throw new Error("approved checkpoint must be reconciled before replacement"); }
270
+ const boundReviewText=reviewWithCommit(gate,reviewText,integrationCommit);
271
+ validateGateSnapshot(current,taskId,gate,boundReviewText,integrationCommit);
272
+ const item={ id:snapshotId(workspace,taskId,current.revision,gate,boundReviewText,integrationCommit), workspace,taskId,gate,stateRevision:current.revision,taskDigest:taskDigest(current.state.tasks[taskId]),integrationCommit:integrationCommit??null,reviewText:boundReviewText,createdAt:new Date().toISOString() };
273
+ this.db.prepare("INSERT OR IGNORE INTO state_gate_snapshots VALUES (?,?,?,?,?,?,?,?,?)").run(item.id,workspace,taskId,gate,current.revision,item.taskDigest,item.integrationCommit,boundReviewText,item.createdAt);
274
+ item.createdAt=this.db.prepare("SELECT created_at FROM state_gate_snapshots WHERE id=?").get(item.id).created_at;
275
+ if (checkpoint) this.db.prepare("INSERT INTO state_gate_checkpoints (workspace,task_id,snapshot_id,gate,task_digest,state_revision,integration_commit,status,approval_revision,phase) VALUES (?,?,?,?,?,?,?,'pending',NULL,?) ON CONFLICT(workspace,task_id) DO UPDATE SET snapshot_id=excluded.snapshot_id,gate=excluded.gate,task_digest=excluded.task_digest,state_revision=excluded.state_revision,integration_commit=excluded.integration_commit,status='pending',approval_revision=NULL,phase=excluded.phase").run(workspace,taskId,item.id,gate,item.taskDigest,current.revision,item.integrationCommit,current.state.tasks[taskId].phase);
276
+ this.db.prepare("INSERT INTO state_idempotency VALUES (?,?,?)").run(workspace,idempotencyKey,encode(item));
277
+ this.db.exec("COMMIT"); return item;
278
+ } catch(error){try{this.db.exec("ROLLBACK");}catch{} throw error;}
279
+ }
280
+
281
+ async gateCheckpoint(workspace, taskId) { return this.db.prepare("SELECT snapshot_id AS snapshotId, gate, phase, task_digest AS taskDigest, state_revision AS stateRevision, integration_commit AS integrationCommit, status, approval_revision AS approvalRevision FROM state_gate_checkpoints WHERE workspace=? AND task_id=?").get(workspace,taskId) ?? null; }
282
+
283
+ async approveGate(workspace,expectedRevision,idempotencyKey,taskId,gateSnapshotId,source,clientProjectRoot,integrationCommit,principal) {
284
+ if(typeof idempotencyKey!=="string"||!idempotencyKey.trim()||typeof clientProjectRoot!=="string"||!isAbsolute(clientProjectRoot)) throw new Error("invalid approval precondition");
285
+ this.db.exec("BEGIN IMMEDIATE");
286
+ try {
287
+ this.#ensure(workspace);
288
+ sqliteRequest(this.db,workspace,idempotencyKey,principal,"gate_approve",{expectedRevision,taskId,gateSnapshotId,source,clientProjectRoot,integrationCommit});
289
+ const cached=this.db.prepare("SELECT response FROM state_idempotency WHERE workspace=? AND idempotency_key=?").get(workspace,idempotencyKey);
290
+ if(cached){this.db.exec("COMMIT");return parse(cached.response);}
291
+ const current=snapshot(this.db.prepare("SELECT * FROM state_workspaces WHERE workspace=?").get(workspace));
292
+ if(current.revision!==expectedRevision) throw new RevisionConflict(current);
293
+ const gateSnapshot=this.db.prepare("SELECT * FROM state_gate_snapshots WHERE workspace=? AND id=?").get(workspace,gateSnapshotId);
294
+ const checkpointRow=this.db.prepare("SELECT * FROM state_gate_checkpoints WHERE workspace=? AND task_id=?").get(workspace,taskId);
295
+ const fleetRow=this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='fleet_workspaces'").get() ? this.db.prepare("SELECT fleet FROM fleet_workspaces WHERE workspace=?").get(workspace) : null;
296
+ const managed=fleetRow && parse(fleetRow.fleet)?.supervisors?.[taskId];
297
+ if (managed && (!checkpointRow || checkpointRow.status !== "pending" || checkpointRow.snapshot_id !== gateSnapshotId)) throw new Error("supervised approval requires matching pending checkpoint");
298
+ if (checkpointRow?.status === "pending" && checkpointRow.snapshot_id !== gateSnapshotId) throw new Error("gate checkpoint does not match snapshot");
299
+ const next=approveSnapshot(current,gateSnapshot,taskId,source,integrationCommit); const revision=current.revision+1; const updatedAt=new Date().toISOString(); next.index={...next.index,sourceRevision:revision,updatedAt};
300
+ const response={workspace,revision,state:next.state,index:next.index,nextActions:{[taskId]:nextAction(next.state.tasks[taskId],resolve(clientProjectRoot))},updatedAt};
301
+ this.db.prepare("UPDATE state_workspaces SET revision=?,state=?,task_index=?,updated_at=? WHERE workspace=?").run(revision,encode(next.state),encode(next.index),updatedAt,workspace);
302
+ this.db.prepare("UPDATE state_gate_checkpoints SET status='approved',approval_revision=? WHERE workspace=? AND task_id=? AND snapshot_id=? AND status='pending'").run(revision,workspace,taskId,gateSnapshotId);
303
+ this.db.prepare("INSERT INTO state_events (workspace,revision,idempotency_key,command,recorded_at) VALUES (?,?,?,?,?)").run(workspace,revision,idempotencyKey,encode({type:"gate_approve",taskId,snapshotId:gateSnapshotId,integrationCommit:integrationCommit??null}),updatedAt);
304
+ this.db.prepare("INSERT INTO state_idempotency VALUES (?,?,?)").run(workspace,idempotencyKey,encode(response));
305
+ this.db.exec("COMMIT");return response;
306
+ } catch(error){try{this.db.exec("ROLLBACK");}catch{} throw error;}
307
+ }
308
+
169
309
  close() { this.db.close(); }
170
310
  }
171
311
 
@@ -175,28 +315,51 @@ export class PostgresStateRepository {
175
315
  await client.query(`CREATE TABLE IF NOT EXISTS state_workspaces (workspace TEXT PRIMARY KEY, revision BIGINT NOT NULL, state JSONB NOT NULL, task_index JSONB NOT NULL, updated_at TEXT NOT NULL)`);
176
316
  await client.query(`CREATE TABLE IF NOT EXISTS state_events (id BIGSERIAL PRIMARY KEY, workspace TEXT NOT NULL, revision BIGINT NOT NULL, idempotency_key TEXT NOT NULL, command JSONB NOT NULL, recorded_at TEXT NOT NULL, UNIQUE(workspace, revision))`);
177
317
  await client.query(`CREATE TABLE IF NOT EXISTS state_idempotency (workspace TEXT NOT NULL, idempotency_key TEXT NOT NULL, response JSONB NOT NULL, PRIMARY KEY(workspace, idempotency_key))`);
318
+ await client.query(`CREATE TABLE IF NOT EXISTS state_request_fingerprints (workspace TEXT NOT NULL, idempotency_key TEXT NOT NULL, digest TEXT NOT NULL, PRIMARY KEY(workspace,idempotency_key))`);
319
+ await client.query(`CREATE TABLE IF NOT EXISTS state_gate_snapshots (id TEXT PRIMARY KEY, workspace TEXT NOT NULL, task_id TEXT NOT NULL, gate TEXT NOT NULL, state_revision BIGINT NOT NULL, task_digest TEXT NOT NULL, integration_commit TEXT, review_text TEXT NOT NULL, created_at TEXT NOT NULL)`);
320
+ await client.query(`CREATE TABLE IF NOT EXISTS state_gate_checkpoints (workspace TEXT NOT NULL, task_id TEXT NOT NULL, snapshot_id TEXT NOT NULL, gate TEXT NOT NULL, task_digest TEXT NOT NULL, state_revision BIGINT NOT NULL, integration_commit TEXT, status TEXT NOT NULL, approval_revision BIGINT, PRIMARY KEY(workspace, task_id))`);
321
+ await client.query(`ALTER TABLE state_gate_checkpoints ADD COLUMN IF NOT EXISTS phase TEXT`);
322
+ await client.query(`ALTER TABLE state_gate_snapshots ADD COLUMN IF NOT EXISTS task_digest TEXT NOT NULL DEFAULT ''`);
323
+ await client.query(`ALTER TABLE state_gate_snapshots ADD COLUMN IF NOT EXISTS integration_commit TEXT`);
178
324
  }
179
325
  async get(workspace) {
180
326
  const client = await this.pool.connect();
181
327
  try { await this.#init(client); await client.query("INSERT INTO state_workspaces (workspace, revision, state, task_index, updated_at) VALUES ($1, 0, $2, $3, $4) ON CONFLICT DO NOTHING", [workspace, emptyState(), emptyIndex(0), new Date().toISOString()]); const row = (await client.query("SELECT workspace, revision, state, task_index, updated_at FROM state_workspaces WHERE workspace = $1", [workspace])).rows[0]; return { ...row, revision: Number(row.revision), state: upgradeStoredState(row.state), index: row.task_index }; } finally { client.release(); }
182
328
  }
183
329
  async eventsSince(workspace, cursor = 0) { const client = await this.pool.connect(); try { await this.#init(client); return (await client.query("SELECT id, revision, command, recorded_at FROM state_events WHERE workspace = $1 AND id > $2 ORDER BY id ASC LIMIT 100", [workspace, cursor])).rows.map((row) => ({ cursor: Number(row.id), revision: Number(row.revision), command: row.command, recordedAt: row.recorded_at })); } finally { client.release(); } }
184
- async apply(workspace, expectedRevision, idempotencyKey, command, clientProjectRoot) {
330
+ async apply(workspace, expectedRevision, idempotencyKey, command, clientProjectRoot, leadEpoch, leadOwnerToken, principal) {
331
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) throw new Error("expectedRevision must be a non-negative integer");
332
+ if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) throw new Error("idempotencyKey is required");
185
333
  if (typeof clientProjectRoot !== "string" || !clientProjectRoot.trim() || !isAbsolute(clientProjectRoot)) throw new Error("clientProjectRoot must be an absolute path");
186
334
  const actionRoot = resolve(clientProjectRoot);
187
335
  const client = await this.pool.connect();
188
336
  try {
189
337
  await this.#init(client); await client.query("BEGIN");
338
+ if (leadEpoch !== undefined) { const fleetRow=(await client.query("SELECT fleet FROM fleet_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0]; verifyFence(fleetRow?.fleet,command.taskId,leadEpoch,leadOwnerToken,Number((await client.query("SELECT extract(epoch from clock_timestamp()) * 1000 AS ms")).rows[0].ms)); if((await client.query("SELECT to_regclass('fleet_git_operations') AS name")).rows[0].name && (await client.query("SELECT 1 FROM fleet_git_operations WHERE workspace=$1 AND task_id=$2 AND status='pending' LIMIT 1",[workspace,command.taskId])).rowCount) throw new Error("pending Git operation requires reconciliation"); }
190
339
  await client.query("INSERT INTO state_workspaces (workspace, revision, state, task_index, updated_at) VALUES ($1, 0, $2, $3, $4) ON CONFLICT DO NOTHING", [workspace, emptyState(), emptyIndex(0), new Date().toISOString()]);
191
- const stored = (await client.query("SELECT response FROM state_idempotency WHERE workspace = $1 AND idempotency_key = $2", [workspace, idempotencyKey])).rows[0]; if (stored) { await client.query("COMMIT"); return stored.response; }
192
340
  const row = (await client.query("SELECT workspace, revision, state, task_index, updated_at FROM state_workspaces WHERE workspace = $1 FOR UPDATE", [workspace])).rows[0]; const current = { workspace: row.workspace, revision: Number(row.revision), state: upgradeStoredState(row.state), index: row.task_index, updatedAt: row.updated_at };
341
+ await postgresRequest(client,workspace,idempotencyKey,principal,"apply",{expectedRevision,command,clientProjectRoot,leadEpoch,leadOwnerToken});
342
+ const stored = (await client.query("SELECT response FROM state_idempotency WHERE workspace = $1 AND idempotency_key = $2", [workspace, idempotencyKey])).rows[0]; if (stored) { await client.query("COMMIT"); return stored.response; }
193
343
  if (current.revision !== expectedRevision) throw new RevisionConflict(current);
194
344
  const next = applyCommand(current, command); const revision = current.revision + 1; const updatedAt = new Date().toISOString(); next.index = { ...next.index, sourceRevision: revision, updatedAt }; const nextActions = Object.fromEntries(next.touchedTaskIds.flatMap((taskId) => next.state.tasks?.[taskId] ? [[taskId, nextAction(next.state.tasks[taskId], actionRoot)]] : [])); const response = { workspace, revision, state: next.state, index: next.index, nextActions, updatedAt };
345
+ if(leadEpoch!==undefined) { rejectLeadApprovals(current.state,next.state); assertLeadTransitions(current.state,next.state); }
195
346
  await client.query("UPDATE state_workspaces SET revision = $1, state = $2, task_index = $3, updated_at = $4 WHERE workspace = $5", [revision, next.state, next.index, updatedAt, workspace]);
347
+ if (leadEpoch !== undefined) {
348
+ const fleetRow=(await client.query("SELECT fleet FROM fleet_workspaces WHERE workspace=$1",[workspace])).rows[0];
349
+ const checkpointId=fleetRow?.fleet?.leads?.[command.taskId]?.checkpointSnapshotId;
350
+ if (checkpointId) {
351
+ const changed=await client.query("UPDATE state_gate_checkpoints SET status='consumed' WHERE workspace=$1 AND task_id=$2 AND snapshot_id=$3 AND status='approved'",[workspace,command.taskId,checkpointId]);
352
+ if (!changed.rowCount) { const cp=(await client.query("SELECT snapshot_id,status FROM state_gate_checkpoints WHERE workspace=$1 AND task_id=$2",[workspace,command.taskId])).rows[0]; if (!cp || cp.snapshot_id!==checkpointId || cp.status!=="consumed") throw new Error("checkpoint changed before fenced side effect"); }
353
+ }
354
+ }
196
355
  await client.query("INSERT INTO state_events (workspace, revision, idempotency_key, command, recorded_at) VALUES ($1, $2, $3, $4, $5)", [workspace, revision, idempotencyKey, command, updatedAt]);
197
356
  await client.query("INSERT INTO state_idempotency (workspace, idempotency_key, response) VALUES ($1, $2, $3)", [workspace, idempotencyKey, response]); await client.query("COMMIT"); return response;
198
357
  } catch (error) { try { await client.query("ROLLBACK"); } catch {} throw error; } finally { client.release(); }
199
358
  }
359
+ async gateInbox(workspace) { const current=await this.get(workspace); const c=await this.pool.connect(); try { await this.#init(c); return (await c.query("SELECT id,task_id AS \"taskId\",gate,state_revision AS \"stateRevision\",task_digest AS \"taskDigest\",integration_commit AS \"integrationCommit\",review_text AS \"reviewText\",created_at AS \"createdAt\" FROM state_gate_snapshots WHERE workspace=$1 ORDER BY created_at DESC,id DESC LIMIT 100",[workspace])).rows.filter((item)=>current.state.tasks[item.taskId]?.status==="blocked_on_user"&&item.taskDigest===taskDigest(current.state.tasks[item.taskId])).map((item)=>({...item,currentRevision:current.revision})); } finally { c.release(); } }
360
+ async putGateSnapshot(workspace,expectedRevision,idempotencyKey,taskId,gate,reviewText,leadEpoch,integrationCommit,leadOwnerToken,checkpoint=false,principal) { if(typeof idempotencyKey!=="string"||!idempotencyKey.trim()) throw new Error("idempotencyKey is required"); const c=await this.pool.connect(); try { await this.#init(c); await c.query("BEGIN"); if(leadEpoch!==undefined){const fleetRow=(await c.query("SELECT fleet FROM fleet_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0];verifyFence(fleetRow?.fleet,taskId,leadEpoch,leadOwnerToken,Number((await c.query("SELECT extract(epoch from clock_timestamp()) * 1000 AS ms")).rows[0].ms));} await c.query("INSERT INTO state_workspaces VALUES ($1,0,$2,$3,$4) ON CONFLICT DO NOTHING",[workspace,emptyState(),emptyIndex(0),new Date().toISOString()]); const row=(await c.query("SELECT * FROM state_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0]; await postgresRequest(c,workspace,idempotencyKey,principal,"gate_snapshot_put",{expectedRevision,taskId,gate,reviewText,leadEpoch,integrationCommit,leadOwnerToken,checkpoint}); const cached=(await c.query("SELECT response FROM state_idempotency WHERE workspace=$1 AND idempotency_key=$2",[workspace,idempotencyKey])).rows[0]; if(cached){await c.query("COMMIT");return cached.response;} const current={workspace,revision:Number(row.revision),state:upgradeStoredState(row.state),index:row.task_index,updatedAt:row.updated_at}; if(current.revision!==expectedRevision) throw new RevisionConflict(current); if(checkpoint){const prior=(await c.query("SELECT status FROM state_gate_checkpoints WHERE workspace=$1 AND task_id=$2 FOR UPDATE",[workspace,taskId])).rows[0];if(prior?.status==="approved") throw new Error("approved checkpoint must be reconciled before replacement");} const boundReviewText=reviewWithCommit(gate,reviewText,integrationCommit); validateGateSnapshot(current,taskId,gate,boundReviewText,integrationCommit); const item={id:snapshotId(workspace,taskId,current.revision,gate,boundReviewText,integrationCommit),workspace,taskId,gate,stateRevision:current.revision,taskDigest:taskDigest(current.state.tasks[taskId]),integrationCommit:integrationCommit??null,reviewText:boundReviewText,createdAt:new Date().toISOString()}; await c.query("INSERT INTO state_gate_snapshots VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING",[item.id,workspace,taskId,gate,current.revision,item.taskDigest,item.integrationCommit,boundReviewText,item.createdAt]); item.createdAt=(await c.query("SELECT created_at FROM state_gate_snapshots WHERE id=$1",[item.id])).rows[0].created_at; if(checkpoint) await c.query("INSERT INTO state_gate_checkpoints (workspace,task_id,snapshot_id,gate,task_digest,state_revision,integration_commit,status,approval_revision,phase) VALUES ($1,$2,$3,$4,$5,$6,$7,'pending',NULL,$8) ON CONFLICT(workspace,task_id) DO UPDATE SET snapshot_id=excluded.snapshot_id,gate=excluded.gate,task_digest=excluded.task_digest,state_revision=excluded.state_revision,integration_commit=excluded.integration_commit,status='pending',approval_revision=NULL,phase=excluded.phase",[workspace,taskId,item.id,gate,item.taskDigest,current.revision,item.integrationCommit,current.state.tasks[taskId].phase]); await c.query("INSERT INTO state_idempotency VALUES ($1,$2,$3)",[workspace,idempotencyKey,item]); await c.query("COMMIT");return item; } catch(error){try{await c.query("ROLLBACK");}catch{} throw error;} finally{c.release();} }
361
+ async gateCheckpoint(workspace,taskId) { const c=await this.pool.connect(); try { await this.#init(c); const row=(await c.query("SELECT snapshot_id AS \"snapshotId\",gate,phase,task_digest AS \"taskDigest\",state_revision AS \"stateRevision\",integration_commit AS \"integrationCommit\",status,approval_revision AS \"approvalRevision\" FROM state_gate_checkpoints WHERE workspace=$1 AND task_id=$2",[workspace,taskId])).rows[0]; return row ? {...row,stateRevision:Number(row.stateRevision),approvalRevision:row.approvalRevision===null?null:Number(row.approvalRevision)} : null; } finally { c.release(); } }
362
+ async approveGate(workspace,expectedRevision,idempotencyKey,taskId,gateSnapshotId,source,clientProjectRoot,integrationCommit,principal) { if(typeof idempotencyKey!=="string"||!idempotencyKey.trim()||typeof clientProjectRoot!=="string"||!isAbsolute(clientProjectRoot)) throw new Error("invalid approval precondition"); const c=await this.pool.connect(); try { await this.#init(c); await c.query("BEGIN"); const fleetTable=(await c.query("SELECT to_regclass('fleet_workspaces') AS name")).rows[0].name; const fleetRow=fleetTable ? (await c.query("SELECT fleet FROM fleet_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0] : null; await c.query("INSERT INTO state_workspaces VALUES ($1,0,$2,$3,$4) ON CONFLICT DO NOTHING",[workspace,emptyState(),emptyIndex(0),new Date().toISOString()]); const row=(await c.query("SELECT * FROM state_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0]; await postgresRequest(c,workspace,idempotencyKey,principal,"gate_approve",{expectedRevision,taskId,gateSnapshotId,source,clientProjectRoot,integrationCommit}); const cached=(await c.query("SELECT response FROM state_idempotency WHERE workspace=$1 AND idempotency_key=$2",[workspace,idempotencyKey])).rows[0]; if(cached){await c.query("COMMIT");return cached.response;} const current={workspace,revision:Number(row.revision),state:upgradeStoredState(row.state),index:row.task_index,updatedAt:row.updated_at}; if(current.revision!==expectedRevision) throw new RevisionConflict(current); const gateSnapshot=(await c.query("SELECT * FROM state_gate_snapshots WHERE workspace=$1 AND id=$2",[workspace,gateSnapshotId])).rows[0]; const checkpointRow=(await c.query("SELECT * FROM state_gate_checkpoints WHERE workspace=$1 AND task_id=$2 FOR UPDATE",[workspace,taskId])).rows[0]; if(fleetRow?.fleet?.supervisors?.[taskId] && (!checkpointRow || checkpointRow.status!=="pending" || checkpointRow.snapshot_id!==gateSnapshotId)) throw new Error("supervised approval requires matching pending checkpoint"); if(checkpointRow?.status==="pending" && checkpointRow.snapshot_id!==gateSnapshotId) throw new Error("gate checkpoint does not match snapshot"); const next=approveSnapshot(current,gateSnapshot,taskId,source,integrationCommit); const revision=current.revision+1; const updatedAt=new Date().toISOString(); next.index={...next.index,sourceRevision:revision,updatedAt}; const response={workspace,revision,state:next.state,index:next.index,nextActions:{[taskId]:nextAction(next.state.tasks[taskId],resolve(clientProjectRoot))},updatedAt}; await c.query("UPDATE state_workspaces SET revision=$1,state=$2,task_index=$3,updated_at=$4 WHERE workspace=$5",[revision,next.state,next.index,updatedAt,workspace]); await c.query("UPDATE state_gate_checkpoints SET status='approved',approval_revision=$1 WHERE workspace=$2 AND task_id=$3 AND snapshot_id=$4 AND status='pending'",[revision,workspace,taskId,gateSnapshotId]); await c.query("INSERT INTO state_events (workspace,revision,idempotency_key,command,recorded_at) VALUES ($1,$2,$3,$4,$5)",[workspace,revision,idempotencyKey,{type:"gate_approve",taskId,snapshotId:gateSnapshotId,integrationCommit:integrationCommit??null},updatedAt]); await c.query("INSERT INTO state_idempotency VALUES ($1,$2,$3)",[workspace,idempotencyKey,response]); await c.query("COMMIT");return response; } catch(error){try{await c.query("ROLLBACK");}catch{} throw error;} finally{c.release();} }
200
363
  async close() { await this.pool.end(); }
201
364
  }
202
365
 
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { timingSafeEqual } from "node:crypto";
2
3
  import { McpServer } from "@modelcontextprotocol/server";
3
4
  import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
4
5
  import { createMcpExpressApp } from "@modelcontextprotocol/express";
@@ -6,44 +7,87 @@ import { z } from "zod";
6
7
  import { RevisionConflict, createRepository } from "./repository.mjs";
7
8
  import { providerPreflight } from "./providers.mjs";
8
9
  import { CoordinationConflict, SqliteCoordinationRepository, createCoordinationRepository } from "./coordination.mjs";
10
+ import { FleetConflict, SqliteFleetRepository, createFleetRepository, featureReady, validateCanonicalFeature } from "./fleet.mjs";
11
+ import { nextAction } from "./lifecycle-core.mjs";
9
12
 
10
13
  const result = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
11
- const failure = (error) => ({ isError: true, content: [{ type: "text", text: JSON.stringify(error instanceof RevisionConflict ? { code: "REVISION_CONFLICT", current: error.current } : error instanceof CoordinationConflict ? { code: "COORDINATION_CONFLICT", current: error.current } : { code: "STATE_ERROR", message: error instanceof Error ? error.message : String(error) }) }] });
14
+ const failure = (error) => ({ isError: true, content: [{ type: "text", text: JSON.stringify(error instanceof RevisionConflict ? { code: "REVISION_CONFLICT", current: error.current } : error instanceof CoordinationConflict ? { code: "COORDINATION_CONFLICT", current: error.current } : error instanceof FleetConflict ? { code: "FLEET_CONFLICT", current: error.current, message: error.message } : { code: "STATE_ERROR", message: error instanceof Error ? error.message : String(error) }) }] });
12
15
  const workspace = z.string().min(1).max(128);
13
16
 
14
- export const buildServer = (repository, coordination = new SqliteCoordinationRepository()) => {
17
+ export const buildServer = (repository, coordination = new SqliteCoordinationRepository(), fleet = new SqliteFleetRepository(), identity = null) => {
15
18
  const server = new McpServer({ name: "aidlc-coordination", version: "0.1.0" });
19
+ const requireRole = (...allowed) => { if (identity && !allowed.includes(identity.role)) throw new Error("credential role is not authorized for this operation"); };
20
+ const actorFor = (actor) => identity ? { id: identity.id, role: { lead: "admin", worker: "commenter", reviewer: "reader" }[identity.role] } : actor;
16
21
  server.registerTool("state_get", { description: "Pull the current workflow state and derived index for a workspace.", inputSchema: z.object({ workspace }) }, async ({ workspace: id }) => { try { return result(await repository.get(id)); } catch (error) { return failure(error); } });
22
+ server.registerTool("task_get", { description: "Read one canonical task and its fresh next action.", inputSchema: z.object({ workspace, taskId: z.string().min(1).max(128), clientProjectRoot: z.string().min(1) }) }, async ({workspace:id,taskId,clientProjectRoot}) => { try { const current=await repository.get(id); const task=current.state.tasks[taskId]; if(!task) throw new Error("task not found"); return result({workspace:id,revision:current.revision,task,nextAction:nextAction(task,clientProjectRoot)}); } catch(error){return failure(error);} });
17
23
  server.registerTool("state_events_since", { description: "Poll committed state events after a cursor; no realtime transport is used.", inputSchema: z.object({ workspace, cursor: z.number().int().nonnegative().default(0) }) }, async ({ workspace: id, cursor }) => { try { return result(await repository.eventsSince(id, cursor)); } catch (error) { return failure(error); } });
18
- server.registerTool("state_apply", { description: "Atomically apply a lifecycle-valid state/task/index command with revision and idempotency protection.", inputSchema: z.object({ workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256), clientProjectRoot: z.string().min(1), command: z.object({ type: z.enum(["replaceState", "upsertTask", "removeTask", "replaceIndex", "transition"]), taskId: z.string().optional(), target: z.string().optional(), task: z.unknown().optional(), state: z.unknown().optional(), index: z.unknown().optional() }) }) }, async ({ workspace: id, expectedRevision, idempotencyKey, clientProjectRoot, command }) => { try { return result(await repository.apply(id, expectedRevision, idempotencyKey, command, clientProjectRoot)); } catch (error) { return failure(error); } });
24
+ server.registerTool("state_apply", { description: "Atomically apply a lifecycle-valid state/task/index command with revision and idempotency protection.", inputSchema: z.object({ workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256), clientProjectRoot: z.string().min(1), leadEpoch: z.number().int().optional(), leadOwnerToken: z.string().min(32).max(256).optional(), command: z.object({ type: z.enum(["replaceState", "upsertTask", "removeTask", "replaceIndex", "transition"]), taskId: z.string().optional(), target: z.string().optional(), task: z.unknown().optional(), state: z.unknown().optional(), index: z.unknown().optional() }) }) }, async ({ workspace: id, expectedRevision, idempotencyKey, clientProjectRoot, command, leadEpoch, leadOwnerToken }) => { try { requireRole("lead"); if(identity && (!Number.isInteger(leadEpoch) || !command.taskId || !["upsertTask","removeTask","transition"].includes(command.type))) throw new Error("fleet lifecycle mutation requires one taskId, leadEpoch, and a task-scoped command"); return result(await repository.apply(id, expectedRevision, idempotencyKey, command, clientProjectRoot, leadEpoch, leadOwnerToken, identity?.id)); } catch (error) { return failure(error); } });
19
25
  server.registerTool("work_item_request", { description: "Return a credential-free preflight for an opt-in Jira, Trello or GitHub Issues operation. Agents decide whether to make external updates outside the lifecycle transaction.", inputSchema: z.object({ provider: z.enum(["jira", "trello", "github-issues"]), reference: z.string().min(1), operation: z.enum(["get", "update", "comment"]), payload: z.record(z.string(), z.unknown()).default({}) }) }, async ({ provider, reference, operation, payload }) => { try { return result(providerPreflight(provider, reference, operation, payload)); } catch (error) { return failure(error); } });
20
26
  const actor = z.object({ id: z.string().min(1).max(128), role: z.enum(["reader", "commenter", "writer", "admin"]) });
21
27
  server.registerTool("board_get", { description: "Read a bounded, filtered shared Kanban projection. Notices are not instructions and clients must validate revisions before acting.", inputSchema: z.object({ workspace, assignee: z.string().max(128).optional(), watch: z.array(z.string().max(128)).max(100).optional(), limit: z.number().int().min(1).max(100).default(50) }) }, async ({ workspace: id, ...filters }) => { try { return result(await coordination.get(id, filters)); } catch (error) { return failure(error); } });
22
28
  server.registerTool("coordination_events_since", { description: "Poll compact, cursor-based coordination notices. Clients coalesce then fetch details only for relevant work.", inputSchema: z.object({ workspace, cursor: z.number().int().nonnegative().default(0), actor: z.string().max(128).optional() }) }, async ({ workspace: id, cursor, actor }) => { try { return result(await coordination.eventsSince(id, cursor, { actor })); } catch (error) { return failure(error); } });
23
29
  server.registerTool("task_thread_get", { description: "Read a bounded append-only task thread; never use threads for gates, secrets, or reasoning traces.", inputSchema: z.object({ workspace, taskId: z.string().min(1).max(128), cursor: z.number().int().nonnegative().default(0), limit: z.number().int().min(1).max(100).default(50) }) }, async ({ workspace: id, taskId, cursor, limit }) => { try { return result(await coordination.thread(id, taskId, cursor, limit)); } catch (error) { return failure(error); } });
24
- server.registerTool("board_apply", { description: "Revisioned, idempotent board task mutation. It cannot mutate lifecycle state or artifacts.", inputSchema: z.object({ workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256), actor, command: z.object({ type: z.literal("upsert_task"), task: z.object({ id: z.string().min(1).max(128), title: z.string().min(1).max(256), description: z.string().max(2000).optional(), status: z.enum(["backlog", "ready", "in_progress", "blocked", "done", "cancelled"]), priority: z.string().max(32).optional(), labels: z.array(z.string().max(64)).max(20).optional(), assignees: z.array(z.string().max(128)).max(10).optional(), lifecycleTaskId: z.string().max(128).optional(), artifactLinks: z.array(z.string().max(512)).max(10).optional(), revision: z.number().int().nonnegative().optional() }) }) }) }, async ({ workspace: id, expectedRevision, idempotencyKey, actor, command }) => { try { return result(await coordination.apply(id, expectedRevision, idempotencyKey, actor.id, actor.role, command)); } catch (error) { return failure(error); } });
25
- server.registerTool("task_comment_add", { description: "Append a short structured task comment with revision/idempotency protection; comments cannot approve gates.", inputSchema: z.object({ workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256), actor, command: z.object({ type: z.literal("add_comment"), taskId: z.string().min(1).max(128), authorType: z.enum(["human", "agent"]), intent: z.enum(["question", "status", "handoff", "decision"]), mentions: z.array(z.string().max(128)).max(20).optional(), body: z.string().min(1).max(2000) }) }) }, async ({ workspace: id, expectedRevision, idempotencyKey, actor, command }) => { try { return result(await coordination.apply(id, expectedRevision, idempotencyKey, actor.id, actor.role, command)); } catch (error) { return failure(error); } });
26
- server.registerTool("task_claim", { description: "Acquire or renew a revisioned short-lived task claim; conflicting live claims are rejected.", inputSchema: z.object({ workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256), actor, command: z.object({ type: z.literal("claim"), taskId: z.string().min(1).max(128), leaseMs: z.number().int().min(60000).max(3600000) }) }) }, async ({ workspace: id, expectedRevision, idempotencyKey, actor, command }) => { try { return result(await coordination.apply(id, expectedRevision, idempotencyKey, actor.id, actor.role, command)); } catch (error) { return failure(error); } });
30
+ server.registerTool("board_apply", { description: "Revisioned, idempotent board task mutation. It cannot mutate lifecycle state or artifacts.", inputSchema: z.object({ workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256), actor, command: z.object({ type: z.literal("upsert_task"), task: z.object({ id: z.string().min(1).max(128), title: z.string().min(1).max(256), description: z.string().max(2000).optional(), status: z.enum(["backlog", "ready", "in_progress", "blocked", "done", "cancelled"]), priority: z.string().max(32).optional(), labels: z.array(z.string().max(64)).max(20).optional(), assignees: z.array(z.string().max(128)).max(10).optional(), lifecycleTaskId: z.string().max(128).optional(), artifactLinks: z.array(z.string().max(512)).max(10).optional(), revision: z.number().int().nonnegative().optional() }) }) }) }, async ({ workspace: id, expectedRevision, idempotencyKey, actor, command }) => { try { const principal=actorFor(actor); return result(await coordination.apply(id, expectedRevision, idempotencyKey, principal.id, principal.role, command)); } catch (error) { return failure(error); } });
31
+ server.registerTool("task_comment_add", { description: "Append a short structured task comment with revision/idempotency protection; comments cannot approve gates.", inputSchema: z.object({ workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256), actor, command: z.object({ type: z.literal("add_comment"), taskId: z.string().min(1).max(128), authorType: z.enum(["human", "agent"]), intent: z.enum(["question", "status", "handoff", "decision"]), mentions: z.array(z.string().max(128)).max(20).optional(), body: z.string().min(1).max(2000) }) }) }, async ({ workspace: id, expectedRevision, idempotencyKey, actor, command }) => { try { const principal=actorFor(actor); return result(await coordination.apply(id, expectedRevision, idempotencyKey, principal.id, principal.role, command)); } catch (error) { return failure(error); } });
32
+ server.registerTool("task_claim", { description: "Acquire or renew a revisioned short-lived task claim; conflicting live claims are rejected.", inputSchema: z.object({ workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256), actor, command: z.object({ type: z.literal("claim"), taskId: z.string().min(1).max(128), leaseMs: z.number().int().min(60000).max(3600000) }) }) }, async ({ workspace: id, expectedRevision, idempotencyKey, actor, command }) => { try { const principal=actorFor(actor); return result(await coordination.apply(id, expectedRevision, idempotencyKey, principal.id, principal.role, command)); } catch (error) { return failure(error); } });
33
+ if (identity) {
34
+ const precondition = { workspace, expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.string().min(1).max(256) };
35
+ const fleetMutation = (name, role, schema, type) => server.registerTool(name, { description: `Fenced fleet ${name} operation.`, inputSchema: z.object({ ...precondition, ...schema }) }, async ({ workspace: id, expectedRevision, idempotencyKey, ...args }) => { try { requireRole(role); return result(await fleet.apply(id, expectedRevision, idempotencyKey, identity.id, role, { type, ...args })); } catch(error) { return failure(error); } });
36
+ server.registerTool("fleet_get", { description: "Read versioned feature and assignment records.", inputSchema: z.object({ workspace }) }, async ({workspace:id}) => { try { const current=await fleet.get(id); if(identity.role==="worker") current.fleet.jobs=Object.fromEntries(Object.entries(current.fleet.jobs).filter(([,job])=>job.workerId===identity.id)); return result(current); } catch(error) { return failure(error); } });
37
+ server.registerTool("feature_get", { description: "Read one versioned feature manifest.", inputSchema: z.object({workspace,featureId:z.string()}) }, async ({workspace:id,featureId}) => { try { const current=await fleet.get(id); return result({workspace:id,revision:current.revision,feature:current.fleet.features[featureId]??null}); } catch(error){return failure(error);} });
38
+ server.registerTool("feature_apply", { description: "CAS-update a feature DAG using canonical task IDs.", inputSchema: z.object({...precondition,feature:z.unknown()}) }, async ({workspace:id,expectedRevision,idempotencyKey,feature}) => { try { requireRole("lead"); validateCanonicalFeature(feature,(await repository.get(id)).state); return result(await fleet.apply(id,expectedRevision,idempotencyKey,identity.id,"lead",{type:"feature_apply",feature})); } catch(error){return failure(error);} });
39
+ server.registerTool("feature_ready", { description: "Read dependency-ready feature tasks from canonical completion state.", inputSchema: z.object({workspace,featureId:z.string()}) }, async ({workspace:id,featureId}) => { try { const current=await fleet.get(id); const feature=current.fleet.features[featureId];if(!feature) throw new Error("feature not found");const canonical=await repository.get(id);const completed=feature.tasks.filter((member)=>canonical.state.tasks[member.id]?.status==="done"||canonical.state.archive?.[member.id]?.status==="done").map((member)=>member.id);return result({workspace:id,featureId,featureRevision:feature.revision,stateRevision:canonical.revision,items:featureReady(feature,completed)}); } catch(error){return failure(error);} });
40
+ fleetMutation("supervisor_claim","lead",{taskId:z.string(),leaseMs:z.number().int(),ownerToken:z.string().min(32).max(256)},"supervisor_claim");
41
+ fleetMutation("supervisor_heartbeat","lead",{taskId:z.string(),epoch:z.number().int(),leaseMs:z.number().int(),ownerToken:z.string().min(32).max(256)},"supervisor_heartbeat");
42
+ fleetMutation("supervisor_release","lead",{taskId:z.string(),epoch:z.number().int(),ownerToken:z.string().min(32).max(256)},"supervisor_release");
43
+ fleetMutation("run_claim","lead",{taskId:z.string(),leaseMs:z.number().int(),ownerToken:z.string().min(32).max(256).optional(),supervisorEpoch:z.number().int().optional(),supervisorOwnerToken:z.string().min(32).max(256).optional(),expectedStateRevision:z.number().int().nonnegative().optional(),expectedTaskDigest:z.string().regex(/^[a-f0-9]{64}$/).optional(),checkpointSnapshotId:z.string().optional(),checkpointApprovalRevision:z.number().int().nonnegative().optional()},"run_claim");
44
+ fleetMutation("run_heartbeat","lead",{taskId:z.string(),epoch:z.number().int(),leaseMs:z.number().int(),ownerToken:z.string().min(32).max(256).optional()},"run_heartbeat");
45
+ fleetMutation("run_release","lead",{taskId:z.string(),epoch:z.number().int(),ownerToken:z.string().min(32).max(256).optional()},"run_release");
46
+ fleetMutation("job_schedule","lead",{jobId:z.string(),taskId:z.string(),itemId:z.string(),workerId:z.string(),agent:z.enum(["codex","claude","opencode"]),attempt:z.number().int(),leadEpoch:z.number().int(),ownerToken:z.string().min(32).max(256).optional(),ownedPaths:z.array(z.string()),baseCommit:z.string(),branch:z.string()},"job_schedule");
47
+ fleetMutation("job_claim","worker",{jobId:z.string(),leaseMs:z.number().int()},"job_claim");
48
+ fleetMutation("job_heartbeat","worker",{jobId:z.string(),epoch:z.number().int(),leaseMs:z.number().int()},"job_heartbeat");
49
+ fleetMutation("job_checkpoint","worker",{jobId:z.string(),epoch:z.number().int(),sessionId:z.string(),checkpoint:z.string()},"job_checkpoint");
50
+ fleetMutation("job_report","worker",{jobId:z.string(),epoch:z.number().int(),commitSha:z.string(),changedPaths:z.array(z.string()),summary:z.string().optional()},"job_report");
51
+ fleetMutation("job_abandon","worker",{jobId:z.string(),epoch:z.number().int(),reason:z.string().min(1).max(2000)},"job_abandon");
52
+ fleetMutation("job_recover","lead",{jobId:z.string(),leadEpoch:z.number().int(),ownerToken:z.string().min(32).max(256).optional(),commitSha:z.string(),changedPaths:z.array(z.string()),summary:z.string().optional()},"job_recover");
53
+ server.registerTool("job_reconcile", { description: "Lead-only job reconciliation after checking canonical item state.", inputSchema: z.object({...precondition,jobId:z.string(),leadEpoch:z.number().int(),ownerToken:z.string().min(32).max(256).optional(),state:z.enum(["ready","uncertain","integrated"])}) }, async ({workspace:id,expectedRevision,idempotencyKey,jobId,leadEpoch,ownerToken,state}) => { try { requireRole("lead"); if(state==="integrated"){const current=await fleet.get(id);const job=current.fleet.jobs[jobId];const canonical=await repository.get(id);if(!job||canonical.state.tasks[job.taskId]?.tasks.find((item)=>item.id===job.itemId)?.status!=="done") throw new Error("canonical item must be done before job integration");} return result(await fleet.apply(id,expectedRevision,idempotencyKey,identity.id,"lead",{type:"job_reconcile",jobId,leadEpoch,ownerToken,state})); } catch(error){return failure(error);} });
54
+ server.registerTool("gate_inbox_get", { description: "Read pending immutable gate review snapshots.", inputSchema: z.object({workspace}) }, async ({workspace:id}) => { try { requireRole("lead","reviewer"); return result(await repository.gateInbox(id)); } catch(error) { return failure(error); } });
55
+ server.registerTool("gate_snapshot_put", { description: "Publish a review snapshot at the current canonical task revision, binding G2 to an integration commit.", inputSchema: z.object({...precondition,taskId:z.string(),leadEpoch:z.number().int(),leadOwnerToken:z.string().min(32).max(256).optional(),checkpoint:z.boolean().optional(),gate:z.enum(["G0_confirm","G1_review","G2_codereview"]),reviewText:z.string().min(1).max(20000),integrationCommit:z.string().regex(/^[a-f0-9]{40}$/).optional()}) }, async ({workspace:id,expectedRevision,idempotencyKey,taskId,leadEpoch,leadOwnerToken,checkpoint,gate,reviewText,integrationCommit}) => { try { requireRole("lead"); return result(await repository.putGateSnapshot(id,expectedRevision,idempotencyKey,taskId,gate,reviewText,leadEpoch,integrationCommit,leadOwnerToken,checkpoint,identity.id)); } catch(error){return failure(error);} });
56
+ server.registerTool("gate_checkpoint_get", { description: "Read durable supervisor gate checkpoint.", inputSchema: z.object({workspace,taskId:z.string()}) }, async ({workspace:id,taskId}) => { try { requireRole("lead","reviewer"); return result(await repository.gateCheckpoint(id,taskId)); } catch(error){return failure(error);} });
57
+ server.registerTool("gate_approve", { description: "Reviewer-only atomic approval against an unchanged task revision and reviewed integration commit.", inputSchema: z.object({...precondition,taskId:z.string(),snapshotId:z.string(),source:z.string().min(1).max(2000),clientProjectRoot:z.string(),integrationCommit:z.string().regex(/^[a-f0-9]{40}$/).optional()}) }, async ({workspace:id,expectedRevision,idempotencyKey,taskId,snapshotId,source,clientProjectRoot,integrationCommit}) => { try { requireRole("reviewer"); return result(await repository.approveGate(id,expectedRevision,idempotencyKey,taskId,snapshotId,source,clientProjectRoot,integrationCommit,identity.id)); } catch(error){return failure(error);} });
58
+ }
27
59
  return server;
28
60
  };
29
61
 
62
+ const fleetEnabled = process.env.MCP_FLEET_ENABLED === "1";
63
+ if (fleetEnabled && !["sqlite", "postgres"].includes(process.env.MCP_STATE_DATABASE ?? "sqlite")) throw new Error("unsupported fleet database");
30
64
  const repository = createRepository();
31
65
  const coordination = createCoordinationRepository();
66
+ const fleet = fleetEnabled ? createFleetRepository() : null;
32
67
  const host = process.env.MCP_STATE_HOST ?? "127.0.0.1";
33
68
  const port = Number(process.env.MCP_STATE_PORT ?? 8787);
34
69
  const token = process.env.MCP_STATE_AUTH_TOKEN;
35
- if (!["127.0.0.1", "localhost", "::1"].includes(host) && !token) throw new Error("MCP_STATE_AUTH_TOKEN is required when binding beyond localhost");
70
+ if (!fleetEnabled && !["127.0.0.1", "localhost", "::1"].includes(host) && !token) throw new Error("MCP_STATE_AUTH_TOKEN is required when binding beyond localhost");
71
+ const fleetCredentials = fleetEnabled ? [
72
+ { id: "lead", role: "lead", token: process.env.MCP_FLEET_LEAD_TOKEN },
73
+ { id: "reviewer", role: "reviewer", token: process.env.MCP_FLEET_REVIEWER_TOKEN },
74
+ ...Object.entries(JSON.parse(process.env.MCP_FLEET_WORKER_TOKENS ?? "{}" )).map(([id, token]) => ({ id, role: "worker", token }))
75
+ ] : [];
76
+ if (fleetEnabled && (fleetCredentials.length < 3 || fleetCredentials.some((entry) => typeof entry.token !== "string" || entry.token.length < 24) || new Set(fleetCredentials.map((entry) => entry.token)).size !== fleetCredentials.length)) throw new Error("fleet mode requires distinct lead, reviewer, and worker bearer credentials of at least 24 characters");
77
+ const equalToken = (left,right) => { const a=Buffer.from(left); const b=Buffer.from(right); return a.length===b.length && timingSafeEqual(a,b); };
78
+ const principalFor = (authorization) => fleetCredentials.find((entry) => equalToken(authorization,`Bearer ${entry.token}`));
36
79
  const app = createMcpExpressApp({ host });
37
80
  app.use((req, res, next) => {
81
+ if (fleetEnabled) { const principal=principalFor(req.headers.authorization ?? ""); if (!principal) return res.status(401).json({ error: "unauthorized" }); req.fleetPrincipal={id:principal.id,role:principal.role}; return next(); }
38
82
  if (!token || req.headers.authorization === `Bearer ${token}`) return next();
39
83
  res.status(401).json({ error: "unauthorized" });
40
84
  });
41
85
  app.post("/mcp", async (req, res) => {
42
86
  const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
43
- const server = buildServer(repository, coordination);
87
+ const server = buildServer(repository, coordination, fleet, req.fleetPrincipal ?? null);
44
88
  await server.connect(transport);
45
89
  await transport.handleRequest(req, res, req.body);
46
90
  });
47
91
  const listener = app.listen(port, host, () => console.error(`aidlc MCP state service listening at http://${host}:${port}/mcp`));
48
- const shutdown = async () => { listener.close(); await repository.close(); coordination.close(); };
92
+ const shutdown = async () => { listener.close(); await repository.close(); await coordination.close(); await fleet?.close(); };
49
93
  process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown);