taskforce-loop-engineering 0.10.0 → 0.13.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/CHANGELOG.md +24 -0
- package/MIGRATING.md +47 -2
- package/README.md +70 -0
- package/bin/loop-engineering.mjs +192 -0
- package/docs/architecture.md +444 -0
- package/docs/multi-agent-control-plane.md +31 -0
- package/docs/operator-dashboard.md +27 -0
- package/docs/production-operations.md +29 -0
- package/docs/production-trust-backlog.json +13 -0
- package/docs/production-trust-contract.md +54 -0
- package/docs/release-0.12-acceptance.md +35 -0
- package/lib/action-reservations.mjs +196 -0
- package/lib/core.mjs +219 -2
- package/lib/durable-journal.mjs +90 -0
- package/lib/operator-dashboard.mjs +198 -0
- package/lib/runtime-adapter-v1.mjs +36 -0
- package/lib/todo-control-plane.mjs +287 -0
- package/lib/upgrade-planner.mjs +24 -0
- package/package.json +4 -2
- package/scripts/action-reservation-self-test.mjs +65 -0
- package/scripts/async-acceptance-refresh-self-test.mjs +46 -0
- package/scripts/durable-journal-self-test.mjs +24 -0
- package/scripts/human-gate-lifecycle-v2-self-test.mjs +81 -0
- package/scripts/live-runtime-soak.mjs +86 -0
- package/scripts/operator-dashboard-self-test.mjs +74 -0
- package/scripts/production-acceptance.mjs +8 -0
- package/scripts/production-soak.mjs +19 -0
- package/scripts/route-notify-self-test.mjs +1 -0
- package/scripts/runtime-adapter-contract-self-test.mjs +14 -0
- package/scripts/todo-control-plane-self-test.mjs +74 -0
- package/scripts/upgrade-planner-self-test.mjs +9 -0
- package/templates/operator-projection.schema.json +1 -0
- package/templates/todo.schema.json +28 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const DASHBOARD_SCHEMA_VERSION = '1.0.0';
|
|
6
|
+
const SENSITIVE = /(^|_)(secret|token|password|credential|api[_-]?key|private[_-]?key|provider)(_|$)/i;
|
|
7
|
+
const STATES = new Set(['runnable', 'active', 'parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated', 'reconciliation_required', 'blocked', 'completed', 'failed']);
|
|
8
|
+
|
|
9
|
+
function clean(value, key = '') {
|
|
10
|
+
if (SENSITIVE.test(key)) return '[REDACTED]';
|
|
11
|
+
if (Array.isArray(value)) return value.map((item) => clean(item));
|
|
12
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((name) => [name, clean(value[name], name)]));
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function safeId(value) {
|
|
17
|
+
const result = String(value ?? '');
|
|
18
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,299}$/.test(result)) throw new Error('Unsafe dashboard identifier.');
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function relativeLink(root, file) {
|
|
23
|
+
const relative = path.relative(root, file);
|
|
24
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative.split(path.sep).join('/') : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function json(file, warnings, root) {
|
|
28
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
29
|
+
try { return JSON.parse(await readFile(file, 'utf8')); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT') return null;
|
|
32
|
+
if (attempt === 0) continue;
|
|
33
|
+
warnings.push({ code: 'malformed_artifact', artifact: relativeLink(root, file), message: String(error.message).split('\n')[0] });
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function dirs(dir) {
|
|
40
|
+
return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((item) => item.isDirectory()).map((item) => item.name).sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function files(dir, suffix = '.json') {
|
|
44
|
+
return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((item) => item.isFile() && item.name.endsWith(suffix)).map((item) => path.join(dir, item.name)).sort();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizedState(item, location, nowMs) {
|
|
48
|
+
const raw = String(item.state ?? item.status ?? '').toLowerCase();
|
|
49
|
+
if (raw === 'unknown' || item.reconciliation?.required) return 'reconciliation_required';
|
|
50
|
+
if (raw === 'claimed' || raw === 'running' || raw === 'active' || location === 'active') {
|
|
51
|
+
if (item.claim?.lease_expires_at && Date.parse(item.claim.lease_expires_at) <= nowMs) return 'reconciliation_required';
|
|
52
|
+
return 'active';
|
|
53
|
+
}
|
|
54
|
+
if (['completed', 'accepted', 'settled', 'released', 'success', 'succeeded'].includes(raw) || location === 'completed') return 'completed';
|
|
55
|
+
if (['failed', 'error', 'cancelled', 'goal_unreachable'].includes(raw) || location === 'failed') return 'failed';
|
|
56
|
+
if (raw.includes('timeout') || raw.includes('escalat')) return 'timed_out_or_escalated';
|
|
57
|
+
const parked = item.parked ?? item.wait ?? (location === 'waiting' ? item : null);
|
|
58
|
+
const wait = String(parked?.state ?? parked?.kind ?? raw).toLowerCase();
|
|
59
|
+
if (wait.includes('human')) return 'waiting_for_human';
|
|
60
|
+
if (wait.includes('external') || wait.includes('condition')) return 'waiting_for_external_condition';
|
|
61
|
+
if (parked || location === 'waiting' || raw === 'parked') return 'parked';
|
|
62
|
+
if (raw === 'blocked' || item.blocked_reasons?.length) return 'blocked';
|
|
63
|
+
if (raw === 'handoff_pending') return 'active';
|
|
64
|
+
return 'runnable';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function todoProjection(todo, nowMs) {
|
|
68
|
+
const state = normalizedState(todo, null, nowMs);
|
|
69
|
+
return clean({
|
|
70
|
+
id: String(todo.id), title: todo.title ?? todo.goal ?? String(todo.id), project_id: todo.project_id ?? todo.projectId ?? null,
|
|
71
|
+
state, source_version: todo.version ?? 1, priority: Number(todo.priority ?? 0), risk: todo.risk ?? null,
|
|
72
|
+
authority: todo.authority_class ?? todo.authorization?.scope ?? null, required_capabilities: todo.required_capabilities ?? [],
|
|
73
|
+
owner: todo.claim?.owner ?? null, lease: todo.claim ? { fencing_token: todo.claim.fencing_token ?? null, claimed_at: todo.claim.claimed_at ?? null, expires_at: todo.claim.lease_expires_at ?? null, expired: Date.parse(todo.claim.lease_expires_at ?? '') <= nowMs } : null,
|
|
74
|
+
gate: todo.parked ?? null, blocked_reasons: todo.blocked_reasons ?? [], lineage: todo.lineage ?? null,
|
|
75
|
+
acceptance: todo.acceptance_contract ?? null, evidence: todo.evidence ?? [], evidence_requirements: todo.evidence_requirements ?? [],
|
|
76
|
+
cost: todo.cost_envelope ?? null, idempotency_keys: todo.idempotency_keys ?? [], next_action: state === 'reconciliation_required' ? 'reconcile_unknown_action_or_expired_lease' : state === 'waiting_for_human' ? 'await_human_response' : state === 'waiting_for_external_condition' ? 'verify_external_condition' : state === 'runnable' ? 'claim_todo' : null,
|
|
77
|
+
created_at: todo.created_at ?? null, updated_at: todo.updated_at ?? null
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function controlPlane(root, warnings, nowMs) {
|
|
82
|
+
const file = path.join(root, 'runtime', 'loops', 'control-plane', 'state.json');
|
|
83
|
+
const state = await json(file, warnings, root);
|
|
84
|
+
if (!state) return { todos: [], agents: [], handoffs: [], quotas: {} };
|
|
85
|
+
return {
|
|
86
|
+
todos: Object.values(state.todos ?? {}).map((todo) => todoProjection(todo, nowMs)).sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id)),
|
|
87
|
+
agents: Object.values(state.agents ?? {}).map(clean).sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
|
88
|
+
handoffs: Object.values(state.handoffs ?? {}).map(clean).sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')) || String(a.id).localeCompare(String(b.id))),
|
|
89
|
+
quotas: clean(state.quotas ?? {}), updated_at: state.updated_at ?? null, source_version: state.version ?? 1
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function actions(root, warnings, nowMs) {
|
|
94
|
+
const dir = path.join(root, 'runtime', 'loops', 'action-reservations');
|
|
95
|
+
const result = [];
|
|
96
|
+
for (const file of await files(dir)) {
|
|
97
|
+
const item = await json(file, warnings, root);
|
|
98
|
+
if (!item) continue;
|
|
99
|
+
const state = normalizedState(item, null, nowMs);
|
|
100
|
+
result.push(clean({ idempotency_key: item.idempotency_key, kind: item.kind, state, reservation_state: item.state, request_fingerprint: item.request_fingerprint, authorization: item.authorization, owner: item.claim?.owner ?? null, lease: item.claim ? { fencing_token: item.claim.fencing_token, expires_at: item.claim.lease_expires_at, expired: Date.parse(item.claim.lease_expires_at ?? '') <= nowMs } : null, reconciliation: item.reconciliation, settlement: item.settlement, release: item.release, created_at: item.created_at, updated_at: item.updated_at }));
|
|
101
|
+
}
|
|
102
|
+
return result.sort((a, b) => String(a.idempotency_key).localeCompare(String(b.idempotency_key)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function legacyQueues(root, warnings, nowMs) {
|
|
106
|
+
const loops = path.join(root, 'runtime', 'loops');
|
|
107
|
+
const excluded = new Set(['control-plane', 'action-reservations', 'projects']);
|
|
108
|
+
const queues = [];
|
|
109
|
+
for (const name of (await dirs(loops)).filter((item) => !excluded.has(item))) {
|
|
110
|
+
const base = path.join(loops, name); const counts = Object.fromEntries([...STATES].map((state) => [state, 0])); const tasks = [];
|
|
111
|
+
for (const location of ['inbox', 'active', 'waiting', 'completed', 'failed']) {
|
|
112
|
+
for (const file of await files(path.join(base, location))) {
|
|
113
|
+
const item = await json(file, warnings, root); if (!item) continue;
|
|
114
|
+
const state = normalizedState(item, location, nowMs); counts[state] += 1;
|
|
115
|
+
tasks.push(clean({ id: String(item.id ?? path.basename(file, '.json')), title: item.title ?? item.goal ?? item.task ?? path.basename(file, '.json'), state, queue: name, location, project_id: item.project_id ?? item.projectId ?? null, owner: item.owner ?? item.claim?.owner ?? null, gate: item.parked ?? item.wait ?? null, next_wake: item.next_wake_at ?? item.nextWakeAt ?? item.parked?.next_check_at ?? null, next_action: item.next_action ?? item.nextAction ?? null, risk: item.risk ?? null, evidence_links: [relativeLink(root, file)].filter(Boolean), updated_at: item.updated_at ?? item.created_at ?? null }));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const stateFile = path.join(base, 'state.json'); const state = await json(stateFile, warnings, root);
|
|
119
|
+
queues.push({ id: name, counts, tasks: tasks.sort((a, b) => a.id.localeCompare(b.id)), scheduler: clean(state), source_version: state?.version ?? 1 });
|
|
120
|
+
}
|
|
121
|
+
return queues;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function projects(root, warnings) {
|
|
125
|
+
const result = [];
|
|
126
|
+
const runtime = path.join(root, 'runtime', 'loops', 'projects');
|
|
127
|
+
for (const id of await dirs(runtime)) {
|
|
128
|
+
const base = path.join(runtime, id);
|
|
129
|
+
const intake = await json(path.join(base, 'intake', 'latest.json'), warnings, root);
|
|
130
|
+
const backlog = await json(path.join(base, 'backlog', 'initial.json'), warnings, root);
|
|
131
|
+
const completion = await json(path.join(base, 'completion', 'latest.json'), warnings, root);
|
|
132
|
+
result.push(clean({ id, goal: intake?.goal ?? intake?.brief ?? null, queue: intake?.queue ?? null, status: completion?.status ?? (completion ? 'completed' : 'active'), acceptance: intake?.acceptance ?? null, backlog: backlog?.tasks ?? backlog?.items ?? backlog ?? null, evidence_links: [intake && relativeLink(root, path.join(base, 'intake', 'latest.json')), backlog && relativeLink(root, path.join(base, 'backlog', 'initial.json')), completion && relativeLink(root, path.join(base, 'completion', 'latest.json'))].filter(Boolean) }));
|
|
133
|
+
}
|
|
134
|
+
return result.sort((a, b) => a.id.localeCompare(b.id));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function buildOperatorProjection(root, options = {}) {
|
|
138
|
+
const resolved = path.resolve(root); const now = options.now ? new Date(options.now) : new Date();
|
|
139
|
+
if (Number.isNaN(now.getTime())) throw new Error('Invalid projection time.');
|
|
140
|
+
const warnings = []; const before = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
|
|
141
|
+
const [control, reservations, queueList, projectList] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings)]);
|
|
142
|
+
const newest = [control.updated_at, ...control.todos.map((item) => item.updated_at), ...reservations.map((item) => item.updated_at), ...queueList.flatMap((queue) => queue.tasks.map((item) => item.updated_at))].filter(Boolean).sort().at(-1) ?? null;
|
|
143
|
+
const counts = Object.fromEntries([...STATES].map((state) => [state, 0]));
|
|
144
|
+
for (const item of [...control.todos, ...queueList.flatMap((queue) => queue.tasks)]) counts[item.state] += 1;
|
|
145
|
+
const after = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
|
|
146
|
+
if (before && after && before.mtimeMs !== after.mtimeMs) warnings.push({ code: 'concurrent_update', artifact: 'runtime/loops', message: 'Artifacts changed while the projection was read; refresh recommended.' });
|
|
147
|
+
return clean({ schema_version: DASHBOARD_SCHEMA_VERSION, generated_at: now.toISOString(), source: { root: resolved, read_only: true, newest_artifact_at: newest, freshness_seconds: newest ? Math.max(0, Math.floor((now.getTime() - Date.parse(newest)) / 1000)) : null }, health: { status: warnings.length ? 'degraded' : 'ok', warnings }, overview: { counts, queue_count: queueList.length, project_count: projectList.length, todo_count: control.todos.length, action_count: reservations.length }, projects: projectList, queues: queueList, todos: control.todos, agents: control.agents, handoffs: control.handoffs, gates: control.todos.filter((item) => ['parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated'].includes(item.state)).map((item) => ({ todo_id: item.id, state: item.state, gate: item.gate, next_action: item.next_action })), actions: reservations, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function filterProjection(projection, options = {}) {
|
|
151
|
+
const query = String(options.query ?? '').toLowerCase(); const state = options.state;
|
|
152
|
+
const match = (item) => (!state || item.state === state) && (!query || JSON.stringify(item).toLowerCase().includes(query));
|
|
153
|
+
return { ...projection, todos: projection.todos.filter(match), queues: projection.queues.map((queue) => ({ ...queue, tasks: queue.tasks.filter(match) })), actions: projection.actions.filter(match) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function dashboardHealth(projection, options = {}) {
|
|
157
|
+
const maxAge = Number(options.maxAgeSeconds ?? 3600);
|
|
158
|
+
const stale = projection.source.freshness_seconds !== null && projection.source.freshness_seconds > maxAge;
|
|
159
|
+
return { schema_version: DASHBOARD_SCHEMA_VERSION, status: projection.health.status === 'ok' && !stale ? 'ok' : 'degraded', read_only: true, stale, freshness_seconds: projection.source.freshness_seconds, warnings: projection.health.warnings };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function html() {
|
|
163
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Loop Engineering Operator Dashboard</title><style>body{font:14px system-ui;margin:2rem;background:#10151c;color:#e8edf2}input,select{padding:.55rem;background:#18222e;color:inherit;border:1px solid #445}table{width:100%;border-collapse:collapse;margin-top:1rem}th,td{text-align:left;padding:.55rem;border-bottom:1px solid #344}.pill{padding:.2rem .5rem;border-radius:1rem;background:#25364a}a{color:#78b7ff}</style></head><body><h1>Loop Engineering</h1><p id="health">Loading read-only projection…</p><input id="q" placeholder="Search"><select id="s"><option value="">All states</option></select><table><thead><tr><th>State</th><th>Task</th><th>Owner</th><th>Next action</th></tr></thead><tbody id="rows"></tbody></table><script>const states=['runnable','active','parked','waiting_for_human','waiting_for_external_condition','timed_out_or_escalated','reconciliation_required','blocked','completed','failed'];s.innerHTML+=states.map(x=>'<option>'+x+'</option>').join('');async function draw(){const p=new URLSearchParams({q:q.value,state:s.value});const d=await fetch('/api/v1/overview?'+p).then(r=>r.json());health.textContent=d.health.status+' · '+d.overview.todo_count+' typed todos · '+d.overview.queue_count+' queues';const all=[...d.todos,...d.queues.flatMap(x=>x.tasks)];rows.replaceChildren(...all.map(x=>{const tr=document.createElement('tr');for(const v of [x.state,x.title,x.owner??'—',x.next_action??'—']){const td=document.createElement('td');td.textContent=String(v);tr.append(td)}return tr}))}q.oninput=draw;s.onchange=draw;draw()</script></body></html>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function loopback(host) { return host === '127.0.0.1' || host === '::1' || host === 'localhost'; }
|
|
167
|
+
|
|
168
|
+
export async function createDashboardServer(root, options = {}) {
|
|
169
|
+
const host = options.host ?? '127.0.0.1'; const port = Number(options.port ?? 0);
|
|
170
|
+
if (!loopback(host) && options.allowNonLoopback !== true) throw new Error('Non-loopback dashboard bind requires --allow-non-loopback.');
|
|
171
|
+
const server = createServer(async (request, response) => {
|
|
172
|
+
try {
|
|
173
|
+
const url = new URL(request.url, 'http://localhost');
|
|
174
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405, { Allow: 'GET, HEAD' }); return response.end(); }
|
|
175
|
+
if (url.pathname.includes('..') || /%2e/i.test(request.url)) { response.writeHead(400); return response.end('unsafe path'); }
|
|
176
|
+
if (url.pathname === '/') { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'none'", 'x-content-type-options': 'nosniff' }); return response.end(request.method === 'HEAD' ? '' : html()); }
|
|
177
|
+
const projection = await buildOperatorProjection(root);
|
|
178
|
+
let body;
|
|
179
|
+
if (url.pathname === '/api/v1/overview') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') });
|
|
180
|
+
else if (url.pathname === '/api/v1/health') body = dashboardHealth(projection, { maxAgeSeconds: url.searchParams.get('max_age_seconds') ?? 3600 });
|
|
181
|
+
else if (url.pathname === '/api/v1/todos') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') }).todos;
|
|
182
|
+
else if (url.pathname.startsWith('/api/v1/todos/')) body = projection.todos.find((item) => item.id === safeId(decodeURIComponent(url.pathname.slice('/api/v1/todos/'.length)))) ?? null;
|
|
183
|
+
else if (url.pathname === '/api/v1/actions') body = projection.actions;
|
|
184
|
+
else { response.writeHead(404); return response.end('not found'); }
|
|
185
|
+
response.writeHead(body === null ? 404 : 200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); response.end(request.method === 'HEAD' ? '' : `${JSON.stringify(body)}\n`);
|
|
186
|
+
} catch (error) { response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' }); response.end(`${JSON.stringify({ error: 'projection_failed', message: String(error.message) })}\n`); }
|
|
187
|
+
});
|
|
188
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
|
|
189
|
+
return server;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function exportDashboard(root, outputDir, options = {}) {
|
|
193
|
+
const target = path.resolve(outputDir); const projection = await buildOperatorProjection(root, options);
|
|
194
|
+
await mkdir(target, { recursive: true });
|
|
195
|
+
await writeFile(path.join(target, 'projection.json'), `${JSON.stringify(projection, null, 2)}\n`);
|
|
196
|
+
await writeFile(path.join(target, 'index.html'), html().replace("fetch('/api/v1/overview?'+p)", "fetch('./projection.json')"));
|
|
197
|
+
return { schema_version: DASHBOARD_SCHEMA_VERSION, output_dir: target, files: ['index.html', 'projection.json'], read_only_source: true };
|
|
198
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const ADAPTER_CONTRACT = 'loop.runtime-adapter';
|
|
2
|
+
export const ADAPTER_MAJOR = 1;
|
|
3
|
+
|
|
4
|
+
function requiredFunction(adapter, name) {
|
|
5
|
+
if (typeof adapter?.[name] !== 'function') throw new Error(`adapter.${name} must be a function`);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function validateRuntimeAdapter(adapter) {
|
|
9
|
+
if (adapter?.contract !== ADAPTER_CONTRACT || adapter?.version !== ADAPTER_MAJOR) {
|
|
10
|
+
throw new Error(`unsupported runtime adapter contract: ${adapter?.contract}@${adapter?.version}`);
|
|
11
|
+
}
|
|
12
|
+
if (!['openclaw', 'hermes', 'custom'].includes(adapter.runtime)) throw new Error('unsupported adapter runtime');
|
|
13
|
+
for (const name of ['dispatch', 'heartbeat', 'reconcile']) requiredFunction(adapter, name);
|
|
14
|
+
return adapter;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function defineRuntimeAdapter({ runtime, dispatch, heartbeat, reconcile, capabilities = [] }) {
|
|
18
|
+
return validateRuntimeAdapter({ contract: ADAPTER_CONTRACT, version: ADAPTER_MAJOR, runtime, capabilities: [...new Set(capabilities)].sort(), dispatch, heartbeat, reconcile });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const openClawAdapter = defineRuntimeAdapter({
|
|
22
|
+
runtime: 'openclaw', capabilities: ['dispatch', 'heartbeat', 'reconcile'],
|
|
23
|
+
dispatch: async (request, io) => io.invoke('openclaw', ['agent', '--agent', request.worker, '--message', request.prompt]),
|
|
24
|
+
heartbeat: async (_request, io) => io.now(), reconcile: async (request, io) => io.lookup(request.idempotencyKey)
|
|
25
|
+
});
|
|
26
|
+
export const hermesAdapter = defineRuntimeAdapter({
|
|
27
|
+
runtime: 'hermes', capabilities: ['dispatch', 'heartbeat', 'reconcile'],
|
|
28
|
+
dispatch: async (request, io) => io.invoke('hermes', ['-z', request.prompt]),
|
|
29
|
+
heartbeat: async (_request, io) => io.now(), reconcile: async (request, io) => io.lookup(request.idempotencyKey)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const customAdapterExample = defineRuntimeAdapter({
|
|
33
|
+
runtime: 'custom', capabilities: ['dispatch', 'heartbeat', 'reconcile'],
|
|
34
|
+
dispatch: async (request, io) => io.invoke('example-runtime', [request.prompt]),
|
|
35
|
+
heartbeat: async (_request, io) => io.now(), reconcile: async (request, io) => io.lookup(request.idempotencyKey)
|
|
36
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { inspectAction } from './action-reservations.mjs';
|
|
5
|
+
|
|
6
|
+
const TODO_STATES = new Set(['runnable', 'blocked', 'claimed', 'handoff_pending', 'completed']);
|
|
7
|
+
const RISK = new Set(['low', 'medium', 'high', 'critical']);
|
|
8
|
+
|
|
9
|
+
function text(value, label) {
|
|
10
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be a non-empty string.`);
|
|
11
|
+
return value.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function id(value, label) {
|
|
15
|
+
const result = text(value, label);
|
|
16
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/.test(result)) throw new Error(`${label} contains unsafe characters.`);
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function strings(value, label) {
|
|
21
|
+
if (value === undefined) return [];
|
|
22
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) throw new Error(`${label} must be an array of strings.`);
|
|
23
|
+
return [...new Set(value.map((item) => item.trim()))].sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function location(root) {
|
|
27
|
+
const dir = path.join(root, 'runtime', 'loops', 'control-plane');
|
|
28
|
+
return { dir, file: path.join(dir, 'state.json'), lock: path.join(dir, 'state.lock'), audit: path.join(dir, 'audit.jsonl') };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function emptyState() {
|
|
32
|
+
return { version: 2, fencing_counter: 0, agents: {}, todos: {}, handoffs: {}, quotas: {}, updated_at: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readState(file) {
|
|
36
|
+
try { return JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return emptyState(); throw error; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function atomicWrite(file, value) {
|
|
40
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
41
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
|
|
42
|
+
await rename(temp, file);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function acquire(lock, timeoutMs = 5000) {
|
|
46
|
+
const deadline = Date.now() + timeoutMs;
|
|
47
|
+
while (true) {
|
|
48
|
+
try { await mkdir(lock); return; } catch (error) {
|
|
49
|
+
if (error.code !== 'EEXIST') throw error;
|
|
50
|
+
const info = await stat(lock).catch(() => null);
|
|
51
|
+
if (info && Date.now() - info.mtimeMs > 30_000) await rm(lock, { recursive: true, force: true });
|
|
52
|
+
else if (Date.now() >= deadline) throw new Error('Timed out acquiring control-plane mutex.');
|
|
53
|
+
else await new Promise((resolve) => setTimeout(resolve, 10));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function transaction(root, operation) {
|
|
59
|
+
const place = location(root);
|
|
60
|
+
await mkdir(place.dir, { recursive: true });
|
|
61
|
+
await acquire(place.lock);
|
|
62
|
+
try {
|
|
63
|
+
const state = await readState(place.file);
|
|
64
|
+
const result = await operation(state);
|
|
65
|
+
if (result.changed) {
|
|
66
|
+
state.updated_at = new Date().toISOString();
|
|
67
|
+
await atomicWrite(place.file, state);
|
|
68
|
+
if (result.event) await writeFile(place.audit, `${JSON.stringify(result.event)}\n`, { flag: 'a' });
|
|
69
|
+
}
|
|
70
|
+
return result.output;
|
|
71
|
+
} finally { await rm(place.lock, { recursive: true, force: true }); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function event(type, todo, extra = {}) {
|
|
75
|
+
return { version: 1, event_id: randomUUID(), type, todo_id: todo.id, at: new Date().toISOString(), owner: todo.claim?.owner ?? null, fencing_token: todo.claim?.fencing_token ?? null, ...extra };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeAgent(input) {
|
|
79
|
+
const authority = strings(input.authority_grants ?? input.authorityGrants, 'authority_grants');
|
|
80
|
+
return { id: id(input.id ?? input.agent_id, 'agent id'), capabilities: strings(input.capabilities, 'capabilities'), authority_grants: authority, quota_grants: { ...(input.quota_grants ?? input.quotaGrants ?? {}) }, registered_at: new Date().toISOString() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function registerAgent(root, input) {
|
|
84
|
+
const agent = normalizeAgent(input);
|
|
85
|
+
return transaction(root, async (state) => {
|
|
86
|
+
const previous = state.agents[agent.id];
|
|
87
|
+
state.agents[agent.id] = { ...agent, registered_at: previous?.registered_at ?? agent.registered_at, updated_at: new Date().toISOString() };
|
|
88
|
+
return { changed: true, output: state.agents[agent.id], event: { version: 1, event_id: randomUUID(), type: previous ? 'agent_updated' : 'agent_registered', agent_id: agent.id, at: new Date().toISOString() } };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeTodo(input) {
|
|
93
|
+
const todoId = id(input.id, 'todo id');
|
|
94
|
+
const risk = input.risk ?? 'low';
|
|
95
|
+
if (!RISK.has(risk)) throw new Error(`Unsupported risk: ${risk}`);
|
|
96
|
+
const priority = Number(input.priority ?? 0);
|
|
97
|
+
const cost = Number(input.cost ?? input.cost_envelope?.amount ?? 0);
|
|
98
|
+
if (!Number.isFinite(priority) || !Number.isFinite(cost) || cost < 0) throw new Error('priority and cost must be valid numbers.');
|
|
99
|
+
const acceptance = input.acceptance_contract ?? input.acceptanceContract;
|
|
100
|
+
if (!acceptance || typeof acceptance !== 'object') throw new Error('acceptance_contract is required.');
|
|
101
|
+
const evidence = strings(input.evidence_requirements ?? input.evidenceRequirements, 'evidence_requirements');
|
|
102
|
+
if (!evidence.length) throw new Error('evidence_requirements must not be empty.');
|
|
103
|
+
const created = new Date().toISOString();
|
|
104
|
+
return {
|
|
105
|
+
version: 2, id: todoId, title: text(input.title, 'title'), project_id: input.project_id ?? input.projectId ?? null,
|
|
106
|
+
dependencies: strings(input.dependencies, 'dependencies'), priority, risk, authority_class: text(input.authority_class ?? input.authorityClass ?? 'local', 'authority_class'),
|
|
107
|
+
required_capabilities: strings(input.required_capabilities ?? input.requiredCapabilities, 'required_capabilities'), acceptance_contract: acceptance,
|
|
108
|
+
evidence_requirements: evidence, cost_envelope: { quota: input.quota ?? input.cost_envelope?.quota ?? 'default', amount: cost },
|
|
109
|
+
state: 'runnable', blocked_reasons: [], claim: null, handoff_id: null, parked: input.parked ?? null,
|
|
110
|
+
authorization: input.authorization ?? null, idempotency_keys: strings(input.idempotency_keys ?? input.idempotencyKeys, 'idempotency_keys'),
|
|
111
|
+
lineage: input.lineage ?? { root_todo_id: todoId, parent_todo_id: null }, context: input.context ?? {}, evidence: input.evidence ?? [],
|
|
112
|
+
created_at: created, updated_at: created
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function createTodo(root, input) {
|
|
117
|
+
const todo = normalizeTodo(input);
|
|
118
|
+
return transaction(root, async (state) => {
|
|
119
|
+
if (state.todos[todo.id]) throw new Error(`Todo already exists: ${todo.id}`);
|
|
120
|
+
for (const dependency of todo.dependencies) if (dependency === todo.id) throw new Error('A todo cannot depend on itself.');
|
|
121
|
+
state.todos[todo.id] = todo;
|
|
122
|
+
return { changed: true, output: todo, event: event('todo_created', todo) };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function capabilityEligible(agent, todo) { return todo.required_capabilities.every((item) => agent.capabilities.includes(item)); }
|
|
127
|
+
function authorityEligible(agent, todo) { return agent.authority_grants.includes('*') || agent.authority_grants.includes(todo.authority_class); }
|
|
128
|
+
|
|
129
|
+
async function eligibility(root, state, todo, agent, now = Date.now()) {
|
|
130
|
+
const reasons = [];
|
|
131
|
+
if (!['runnable', 'blocked'].includes(todo.state)) reasons.push(`state:${todo.state}`);
|
|
132
|
+
if (todo.parked && !['runnable', 'resumed'].includes(todo.parked.state)) reasons.push('parked_human_gate');
|
|
133
|
+
const missing = todo.dependencies.filter((dep) => state.todos[dep]?.state !== 'completed');
|
|
134
|
+
if (missing.length) reasons.push(`dependencies:${missing.join(',')}`);
|
|
135
|
+
if (!capabilityEligible(agent, todo)) reasons.push('capability_mismatch');
|
|
136
|
+
if (!authorityEligible(agent, todo)) reasons.push('authority_mismatch');
|
|
137
|
+
const quota = todo.cost_envelope.quota;
|
|
138
|
+
const available = Number(agent.quota_grants?.[quota] ?? state.quotas?.[quota] ?? 0);
|
|
139
|
+
if (todo.cost_envelope.amount > available) reasons.push('quota_exhausted');
|
|
140
|
+
for (const key of todo.idempotency_keys) {
|
|
141
|
+
const action = await inspectAction(root, key);
|
|
142
|
+
if (action?.state === 'unknown') reasons.push(`action_reconciliation:${key}`);
|
|
143
|
+
if (action?.state === 'claimed' && Date.parse(action.claim?.lease_expires_at ?? '') <= now) reasons.push(`action_reconciliation:${key}`);
|
|
144
|
+
}
|
|
145
|
+
return { eligible: reasons.length === 0, reasons };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function listTodos(root, options = {}) {
|
|
149
|
+
const state = await readState(location(root).file);
|
|
150
|
+
return Object.values(state.todos).filter((todo) => !options.state || todo.state === options.state).sort((a, b) => b.priority - a.priority || a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function inspectTodo(root, todoId) { return (await readState(location(root).file)).todos[id(todoId, 'todo id')] ?? null; }
|
|
154
|
+
|
|
155
|
+
export async function claimTodo(root, input) {
|
|
156
|
+
const agentId = id(input.agent_id ?? input.agentId, 'agent id');
|
|
157
|
+
const leaseMs = Number(input.lease_ms ?? input.leaseMs ?? 60_000);
|
|
158
|
+
if (!Number.isInteger(leaseMs) || leaseMs <= 0) throw new Error('lease_ms must be a positive integer.');
|
|
159
|
+
return transaction(root, async (state) => {
|
|
160
|
+
const agent = state.agents[agentId];
|
|
161
|
+
if (!agent) throw new Error(`Agent not registered: ${agentId}`);
|
|
162
|
+
const candidates = input.todo_id ?? input.todoId ? [state.todos[id(input.todo_id ?? input.todoId, 'todo id')]].filter(Boolean) : Object.values(state.todos);
|
|
163
|
+
candidates.sort((a, b) => b.priority - a.priority || a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
164
|
+
const rejected = [];
|
|
165
|
+
for (const todo of candidates) {
|
|
166
|
+
const check = await eligibility(root, state, todo, agent);
|
|
167
|
+
if (!check.eligible) { rejected.push({ todo_id: todo.id, reasons: check.reasons }); continue; }
|
|
168
|
+
const now = new Date();
|
|
169
|
+
const token = ++state.fencing_counter;
|
|
170
|
+
todo.state = 'claimed'; todo.blocked_reasons = []; todo.updated_at = now.toISOString();
|
|
171
|
+
todo.claim = { owner: agentId, fencing_token: token, claimed_at: now.toISOString(), lease_expires_at: new Date(now.getTime() + leaseMs).toISOString() };
|
|
172
|
+
const audit = event('todo_claimed', todo, { agent_id: agentId });
|
|
173
|
+
todo.ownership_events = [...(todo.ownership_events ?? []), audit];
|
|
174
|
+
return { changed: true, output: { claimed: true, todo, fencing_token: token }, event: audit };
|
|
175
|
+
}
|
|
176
|
+
return { changed: false, output: { claimed: false, reason: rejected[0]?.reasons?.[0] ?? 'no_eligible_todo', rejected } };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function assertClaim(todo, agentId, token) {
|
|
181
|
+
if (!todo || todo.state !== 'claimed' || todo.claim?.owner !== agentId || todo.claim?.fencing_token !== Number(token)) throw new Error('Stale or invalid todo fencing token.');
|
|
182
|
+
if (Date.parse(todo.claim.lease_expires_at) <= Date.now()) throw new Error('Todo lease has expired.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function renewTodo(root, input) {
|
|
186
|
+
return transaction(root, async (state) => {
|
|
187
|
+
const todo = state.todos[id(input.todo_id ?? input.todoId, 'todo id')];
|
|
188
|
+
assertClaim(todo, id(input.agent_id ?? input.agentId, 'agent id'), input.fencing_token ?? input.fencingToken);
|
|
189
|
+
const leaseMs = Number(input.lease_ms ?? input.leaseMs ?? 60_000);
|
|
190
|
+
if (!Number.isInteger(leaseMs) || leaseMs <= 0) throw new Error('lease_ms must be a positive integer.');
|
|
191
|
+
todo.claim.lease_expires_at = new Date(Date.now() + leaseMs).toISOString(); todo.updated_at = new Date().toISOString();
|
|
192
|
+
const audit = event('todo_lease_renewed', todo);
|
|
193
|
+
todo.ownership_events.push(audit);
|
|
194
|
+
return { changed: true, output: todo, event: audit };
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function releaseTodo(root, input) {
|
|
199
|
+
return transaction(root, async (state) => {
|
|
200
|
+
const todo = state.todos[id(input.todo_id ?? input.todoId, 'todo id')];
|
|
201
|
+
assertClaim(todo, id(input.agent_id ?? input.agentId, 'agent id'), input.fencing_token ?? input.fencingToken);
|
|
202
|
+
const previous = todo.claim; todo.state = input.completed ? 'completed' : 'runnable'; todo.claim = null; todo.updated_at = new Date().toISOString();
|
|
203
|
+
if (input.evidence) todo.evidence = [...todo.evidence, input.evidence];
|
|
204
|
+
const audit = event(input.completed ? 'todo_completed' : 'todo_released', todo, { previous_owner: previous.owner, previous_fencing_token: previous.fencing_token, reason: input.reason ?? null });
|
|
205
|
+
todo.ownership_events.push(audit);
|
|
206
|
+
return { changed: true, output: todo, event: audit };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function handoffTodo(root, input) {
|
|
211
|
+
return transaction(root, async (state) => {
|
|
212
|
+
const todo = state.todos[id(input.todo_id ?? input.todoId, 'todo id')];
|
|
213
|
+
const source = id(input.agent_id ?? input.agentId, 'agent id');
|
|
214
|
+
assertClaim(todo, source, input.fencing_token ?? input.fencingToken);
|
|
215
|
+
const target = id(input.target_agent_id ?? input.targetAgentId, 'target agent id');
|
|
216
|
+
if (!state.agents[target]) throw new Error(`Agent not registered: ${target}`);
|
|
217
|
+
const targetCheck = await eligibility(root, { ...state, todos: { ...state.todos, [todo.id]: { ...todo, state: 'runnable' } } }, { ...todo, state: 'runnable' }, state.agents[target]);
|
|
218
|
+
if (!targetCheck.eligible) throw new Error(`Target agent is ineligible: ${targetCheck.reasons.join(', ')}`);
|
|
219
|
+
const handoffId = id(input.handoff_id ?? input.handoffId ?? `handoff:${todo.id}:${state.fencing_counter + 1}`, 'handoff id');
|
|
220
|
+
const packet = { version: 1, id: handoffId, todo_id: todo.id, from_agent_id: source, to_agent_id: target, state: 'pending', created_at: new Date().toISOString(), lineage: todo.lineage, context: todo.context, evidence: todo.evidence, authorization: todo.authorization, idempotency_keys: todo.idempotency_keys, source_fencing_token: todo.claim.fencing_token };
|
|
221
|
+
state.handoffs[handoffId] = packet; todo.state = 'handoff_pending'; todo.handoff_id = handoffId;
|
|
222
|
+
const audit = event('handoff_created', todo, { handoff_id: handoffId, from_agent_id: source, to_agent_id: target }); todo.ownership_events.push(audit);
|
|
223
|
+
return { changed: true, output: packet, event: audit };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function decideHandoff(root, input) {
|
|
228
|
+
return transaction(root, async (state) => {
|
|
229
|
+
const packet = state.handoffs[id(input.handoff_id ?? input.handoffId, 'handoff id')];
|
|
230
|
+
if (!packet || packet.state !== 'pending') throw new Error('Pending handoff not found.');
|
|
231
|
+
const target = id(input.agent_id ?? input.agentId, 'agent id');
|
|
232
|
+
if (packet.to_agent_id !== target) throw new Error('Only the target agent can decide a handoff.');
|
|
233
|
+
const todo = state.todos[packet.todo_id];
|
|
234
|
+
if (input.accept) {
|
|
235
|
+
const check = await eligibility(root, { ...state, todos: { ...state.todos, [todo.id]: { ...todo, state: 'runnable' } } }, { ...todo, state: 'runnable' }, state.agents[target]);
|
|
236
|
+
if (!check.eligible) throw new Error(`Target agent is no longer eligible: ${check.reasons.join(', ')}`);
|
|
237
|
+
const token = ++state.fencing_counter; const now = new Date();
|
|
238
|
+
todo.state = 'claimed'; todo.claim = { owner: target, fencing_token: token, claimed_at: now.toISOString(), lease_expires_at: new Date(now.getTime() + Number(input.lease_ms ?? input.leaseMs ?? 60_000)).toISOString() };
|
|
239
|
+
packet.state = 'accepted'; packet.decided_at = now.toISOString(); packet.target_fencing_token = token;
|
|
240
|
+
} else {
|
|
241
|
+
packet.state = 'rejected'; packet.decided_at = new Date().toISOString(); packet.reason = input.reason ?? null;
|
|
242
|
+
if (todo.claim && Date.parse(todo.claim.lease_expires_at) > Date.now()) todo.state = 'claimed'; else { todo.state = 'runnable'; todo.claim = null; }
|
|
243
|
+
}
|
|
244
|
+
todo.handoff_id = null; todo.updated_at = new Date().toISOString();
|
|
245
|
+
const audit = event(input.accept ? 'handoff_accepted' : 'handoff_rejected', todo, { handoff_id: packet.id, from_agent_id: packet.from_agent_id, to_agent_id: target }); todo.ownership_events.push(audit);
|
|
246
|
+
return { changed: true, output: { packet, todo }, event: audit };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function recoverTodos(root, input = {}) {
|
|
251
|
+
return transaction(root, async (state) => {
|
|
252
|
+
const results = []; const now = Number(input.now ?? Date.now()); const audits = [];
|
|
253
|
+
for (const todo of Object.values(state.todos)) {
|
|
254
|
+
if (!['claimed', 'handoff_pending'].includes(todo.state) || !todo.claim || Date.parse(todo.claim.lease_expires_at) > now) continue;
|
|
255
|
+
if (todo.parked && !['runnable', 'resumed'].includes(todo.parked.state)) { results.push({ todo_id: todo.id, outcome: 'parked_not_recovered' }); continue; }
|
|
256
|
+
const actionBlocks = [];
|
|
257
|
+
for (const key of todo.idempotency_keys) { const action = await inspectAction(root, key); if (['claimed', 'unknown'].includes(action?.state)) actionBlocks.push(key); }
|
|
258
|
+
const previous = todo.claim;
|
|
259
|
+
if (actionBlocks.length) { todo.state = 'blocked'; todo.blocked_reasons = actionBlocks.map((key) => `action_reconciliation:${key}`); }
|
|
260
|
+
else { todo.state = 'runnable'; todo.blocked_reasons = []; }
|
|
261
|
+
todo.claim = null; todo.handoff_id = null; todo.updated_at = new Date(now).toISOString();
|
|
262
|
+
const audit = event('todo_orphan_recovered', todo, { previous_owner: previous.owner, previous_fencing_token: previous.fencing_token, outcome: todo.state }); todo.ownership_events.push(audit); audits.push(audit);
|
|
263
|
+
results.push({ todo_id: todo.id, outcome: todo.state, blocked_reasons: todo.blocked_reasons });
|
|
264
|
+
}
|
|
265
|
+
return { changed: results.length > 0, output: { recovered: results.length, results }, event: audits.length ? { version: 1, event_id: randomUUID(), type: 'recovery_batch', at: new Date().toISOString(), transitions: audits } : null };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function importLegacyTodos(root, input = {}) {
|
|
270
|
+
const queueRoot = path.join(root, 'runtime', 'loops'); const imported = [];
|
|
271
|
+
for (const queue of await readdir(queueRoot, { withFileTypes: true }).catch(() => [])) {
|
|
272
|
+
if (!queue.isDirectory() || queue.name === 'control-plane' || queue.name === 'action-reservations') continue;
|
|
273
|
+
for (const subdir of ['inbox', 'waiting', 'active']) {
|
|
274
|
+
const dir = path.join(queueRoot, queue.name, subdir);
|
|
275
|
+
for (const file of await readdir(dir).catch(() => [])) {
|
|
276
|
+
if (!file.endsWith('.json')) continue;
|
|
277
|
+
const legacy = JSON.parse(await readFile(path.join(dir, file), 'utf8'));
|
|
278
|
+
const legacyId = legacy.id ?? path.basename(file, '.json');
|
|
279
|
+
try {
|
|
280
|
+
const todo = await createTodo(root, { id: `legacy:${queue.name}:${legacyId}`, title: legacy.title ?? legacy.goal ?? legacyId, project_id: legacy.projectId ?? null, priority: legacy.priority ?? 0, risk: legacy.risk ?? 'medium', authority_class: legacy.authorityClass ?? 'local', required_capabilities: legacy.requiredCapabilities ?? ['loop-task'], dependencies: legacy.dependencies ?? [], acceptance_contract: legacy.acceptance_contract ?? { source: 'legacy', checks: legacy.checks ?? [] }, evidence_requirements: legacy.evidence_requirements ?? ['legacy-task-result'], quota: 'default', cost: 0, parked: subdir === 'waiting' ? (legacy.parked ?? { state: 'waiting_for_human' }) : null, context: { legacy_queue: queue.name, legacy_file: path.relative(root, path.join(dir, file)) }, lineage: legacy.lineage });
|
|
281
|
+
imported.push(todo.id);
|
|
282
|
+
} catch (error) { if (!String(error.message).startsWith('Todo already exists:')) throw error; }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { imported: imported.length, todo_ids: imported };
|
|
287
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
5
|
+
async function exists(file) { try { await access(file); return true; } catch { return false; } }
|
|
6
|
+
|
|
7
|
+
export async function planIronmanUpgrade(root, desired = []) {
|
|
8
|
+
const manifestFile = path.join(root, 'runtime', 'loop-engineering-openclaw-install.json');
|
|
9
|
+
const manifest = await exists(manifestFile) ? JSON.parse(await readFile(manifestFile, 'utf8')) : null;
|
|
10
|
+
const known = new Map((manifest?.managedFiles ?? []).map((item) => [item.path, item.sha256]));
|
|
11
|
+
const entries = [];
|
|
12
|
+
for (const item of desired) {
|
|
13
|
+
const target = path.join(root, item.path); const present = await exists(target);
|
|
14
|
+
const current = present ? await readFile(target, 'utf8') : null;
|
|
15
|
+
const managedClean = present && known.has(item.path) && known.get(item.path) === sha256(current);
|
|
16
|
+
const customized = present && !managedClean;
|
|
17
|
+
entries.push({ path: item.path, present, customized, action: !present ? 'create' : managedClean ? 'replace_managed' : 'preserve_customized', desiredSha256: sha256(item.content), currentSha256: present ? sha256(current) : null });
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
version: 1, layout: manifest ? 'managed' : desired.some((item) => item.path.includes('ironman')) ? 'custom_ironman' : 'unmanaged',
|
|
21
|
+
readOnly: true, entries, destructive: false, readyToApply: entries.every((item) => !item.customized),
|
|
22
|
+
backupRequired: entries.some((item) => item.present), rollback: { strategy: 'restore_byte_exact_backup', requiredBeforeApply: true }
|
|
23
|
+
};
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
|
|
6
6
|
"type": "module",
|
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
"run-loop-cron.sh": "scripts/run-loop-cron.sh"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
+
"check:production-trust": "node --check lib/runtime-adapter-v1.mjs && node --check lib/durable-journal.mjs && node --check lib/upgrade-planner.mjs && node scripts/production-acceptance.mjs",
|
|
20
21
|
"check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
|
|
21
22
|
"check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
|
|
22
23
|
"check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
|
|
23
|
-
"check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
|
|
24
|
+
"check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node --check lib/action-reservations.mjs && node --check lib/todo-control-plane.mjs && node --check lib/operator-dashboard.mjs && node scripts/action-reservation-self-test.mjs && node scripts/todo-control-plane-self-test.mjs && node scripts/operator-dashboard-self-test.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node scripts/human-gate-lifecycle-v2-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs dashboard-health --root . --max-age-seconds 999999999 --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
|
|
24
25
|
"pack:dry": "npm pack --dry-run"
|
|
25
26
|
},
|
|
26
27
|
"engines": {
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"MIGRATING.md",
|
|
32
33
|
"README.md",
|
|
33
34
|
"bin/",
|
|
35
|
+
"docs/",
|
|
34
36
|
"lib/",
|
|
35
37
|
"scripts/",
|
|
36
38
|
"templates/",
|