taskforce-loop-engineering 0.10.0 → 0.12.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 +17 -0
- package/MIGRATING.md +47 -2
- package/README.md +61 -0
- package/bin/loop-engineering.mjs +181 -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/release-0.12-acceptance.md +35 -0
- package/lib/action-reservations.mjs +196 -0
- package/lib/core.mjs +169 -2
- package/lib/operator-dashboard.mjs +198 -0
- package/lib/todo-control-plane.mjs +287 -0
- package/package.json +3 -2
- package/scripts/action-reservation-self-test.mjs +65 -0
- package/scripts/human-gate-lifecycle-v2-self-test.mjs +81 -0
- package/scripts/operator-dashboard-self-test.mjs +74 -0
- package/scripts/route-notify-self-test.mjs +1 -0
- package/scripts/todo-control-plane-self-test.mjs +74 -0
- package/templates/operator-projection.schema.json +1 -0
- package/templates/todo.schema.json +28 -0
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
|
|
21
21
|
"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
22
|
"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",
|
|
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 --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
24
|
"pack:dry": "npm pack --dry-run"
|
|
25
25
|
},
|
|
26
26
|
"engines": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"MIGRATING.md",
|
|
32
32
|
"README.md",
|
|
33
33
|
"bin/",
|
|
34
|
+
"docs/",
|
|
34
35
|
"lib/",
|
|
35
36
|
"scripts/",
|
|
36
37
|
"templates/",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import {
|
|
6
|
+
actionAdapters,
|
|
7
|
+
claimAction,
|
|
8
|
+
inspectAction,
|
|
9
|
+
markActionUnknown,
|
|
10
|
+
migrateLegacyActionArtifact,
|
|
11
|
+
reconcileAction,
|
|
12
|
+
releaseAction,
|
|
13
|
+
reserveAction,
|
|
14
|
+
settleAction
|
|
15
|
+
} from '../lib/action-reservations.mjs';
|
|
16
|
+
|
|
17
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-action-reservation-'));
|
|
18
|
+
const base = { idempotencyKey: 'paid:task-1:step-1', kind: 'paid_api', authorizationScope: 'approval:task-1:provider-call', request: { model: 'mock', promptHash: 'abc', cents: 4 } };
|
|
19
|
+
|
|
20
|
+
const reserved = await reserveAction(root, base);
|
|
21
|
+
assert.equal(reserved.created, true);
|
|
22
|
+
assert.equal((await reserveAction(root, base)).duplicate, true);
|
|
23
|
+
await assert.rejects(() => reserveAction(root, { ...base, request: { ...base.request, cents: 5 } }), /different request/);
|
|
24
|
+
|
|
25
|
+
// Concurrent workers get one atomic lease and one fencing token.
|
|
26
|
+
const claims = await Promise.all(Array.from({ length: 12 }, (_, i) => claimAction(root, { idempotencyKey: base.idempotencyKey, owner: `worker-${i}`, leaseMs: 1000 })));
|
|
27
|
+
assert.equal(claims.filter((item) => item.claimed).length, 1);
|
|
28
|
+
const winner = claims.find((item) => item.claimed);
|
|
29
|
+
await assert.rejects(() => settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken + 1 }), /fencing token/);
|
|
30
|
+
assert.equal((await settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken, evidence: { upstreamId: 'mock-1' } })).settled, true);
|
|
31
|
+
assert.equal((await settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken })).duplicate, true);
|
|
32
|
+
assert.equal((await inspectAction(root, base.idempotencyKey)).authorization.state, 'consumed');
|
|
33
|
+
await assert.rejects(() => releaseAction(root, { idempotencyKey: base.idempotencyKey, reason: 'late release' }), /cannot be released/);
|
|
34
|
+
|
|
35
|
+
// Crash before send: an expired claim becomes unknown, never blindly claimable.
|
|
36
|
+
const beforeSend = { ...base, idempotencyKey: 'paid:crash-before-send' };
|
|
37
|
+
await reserveAction(root, beforeSend);
|
|
38
|
+
await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'crashed', leaseMs: 1 });
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
40
|
+
assert.equal((await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'recovery', leaseMs: 10 })).reason, 'reconcile_required');
|
|
41
|
+
await reconcileAction(root, { idempotencyKey: beforeSend.idempotencyKey, outcome: 'not_accepted', evidence: { local: 'adapter_not_invoked' } });
|
|
42
|
+
assert.equal((await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'recovery', leaseMs: 100 })).claimed, true);
|
|
43
|
+
|
|
44
|
+
// Crash after upstream acceptance: reconciliation settles without another send/charge.
|
|
45
|
+
const afterAcceptance = { ...base, idempotencyKey: 'paid:crash-after-acceptance' };
|
|
46
|
+
await reserveAction(root, afterAcceptance);
|
|
47
|
+
const afterClaim = await claimAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, owner: 'worker', leaseMs: 100 });
|
|
48
|
+
await markActionUnknown(root, { idempotencyKey: afterAcceptance.idempotencyKey, fencingToken: afterClaim.fencingToken, reason: 'accepted_before_local_commit' });
|
|
49
|
+
await reconcileAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, outcome: 'accepted', evidence: { upstreamId: 'mock-accepted' } });
|
|
50
|
+
assert.equal((await claimAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, owner: 'retry' })).reason, 'settled');
|
|
51
|
+
|
|
52
|
+
// Notification adapter suppresses duplicates, and an unused reservation can release authorization.
|
|
53
|
+
await actionAdapters.notification.reserve(root, { idempotencyKey: 'notify:task-1:terminal', authorizationScope: 'task-1:source-chat', request: { target: 'mock-chat', digest: 'done' } });
|
|
54
|
+
const notifyDuplicate = await actionAdapters.notification.reserve(root, { idempotencyKey: 'notify:task-1:terminal', authorizationScope: 'task-1:source-chat', request: { target: 'mock-chat', digest: 'done' } });
|
|
55
|
+
assert.equal(notifyDuplicate.duplicate, true);
|
|
56
|
+
const releasable = { ...base, idempotencyKey: 'deploy:cancelled', kind: 'deployment', authorizationScope: 'approval:deploy-staging' };
|
|
57
|
+
await reserveAction(root, releasable);
|
|
58
|
+
assert.equal((await releaseAction(root, { idempotencyKey: releasable.idempotencyKey, reason: 'operator_cancelled', evidence: { ticket: 'mock' } })).record.authorization.state, 'released');
|
|
59
|
+
|
|
60
|
+
// Legacy artifacts are imported without changing their logical identity.
|
|
61
|
+
const migrated = await migrateLegacyActionArtifact(root, { idempotency_key: 'legacy:notification:1', kind: 'notification', authorization_scope: 'legacy:chat', request: { digest: 'old' } });
|
|
62
|
+
assert.equal(migrated.created, true);
|
|
63
|
+
assert.equal((await migrateLegacyActionArtifact(root, { idempotency_key: 'legacy:notification:1', kind: 'notification', authorization_scope: 'legacy:chat', request: { digest: 'old' } })).duplicate, true);
|
|
64
|
+
|
|
65
|
+
console.log(JSON.stringify({ status: 'ok', assertions: 'reservation, fingerprint, concurrency, fencing, crash recovery, reconciliation, authorization, release, adapters, migration' }));
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import {
|
|
6
|
+
ensureQueueDirs,
|
|
7
|
+
parkQueueTask,
|
|
8
|
+
queueStatus,
|
|
9
|
+
queueSubdirFor,
|
|
10
|
+
readJson,
|
|
11
|
+
resumeParkedTask,
|
|
12
|
+
tickParkedTasks,
|
|
13
|
+
writeJson
|
|
14
|
+
} from '../lib/core.mjs';
|
|
15
|
+
|
|
16
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-human-gate-v2-'));
|
|
17
|
+
const queue = 'vps-fixture';
|
|
18
|
+
const taskId = 'vps-down-ssh-banner-timeout';
|
|
19
|
+
const taskFile = path.join(queueSubdirFor(root, queue, 'inbox'), `${taskId}.json`);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await ensureQueueDirs(root, queue);
|
|
23
|
+
await writeJson(taskFile, { version: 1, id: taskId, title: 'Recover provider VPS', status: 'queued' });
|
|
24
|
+
const parked = await parkQueueTask(root, {
|
|
25
|
+
queue,
|
|
26
|
+
taskId,
|
|
27
|
+
kind: 'external_condition',
|
|
28
|
+
reason: 'VPS is down; SSH banner timed out.',
|
|
29
|
+
now: '2026-08-13T00:00:00.000Z',
|
|
30
|
+
executionKey: 'provider-call-1',
|
|
31
|
+
authorization: { state: 'unconsumed', scope: 'provider_call' },
|
|
32
|
+
policy: { timeoutMs: 2_000, reminderIntervalMs: 1_000, escalationIntervalMs: 2_000, maxReminders: 1 }
|
|
33
|
+
});
|
|
34
|
+
assert.equal(parked.outcome, 'parked');
|
|
35
|
+
assert.equal(parked.task.parked.authorization.state, 'unconsumed');
|
|
36
|
+
assert.equal(parked.task.parked.execution_boundary.action_executed, false);
|
|
37
|
+
|
|
38
|
+
const status = await queueStatus(root, queue);
|
|
39
|
+
assert.equal(status.waitingStates.timed_out_or_escalated, 1);
|
|
40
|
+
assert.equal(status.waitingTasks[0].waitKind, 'external_condition');
|
|
41
|
+
assert.equal(status.waitingTasks[0].authorizationState, 'unconsumed');
|
|
42
|
+
|
|
43
|
+
const notifyCommand = 'node -e "process.exit(0)"';
|
|
44
|
+
const reminder = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:01.000Z', notifyCommand });
|
|
45
|
+
assert.equal(reminder.results[0].type, 'reminder');
|
|
46
|
+
const duplicateTick = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:01.000Z', notifyCommand });
|
|
47
|
+
assert.equal(duplicateTick.results[0].outcome, 'throttled');
|
|
48
|
+
const escalation = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:03.000Z', notifyCommand });
|
|
49
|
+
assert.equal(escalation.results[0].type, 'escalation');
|
|
50
|
+
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
resumeParkedTask(root, { queue, taskId, recoverySignal: 'ssh banner verified' }),
|
|
53
|
+
/--verified/
|
|
54
|
+
);
|
|
55
|
+
const resumed = await resumeParkedTask(root, {
|
|
56
|
+
queue,
|
|
57
|
+
taskId,
|
|
58
|
+
verified: true,
|
|
59
|
+
recoverySignal: 'probe=vps-1;ssh_banner=verified',
|
|
60
|
+
now: '2026-08-13T00:00:04.000Z'
|
|
61
|
+
});
|
|
62
|
+
assert.equal(resumed.outcome, 'verified_and_requeued');
|
|
63
|
+
assert.equal(resumed.task.parked.state, 'runnable');
|
|
64
|
+
assert.equal(resumed.task.parked.authorization.state, 'unconsumed');
|
|
65
|
+
assert.equal(resumed.task.parked.execution_boundary.action_executed, false);
|
|
66
|
+
assert.ok(resumed.signalSha256);
|
|
67
|
+
|
|
68
|
+
const afterRestart = await resumeParkedTask(root, {
|
|
69
|
+
queue,
|
|
70
|
+
taskId,
|
|
71
|
+
verified: true,
|
|
72
|
+
recoverySignal: 'probe=vps-1;ssh_banner=verified'
|
|
73
|
+
});
|
|
74
|
+
assert.equal(afterRestart.outcome, 'already_resumed');
|
|
75
|
+
const durable = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), `${taskId}.json`));
|
|
76
|
+
assert.equal(durable.parked.execution_boundary.key, 'provider-call-1');
|
|
77
|
+
assert.equal(durable.parked.authorization.state, 'unconsumed');
|
|
78
|
+
console.log('human-gate-lifecycle-v2 self-test: ok');
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { buildOperatorProjection, createDashboardServer, dashboardHealth, exportDashboard, filterProjection } from '../lib/operator-dashboard.mjs';
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-dashboard-'));
|
|
8
|
+
const loops = path.join(root, 'runtime', 'loops');
|
|
9
|
+
await mkdir(path.join(loops, 'control-plane'), { recursive: true });
|
|
10
|
+
await mkdir(path.join(loops, 'action-reservations'), { recursive: true });
|
|
11
|
+
await mkdir(path.join(loops, 'legacy', 'waiting'), { recursive: true });
|
|
12
|
+
await mkdir(path.join(loops, 'legacy', 'active'), { recursive: true });
|
|
13
|
+
await mkdir(path.join(loops, 'projects', 'p3', 'intake'), { recursive: true });
|
|
14
|
+
const now = '2026-08-13T16:00:00.000Z';
|
|
15
|
+
const control = {
|
|
16
|
+
version: 2, updated_at: now, quotas: { credits: 9 }, agents: { a: { id: 'a', capabilities: ['code'], authority_grants: ['local'], provider_token: 'never-show' } },
|
|
17
|
+
handoffs: { h: { id: 'h', todo_id: 'leased', from_agent_id: 'a', to_agent_id: 'b', state: 'pending', created_at: now } },
|
|
18
|
+
todos: {
|
|
19
|
+
human: { version: 2, id: 'human', title: '<img src=x onerror=alert(1)>', state: 'runnable', priority: 2, risk: 'medium', authority_class: 'local', required_capabilities: [], acceptance_contract: { checks: ['ok'] }, evidence_requirements: ['test'], cost_envelope: { quota: 'credits', amount: 2 }, parked: { state: 'waiting_for_human', gate_id: 'g1', secret_input: 'hide' }, blocked_reasons: [], claim: null, lineage: { root_todo_id: 'human' }, evidence: [], idempotency_keys: [], created_at: now, updated_at: now },
|
|
20
|
+
leased: { version: 2, id: 'leased', title: 'Lease expired', state: 'claimed', priority: 1, authority_class: 'local', required_capabilities: [], acceptance_contract: {}, evidence_requirements: ['lease'], cost_envelope: { quota: 'credits', amount: 3 }, claim: { owner: 'a', fencing_token: 7, claimed_at: '2026-08-13T15:00:00.000Z', lease_expires_at: '2026-08-13T15:01:00.000Z' }, blocked_reasons: [], lineage: {}, evidence: [], idempotency_keys: ['paid:x'], created_at: now, updated_at: now }
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
await writeFile(path.join(loops, 'control-plane', 'state.json'), JSON.stringify(control));
|
|
24
|
+
await writeFile(path.join(loops, 'action-reservations', 'x.json'), JSON.stringify({ version: 1, idempotency_key: 'paid:x', kind: 'paid_api', state: 'unknown', request: { api_key: 'hide', prompt: '<script>x</script>' }, request_fingerprint: 'abc', authorization: { scope: 'paid', credential: 'hide' }, claim: null, reconciliation: { required: true, reason: 'stale_lease' }, created_at: now, updated_at: now }));
|
|
25
|
+
await writeFile(path.join(loops, 'legacy', 'waiting', 'vps.json'), JSON.stringify({ id: 'vps', title: 'VPS down', parked: { kind: 'external_condition', next_check_at: '2026-08-13T17:00:00.000Z' }, provider: 'hidden' }));
|
|
26
|
+
await writeFile(path.join(loops, 'legacy', 'active', 'bad.json'), '{broken');
|
|
27
|
+
await writeFile(path.join(loops, 'projects', 'p3', 'intake', 'latest.json'), JSON.stringify({ version: 1, goal: 'Operator dashboard', token: 'hidden' }));
|
|
28
|
+
|
|
29
|
+
const before = await stat(path.join(loops, 'control-plane', 'state.json'));
|
|
30
|
+
const first = await buildOperatorProjection(root, { now });
|
|
31
|
+
const second = await buildOperatorProjection(root, { now });
|
|
32
|
+
const after = await stat(path.join(loops, 'control-plane', 'state.json'));
|
|
33
|
+
assert.deepEqual(first, second, 'fixed-time projections are deterministic');
|
|
34
|
+
assert.equal(before.mtimeMs, after.mtimeMs, 'projection does not mutate source state');
|
|
35
|
+
assert.equal(first.todos.find((item) => item.id === 'human').state, 'waiting_for_human');
|
|
36
|
+
assert.equal(first.todos.find((item) => item.id === 'leased').state, 'reconciliation_required');
|
|
37
|
+
assert.equal(first.actions[0].state, 'reconciliation_required');
|
|
38
|
+
assert.equal(first.queues[0].tasks[0].state, 'waiting_for_external_condition');
|
|
39
|
+
assert.equal(first.agents[0].provider_token, '[REDACTED]');
|
|
40
|
+
assert.equal(first.todos[0].gate.secret_input, '[REDACTED]');
|
|
41
|
+
assert.equal(JSON.stringify(first).includes('never-show'), false);
|
|
42
|
+
assert.equal(JSON.stringify(first).includes('hidden'), false);
|
|
43
|
+
assert.equal(first.health.status, 'degraded');
|
|
44
|
+
assert.equal(filterProjection(first, { state: 'waiting_for_human', query: 'img' }).todos.length, 1);
|
|
45
|
+
assert.equal(dashboardHealth(first, { maxAgeSeconds: 1 }).stale, false);
|
|
46
|
+
|
|
47
|
+
const output = path.join(root, 'export');
|
|
48
|
+
await exportDashboard(root, output, { now });
|
|
49
|
+
assert.match(await readFile(path.join(output, 'index.html'), 'utf8'), /projection\.json/);
|
|
50
|
+
assert.equal(JSON.parse(await readFile(path.join(output, 'projection.json'), 'utf8')).schema_version, '1.0.0');
|
|
51
|
+
await assert.rejects(() => createDashboardServer(root, { host: '0.0.0.0' }), /requires --allow-non-loopback/);
|
|
52
|
+
|
|
53
|
+
const server = await createDashboardServer(root, { host: '127.0.0.1', port: 0 });
|
|
54
|
+
const address = server.address(); const base = `http://127.0.0.1:${address.port}`;
|
|
55
|
+
try {
|
|
56
|
+
const overview = await fetch(`${base}/api/v1/overview?state=waiting_for_human`).then((response) => response.json());
|
|
57
|
+
assert.equal(overview.todos.length, 1);
|
|
58
|
+
const detail = await fetch(`${base}/api/v1/todos/human`).then((response) => response.json());
|
|
59
|
+
assert.match(detail.title, /onerror/);
|
|
60
|
+
const page = await fetch(base).then((response) => response.text());
|
|
61
|
+
assert.doesNotMatch(page, /<img src=x/);
|
|
62
|
+
assert.equal((await fetch(`${base}/api/v1/todos/%2e%2e%2fsecret`)).status, 400);
|
|
63
|
+
assert.equal((await fetch(`${base}/api/v1/unknown`)).status, 404);
|
|
64
|
+
assert.equal((await fetch(`${base}/api/v1/overview`, { method: 'POST' })).status, 405);
|
|
65
|
+
} finally { await new Promise((resolve) => server.close(resolve)); }
|
|
66
|
+
|
|
67
|
+
// Large queue stays dependency-light and completes within a generous local budget.
|
|
68
|
+
await mkdir(path.join(loops, 'large', 'inbox'), { recursive: true });
|
|
69
|
+
await Promise.all(Array.from({ length: 500 }, (_, i) => writeFile(path.join(loops, 'large', 'inbox', `${i}.json`), JSON.stringify({ id: `bulk-${i}`, title: `Task ${i}`, state: 'runnable' }))));
|
|
70
|
+
const started = performance.now(); const large = await buildOperatorProjection(root, { now });
|
|
71
|
+
assert.equal(large.queues.find((queue) => queue.id === 'large').tasks.length, 500);
|
|
72
|
+
assert.ok(performance.now() - started < 5000, '500 task projection should finish under 5 seconds');
|
|
73
|
+
|
|
74
|
+
console.log(JSON.stringify({ status: 'ok', assertions: 'empty-compatible, legacy, malformed, deterministic, read-only, P0 gates, P1 reconciliation, P2 lease/handoff, redaction, XSS, traversal, bind safety, export, restart-safe server, large queue performance' }));
|
|
@@ -57,6 +57,7 @@ assert.deepEqual(classifyLoopMessage('用 loop engineering 把现有的 growth o
|
|
|
57
57
|
assert.equal(classifyLoopMessage('Use Loop Engineering to fix this issue.').intent, 'execute');
|
|
58
58
|
assert.equal(classifyLoopMessage('Run this through Loop Engineering.').intent, 'execute');
|
|
59
59
|
assert.equal(classifyLoopMessage('Continue the current loop with this amendment: add English examples.').intent, 'execute');
|
|
60
|
+
assert.equal(classifyLoopMessage('我们继续开发我们的loop engineering').intent, 'execute');
|
|
60
61
|
|
|
61
62
|
const routed = await routeLoopMessage(root, {
|
|
62
63
|
route: true,
|