@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.
- package/DEVELOPMENT.md +1 -1
- package/README.md +5 -4
- package/dist/assets/.agents/aidlc/conventions.md +8 -8
- package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
- package/dist/assets/.agents/aidlc/phase-build.md +7 -7
- package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
- package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
- package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
- package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
- package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
- package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +8 -1
- package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +215 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +314 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +161 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +254 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +65 -0
- package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +39 -4
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +309 -0
- package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/state.mjs +67 -11
- package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
- package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
- package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
- package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
- package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
- package/dist/services/aidlc-coordination/compose.yaml +23 -2
- package/dist/services/aidlc-coordination/package-lock.json +1 -0
- package/dist/services/aidlc-coordination/package.json +2 -1
- package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
- package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
- package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
- package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
- package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +39 -4
- package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
- package/dist/services/aidlc-coordination/src/server.mjs +53 -9
- package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
- package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
- package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
- package/dist/src/adapters.js +25 -5
- package/dist/src/cli.js +66 -6
- package/dist/src/gate.js +3 -3
- package/dist/src/installer.d.ts +5 -1
- package/dist/src/installer.js +114 -30
- package/dist/src/managed-content.js +1 -1
- package/dist/src/model.d.ts +15 -0
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +3 -1
- package/dist/src/profiles.js +26 -3
- package/dist/src/state.d.ts +7 -0
- package/dist/src/state.js +54 -3
- package/dist/src/upgrade.d.ts +2 -2
- package/dist/src/upgrade.js +39 -4
- package/dist/src/workflow.d.ts +1 -0
- package/dist/src/workflow.js +2 -1
- package/docs/README.md +2 -2
- package/docs/architecture.md +6 -1
- package/docs/command-reference.md +63 -5
- package/docs/configuration.md +20 -1
- package/docs/coordination-service.md +54 -1
- package/docs/development.md +1 -1
- package/docs/operating-workflow.md +50 -14
- package/docs/testing-and-release.md +47 -4
- package/package.json +2 -2
|
@@ -12,13 +12,34 @@ services:
|
|
|
12
12
|
|
|
13
13
|
postgres:
|
|
14
14
|
image: postgres:16-alpine
|
|
15
|
-
profiles: [postgres]
|
|
15
|
+
profiles: [postgres, fleet]
|
|
16
16
|
environment:
|
|
17
17
|
POSTGRES_DB: aidlc
|
|
18
18
|
POSTGRES_USER: aidlc
|
|
19
|
-
POSTGRES_PASSWORD:
|
|
19
|
+
POSTGRES_PASSWORD: ${MCP_POSTGRES_PASSWORD:?set MCP_POSTGRES_PASSWORD in .env}
|
|
20
20
|
volumes:
|
|
21
21
|
- postgres-data:/var/lib/postgresql/data
|
|
22
|
+
healthcheck:
|
|
23
|
+
test: ["CMD-SHELL", "pg_isready -U aidlc -d aidlc"]
|
|
24
|
+
interval: 5s
|
|
25
|
+
timeout: 3s
|
|
26
|
+
retries: 20
|
|
27
|
+
|
|
28
|
+
fleet-coordination:
|
|
29
|
+
profiles: [fleet]
|
|
30
|
+
build: .
|
|
31
|
+
ports:
|
|
32
|
+
- "${MCP_FLEET_BIND_ADDRESS:-127.0.0.1}:${MCP_FLEET_PORT:-8788}:8787"
|
|
33
|
+
depends_on:
|
|
34
|
+
postgres:
|
|
35
|
+
condition: service_healthy
|
|
36
|
+
environment:
|
|
37
|
+
MCP_STATE_DATABASE: postgres
|
|
38
|
+
MCP_STATE_POSTGRES_URL: postgres://aidlc:${MCP_POSTGRES_PASSWORD:?set MCP_POSTGRES_PASSWORD in .env}@postgres:5432/aidlc
|
|
39
|
+
MCP_FLEET_ENABLED: "1"
|
|
40
|
+
MCP_FLEET_LEAD_TOKEN: ${MCP_FLEET_LEAD_TOKEN:?set MCP_FLEET_LEAD_TOKEN in .env}
|
|
41
|
+
MCP_FLEET_REVIEWER_TOKEN: ${MCP_FLEET_REVIEWER_TOKEN:?set MCP_FLEET_REVIEWER_TOKEN in .env}
|
|
42
|
+
MCP_FLEET_WORKER_TOKENS: ${MCP_FLEET_WORKER_TOKENS:?set MCP_FLEET_WORKER_TOKENS JSON in .env}
|
|
22
43
|
|
|
23
44
|
volumes:
|
|
24
45
|
aidlc-coordination-data:
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
"name": "@felixdotgo/aidlc-coordination",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
+
"bin": { "aidlc-fleet": "src/fleet-run.mjs" },
|
|
5
6
|
"engines": { "node": ">=20" },
|
|
6
7
|
"scripts": {
|
|
7
8
|
"start": "node src/server.mjs",
|
|
8
9
|
"test": "node --test test/*.test.mjs",
|
|
9
|
-
"lint": "node --check src/repository.mjs && node --check src/coordination.mjs && node --check src/server.mjs"
|
|
10
|
+
"lint": "node --check src/repository.mjs && node --check src/coordination.mjs && node --check src/fleet.mjs && node --check src/fleet-client.mjs && node --check src/fleet-run.mjs && node --check src/server.mjs"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
13
|
"@modelcontextprotocol/express": "^2.0.0",
|
|
@@ -61,7 +61,7 @@ export class PostgresCoordinationRepository {
|
|
|
61
61
|
async get(workspace, filters = {}) { const c = await this.pool.connect(); try { await this.#init(c); await c.query("INSERT INTO coordination_workspaces VALUES ($1,0,$2,$3) ON CONFLICT DO NOTHING", [workspace,{tasks:{},comments:{}},now()]); const row=(await c.query("SELECT * FROM coordination_workspaces WHERE workspace=$1",[workspace])).rows[0]; return { workspace, revision:Number(row.revision), board:{tasks:Object.values(row.board.tasks).filter((t)=>!filters.assignee||t.assignees.includes(filters.assignee)).slice(0,Math.min(filters.limit??50,100))}, updatedAt:row.updated_at }; } finally { c.release(); } }
|
|
62
62
|
async eventsSince(workspace,cursor=0,filters={}) { const c=await this.pool.connect(); try { await this.#init(c); return (await c.query("SELECT * FROM coordination_events WHERE workspace=$1 AND id>$2 ORDER BY id LIMIT 100",[workspace,cursor])).rows.filter((e)=>!filters.actor||e.actor===filters.actor||e.type==="mention").map((e)=>({cursor:Number(e.id),taskId:e.task_id,type:e.type,revision:Number(e.revision),actor:e.actor,timestamp:e.recorded_at,digest:e.digest})); } finally { c.release(); } }
|
|
63
63
|
async thread(workspace,taskId,cursor=0,limit=50) { const state=await this.get(workspace); const c=await this.pool.connect(); try { const row=(await c.query("SELECT board FROM coordination_workspaces WHERE workspace=$1",[workspace])).rows[0]; if(!row.board.tasks[taskId]) throw new Error("task not found"); return {revision:state.revision,task:row.board.tasks[taskId],comments:(row.board.comments[taskId]??[]).filter((x)=>x.id>cursor).slice(0,Math.min(limit,100))}; } finally { c.release(); } }
|
|
64
|
-
async apply(workspace,expectedRevision,idempotencyKey,actor,role,command) { if(!Number.isInteger(expectedRevision)||!text(idempotencyKey,256)||!text(actor,128)||!roles.has(role)) throw new Error("invalid coordination mutation precondition"); const c=await this.pool.connect(); try { await this.#init(c); await c.query("BEGIN"); await c.query("INSERT INTO coordination_workspaces VALUES ($1,0,$2,$3) ON CONFLICT DO NOTHING",[workspace,{tasks:{},comments:{}},now()]); const cached=(await c.query("SELECT response FROM coordination_idempotency WHERE workspace=$1 AND idempotency_key=$2",[workspace,idempotencyKey])).rows[0]; if(cached){await c.query("COMMIT");return cached.response;} const row=(await c.query("SELECT * FROM coordination_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0]; const current={workspace,revision:Number(row.revision),board:row.board}; if(current.revision!==expectedRevision) throw new CoordinationConflict(current); const bridge=new SqliteCoordinationRepository(":memory:"); try { await bridge.
|
|
64
|
+
async apply(workspace,expectedRevision,idempotencyKey,actor,role,command) { if(!Number.isInteger(expectedRevision)||!text(idempotencyKey,256)||!text(actor,128)||!roles.has(role)) throw new Error("invalid coordination mutation precondition"); const c=await this.pool.connect(); try { await this.#init(c); await c.query("BEGIN"); await c.query("INSERT INTO coordination_workspaces VALUES ($1,0,$2,$3) ON CONFLICT DO NOTHING",[workspace,{tasks:{},comments:{}},now()]); const cached=(await c.query("SELECT response FROM coordination_idempotency WHERE workspace=$1 AND idempotency_key=$2",[workspace,idempotencyKey])).rows[0]; if(cached){await c.query("COMMIT");return cached.response;} const row=(await c.query("SELECT * FROM coordination_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0]; const current={workspace,revision:Number(row.revision),board:row.board}; if(current.revision!==expectedRevision) throw new CoordinationConflict(current); const bridge=new SqliteCoordinationRepository(":memory:"); try { await bridge.get(workspace); bridge.db.prepare("UPDATE coordination_workspaces SET revision=?,board=? WHERE workspace=?").run(current.revision,encode(current.board),workspace); const response=await bridge.apply(workspace,expectedRevision,idempotencyKey,actor,role,command); const next=parse(bridge.db.prepare("SELECT board FROM coordination_workspaces WHERE workspace=?").get(workspace).board); const event=(await bridge.eventsSince(workspace,0)).at(-1); await c.query("UPDATE coordination_workspaces SET revision=$1,board=$2,updated_at=$3 WHERE workspace=$4",[response.revision,next,response.updatedAt,workspace]); await c.query("INSERT INTO coordination_events (workspace,task_id,type,revision,actor,digest,recorded_at) VALUES ($1,$2,$3,$4,$5,$6,$7)",[workspace,event.taskId,event.type,response.revision,actor,event.digest,response.updatedAt]); await c.query("INSERT INTO coordination_idempotency VALUES ($1,$2,$3)",[workspace,idempotencyKey,response]); await c.query("COMMIT"); return response; } finally { bridge.close(); } } catch(error){try{await c.query("ROLLBACK");}catch{} throw error;} finally{c.release();} }
|
|
65
65
|
async close(){await this.pool.end();}
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const responseMessage = (body, contentType, id) => {
|
|
4
|
+
if (contentType.includes("text/event-stream")) {
|
|
5
|
+
const events = body.split(/\r?\n\r?\n/).flatMap((block) => {
|
|
6
|
+
const data = block.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("\n");
|
|
7
|
+
if (!data || data === "[DONE]") return [];
|
|
8
|
+
try { return [JSON.parse(data)]; } catch { return []; }
|
|
9
|
+
});
|
|
10
|
+
return events.find((event) => event.id === id && (event.result || event.error));
|
|
11
|
+
}
|
|
12
|
+
const parsed = JSON.parse(body);
|
|
13
|
+
return Array.isArray(parsed) ? parsed.find((item) => item.id === id) : parsed;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const callTool = async ({ url, token, name, args = {}, timeoutMs = 20000, fetchImpl = fetch }) => {
|
|
17
|
+
if (typeof url !== "string" || !/^https?:\/\//.test(url) || typeof token !== "string" || !token || typeof name !== "string" || !name) throw new Error("MCP URL, token, and tool name are required");
|
|
18
|
+
const id = randomUUID();
|
|
19
|
+
const response = await fetchImpl(url, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json, text/event-stream", "Content-Type": "application/json", "MCP-Protocol-Version": "2025-06-18" },
|
|
22
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }),
|
|
23
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
24
|
+
});
|
|
25
|
+
const body = await response.text();
|
|
26
|
+
if (!response.ok) throw new Error(`MCP HTTP ${response.status}: ${body.slice(0, 500)}`);
|
|
27
|
+
const message = responseMessage(body, response.headers.get("content-type") ?? "", id);
|
|
28
|
+
if (!message) throw new Error("MCP response did not contain a matching result");
|
|
29
|
+
if (message.error) throw new Error(`MCP ${message.error.code}: ${message.error.message}`);
|
|
30
|
+
const tool = message.result;
|
|
31
|
+
if (!Array.isArray(tool?.content) || tool.content[0]?.type !== "text") throw new Error("MCP tool returned no JSON text");
|
|
32
|
+
let value;
|
|
33
|
+
try { value = JSON.parse(tool.content[0].text); } catch { throw new Error("MCP tool returned invalid JSON text"); }
|
|
34
|
+
if (tool.isError) { const error = new Error(value.message ?? value.code ?? "MCP tool failed"); error.code = value.code; error.current = value.current; throw error; }
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { callTool } from "./fleet-client.mjs";
|
|
5
|
+
|
|
6
|
+
const source = new URL("../../../assets/aidlc/scripts/lib/loop-fleet-cli.mjs", import.meta.url);
|
|
7
|
+
const packaged = new URL("../../../assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs", import.meta.url);
|
|
8
|
+
const installed = new URL("../../aidlc/scripts/lib/loop-fleet-cli.mjs", import.meta.url);
|
|
9
|
+
const entry = [installed, source, packaged].find((candidate) => existsSync(fileURLToPath(candidate)));
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
if (!entry) throw new Error("Fleet runner assets are missing from this installation");
|
|
13
|
+
const { runFleetCli } = await import(entry.href);
|
|
14
|
+
await runFleetCli(process.argv.slice(2), { callTool });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { nextAction } from "./lifecycle-core.mjs";
|
|
5
|
+
|
|
6
|
+
const now = () => Date.now();
|
|
7
|
+
const iso = (time) => new Date(time).toISOString();
|
|
8
|
+
const id = (value) => typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
|
|
9
|
+
const branchName = (value) => typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(value) && !value.includes("..") && !value.endsWith("/");
|
|
10
|
+
const copy = (value) => structuredClone(value);
|
|
11
|
+
const initial = () => ({ schemaVersion: 1, features: {}, leads: {}, supervisors: {}, jobs: {} });
|
|
12
|
+
const validLease = (value) => Number.isInteger(value) && value >= 60000 && value <= 3600000;
|
|
13
|
+
|
|
14
|
+
export class FleetConflict extends Error {
|
|
15
|
+
constructor(message, current) { super(message); this.name = "FleetConflict"; this.current = current; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const validateFeature = (feature) => {
|
|
19
|
+
if (!feature || !id(feature.id) || !Array.isArray(feature.tasks) || !feature.tasks.length || feature.tasks.length > 100 || !branchName(feature.integrationBranch)) throw new Error("invalid feature manifest");
|
|
20
|
+
const tasks = new Map();
|
|
21
|
+
for (const task of feature.tasks) {
|
|
22
|
+
if (!id(task?.id) || tasks.has(task.id) || !Array.isArray(task.dependsOn) || task.dependsOn.some((dependency) => !id(dependency))) throw new Error("invalid or duplicate feature task");
|
|
23
|
+
tasks.set(task.id, task);
|
|
24
|
+
}
|
|
25
|
+
const visiting = new Set(), visited = new Set();
|
|
26
|
+
const visit = (taskId) => {
|
|
27
|
+
if (!tasks.has(taskId)) throw new Error(`unknown feature dependency: ${taskId}`);
|
|
28
|
+
if (visiting.has(taskId)) throw new Error("feature dependency cycle");
|
|
29
|
+
if (visited.has(taskId)) return;
|
|
30
|
+
visiting.add(taskId);
|
|
31
|
+
for (const dependency of tasks.get(taskId).dependsOn) visit(dependency);
|
|
32
|
+
visiting.delete(taskId); visited.add(taskId);
|
|
33
|
+
};
|
|
34
|
+
for (const taskId of tasks.keys()) visit(taskId);
|
|
35
|
+
return { schemaVersion: 1, id: feature.id, integrationBranch: feature.integrationBranch, tasks: feature.tasks.map((task) => ({ id: task.id, dependsOn: [...new Set(task.dependsOn)] })) };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const featureReady = (feature, completedIds = []) => {
|
|
39
|
+
const completed = new Set(completedIds);
|
|
40
|
+
return feature.tasks.map((task) => ({ taskId: task.id, ready: !completed.has(task.id) && task.dependsOn.every((dependency) => completed.has(dependency)), blockers: task.dependsOn.filter((dependency) => !completed.has(dependency)) }));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const validateCanonicalFeature = (feature, state) => {
|
|
44
|
+
const manifest=validateFeature(feature);
|
|
45
|
+
for(const member of manifest.tasks) {
|
|
46
|
+
const task=state.tasks?.[member.id];
|
|
47
|
+
if(!task) throw new Error(`feature task does not exist in canonical state: ${member.id}`);
|
|
48
|
+
if(task.featureId && task.featureId!==manifest.id) throw new Error(`task belongs to another feature: ${member.id}`);
|
|
49
|
+
}
|
|
50
|
+
return manifest;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const tokenHash = (token) => createHash("sha256").update(token).digest("hex");
|
|
54
|
+
const requireSupervisor = (state, taskId, actor, epoch, time, ownerToken) => {
|
|
55
|
+
const supervisor = state.supervisors?.[taskId];
|
|
56
|
+
if (!supervisor || supervisor.actor !== actor || supervisor.epoch !== epoch || Date.parse(supervisor.expiresAt) <= time || typeof ownerToken !== "string" || tokenHash(ownerToken) !== supervisor.ownerTokenHash) throw new FleetConflict("stale or expired supervisor claim");
|
|
57
|
+
return supervisor;
|
|
58
|
+
};
|
|
59
|
+
const verifyBoundSupervisor = (state, lead, time) => {
|
|
60
|
+
if (state.supervisors?.[lead.taskId]) {
|
|
61
|
+
const supervisor = state.supervisors[lead.taskId];
|
|
62
|
+
if (!lead.supervisorEpoch || supervisor.epoch !== lead.supervisorEpoch || supervisor.actor !== lead.actor || supervisor.ownerTokenHash !== lead.supervisorOwnerTokenHash || Date.parse(supervisor.expiresAt) <= time) throw new FleetConflict("stale or expired bound supervisor claim");
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const requireLead = (state, taskId, actor, epoch, time, ownerToken) => {
|
|
66
|
+
const lead = state.leads[taskId];
|
|
67
|
+
if (!lead || lead.actor !== actor || lead.epoch !== epoch || Date.parse(lead.expiresAt) <= time || (lead.ownerTokenHash && (typeof ownerToken !== "string" || tokenHash(ownerToken) !== lead.ownerTokenHash))) throw new FleetConflict("stale or expired lead claim");
|
|
68
|
+
verifyBoundSupervisor(state, lead, time);
|
|
69
|
+
return lead;
|
|
70
|
+
};
|
|
71
|
+
const requireJob = (state, jobId, actor, epoch, time) => {
|
|
72
|
+
const job = state.jobs[jobId];
|
|
73
|
+
if (!job || job.workerId !== actor || job.epoch !== epoch || job.state !== "leased" || Date.parse(job.expiresAt) <= time) throw new FleetConflict("stale or expired job claim");
|
|
74
|
+
const lead = state.leads[job.taskId];
|
|
75
|
+
if (state.supervisors?.[job.taskId] && (!lead || lead.epoch !== job.leadEpoch || Date.parse(lead.expiresAt) <= time)) throw new FleetConflict("job lead lease is stale");
|
|
76
|
+
if (lead) verifyBoundSupervisor(state, lead, time);
|
|
77
|
+
return job;
|
|
78
|
+
};
|
|
79
|
+
const validChangedPaths = (job, paths) => Array.isArray(paths) && paths.length <= 100 && paths.every((path) => typeof path === "string" && Boolean(path) && !path.startsWith("/") && !path.split("/").includes("..") && job.ownedPaths.some((scope) => path === scope || path.startsWith(`${scope.replace(/\/$/, "")}/`)));
|
|
80
|
+
|
|
81
|
+
export const applyFleetCommand = (before, command, actor, role, time = now()) => {
|
|
82
|
+
const state = copy(before);
|
|
83
|
+
const c = command ?? {};
|
|
84
|
+
if (c.type === "feature_apply") {
|
|
85
|
+
if (role !== "lead") throw new Error("lead credential required");
|
|
86
|
+
const feature = validateFeature(c.feature);
|
|
87
|
+
for (const existing of Object.values(state.features)) for (const member of existing.tasks) if (existing.id !== feature.id && feature.tasks.some((task) => task.id === member.id)) throw new Error(`task already belongs to feature ${existing.id}`);
|
|
88
|
+
const old = state.features[feature.id];
|
|
89
|
+
if (old && c.feature.revision !== old.revision) throw new FleetConflict("feature revision conflict");
|
|
90
|
+
state.features[feature.id] = { ...feature, revision: (old?.revision ?? 0) + 1, updatedAt: iso(time) };
|
|
91
|
+
} else if (c.type === "supervisor_claim") {
|
|
92
|
+
if (role !== "lead" || !id(c.taskId) || !validLease(c.leaseMs) || typeof c.ownerToken !== "string" || c.ownerToken.length < 32 || c.ownerToken.length > 256) throw new Error("invalid supervisor claim");
|
|
93
|
+
state.supervisors ??= {};
|
|
94
|
+
const old = state.supervisors[c.taskId], lead = state.leads[c.taskId];
|
|
95
|
+
if ((old && Date.parse(old.expiresAt) > time) || (lead && Date.parse(lead.expiresAt) > time)) throw new FleetConflict("supervisor or prior lead claim is held");
|
|
96
|
+
state.supervisors[c.taskId] = { taskId: c.taskId, actor, epoch: (old?.epoch ?? 0) + 1, expiresAt: iso(time + c.leaseMs), ownerTokenHash: tokenHash(c.ownerToken) };
|
|
97
|
+
} else if (c.type === "supervisor_heartbeat" || c.type === "supervisor_release") {
|
|
98
|
+
if (role !== "lead" || !id(c.taskId) || !Number.isInteger(c.epoch)) throw new Error("invalid supervisor mutation");
|
|
99
|
+
const supervisor = requireSupervisor(state, c.taskId, actor, c.epoch, time, c.ownerToken);
|
|
100
|
+
if (c.type === "supervisor_heartbeat") { if (!validLease(c.leaseMs)) throw new Error("invalid lease duration"); supervisor.expiresAt = iso(time + c.leaseMs); }
|
|
101
|
+
else supervisor.expiresAt = iso(time);
|
|
102
|
+
} else if (c.type === "run_claim") {
|
|
103
|
+
if (role !== "lead" || !id(c.taskId) || !validLease(c.leaseMs)) throw new Error("invalid lead claim");
|
|
104
|
+
if (c.ownerToken !== undefined && (typeof c.ownerToken !== "string" || c.ownerToken.length < 32 || c.ownerToken.length > 256)) throw new Error("invalid owner token");
|
|
105
|
+
const supervisor = state.supervisors?.[c.taskId];
|
|
106
|
+
if (supervisor) {
|
|
107
|
+
requireSupervisor(state, c.taskId, actor, c.supervisorEpoch, time, c.supervisorOwnerToken);
|
|
108
|
+
if (typeof c.ownerToken !== "string" || c.ownerToken.length < 32) throw new Error("supervised run requires a secret owner token");
|
|
109
|
+
}
|
|
110
|
+
const old = state.leads[c.taskId];
|
|
111
|
+
if (old && Date.parse(old.expiresAt) > time) throw new FleetConflict("lead claim is held");
|
|
112
|
+
state.leads[c.taskId] = { taskId: c.taskId, actor, epoch: (old?.epoch ?? 0) + 1, expiresAt: iso(time + c.leaseMs), ...(c.ownerToken ? { ownerTokenHash: tokenHash(c.ownerToken) } : {}), ...(supervisor ? { supervisorEpoch: supervisor.epoch, supervisorOwnerTokenHash: supervisor.ownerTokenHash, ...(c.checkpointSnapshotId ? { checkpointSnapshotId: c.checkpointSnapshotId } : {}) } : {}) };
|
|
113
|
+
for (const job of Object.values(state.jobs)) if (job.taskId === c.taskId && job.state !== "integrated" && job.leadEpoch < state.leads[c.taskId].epoch) { job.state = "uncertain"; job.uncertainAt = iso(time); job.expiresAt = null; }
|
|
114
|
+
} else if (c.type === "run_heartbeat" || c.type === "run_release") {
|
|
115
|
+
if (role !== "lead" || !id(c.taskId) || !Number.isInteger(c.epoch)) throw new Error("invalid lead mutation");
|
|
116
|
+
const lead = requireLead(state, c.taskId, actor, c.epoch, time, c.ownerToken);
|
|
117
|
+
if (c.type === "run_heartbeat") { if (!validLease(c.leaseMs)) throw new Error("invalid lease duration"); lead.expiresAt = iso(time + c.leaseMs); }
|
|
118
|
+
else lead.expiresAt = iso(time);
|
|
119
|
+
} else if (c.type === "job_schedule") {
|
|
120
|
+
if (role !== "lead" || !id(c.jobId) || !id(c.taskId) || !id(c.itemId) || !id(c.workerId) || !["codex", "claude", "opencode"].includes(c.agent) || !Number.isInteger(c.attempt) || c.attempt < 1 || !Array.isArray(c.ownedPaths) || c.ownedPaths.length > 100 || c.ownedPaths.some((path) => typeof path !== "string" || !path || path.startsWith("/") || path.split("/").includes("..")) || !/^[a-f0-9]{40}$/.test(c.baseCommit) || !branchName(c.branch)) throw new Error("invalid job schedule");
|
|
121
|
+
requireLead(state, c.taskId, actor, c.leadEpoch, time, c.ownerToken);
|
|
122
|
+
if (state.jobs[c.jobId]) throw new FleetConflict("job ID already exists");
|
|
123
|
+
const priorJobs=Object.values(state.jobs).filter((job)=>job.taskId===c.taskId&&job.itemId===c.itemId);
|
|
124
|
+
if(priorJobs.some((job)=>job.state==="integrated")) throw new FleetConflict("item already integrated");
|
|
125
|
+
if(priorJobs.some((job)=>!["integrated","uncertain"].includes(job.state))) throw new FleetConflict("item already has an active job");
|
|
126
|
+
if(priorJobs.length && c.attempt<=Math.max(...priorJobs.map((job)=>job.attempt))) throw new FleetConflict("job attempt must increase after reconciliation");
|
|
127
|
+
state.jobs[c.jobId] = { schemaVersion: 1, id: c.jobId, taskId: c.taskId, itemId: c.itemId, workerId: c.workerId, agent: c.agent, attempt: c.attempt, leadEpoch: c.leadEpoch, epoch: 0, ownedPaths: c.ownedPaths, baseCommit: c.baseCommit, branch: c.branch, state: "ready", expiresAt: null, uncertainAt: null, sessionId: null, checkpoint: null, report: null };
|
|
128
|
+
} else if (c.type === "job_claim") {
|
|
129
|
+
if (role !== "worker" || !id(c.jobId) || !validLease(c.leaseMs)) throw new Error("invalid job claim");
|
|
130
|
+
const job = state.jobs[c.jobId];
|
|
131
|
+
if (!job || job.workerId !== actor) throw new Error("job is not assigned to worker");
|
|
132
|
+
{ const lead = state.leads[job.taskId]; if (!lead || lead.epoch !== job.leadEpoch || Date.parse(lead.expiresAt) <= time) throw new FleetConflict("stale or expired lead claim"); verifyBoundSupervisor(state, lead, time); }
|
|
133
|
+
if (job.state === "leased") throw new FleetConflict(Date.parse(job.expiresAt) > time ? "job lease is held" : "expired job lease requires lead reconciliation");
|
|
134
|
+
if (job.state !== "ready") throw new FleetConflict("job requires lead reconciliation");
|
|
135
|
+
job.epoch += 1; job.state = "leased"; job.expiresAt = iso(time + c.leaseMs); job.sessionId = null; job.checkpoint = null;
|
|
136
|
+
} else if (c.type === "job_heartbeat" || c.type === "job_checkpoint" || c.type === "job_report" || c.type === "job_abandon") {
|
|
137
|
+
if (role !== "worker" || !id(c.jobId) || !Number.isInteger(c.epoch)) throw new Error("invalid job mutation");
|
|
138
|
+
const job = requireJob(state, c.jobId, actor, c.epoch, time);
|
|
139
|
+
{ const lead = state.leads[job.taskId]; if (!lead || lead.epoch !== job.leadEpoch || Date.parse(lead.expiresAt) <= time) throw new FleetConflict("stale or expired lead claim"); }
|
|
140
|
+
if (c.type === "job_heartbeat") { if (!validLease(c.leaseMs)) throw new Error("invalid lease duration"); job.expiresAt = iso(time + c.leaseMs); }
|
|
141
|
+
else if (c.type === "job_checkpoint") { if (typeof c.sessionId !== "string" || !c.sessionId || c.sessionId.length > 256 || typeof c.checkpoint !== "string" || c.checkpoint.length > 4000) throw new Error("invalid job checkpoint"); if (job.sessionId && job.sessionId !== c.sessionId) throw new FleetConflict("job session cannot change within a leased attempt"); job.sessionId = c.sessionId; job.checkpoint = c.checkpoint; }
|
|
142
|
+
else if (c.type === "job_abandon") { if (typeof c.reason !== "string" || !c.reason.trim() || c.reason.length > 2000) throw new Error("invalid abandonment reason"); job.state = "uncertain"; job.uncertainAt = iso(time); job.report = { reason: c.reason, reportedAt: iso(time) }; job.expiresAt = iso(time); }
|
|
143
|
+
else { if (!/^[a-f0-9]{40}$/.test(c.commitSha) || !validChangedPaths(job,c.changedPaths)) throw new Error("invalid or out-of-scope job report"); job.report = { commitSha: c.commitSha, changedPaths: c.changedPaths, summary: typeof c.summary === "string" ? c.summary.slice(0, 2000) : "", reportedAt: iso(time) }; job.state = "reported"; job.expiresAt = iso(time); }
|
|
144
|
+
} else if (c.type === "job_recover") {
|
|
145
|
+
if(role!=="lead" || !id(c.jobId)) throw new Error("invalid job recovery");
|
|
146
|
+
const job=state.jobs[c.jobId]; if(!job) throw new Error("unknown job");
|
|
147
|
+
requireLead(state,job.taskId,actor,c.leadEpoch,time,c.ownerToken);
|
|
148
|
+
if(!["uncertain","ready"].includes(job.state)) throw new FleetConflict("job is not awaiting recovery");
|
|
149
|
+
if(!/^[a-f0-9]{40}$/.test(c.commitSha) || !validChangedPaths(job,c.changedPaths)) throw new Error("invalid or out-of-scope recovered commit");
|
|
150
|
+
job.leadEpoch=c.leadEpoch; job.report={commitSha:c.commitSha,changedPaths:c.changedPaths,summary:typeof c.summary==="string"?c.summary.slice(0,2000):"",recoveredAt:iso(time)};
|
|
151
|
+
job.state="reported"; job.expiresAt=null; job.uncertainAt=null;
|
|
152
|
+
} else if (c.type === "job_reconcile") {
|
|
153
|
+
if (role !== "lead" || !id(c.jobId) || !["ready", "uncertain", "integrated"].includes(c.state)) throw new Error("invalid job reconciliation");
|
|
154
|
+
const job = state.jobs[c.jobId]; if (!job) throw new Error("unknown job"); requireLead(state, job.taskId, actor, c.leadEpoch, time, c.ownerToken); if(c.state==="integrated" && (job.state!=="reported" || !job.report?.commitSha || job.leadEpoch!==c.leadEpoch)) throw new Error("only a current-epoch reported commit may be integrated"); if(c.state==="ready") { if(job.state!=="uncertain") throw new Error("only an uncertain job may be retried"); job.leadEpoch=c.leadEpoch; job.sessionId=null; job.checkpoint=null; job.report=null; job.uncertainAt=null; } if(c.state==="uncertain") job.uncertainAt=iso(time); if(c.state==="integrated") job.uncertainAt=null; job.state = c.state; job.expiresAt = null;
|
|
155
|
+
} else throw new Error("unsupported fleet command");
|
|
156
|
+
return state;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const validateMutation = (workspace, revision, key, actor, role) => {
|
|
160
|
+
if (!id(workspace) || !Number.isInteger(revision) || revision < 0 || !id(key) || !id(actor) || !["lead", "worker"].includes(role)) throw new Error("invalid fleet mutation precondition");
|
|
161
|
+
};
|
|
162
|
+
const publicFleet = (input) => {
|
|
163
|
+
const fleet = copy(input);
|
|
164
|
+
for (const entry of Object.values(fleet.leads ?? {})) { delete entry.ownerTokenHash; delete entry.supervisorOwnerTokenHash; }
|
|
165
|
+
for (const entry of Object.values(fleet.supervisors ?? {})) delete entry.ownerTokenHash;
|
|
166
|
+
return fleet;
|
|
167
|
+
};
|
|
168
|
+
const payloadDigest = (role, actor, command) => createHash("sha256").update(JSON.stringify([role, actor, command])).digest("hex");
|
|
169
|
+
const verifyReplayFence = (state, command, actor, role, time = now()) => {
|
|
170
|
+
const type = command.type;
|
|
171
|
+
if (type.startsWith("supervisor_")) return requireSupervisor(state, command.taskId, actor, type === "supervisor_claim" ? state.supervisors?.[command.taskId]?.epoch : command.epoch, time, command.ownerToken);
|
|
172
|
+
if (type === "run_claim") return requireLead(state, command.taskId, actor, state.leads?.[command.taskId]?.epoch, time, command.ownerToken);
|
|
173
|
+
if (type === "run_heartbeat" || type === "run_release") return requireLead(state, command.taskId, actor, command.epoch, time, command.ownerToken);
|
|
174
|
+
if (["job_schedule", "job_reconcile", "job_recover"].includes(type)) return requireLead(state, command.taskId ?? state.jobs?.[command.jobId]?.taskId, actor, command.leadEpoch, time, command.ownerToken);
|
|
175
|
+
if (["job_claim", "job_heartbeat", "job_checkpoint", "job_report", "job_abandon"].includes(type)) return requireJob(state, command.jobId, actor, type === "job_claim" ? state.jobs?.[command.jobId]?.epoch : command.epoch, time);
|
|
176
|
+
};
|
|
177
|
+
const verifyResume = (command, row, checkpoint, review) => {
|
|
178
|
+
if (!row || !Number.isSafeInteger(command.expectedStateRevision) || command.expectedStateRevision !== Number(row.revision)) throw new FleetConflict("canonical revision changed before run claim");
|
|
179
|
+
const state = typeof row.state === "string" ? JSON.parse(row.state) : row.state;
|
|
180
|
+
const task = state?.tasks?.[command.taskId];
|
|
181
|
+
if (!task || typeof command.expectedTaskDigest !== "string" || createHash("sha256").update(JSON.stringify(task)).digest("hex") !== command.expectedTaskDigest || task.status !== "active" || task.handoff || nextAction(task).classification !== "run_phase") throw new FleetConflict("canonical task changed or is not runnable");
|
|
182
|
+
if (checkpoint?.status === "pending") throw new FleetConflict("human approval is still pending");
|
|
183
|
+
if (checkpoint?.status === "approved") {
|
|
184
|
+
const target = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" }[checkpoint.gate];
|
|
185
|
+
const originalPhase = { G0_confirm: "clarify", G1_review: "plan", G2_codereview: "build" }[checkpoint.gate];
|
|
186
|
+
if (checkpoint.phase !== originalPhase || command.checkpointSnapshotId !== checkpoint.snapshot_id || command.checkpointApprovalRevision !== Number(checkpoint.approval_revision) || Number(row.revision) !== Number(checkpoint.approval_revision) || !review || review.task_id !== command.taskId || review.gate !== checkpoint.gate || review.task_digest !== checkpoint.task_digest || Number(review.state_revision) !== Number(checkpoint.state_revision) || review.integration_commit !== checkpoint.integration_commit || task.phase !== target || !task.evidence?.some((e) => e.kind === "approval" && e.gate === checkpoint.gate && e.result === "pass" && typeof e.source === "string" && e.source.trim())) throw new FleetConflict("approved gate checkpoint is stale or mismatched");
|
|
187
|
+
} else if (command.checkpointSnapshotId !== undefined || command.checkpointApprovalRevision !== undefined) throw new FleetConflict("unexpected checkpoint precondition");
|
|
188
|
+
};
|
|
189
|
+
const gitIntent = (input) => {
|
|
190
|
+
const {workspace,taskId,epoch,ownerToken,operationId,ref,oldOid,newOid} = input ?? {};
|
|
191
|
+
if (!id(workspace) || !id(taskId) || !Number.isInteger(epoch) || typeof ownerToken !== "string" || !id(operationId) || typeof ref !== "string" || !/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(ref) || ref.includes("..") || ref.endsWith("/") || !/^[a-f0-9]{40}$/.test(oldOid ?? "") || !/^[a-f0-9]{40}$/.test(newOid ?? "") || oldOid === newOid) throw new Error("invalid Git operation intent");
|
|
192
|
+
return {workspace,taskId,epoch,ownerToken,operationId,ref,oldOid,newOid};
|
|
193
|
+
};
|
|
194
|
+
const gitFence = (fleet, taskId, epoch, ownerToken, marginMs = 1000, time = now()) => {
|
|
195
|
+
const lead=fleet?.leads?.[taskId]; if (!fleet?.supervisors?.[taskId] || !lead) throw new FleetConflict("Git gateway requires supervisor-bound run");
|
|
196
|
+
requireLead(fleet,taskId,lead.actor,epoch,time+marginMs,ownerToken);
|
|
197
|
+
};
|
|
198
|
+
const gitRow = (row) => row && ({ workspace:row.workspace, operationId:row.operation_id, taskId:row.task_id, epoch:Number(row.epoch), ref:row.ref, oldOid:row.old_oid, newOid:row.new_oid, status:row.status });
|
|
199
|
+
const view = (row) => ({ workspace: row.workspace, revision: Number(row.revision), fleet: typeof row.fleet === "string" ? JSON.parse(row.fleet) : row.fleet, updatedAt: row.updated_at });
|
|
200
|
+
|
|
201
|
+
export class SqliteFleetRepository {
|
|
202
|
+
constructor(filename = process.env.MCP_STATE_SQLITE_PATH ?? "./data/mcp-state.db") { this.db = new Database(filename); this.db.pragma("journal_mode = WAL"); this.db.pragma("busy_timeout = 5000"); this.db.exec("CREATE TABLE IF NOT EXISTS fleet_workspaces (workspace TEXT PRIMARY KEY, revision INTEGER NOT NULL, fleet TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS fleet_idempotency (workspace TEXT NOT NULL, idempotency_key TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(workspace,idempotency_key)); CREATE TABLE IF NOT EXISTS fleet_git_operations (workspace TEXT NOT NULL, operation_id TEXT NOT NULL, task_id TEXT NOT NULL, epoch INTEGER NOT NULL, ref TEXT NOT NULL, old_oid TEXT NOT NULL, new_oid TEXT NOT NULL, status TEXT NOT NULL, PRIMARY KEY(workspace,operation_id))"); }
|
|
203
|
+
#ensure(workspace) { this.db.prepare("INSERT OR IGNORE INTO fleet_workspaces VALUES (?,0,?,?)").run(workspace, JSON.stringify(initial()), iso(now())); }
|
|
204
|
+
async get(workspace) { if (!id(workspace)) throw new Error("invalid workspace"); this.#ensure(workspace); const current=view(this.db.prepare("SELECT * FROM fleet_workspaces WHERE workspace=?").get(workspace)); return {...current,fleet:publicFleet(current.fleet)}; }
|
|
205
|
+
async apply(workspace, expectedRevision, idempotencyKey, actor, role, command) { validateMutation(workspace, expectedRevision, idempotencyKey, actor, role); const scopedKey=`${role}:${actor}:${idempotencyKey}`; this.db.exec("BEGIN IMMEDIATE"); try { this.#ensure(workspace); const current=view(this.db.prepare("SELECT * FROM fleet_workspaces WHERE workspace=?").get(workspace)); const cached=this.db.prepare("SELECT response FROM fleet_idempotency WHERE workspace=? AND idempotency_key=?").get(workspace,scopedKey); if(cached){const entry=JSON.parse(cached.response); if(entry.payloadDigest!==payloadDigest(role,actor,command)) throw new FleetConflict("idempotency payload mismatch"); verifyReplayFence(current.fleet,command,actor,role); this.db.exec("COMMIT");return entry.response;} if(current.revision!==expectedRevision) throw new FleetConflict("fleet revision conflict",{...current,fleet:publicFleet(current.fleet)}); if(command.type==="run_claim" && current.fleet.supervisors?.[command.taskId]) { if(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 FleetConflict("pending Git operation requires reconciliation"); const row=this.db.prepare("SELECT revision,state FROM state_workspaces WHERE workspace=?").get(workspace); const checkpoint=this.db.prepare("SELECT * FROM state_gate_checkpoints WHERE workspace=? AND task_id=?").get(workspace,command.taskId); const review=checkpoint ? this.db.prepare("SELECT * FROM state_gate_snapshots WHERE workspace=? AND id=?").get(workspace,checkpoint.snapshot_id) : null; verifyResume(command,row,checkpoint,review); } if(command.type==="job_schedule" && 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 FleetConflict("pending Git operation requires reconciliation"); const fleet=applyFleetCommand(current.fleet,command,actor,role); if(command.type==="job_schedule"){const checkpointId=fleet.leads?.[command.taskId]?.checkpointSnapshotId;if(checkpointId){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);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 dispatch");}}} const response={workspace,revision:expectedRevision+1,fleet:publicFleet(fleet),updatedAt:iso(now())}; this.db.prepare("UPDATE fleet_workspaces SET revision=?,fleet=?,updated_at=? WHERE workspace=?").run(response.revision,JSON.stringify(fleet),response.updatedAt,workspace); this.db.prepare("INSERT INTO fleet_idempotency VALUES (?,?,?)").run(workspace,scopedKey,JSON.stringify({payloadDigest:payloadDigest(role,actor,command),response})); this.db.exec("COMMIT"); return response; } catch(error){try{this.db.exec("ROLLBACK");}catch{} throw error;} }
|
|
206
|
+
async prepareGitOperation(input) {
|
|
207
|
+
const intent=gitIntent(input); this.db.exec("BEGIN IMMEDIATE");
|
|
208
|
+
try { const row=this.db.prepare("SELECT * FROM fleet_workspaces WHERE workspace=?").get(intent.workspace); gitFence(row && view(row).fleet,intent.taskId,intent.epoch,intent.ownerToken); const old=gitRow(this.db.prepare("SELECT * FROM fleet_git_operations WHERE workspace=? AND operation_id=?").get(intent.workspace,intent.operationId)); if(old){if(old.taskId!==intent.taskId || old.epoch!==intent.epoch || old.ref!==intent.ref || old.oldOid!==intent.oldOid || old.newOid!==intent.newOid) throw new FleetConflict("Git operation intent mismatch"); this.db.exec("COMMIT");return {status:old.status};} this.db.prepare("INSERT INTO fleet_git_operations VALUES (?,?,?,?,?,?,?,?)").run(intent.workspace,intent.operationId,intent.taskId,intent.epoch,intent.ref,intent.oldOid,intent.newOid,"pending");this.db.exec("COMMIT");return {status:"pending"}; } catch(error){try{this.db.exec("ROLLBACK");}catch{}throw error;}
|
|
209
|
+
}
|
|
210
|
+
async getGitOperation({workspace,operationId}) { if(!id(workspace)||!id(operationId)) throw new Error("invalid Git operation lookup"); return gitRow(this.db.prepare("SELECT * FROM fleet_git_operations WHERE workspace=? AND operation_id=?").get(workspace,operationId)) ?? null; }
|
|
211
|
+
async withGitFence({workspace,taskId,epoch,ownerToken,operationId},callback) {
|
|
212
|
+
if(!id(workspace)||!id(taskId)||!Number.isInteger(epoch)||typeof ownerToken!=="string"||!id(operationId)||typeof callback!=="function") throw new Error("invalid Git fence request");
|
|
213
|
+
this.db.exec("BEGIN IMMEDIATE");try { const row=this.db.prepare("SELECT * FROM fleet_workspaces WHERE workspace=?").get(workspace), fleet=row && view(row).fleet; const record=gitRow(this.db.prepare("SELECT * FROM fleet_git_operations WHERE workspace=? AND operation_id=?").get(workspace,operationId)); const assertValid=()=>gitFence(fleet,taskId,epoch,ownerToken);assertValid();if(!record || record.taskId!==taskId || record.epoch!==epoch || record.status!=="pending") throw new FleetConflict("Git operation is absent, stale or already completed");let complete=false;const result=await callback({record,assertValid,complete:async()=>{assertValid();complete=true;}});assertValid();if(!complete) throw new Error("Git callback did not complete operation");this.db.prepare("UPDATE fleet_git_operations SET status='complete' WHERE workspace=? AND operation_id=? AND status='pending'").run(workspace,operationId);this.db.exec("COMMIT");return result;}catch(error){try{this.db.exec("ROLLBACK");}catch{}throw error;}
|
|
214
|
+
}
|
|
215
|
+
close() { this.db.close(); }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export class PostgresFleetRepository {
|
|
219
|
+
constructor(connectionString = process.env.MCP_STATE_POSTGRES_URL) { if (!connectionString) throw new Error("MCP_STATE_POSTGRES_URL is required"); this.pool = new pg.Pool({ connectionString }); }
|
|
220
|
+
async #init(c) { await c.query("CREATE TABLE IF NOT EXISTS fleet_workspaces (workspace TEXT PRIMARY KEY, revision BIGINT NOT NULL, fleet JSONB NOT NULL, updated_at TEXT NOT NULL)"); await c.query("CREATE TABLE IF NOT EXISTS fleet_idempotency (workspace TEXT NOT NULL, idempotency_key TEXT NOT NULL, response JSONB NOT NULL, PRIMARY KEY(workspace,idempotency_key))"); await c.query("CREATE TABLE IF NOT EXISTS fleet_git_operations (workspace TEXT NOT NULL, operation_id TEXT NOT NULL, task_id TEXT NOT NULL, epoch BIGINT NOT NULL, ref TEXT NOT NULL, old_oid TEXT NOT NULL, new_oid TEXT NOT NULL, status TEXT NOT NULL, PRIMARY KEY(workspace,operation_id))"); }
|
|
221
|
+
async get(workspace) { if (!id(workspace)) throw new Error("invalid workspace"); const c=await this.pool.connect(); try { await this.#init(c); await c.query("INSERT INTO fleet_workspaces VALUES ($1,0,$2,$3) ON CONFLICT DO NOTHING",[workspace,initial(),iso(now())]); const current=view((await c.query("SELECT * FROM fleet_workspaces WHERE workspace=$1",[workspace])).rows[0]); return {...current,fleet:publicFleet(current.fleet)}; } finally { c.release(); } }
|
|
222
|
+
async apply(workspace,expectedRevision,idempotencyKey,actor,role,command) { validateMutation(workspace,expectedRevision,idempotencyKey,actor,role); const scopedKey=`${role}:${actor}:${idempotencyKey}`; const c=await this.pool.connect(); try { await this.#init(c); await c.query("BEGIN"); await c.query("INSERT INTO fleet_workspaces VALUES ($1,0,$2,$3) ON CONFLICT DO NOTHING",[workspace,initial(),iso(now())]); const row=(await c.query("SELECT * FROM fleet_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0]; const current=view(row); const dbTime=Number((await c.query("SELECT extract(epoch from clock_timestamp()) * 1000 AS ms")).rows[0].ms); const cached=(await c.query("SELECT response FROM fleet_idempotency WHERE workspace=$1 AND idempotency_key=$2",[workspace,scopedKey])).rows[0]; if(cached){const entry=cached.response; if(entry.payloadDigest!==payloadDigest(role,actor,command)) throw new FleetConflict("idempotency payload mismatch"); verifyReplayFence(current.fleet,command,actor,role,dbTime); await c.query("COMMIT");return entry.response;} if(current.revision!==expectedRevision) throw new FleetConflict("fleet revision conflict",{...current,fleet:publicFleet(current.fleet)}); if(command.type==="run_claim" && current.fleet.supervisors?.[command.taskId]) { if((await c.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 FleetConflict("pending Git operation requires reconciliation"); const stateRow=(await c.query("SELECT revision,state FROM state_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0]; const checkpoint=(await c.query("SELECT * FROM state_gate_checkpoints WHERE workspace=$1 AND task_id=$2 FOR UPDATE",[workspace,command.taskId])).rows[0]; const review=checkpoint ? (await c.query("SELECT * FROM state_gate_snapshots WHERE workspace=$1 AND id=$2",[workspace,checkpoint.snapshot_id])).rows[0] : null; verifyResume(command,stateRow,checkpoint,review); } if(command.type==="job_schedule" && (await c.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 FleetConflict("pending Git operation requires reconciliation"); const fleet=applyFleetCommand(current.fleet,command,actor,role,dbTime); if(command.type==="job_schedule"){const checkpointId=fleet.leads?.[command.taskId]?.checkpointSnapshotId;if(checkpointId){await c.query("SELECT revision FROM state_workspaces WHERE workspace=$1 FOR UPDATE",[workspace]);const changed=await c.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]);if(!changed.rowCount){const cp=(await c.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 dispatch");}}} const response={workspace,revision:expectedRevision+1,fleet:publicFleet(fleet),updatedAt:iso(now())}; await c.query("UPDATE fleet_workspaces SET revision=$1,fleet=$2,updated_at=$3 WHERE workspace=$4",[response.revision,fleet,response.updatedAt,workspace]); await c.query("INSERT INTO fleet_idempotency VALUES ($1,$2,$3)",[workspace,scopedKey,{payloadDigest:payloadDigest(role,actor,command),response}]); await c.query("COMMIT");return response; } catch(error){try{await c.query("ROLLBACK");}catch{} throw error;} finally{c.release();} }
|
|
223
|
+
async prepareGitOperation(input) {
|
|
224
|
+
const intent=gitIntent(input), c=await this.pool.connect();try{await this.#init(c);await c.query("BEGIN");const row=(await c.query("SELECT * FROM fleet_workspaces WHERE workspace=$1 FOR UPDATE",[intent.workspace])).rows[0];gitFence(row && view(row).fleet,intent.taskId,intent.epoch,intent.ownerToken,1000,Number((await c.query("SELECT extract(epoch from clock_timestamp()) * 1000 AS ms")).rows[0].ms));const old=gitRow((await c.query("SELECT * FROM fleet_git_operations WHERE workspace=$1 AND operation_id=$2 FOR UPDATE",[intent.workspace,intent.operationId])).rows[0]);if(old){if(old.taskId!==intent.taskId||old.epoch!==intent.epoch||old.ref!==intent.ref||old.oldOid!==intent.oldOid||old.newOid!==intent.newOid)throw new FleetConflict("Git operation intent mismatch");await c.query("COMMIT");return {status:old.status};}await c.query("INSERT INTO fleet_git_operations VALUES ($1,$2,$3,$4,$5,$6,$7,'pending')",[intent.workspace,intent.operationId,intent.taskId,intent.epoch,intent.ref,intent.oldOid,intent.newOid]);await c.query("COMMIT");return {status:"pending"};}catch(error){try{await c.query("ROLLBACK");}catch{}throw error;}finally{c.release();}
|
|
225
|
+
}
|
|
226
|
+
async getGitOperation({workspace,operationId}) {if(!id(workspace)||!id(operationId))throw new Error("invalid Git operation lookup");const c=await this.pool.connect();try{await this.#init(c);return gitRow((await c.query("SELECT * FROM fleet_git_operations WHERE workspace=$1 AND operation_id=$2",[workspace,operationId])).rows[0])??null;}finally{c.release();}}
|
|
227
|
+
async withGitFence({workspace,taskId,epoch,ownerToken,operationId},callback) {
|
|
228
|
+
if(!id(workspace)||!id(taskId)||!Number.isInteger(epoch)||typeof ownerToken!=="string"||!id(operationId)||typeof callback!=="function")throw new Error("invalid Git fence request");const c=await this.pool.connect();try{await this.#init(c);await c.query("BEGIN");const row=(await c.query("SELECT * FROM fleet_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0],fleet=row&&view(row).fleet;const record=gitRow((await c.query("SELECT * FROM fleet_git_operations WHERE workspace=$1 AND operation_id=$2 FOR UPDATE",[workspace,operationId])).rows[0]);const assertValid=async()=>gitFence(fleet,taskId,epoch,ownerToken,1000,Number((await c.query("SELECT extract(epoch from clock_timestamp()) * 1000 AS ms")).rows[0].ms));await assertValid();if(!record||record.taskId!==taskId||record.epoch!==epoch||record.status!=="pending")throw new FleetConflict("Git operation is absent, stale or already completed");let complete=false;const result=await callback({record,assertValid,complete:async()=>{await assertValid();complete=true;}});await assertValid();if(!complete)throw new Error("Git callback did not complete operation");await c.query("UPDATE fleet_git_operations SET status='complete' WHERE workspace=$1 AND operation_id=$2 AND status='pending'",[workspace,operationId]);await c.query("COMMIT");return result;}catch(error){try{await c.query("ROLLBACK");}catch{}throw error;}finally{c.release();}
|
|
229
|
+
}
|
|
230
|
+
async close(){await this.pool.end();}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const createFleetRepository = () => process.env.MCP_STATE_DATABASE === "postgres" ? new PostgresFleetRepository() : new SqliteFleetRepository();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const id = (value) => typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
|
|
5
|
+
const sha = (value) => typeof value === "string" && /^[a-f0-9]{40}$/.test(value);
|
|
6
|
+
const git = (root, ...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 }).trim();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Gateway core. The deployment must deny every other writer to protected refs.
|
|
10
|
+
* Authority persists an intent before entering the same lease lock used by claims;
|
|
11
|
+
* a callback may update the ref only while that lock is held. A crash after Git's
|
|
12
|
+
* CAS but before authority commit leaves a pending intent and requires reconciliation.
|
|
13
|
+
*/
|
|
14
|
+
export function createGitGateway({ repo, protectedRefs, authority }) {
|
|
15
|
+
const root = realpathSync(repo);
|
|
16
|
+
if (git(root, "rev-parse", "--is-bare-repository") !== "true") throw new Error("Git gateway requires a bare repository");
|
|
17
|
+
if (!Array.isArray(protectedRefs) || !protectedRefs.length || new Set(protectedRefs).size !== protectedRefs.length) throw new Error("Explicit protected refs are required");
|
|
18
|
+
const permitted = new Set(protectedRefs);
|
|
19
|
+
for (const ref of protectedRefs) {
|
|
20
|
+
if (typeof ref !== "string" || !ref.startsWith("refs/heads/") || ref.includes("\0")) throw new Error("Invalid protected ref");
|
|
21
|
+
git(root, "check-ref-format", ref);
|
|
22
|
+
}
|
|
23
|
+
if (typeof authority?.prepareGitOperation !== "function" || typeof authority?.withGitFence !== "function") throw new Error("Fenced authority with durable operations is required");
|
|
24
|
+
return {
|
|
25
|
+
async update({ workspace, taskId, epoch, ownerToken, operationId, ref, oldCommit, newCommit }) {
|
|
26
|
+
if (!id(workspace) || !id(taskId) || !id(operationId) || !Number.isSafeInteger(epoch) || epoch < 1 || typeof ownerToken !== "string" || ownerToken.length < 32 || ownerToken.length > 256 || !permitted.has(ref) || !sha(oldCommit) || !sha(newCommit) || oldCommit === newCommit) throw new Error("Invalid protected Git update");
|
|
27
|
+
// resolve a commit object before creating the durable intent; no refs are changed.
|
|
28
|
+
if (git(root, "cat-file", "-t", newCommit) !== "commit") throw new Error("Git update requires a commit object");
|
|
29
|
+
try { git(root, "merge-base", "--is-ancestor", oldCommit, newCommit); }
|
|
30
|
+
catch { throw new Error("Protected Git updates must fast-forward"); }
|
|
31
|
+
const operation = { workspace, taskId, operationId, ref, oldOid:oldCommit, newOid:newCommit, epoch };
|
|
32
|
+
const prepared = await authority.prepareGitOperation({ ...operation, ownerToken });
|
|
33
|
+
if (prepared?.status === "complete") return { status: "complete", operationId, ref, newCommit };
|
|
34
|
+
if (prepared?.status !== "pending") throw new Error("Durable Git operation intent unavailable");
|
|
35
|
+
return authority.withGitFence({ workspace, taskId, epoch, ownerToken, operationId }, async (transaction) => {
|
|
36
|
+
if (typeof transaction?.assertValid !== "function" || typeof transaction?.complete !== "function" || transaction.record?.ref !== ref || transaction.record?.oldOid !== oldCommit || transaction.record?.newOid !== newCommit) throw new Error("Atomic fenced operation intent mismatch");
|
|
37
|
+
const current = git(root, "rev-parse", "--verify", ref);
|
|
38
|
+
if (current !== oldCommit) throw new Error(current === newCommit ? "Git update outcome uncertain; reconcile pending intent" : "Protected Git ref conflict");
|
|
39
|
+
// Recheck expiry while the same lease lock is held immediately before CAS.
|
|
40
|
+
await transaction.assertValid();
|
|
41
|
+
// oldCommit is passed to Git itself: competing ref updates fail atomically.
|
|
42
|
+
try { git(root, "update-ref", ref, newCommit, oldCommit); }
|
|
43
|
+
catch { throw new Error("Protected Git ref compare-and-swap failed; reconcile intent"); }
|
|
44
|
+
try { await transaction.complete(); }
|
|
45
|
+
catch { throw new Error("Git ref updated but authority commit uncertain; reconcile pending intent"); }
|
|
46
|
+
return { status: "complete", operationId, ref, newCommit };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -61,7 +61,9 @@ const validateTask = (id, task) => {
|
|
|
61
61
|
if (!task.title || !["feature", "bug", "refactor", "infra"].includes(task.type) || !statuses.includes(task.status) || (task.phase === "done") !== (task.status === "done")) throw new Error(`Invalid task fields: ${id}`);
|
|
62
62
|
if (!["vi", "en"].includes(task.language) || !["low", "normal", "high", "regulated"].includes(task.risk) || !Array.isArray(task.areas) || !task.areas.length || task.areas.some((area) => !text(area))) throw new Error(`Invalid task language, risk, or areas: ${id}`);
|
|
63
63
|
if (task.decisions.some((item) => !item.id || !item.label || !["unresolved", "approved", "changed", "dropped"].includes(item.status) || (item.status === "changed" && !item.resolution))) throw new Error(`Invalid decisions: ${id}`);
|
|
64
|
+
if (task.featureId !== undefined && !idPattern.test(task.featureId)) throw new Error(`Invalid feature id: ${id}`);
|
|
64
65
|
if (task.tasks.some((item) => !item.id || !item.label || !["todo", "in_progress", "done", "deferred"].includes(item.status))) throw new Error(`Invalid execution tasks: ${id}`);
|
|
66
|
+
readyTaskItems(task);
|
|
65
67
|
if (task.evidence.some((item) => !["approval", "spec", "test", "lint", "review", "diagnostic"].includes(item.kind) || !["pass", "fail", "skip"].includes(item.result) || !item.source || !iso(item.recordedAt) || (item.area !== undefined && !text(item.area)) || (item.gate !== undefined && !gates.includes(item.gate)) || (item.kind === "approval" && (!item.gate || item.gate === "none")))) throw new Error(`Invalid evidence: ${id}`);
|
|
66
68
|
const lessonIds = new Set();
|
|
67
69
|
for (const lesson of task.lessons ?? []) { validateLesson(id, lesson); if (lessonIds.has(lesson.id)) throw new Error(`Duplicate lesson id: ${id}/${lesson.id}`); lessonIds.add(lesson.id); }
|
|
@@ -276,6 +278,35 @@ export const transitionTask = (state, id, target) => {
|
|
|
276
278
|
task.phase = target; task.gate = phaseGate(target); task.status = target === "done" ? "done" : "active"; delete task.legacyG2Wait; task.updatedAt = new Date().toISOString(); return task;
|
|
277
279
|
};
|
|
278
280
|
|
|
281
|
+
export const readyTaskItems = (task) => {
|
|
282
|
+
const byId = new Map(task.tasks.map((item) => [item.id, item]));
|
|
283
|
+
if (byId.size !== task.tasks.length) throw new Error(`Duplicate execution item: ${task.id}`);
|
|
284
|
+
const visiting = new Set(); const visited = new Set();
|
|
285
|
+
const visit = (id) => {
|
|
286
|
+
if (visiting.has(id)) throw new Error(`Cyclic execution dependency: ${task.id}/${id}`);
|
|
287
|
+
if (visited.has(id)) return;
|
|
288
|
+
visiting.add(id);
|
|
289
|
+
for (const dep of byId.get(id)?.dependsOn ?? []) {
|
|
290
|
+
if (dep === id || !byId.has(dep)) throw new Error(`Unknown execution dependency: ${task.id}/${id}/${dep}`);
|
|
291
|
+
visit(dep);
|
|
292
|
+
}
|
|
293
|
+
visiting.delete(id); visited.add(id);
|
|
294
|
+
};
|
|
295
|
+
for (const item of task.tasks) {
|
|
296
|
+
if (item.dependsOn !== undefined && (!Array.isArray(item.dependsOn) || new Set(item.dependsOn).size !== item.dependsOn.length || item.dependsOn.some((dep) => typeof dep !== "string" || !dep))) throw new Error(`Invalid execution dependencies: ${task.id}/${item.id}`);
|
|
297
|
+
if (item.ownedPaths !== undefined && (!Array.isArray(item.ownedPaths) || new Set(item.ownedPaths).size !== item.ownedPaths.length || item.ownedPaths.some((path) => typeof path !== "string" || !path || isAbsolute(path) || path === ".." || path.startsWith("../") || path.includes("\\") || path.split("/").includes("..")))) throw new Error(`Invalid owned paths: ${task.id}/${item.id}`);
|
|
298
|
+
visit(item.id);
|
|
299
|
+
}
|
|
300
|
+
const ready = []; const blocked = [];
|
|
301
|
+
for (const item of task.tasks) {
|
|
302
|
+
const waitingOn = (item.dependsOn ?? []).filter((dep) => byId.get(dep)?.status !== "done");
|
|
303
|
+
if (item.status === "done" && waitingOn.length) throw new Error(`Completed item has incomplete prerequisites: ${task.id}/${item.id}`);
|
|
304
|
+
if (item.status !== "todo" && item.status !== "in_progress") continue;
|
|
305
|
+
if (waitingOn.length) blocked.push({ id: item.id, waitingOn }); else ready.push(item.id);
|
|
306
|
+
}
|
|
307
|
+
return { ready, blocked };
|
|
308
|
+
};
|
|
309
|
+
|
|
279
310
|
export const nextAction = (task, root) => {
|
|
280
311
|
const rootSuffix = root === undefined ? "" : ` --root ${JSON.stringify(resolve(root))}`;
|
|
281
312
|
const script = (name) => root === undefined ? `node .agents/aidlc/scripts/${name}` : `node ${JSON.stringify(join(resolve(root), ".agents/aidlc/scripts", name))}`;
|
|
@@ -288,16 +319,20 @@ export const nextAction = (task, root) => {
|
|
|
288
319
|
if (task.status === "blocked_on_user" && task.gate === "G1_review" && task.decisions.some((decision) => decision.status === "unresolved")) return { classification: "run_phase", phase: task.phase, gate: task.gate, command: `${contextCommand} ${task.id} --phase plan${rootSuffix}`, reason: "Invalid premature G1 wait; resolve every decision before presenting G1" };
|
|
289
320
|
if (task.status === "blocked_on_user" && task.gate === "G2_codereview" && (task.tasks.some((item) => !["done", "deferred"].includes(item.status)) || !hasVerification(task) || !hasReview(task))) {
|
|
290
321
|
const open = task.tasks.filter((item) => item.status === "in_progress" || item.status === "todo");
|
|
291
|
-
const
|
|
322
|
+
const ready = new Set(readyTaskItems(task).ready);
|
|
323
|
+
const item = open.find((entry) => entry.status === "in_progress" && ready.has(entry.id)) ?? open.find((entry) => ready.has(entry.id));
|
|
292
324
|
if (item) return { classification: "run_phase", phase: task.phase, gate: task.gate, itemId: item.id, remainingItems: open.length, command: `${contextCommand} ${task.id} --phase build --item ${item.id}${rootSuffix}`, reason: `Invalid premature G2 wait; continue build item ${item.id}` };
|
|
325
|
+
if (open.length) return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `No dependency-ready build item: ${readyTaskItems(task).blocked.map((entry) => `${entry.id} waits for ${entry.waitingOn.join(",")}`).join("; ")}` };
|
|
293
326
|
return { classification: "run_phase", phase: task.phase, gate: task.gate, remainingItems: 0, command: `${contextCommand} ${task.id} --phase build${rootSuffix}`, reason: "Invalid premature G2 wait; continue verification and adversarial review before presenting G2" };
|
|
294
327
|
}
|
|
295
328
|
if (task.status === "blocked_on_user" && task.gate === "none") return { classification: "run_phase", phase: task.phase, gate: task.gate, command: `${stateCommand} task status ${task.id} --status active${rootSuffix}`, reason: "Recover invalid wait at a gateless wrap phase before continuing wrap" };
|
|
296
329
|
if (task.status === "blocked_on_user") return { classification: "await_user", phase: task.phase, gate: task.gate, command: `${stateCommand} gate approve ${task.id} --gate ${task.gate} --source <explicit-user-approval>${rootSuffix}`, reason: `Explicit human approval is required at ${task.gate}` };
|
|
297
330
|
if (task.phase === "build") {
|
|
298
331
|
const open = task.tasks.filter((item) => item.status === "in_progress" || item.status === "todo");
|
|
299
|
-
const
|
|
332
|
+
const ready = new Set(readyTaskItems(task).ready);
|
|
333
|
+
const item = open.find((entry) => entry.status === "in_progress" && ready.has(entry.id)) ?? open.find((entry) => ready.has(entry.id));
|
|
300
334
|
if (item) return { classification: "run_phase", phase: task.phase, gate: task.gate, itemId: item.id, remainingItems: open.length, command: `${contextCommand} ${task.id} --phase build --item ${item.id}${rootSuffix}`, reason: `Continue build item ${item.id}; ${open.length} actionable item(s) remain before verification, review, and G2` };
|
|
335
|
+
if (open.length) return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `No dependency-ready build item: ${readyTaskItems(task).blocked.map((entry) => `${entry.id} waits for ${entry.waitingOn.join(",")}`).join("; ")}` };
|
|
301
336
|
return { classification: "run_phase", phase: task.phase, gate: task.gate, remainingItems: 0, command: `${contextCommand} ${task.id} --phase build${rootSuffix}`, reason: "All build items are terminal; continue verification, adversarial review, and G2 preparation" };
|
|
302
337
|
}
|
|
303
338
|
return { classification: "run_phase", phase: task.phase, gate: task.gate, command: `${contextCommand} ${task.id} --phase ${task.phase}${rootSuffix}`, reason: `Continue ${task.phase} until the next human gate, real blocker, or completion` };
|
|
@@ -322,7 +357,7 @@ export const searchLessons = (root, state, query, areas = [], limit = 5) => { co
|
|
|
322
357
|
|
|
323
358
|
export const listTaskSummaries = (state, options = {}) => { const active = Object.values(state.tasks).map((task) => ({ id: task.id, title: task.title, phase: task.phase, gate: task.gate, status: task.status, risk: task.risk, areas: task.areas, updatedAt: task.updatedAt, source: "active" })); const archived = options.includeArchive ? Object.values(state.archive ?? {}).map((task) => ({ id: task.id, title: task.title, phase: task.phase, gate: task.gate, status: task.status, risk: task.risk, areas: task.areas, updatedAt: task.updatedAt, source: "archive" })) : []; const queryTokens = tokens(options.query ?? ""); const statusSet = new Set(options.statuses ?? []); const filtered = [...active, ...archived].filter((item) => (!statusSet.size || statusSet.has(item.status)) && (!queryTokens.length || queryTokens.every((token) => tokens(`${item.id} ${item.title} ${item.areas.join(" ")}`).includes(token)))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id)); const cursor = Math.max(0, options.cursor ?? 0); const limit = Math.max(1, Math.min(options.limit ?? 20, 100)); const items = filtered.slice(cursor, cursor + limit); return { items, nextCursor: cursor + items.length < filtered.length ? String(cursor + items.length) : null, total: filtered.length }; };
|
|
324
359
|
|
|
325
|
-
export const renderWorkplan = (task) => { const decisions = task.decisions.length ? task.decisions.map((item) => `- [${item.status === "approved" ? "x" : " "}] ${item.id} — ${item.label}${item.resolution ? ` — ${item.resolution}` : ""}`).join("\n") : "- None"; const tasks = task.tasks.length ? task.tasks.map((item) => `- [${item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "}] ${item.id} — ${item.label}`).join("\n") : "- None"; const lifecycle = [`- Status: \`${task.status}\``, task.handoff ? `- Handoff: \`${task.handoff.kind}\` — ${task.handoff.reason}` : "", task.closure ? `- Closure: ${task.closure.reason}` : "", task.predecessorTaskId ? `- Predecessor: \`${task.predecessorTaskId}\`` : "", task.successorTaskId ? `- Successor: \`${task.successorTaskId}\`` : "", task.lessonDisposition ? `- Lessons: \`${task.lessonDisposition.status}\`` : ""].filter(Boolean).join("\n"); return `# Workplan — ${task.title} (\`${task.id}\`)\n\n> Generated from canonical JSON state.\n\n## Lifecycle\n${lifecycle}\n\n## 🧩 Decisions (
|
|
360
|
+
export const renderWorkplan = (task) => { const decisions = task.decisions.length ? task.decisions.map((item) => `- [${item.status === "approved" ? "x" : " "}] ${item.id} — ${item.label}${item.resolution ? ` — ${item.resolution}` : ""}`).join("\n") : "- None"; const tasks = task.tasks.length ? task.tasks.map((item) => `- [${item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "}] ${item.id} — ${item.label}`).join("\n") : "- None"; const lifecycle = [`- Status: \`${task.status}\``, task.handoff ? `- Handoff: \`${task.handoff.kind}\` — ${task.handoff.reason}` : "", task.closure ? `- Closure: ${task.closure.reason}` : "", task.predecessorTaskId ? `- Predecessor: \`${task.predecessorTaskId}\`` : "", task.successorTaskId ? `- Successor: \`${task.successorTaskId}\`` : "", task.lessonDisposition ? `- Lessons: \`${task.lessonDisposition.status}\`` : ""].filter(Boolean).join("\n"); return `# Workplan — ${task.title} (\`${task.id}\`)\n\n> Generated from canonical JSON state.\n\n## Lifecycle\n${lifecycle}\n\n## 🧩 Decisions (Approve plan — before implementation)\n${decisions}\n\n## 🧩 Tasks (Review implementation — work items)\n${tasks}\n`; };
|
|
326
361
|
export const renderViews = (root, state = loadState(root), selected = Object.values(state.tasks)) => { for (const task of selected) { if (!task.artifacts.workplan) continue; assertNoSymlinkPath(root, task.artifacts.workplan); writeAtomic(join(resolve(root), task.artifacts.workplan), renderWorkplan(task)); } };
|
|
327
362
|
|
|
328
363
|
const safeArtifactTarget = (root, path) => { assertNoSymlinkPath(root, path); return join(resolve(root), path); };
|
|
@@ -335,7 +370,7 @@ export const formatDiagnostics = (diagnostics) => diagnostics.map((item) => `${i
|
|
|
335
370
|
|
|
336
371
|
const oneLine = (value) => value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").replace(/ACTION\s+REQUIRED/gi, "ACTION-REQUIRED").trim();
|
|
337
372
|
const markdownText = (value) => oneLine(value).replace(/([\\`*_{}\[\]()<>#+.!|>~])/g, "\\$1");
|
|
338
|
-
const gateMeta = { G0_confirm: { icon: "🟢", title: "
|
|
373
|
+
const gateMeta = { G0_confirm: { icon: "🟢", title: "Review step · Confirm requirements", action: "Reply `ok` to confirm requirements, or state changes." }, G1_review: { icon: "🔵", title: "Review step · Approve plan", action: "Reply `approve` to approve the plan and start implementation, or state decision changes." }, G2_codereview: { icon: "🟣", title: "Review step · Review implementation", action: "Approve the implementation to complete the task, or point out what to fix." } };
|
|
339
374
|
export const gateView = (task, diagnostics) => { if (task.gate === "none") throw new Error(`Task has no human gate: ${task.id}`); const errors = diagnostics.filter((item) => item.level === "ERROR"); if (errors.length) throw new Error(errors.map((item) => item.message).join("; ")); const meta = gateMeta[task.gate]; const counts = (items) => items.reduce((result, item) => ({ ...result, [item.status]: (result[item.status] ?? 0) + 1 }), {}); return { schemaVersion: 1, gate: task.gate, icon: meta.icon, title: meta.title, action: meta.action, task: { id: task.id, title: oneLine(task.title), status: task.status, risk: task.risk, areas: task.areas }, artifacts: Object.entries(task.artifacts).flatMap(([name, path]) => path ? [{ name: oneLine(name), path: oneLine(path) }] : []), decisions: counts(task.decisions), execution: counts(task.tasks), evidence: task.evidence.filter((item) => ["test", "lint", "review"].includes(item.kind)).slice(-5).map((item) => ({ kind: item.kind, result: item.result, source: oneLine(item.source) })), diagnostics: diagnostics.filter((item) => item.level !== "INFO").map((item) => ({ level: item.level, code: item.code, message: oneLine(item.message) })) }; };
|
|
340
375
|
const markdownLinkTarget = (value) => encodeURI(value).replace(/[()#?]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
341
376
|
export const formatGateView = (task, diagnostics, format = "markdown") => { const view = gateView(task, diagnostics); if (format === "json") return `${JSON.stringify(view, null, 2)}\n`; const artifacts = view.artifacts.length ? view.artifacts.map((item) => format === "markdown" ? `- **${markdownText(item.name)}:** [${markdownText(item.path)}](${markdownLinkTarget(item.path)})` : `- ${item.name}: ${item.path}`).join("\n") : "- none"; const decisions = Object.entries(view.decisions).map(([status, count]) => `${count} ${status}`).join(" · ") || "none"; const execution = Object.entries(view.execution).map(([status, count]) => `${count} ${status}`).join(" · ") || "none"; const evidence = view.evidence.length ? view.evidence.map((item) => `- ${item.kind}: ${item.result} — ${format === "markdown" ? markdownText(item.source) : item.source}`).join("\n") : "- none yet"; const warnings = view.diagnostics.length ? `\n\n${view.diagnostics.map((item) => `- ${item.level} ${item.code}: ${format === "markdown" ? markdownText(item.message) : item.message}`).join("\n")}` : ""; if (format === "plain") return [`[IMPORTANT] ${view.icon} ${view.title}`, `Task: ${view.task.id} — ${view.task.title}`, `Status: ${view.task.status} · Risk: ${view.task.risk} · Areas: ${view.task.areas.join(", ")}`, "", "Review artifacts", artifacts, "", `Decisions: ${decisions}`, `Execution: ${execution}`, "Evidence", evidence, warnings, "", `ACTION REQUIRED -> ${oneLine(view.action)}`].join("\n").replace(/\n{3,}/g, "\n\n") + "\n"; return ["> [!IMPORTANT]", `> ${view.icon} **${view.title}**`, `> **Task:** \`${view.task.id}\` — ${markdownText(view.task.title)}`, `> **Status:** \`${view.task.status}\` · **Risk:** \`${view.task.risk}\` · **Areas:** ${view.task.areas.map((area) => `\`${markdownText(area)}\``).join(", ")}`, "", "### Review artifacts", artifacts, "", `**Decisions:** ${decisions} `, `**Execution:** ${execution}`, "", "### Verification evidence", evidence, warnings, "", `> **ACTION REQUIRED →** ${view.action}`].join("\n").replace(/\n{3,}/g, "\n\n") + "\n"; };
|