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,196 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const ACTION_KINDS = new Set(['paid_api', 'notification', 'deployment', 'process_control', 'publication', 'external_message', 'gated_mutation']);
|
|
6
|
+
const TERMINAL_STATES = new Set(['settled', 'released']);
|
|
7
|
+
|
|
8
|
+
function requireText(value, label) {
|
|
9
|
+
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string.`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function canonical(value) {
|
|
14
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
15
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function actionRequestFingerprint(request, authorizationScope) {
|
|
20
|
+
requireText(authorizationScope, 'authorizationScope');
|
|
21
|
+
return createHash('sha256').update(JSON.stringify(canonical({ request, authorizationScope }))).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeKey(key) {
|
|
25
|
+
requireText(key, 'idempotencyKey');
|
|
26
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/.test(key)) throw new Error('idempotencyKey contains unsafe characters or is too long.');
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function paths(root, key) {
|
|
31
|
+
const encoded = createHash('sha256').update(safeKey(key)).digest('hex');
|
|
32
|
+
const dir = path.join(root, 'runtime', 'loops', 'action-reservations');
|
|
33
|
+
return { dir, file: path.join(dir, `${encoded}.json`), lock: path.join(dir, `${encoded}.lock`) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readJson(file) {
|
|
37
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function atomicWrite(file, value) {
|
|
41
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
42
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
|
|
43
|
+
await rename(temp, file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function acquireMutex(lock, timeoutMs = 5000) {
|
|
47
|
+
const deadline = Date.now() + timeoutMs;
|
|
48
|
+
while (true) {
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(lock);
|
|
51
|
+
await writeFile(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }));
|
|
52
|
+
return;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code !== 'EEXIST') throw error;
|
|
55
|
+
const info = await stat(lock).catch(() => null);
|
|
56
|
+
if (info && Date.now() - info.mtimeMs > 30_000) await rm(lock, { recursive: true, force: true });
|
|
57
|
+
else if (Date.now() >= deadline) throw new Error('Timed out acquiring action reservation mutex.');
|
|
58
|
+
else await new Promise((resolve) => setTimeout(resolve, 10));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function mutate(root, key, operation) {
|
|
64
|
+
const location = paths(root, key);
|
|
65
|
+
await mkdir(location.dir, { recursive: true });
|
|
66
|
+
await acquireMutex(location.lock);
|
|
67
|
+
try {
|
|
68
|
+
const record = await readJson(location.file).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
|
|
69
|
+
const result = await operation(record, location.file);
|
|
70
|
+
if (result.write) await atomicWrite(location.file, result.record);
|
|
71
|
+
return result.output ?? result.record;
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(location.lock, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function reserveAction(root, input) {
|
|
78
|
+
const key = safeKey(input.idempotencyKey);
|
|
79
|
+
const kind = requireText(input.kind, 'kind');
|
|
80
|
+
if (!ACTION_KINDS.has(kind)) throw new Error(`Unsupported action kind: ${kind}`);
|
|
81
|
+
const scope = requireText(input.authorizationScope, 'authorizationScope');
|
|
82
|
+
const fingerprint = actionRequestFingerprint(input.request, scope);
|
|
83
|
+
return mutate(root, key, async (record) => {
|
|
84
|
+
if (record) {
|
|
85
|
+
if (record.request_fingerprint !== fingerprint || record.kind !== kind || record.authorization.scope !== scope) {
|
|
86
|
+
throw new Error('Idempotency key is already bound to a different request or authorization scope.');
|
|
87
|
+
}
|
|
88
|
+
return { write: false, output: { created: false, duplicate: true, record } };
|
|
89
|
+
}
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
const created = {
|
|
92
|
+
version: 1, idempotency_key: key, kind, state: 'reserved', request_fingerprint: fingerprint,
|
|
93
|
+
request: canonical(input.request), created_at: now, updated_at: now, fencing_counter: 0, claim: null,
|
|
94
|
+
authorization: { scope, state: 'reserved', reserved_at: now, consumed_at: null, released_at: null },
|
|
95
|
+
settlement: null, release: null, reconciliation: null, events: [{ type: 'reserved', at: now }]
|
|
96
|
+
};
|
|
97
|
+
return { write: true, record: created, output: { created: true, duplicate: false, record: created } };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function inspectAction(root, idempotencyKey) {
|
|
102
|
+
const location = paths(root, idempotencyKey);
|
|
103
|
+
return readJson(location.file).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function claimAction(root, input) {
|
|
107
|
+
const leaseMs = Number(input.leaseMs ?? 60_000);
|
|
108
|
+
if (!Number.isInteger(leaseMs) || leaseMs <= 0) throw new Error('leaseMs must be a positive integer.');
|
|
109
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
110
|
+
if (!record) throw new Error('Action must be reserved before it can be claimed.');
|
|
111
|
+
if (TERMINAL_STATES.has(record.state)) return { write: false, output: { claimed: false, reason: record.state, record } };
|
|
112
|
+
const nowMs = Date.now();
|
|
113
|
+
if (record.state === 'claimed') {
|
|
114
|
+
if (Date.parse(record.claim.lease_expires_at) > nowMs) return { write: false, output: { claimed: false, reason: 'lease_active', record } };
|
|
115
|
+
const at = new Date().toISOString();
|
|
116
|
+
const unknown = { ...record, state: 'unknown', updated_at: at, reconciliation: { required: true, reason: 'stale_lease', marked_at: at }, events: [...record.events, { type: 'outcome_unknown', reason: 'stale_lease', at }] };
|
|
117
|
+
return { write: true, record: unknown, output: { claimed: false, reason: 'reconcile_required', record: unknown } };
|
|
118
|
+
}
|
|
119
|
+
if (record.state === 'unknown') return { write: false, output: { claimed: false, reason: 'reconcile_required', record } };
|
|
120
|
+
const at = new Date().toISOString();
|
|
121
|
+
const token = Number(record.fencing_counter) + 1;
|
|
122
|
+
const claimed = { ...record, state: 'claimed', updated_at: at, fencing_counter: token, claim: { owner: requireText(input.owner, 'owner'), fencing_token: token, claimed_at: at, lease_expires_at: new Date(nowMs + leaseMs).toISOString() }, events: [...record.events, { type: 'claimed', fencing_token: token, at }] };
|
|
123
|
+
return { write: true, record: claimed, output: { claimed: true, fencingToken: token, record: claimed } };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assertFence(record, token) {
|
|
128
|
+
if (record.state !== 'claimed' || record.claim?.fencing_token !== Number(token)) throw new Error('Stale or invalid fencing token.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function markActionUnknown(root, input) {
|
|
132
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
133
|
+
if (!record) throw new Error('Action reservation not found.');
|
|
134
|
+
assertFence(record, input.fencingToken);
|
|
135
|
+
const at = new Date().toISOString();
|
|
136
|
+
const next = { ...record, state: 'unknown', updated_at: at, reconciliation: { required: true, reason: input.reason ?? 'upstream_outcome_unknown', marked_at: at }, events: [...record.events, { type: 'outcome_unknown', reason: input.reason ?? 'upstream_outcome_unknown', at }] };
|
|
137
|
+
return { write: true, record: next };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function settleAction(root, input) {
|
|
142
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
143
|
+
if (!record) throw new Error('Action reservation not found.');
|
|
144
|
+
if (record.state === 'settled') return { write: false, output: { settled: false, duplicate: true, record } };
|
|
145
|
+
assertFence(record, input.fencingToken);
|
|
146
|
+
const at = new Date().toISOString();
|
|
147
|
+
const next = { ...record, state: 'settled', updated_at: at, authorization: { ...record.authorization, state: 'consumed', consumed_at: at }, settlement: { status: 'succeeded', evidence: input.evidence ?? null, settled_at: at, fencing_token: Number(input.fencingToken) }, reconciliation: null, events: [...record.events, { type: 'settled', fencing_token: Number(input.fencingToken), at }] };
|
|
148
|
+
return { write: true, record: next, output: { settled: true, duplicate: false, record: next } };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function releaseAction(root, input) {
|
|
153
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
154
|
+
if (!record) throw new Error('Action reservation not found.');
|
|
155
|
+
if (record.state === 'settled') throw new Error('Consumed authorization cannot be released.');
|
|
156
|
+
if (record.state === 'released') return { write: false, output: { released: false, duplicate: true, record } };
|
|
157
|
+
if (record.state === 'claimed') assertFence(record, input.fencingToken);
|
|
158
|
+
if (record.state === 'unknown') throw new Error('Unknown upstream outcome must be reconciled before release.');
|
|
159
|
+
const at = new Date().toISOString();
|
|
160
|
+
const next = { ...record, state: 'released', updated_at: at, authorization: { ...record.authorization, state: 'released', released_at: at }, release: { reason: requireText(input.reason, 'reason'), evidence: input.evidence ?? null, released_at: at }, events: [...record.events, { type: 'released', at }] };
|
|
161
|
+
return { write: true, record: next, output: { released: true, duplicate: false, record: next } };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function reconcileAction(root, input) {
|
|
166
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
167
|
+
if (!record) throw new Error('Action reservation not found.');
|
|
168
|
+
if (record.state !== 'unknown') throw new Error('Only an unknown action outcome can be reconciled.');
|
|
169
|
+
if (!['accepted', 'not_accepted'].includes(input.outcome)) throw new Error('outcome must be accepted or not_accepted.');
|
|
170
|
+
const at = new Date().toISOString();
|
|
171
|
+
if (input.outcome === 'accepted') {
|
|
172
|
+
const next = { ...record, state: 'settled', updated_at: at, authorization: { ...record.authorization, state: 'consumed', consumed_at: at }, settlement: { status: 'succeeded', evidence: input.evidence ?? null, settled_at: at, reconciled: true }, reconciliation: { required: false, outcome: 'accepted', evidence: input.evidence ?? null, reconciled_at: at }, events: [...record.events, { type: 'reconciled_accepted', at }] };
|
|
173
|
+
return { write: true, record: next };
|
|
174
|
+
}
|
|
175
|
+
const next = { ...record, state: 'reserved', updated_at: at, claim: null, reconciliation: { required: false, outcome: 'not_accepted', evidence: input.evidence ?? null, reconciled_at: at }, events: [...record.events, { type: 'reconciled_not_accepted', at }] };
|
|
176
|
+
return { write: true, record: next };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const actionAdapters = Object.freeze(Object.fromEntries(['paid_api', 'notification', 'deployment'].map((kind) => [kind, Object.freeze({
|
|
181
|
+
kind,
|
|
182
|
+
reserve: (root, input) => reserveAction(root, { ...input, kind }),
|
|
183
|
+
claim: claimAction,
|
|
184
|
+
markUnknown: markActionUnknown,
|
|
185
|
+
settle: settleAction,
|
|
186
|
+
release: releaseAction,
|
|
187
|
+
reconcile: reconcileAction,
|
|
188
|
+
inspect: inspectAction
|
|
189
|
+
})])));
|
|
190
|
+
|
|
191
|
+
export async function migrateLegacyActionArtifact(root, legacy) {
|
|
192
|
+
const key = legacy.idempotency_key ?? legacy.idempotencyKey;
|
|
193
|
+
const request = legacy.request ?? { legacy_artifact: legacy.source ?? 'unknown' };
|
|
194
|
+
const scope = legacy.authorization_scope ?? legacy.authorization?.scope ?? 'legacy:unscoped';
|
|
195
|
+
return reserveAction(root, { idempotencyKey: key, kind: legacy.kind ?? 'gated_mutation', request, authorizationScope: scope });
|
|
196
|
+
}
|
package/lib/core.mjs
CHANGED
|
@@ -947,6 +947,155 @@ export async function ensureQueueDirs(root, queue) {
|
|
|
947
947
|
.map((subdir) => mkdir(queueSubdirFor(root, queue, subdir), { recursive: true })));
|
|
948
948
|
}
|
|
949
949
|
|
|
950
|
+
const PARKED_WAIT_KINDS = new Set(['human_input', 'external_condition']);
|
|
951
|
+
|
|
952
|
+
function normalizeParkPolicy(value = {}) {
|
|
953
|
+
const positive = (input, fallback) => Number.isFinite(Number(input)) && Number(input) > 0 ? Number(input) : fallback;
|
|
954
|
+
return {
|
|
955
|
+
timeoutMs: positive(value.timeoutMs, 24 * 60 * 60 * 1000),
|
|
956
|
+
reminderIntervalMs: positive(value.reminderIntervalMs, 60 * 60 * 1000),
|
|
957
|
+
escalationIntervalMs: positive(value.escalationIntervalMs, 24 * 60 * 60 * 1000),
|
|
958
|
+
maxReminders: Math.max(0, Number.isInteger(Number(value.maxReminders)) ? Number(value.maxReminders) : 3)
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export async function parkQueueTask(root, options = {}) {
|
|
963
|
+
const queue = normalizeLoopId(options.queue);
|
|
964
|
+
const taskId = safeTaskId(options.taskId);
|
|
965
|
+
const kind = String(options.kind ?? 'external_condition');
|
|
966
|
+
if (!PARKED_WAIT_KINDS.has(kind)) throw new Error(`Unsupported wait kind: ${kind}`);
|
|
967
|
+
const found = await findTaskFile(root, queue, taskId, ['inbox', 'active', 'waiting', 'failed']);
|
|
968
|
+
if (!found) throw new Error(`Task not found: ${taskId}`);
|
|
969
|
+
const task = await readJson(found.file);
|
|
970
|
+
if (found.subdir === 'waiting' && task.parked?.state) return { outcome: 'already_parked', task, file: path.relative(root, found.file) };
|
|
971
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
972
|
+
if (!Number.isFinite(now.getTime())) throw new Error(`Invalid park time: ${options.now}`);
|
|
973
|
+
const waitId = options.waitId ? normalizeLoopId(options.waitId) : `${taskId}.${isoStamp(now)}`;
|
|
974
|
+
const parked = {
|
|
975
|
+
version: 2,
|
|
976
|
+
wait_id: waitId,
|
|
977
|
+
kind,
|
|
978
|
+
state: kind === 'human_input' ? 'waiting_for_human' : 'external_condition_wait',
|
|
979
|
+
reason: String(options.reason ?? 'Waiting for an external condition.'),
|
|
980
|
+
parked_at: now.toISOString(),
|
|
981
|
+
policy: normalizeParkPolicy(options.policy),
|
|
982
|
+
reminder_count: 0,
|
|
983
|
+
escalation_count: 0,
|
|
984
|
+
last_notification_at: null,
|
|
985
|
+
recovery: {
|
|
986
|
+
verification_required: true,
|
|
987
|
+
signal_sha256: null,
|
|
988
|
+
verified_at: null
|
|
989
|
+
},
|
|
990
|
+
execution_boundary: {
|
|
991
|
+
key: String(options.executionKey ?? waitId),
|
|
992
|
+
action_executed: Boolean(options.actionExecuted),
|
|
993
|
+
resumed_at: null
|
|
994
|
+
},
|
|
995
|
+
authorization: options.authorization ?? task.parked?.authorization ?? null
|
|
996
|
+
};
|
|
997
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(found.file));
|
|
998
|
+
await writeJson(waitingFile, { ...task, status: 'parked', parked });
|
|
999
|
+
if (path.resolve(found.file) !== path.resolve(waitingFile)) await rm(found.file, { force: true });
|
|
1000
|
+
return { outcome: 'parked', task: { ...task, status: 'parked', parked }, file: path.relative(root, waitingFile) };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function parkedDisplayState(parked, now = Date.now()) {
|
|
1004
|
+
if (!parked) return 'waiting';
|
|
1005
|
+
const timedOut = now >= Date.parse(parked.parked_at) + Number(parked.policy?.timeoutMs ?? Infinity);
|
|
1006
|
+
if (timedOut || Number(parked.escalation_count ?? 0) > 0) return 'timed_out_or_escalated';
|
|
1007
|
+
return parked.state ?? (parked.kind === 'human_input' ? 'waiting_for_human' : 'external_condition_wait');
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export async function tickParkedTasks(root, options = {}) {
|
|
1011
|
+
const queue = normalizeLoopId(options.queue);
|
|
1012
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
1013
|
+
if (!Number.isFinite(now.getTime())) throw new Error(`Invalid tick time: ${options.now}`);
|
|
1014
|
+
if (!options.notifyCommand && !options.dryRun) throw new Error('queue-wait-tick requires --notify-command unless --dry-run is used.');
|
|
1015
|
+
const evidenceDir = path.join(queueDirFor(root, queue), 'wait-notifications');
|
|
1016
|
+
await mkdir(evidenceDir, { recursive: true });
|
|
1017
|
+
const results = [];
|
|
1018
|
+
for (const file of await listJson(queueSubdirFor(root, queue, 'waiting'))) {
|
|
1019
|
+
const full = path.join(queueSubdirFor(root, queue, 'waiting'), file);
|
|
1020
|
+
const task = await readJson(full);
|
|
1021
|
+
if (!task.parked?.wait_id) continue;
|
|
1022
|
+
const parked = task.parked;
|
|
1023
|
+
const policy = normalizeParkPolicy(parked.policy);
|
|
1024
|
+
const elapsed = now.getTime() - Date.parse(parked.parked_at);
|
|
1025
|
+
const lastAt = parked.last_notification_at ? Date.parse(parked.last_notification_at) : 0;
|
|
1026
|
+
const notificationElapsed = lastAt ? now.getTime() - lastAt : Infinity;
|
|
1027
|
+
const timedOut = elapsed >= policy.timeoutMs;
|
|
1028
|
+
const type = timedOut && notificationElapsed >= policy.escalationIntervalMs
|
|
1029
|
+
? 'escalation'
|
|
1030
|
+
: Number(parked.reminder_count ?? 0) < policy.maxReminders && notificationElapsed >= policy.reminderIntervalMs
|
|
1031
|
+
? 'reminder'
|
|
1032
|
+
: null;
|
|
1033
|
+
if (!type) {
|
|
1034
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'throttled', state: parkedDisplayState(parked, now.getTime()) });
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const sequence = type === 'reminder' ? Number(parked.reminder_count ?? 0) + 1 : Number(parked.escalation_count ?? 0) + 1;
|
|
1038
|
+
const evidenceFile = path.join(evidenceDir, `${safeTaskId(task.id)}.${normalizeLoopId(parked.wait_id)}.${type}.${sequence}.json`);
|
|
1039
|
+
if (await exists(evidenceFile)) {
|
|
1040
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'already_notified', type, sequence });
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
const message = `Loop task parked (${parked.kind}): ${task.title}\nreason: ${parked.reason}\nstate: ${timedOut ? 'timed_out_or_escalated' : parked.state}\nwait: ${parked.wait_id}`;
|
|
1044
|
+
if (options.dryRun) {
|
|
1045
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'dry_run', type, sequence, message });
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
// Claim the sequence before the external send. After a crash, a later tick
|
|
1049
|
+
// observes this durable boundary and will not duplicate the notification.
|
|
1050
|
+
await writeJson(evidenceFile, { version: 1, queue, task_id: task.id, wait_id: parked.wait_id, type, sequence, status: 'sending', claimed_at: now.toISOString() });
|
|
1051
|
+
const result = await runCommand(`${options.notifyCommand} ${shellQuote(message)}`, { cwd: root, timeoutMs: options.timeoutMs ?? 60_000 });
|
|
1052
|
+
if (result.exitCode !== 0) {
|
|
1053
|
+
await writeJson(evidenceFile, { version: 1, queue, task_id: task.id, wait_id: parked.wait_id, type, sequence, status: 'failed', attempted_at: now.toISOString(), result: compactCommandResult(result) });
|
|
1054
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'failed', type, result: compactCommandResult(result) });
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const nextParked = {
|
|
1058
|
+
...parked,
|
|
1059
|
+
policy,
|
|
1060
|
+
state: timedOut ? 'timed_out_or_escalated' : parked.state,
|
|
1061
|
+
reminder_count: Number(parked.reminder_count ?? 0) + (type === 'reminder' ? 1 : 0),
|
|
1062
|
+
escalation_count: Number(parked.escalation_count ?? 0) + (type === 'escalation' ? 1 : 0),
|
|
1063
|
+
last_notification_at: now.toISOString()
|
|
1064
|
+
};
|
|
1065
|
+
await writeJson(evidenceFile, { version: 1, queue, task_id: task.id, wait_id: parked.wait_id, type, sequence, status: 'sent', notified_at: now.toISOString(), result: compactCommandResult(result) });
|
|
1066
|
+
await writeJson(full, { ...task, parked: nextParked });
|
|
1067
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'sent', type, sequence, evidence: path.relative(root, evidenceFile) });
|
|
1068
|
+
}
|
|
1069
|
+
return { queue, inspected: results.length, sent: results.filter((item) => item.outcome === 'sent').length, failed: results.filter((item) => item.outcome === 'failed').length, results };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export async function resumeParkedTask(root, options = {}) {
|
|
1073
|
+
const queue = normalizeLoopId(options.queue);
|
|
1074
|
+
const taskId = safeTaskId(options.taskId);
|
|
1075
|
+
if (!options.verified || typeof options.recoverySignal !== 'string' || !options.recoverySignal.trim()) {
|
|
1076
|
+
throw new Error('queue-wait-resume requires --verified and --recovery-signal.');
|
|
1077
|
+
}
|
|
1078
|
+
const found = await findTaskFile(root, queue, taskId, ['waiting', 'inbox']);
|
|
1079
|
+
if (!found) throw new Error(`Parked task not found: ${taskId}`);
|
|
1080
|
+
const task = await readJson(found.file);
|
|
1081
|
+
if (found.subdir === 'inbox' && task.parked?.execution_boundary?.resumed_at) {
|
|
1082
|
+
return { outcome: 'already_resumed', task, file: path.relative(root, found.file) };
|
|
1083
|
+
}
|
|
1084
|
+
if (!task.parked?.wait_id) throw new Error(`Task is not parked: ${taskId}`);
|
|
1085
|
+
const signalSha256 = createHash('sha256').update(options.recoverySignal.trim()).digest('hex');
|
|
1086
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
1087
|
+
const resumed = {
|
|
1088
|
+
...task.parked,
|
|
1089
|
+
state: 'runnable',
|
|
1090
|
+
recovery: { ...task.parked.recovery, signal_sha256: signalSha256, verified_at: now.toISOString() },
|
|
1091
|
+
execution_boundary: { ...task.parked.execution_boundary, resumed_at: now.toISOString() }
|
|
1092
|
+
};
|
|
1093
|
+
const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(found.file));
|
|
1094
|
+
await writeJson(inboxFile, { ...task, status: 'queued', parked: resumed, requeuedAt: now.toISOString(), requeuedFrom: 'waiting' });
|
|
1095
|
+
if (path.resolve(found.file) !== path.resolve(inboxFile)) await rm(found.file, { force: true });
|
|
1096
|
+
return { outcome: 'verified_and_requeued', signalSha256, task: { ...task, status: 'queued', parked: resumed }, file: path.relative(root, inboxFile) };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
950
1099
|
export function taskIdForTitle(title, date = new Date()) {
|
|
951
1100
|
const slug = (title || 'task')
|
|
952
1101
|
.toLowerCase()
|
|
@@ -1520,7 +1669,7 @@ export function classifyLoopMessage(message) {
|
|
|
1520
1669
|
const mentionsLoop = /(loop engineering|loop-engineering|task-runner|队列|queue|\bloop\b)/i.test(text);
|
|
1521
1670
|
const statusIntent = mentionsLoop
|
|
1522
1671
|
&& /(查|看|检查|审计|状态|情况|进度|怎么样|为什么|失败|报错|健康|health|status|progress|audit|summar)/i.test(text);
|
|
1523
|
-
const executeIntent = /(走\s*loop|继续(?:当前|这个)?\s*loop|给(?:当前|这个)?\s*loop\s*(?:补充|增加|加)|用\s*loop.*(?:解决|执行|完成|处理|修复|对齐|补齐|增强|开发|构建|绕过|避开|跳过|bypass|evade)|丢进.*loop|入队|enqueue|run[- ]?queue|立刻执行|立即执行|use\s+(?:loop engineering|the\s+loop)|run\s+(?:this|it|the\s+task).*(?:through|with)\s+(?:loop engineering|the\s+loop)|continue\s+(?:the\s+)?(?:current\s+)?loop|amend\s+(?:the\s+)?(?:current\s+)?loop)/i.test(text);
|
|
1672
|
+
const executeIntent = /(走\s*loop|继续(?:当前|这个)?\s*loop|给(?:当前|这个)?\s*loop\s*(?:补充|增加|加)|用\s*loop.*(?:解决|执行|完成|处理|修复|对齐|补齐|增强|开发|构建|绕过|避开|跳过|bypass|evade)|(?:继续|开始|推进).*(?:开发|建设|完善|增强).*(?:loop engineering|loop-engineering|task-runner)|丢进.*loop|入队|enqueue|run[- ]?queue|立刻执行|立即执行|use\s+(?:loop engineering|the\s+loop)|run\s+(?:this|it|the\s+task).*(?:through|with)\s+(?:loop engineering|the\s+loop)|continue\s+(?:the\s+)?(?:current\s+)?loop|amend\s+(?:the\s+)?(?:current\s+)?loop)/i.test(text);
|
|
1524
1673
|
const intent = executeIntent ? 'execute' : statusIntent ? 'status' : 'direct';
|
|
1525
1674
|
return {
|
|
1526
1675
|
intent,
|
|
@@ -3241,11 +3390,29 @@ export async function queueStatus(root, queue) {
|
|
|
3241
3390
|
const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
|
|
3242
3391
|
const lock = await readQueueLock(root, queue);
|
|
3243
3392
|
const lockOwnerAlive = queueLockOwnerAlive(lock);
|
|
3393
|
+
const waitingTasks = [];
|
|
3394
|
+
for (const file of await listJson(queueSubdirFor(root, queue, 'waiting'))) {
|
|
3395
|
+
const task = await readJson(path.join(queueSubdirFor(root, queue, 'waiting'), file));
|
|
3396
|
+
waitingTasks.push({
|
|
3397
|
+
taskId: task.id,
|
|
3398
|
+
status: task.status,
|
|
3399
|
+
waitId: task.parked?.wait_id ?? task.waitingGateId ?? null,
|
|
3400
|
+
waitKind: task.parked?.kind ?? (task.status === 'waiting_for_human' ? 'human_input' : null),
|
|
3401
|
+
displayState: task.parked ? parkedDisplayState(task.parked) : task.status,
|
|
3402
|
+
parkedAt: task.parked?.parked_at ?? task.waitingSince ?? null,
|
|
3403
|
+
reminderCount: Number(task.parked?.reminder_count ?? 0),
|
|
3404
|
+
escalationCount: Number(task.parked?.escalation_count ?? 0),
|
|
3405
|
+
authorizationState: task.parked?.authorization?.state ?? null,
|
|
3406
|
+
actionExecuted: Boolean(task.parked?.execution_boundary?.action_executed)
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3244
3409
|
return {
|
|
3245
3410
|
queue,
|
|
3246
3411
|
queued: (await listJson(queueSubdirFor(root, queue, 'inbox'))).length,
|
|
3247
3412
|
active: activeFiles.length,
|
|
3248
|
-
waiting:
|
|
3413
|
+
waiting: waitingTasks.length,
|
|
3414
|
+
waitingStates: waitingTasks.reduce((counts, task) => ({ ...counts, [task.displayState]: (counts[task.displayState] ?? 0) + 1 }), {}),
|
|
3415
|
+
waitingTasks,
|
|
3249
3416
|
done: (await listJson(queueSubdirFor(root, queue, 'done'))).length,
|
|
3250
3417
|
failed: (await listJson(queueSubdirFor(root, queue, 'failed'))).length,
|
|
3251
3418
|
canceled: (await listJson(queueSubdirFor(root, queue, 'canceled'))).length,
|
|
@@ -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
|
+
}
|