agentxchain 2.95.0 → 2.97.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/package.json
CHANGED
|
@@ -17,6 +17,190 @@ import { createBridgeServer } from '../lib/dashboard/bridge-server.js';
|
|
|
17
17
|
|
|
18
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
|
|
20
|
+
function restoreExportFiles(root, files, scopeLabel) {
|
|
21
|
+
if (!files || typeof files !== 'object') {
|
|
22
|
+
throw new Error(`${scopeLabel} is missing a valid files object.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let restored = 0;
|
|
26
|
+
for (const [relPath, entry] of Object.entries(files)) {
|
|
27
|
+
const absPath = join(root, relPath);
|
|
28
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
29
|
+
|
|
30
|
+
if (typeof entry === 'string') {
|
|
31
|
+
writeFileSync(absPath, entry);
|
|
32
|
+
restored++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!entry || typeof entry !== 'object' || typeof entry.content_base64 !== 'string') {
|
|
37
|
+
throw new Error(`${scopeLabel} entry "${relPath}" must provide content_base64.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
writeFileSync(absPath, Buffer.from(entry.content_base64, 'base64'));
|
|
41
|
+
restored++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return restored;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function restoreCoordinatorRepos(tempRoot, repos) {
|
|
48
|
+
if (!repos || typeof repos !== 'object') {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let restored = 0;
|
|
53
|
+
for (const [repoId, repoEntry] of Object.entries(repos)) {
|
|
54
|
+
if (!repoEntry || repoEntry.ok === false) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof repoEntry.path !== 'string' || !repoEntry.path.trim()) {
|
|
59
|
+
throw new Error(`Coordinator repo "${repoId}" is marked ok but has no path.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nestedFiles = repoEntry.export?.files;
|
|
63
|
+
if (!nestedFiles || typeof nestedFiles !== 'object') {
|
|
64
|
+
throw new Error(`Coordinator repo "${repoId}" is marked ok but has no nested export.files.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const repoRoot = join(tempRoot, repoEntry.path);
|
|
68
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
69
|
+
restored += restoreExportFiles(repoRoot, nestedFiles, `repos.${repoId}.export.files`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return restored;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readEmbeddedJsonEntry(entry, label) {
|
|
76
|
+
if (!entry || typeof entry !== 'object') {
|
|
77
|
+
throw new Error(`${label} is not a valid export file entry.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (entry.data && typeof entry.data === 'object') {
|
|
81
|
+
return entry.data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof entry.content_base64 !== 'string' || !entry.content_base64) {
|
|
85
|
+
throw new Error(`${label} must provide content_base64.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(Buffer.from(entry.content_base64, 'base64').toString('utf8'));
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new Error(`${label} contains invalid JSON: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getCoordinatorReplayPhases(exportData) {
|
|
96
|
+
const coordinatorConfig = exportData?.config
|
|
97
|
+
|| readEmbeddedJsonEntry(exportData?.files?.['agentxchain-multi.json'], 'files["agentxchain-multi.json"]');
|
|
98
|
+
const routingPhases = Object.keys(coordinatorConfig?.routing || {});
|
|
99
|
+
if (routingPhases.length > 0) {
|
|
100
|
+
return routingPhases;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const phases = [];
|
|
104
|
+
for (const workstreamId of Object.keys(coordinatorConfig?.workstreams || {})) {
|
|
105
|
+
const phase = coordinatorConfig.workstreams?.[workstreamId]?.phase;
|
|
106
|
+
if (phase && !phases.includes(phase)) {
|
|
107
|
+
phases.push(phase);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return phases.length > 0 ? phases : ['planning'];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function restoreFailedCoordinatorRepoStubs(tempRoot, exportData) {
|
|
115
|
+
if (!exportData?.repos || typeof exportData.repos !== 'object') {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const phases = getCoordinatorReplayPhases(exportData);
|
|
120
|
+
const coordinatorState = exportData?.files?.['.agentxchain/multirepo/state.json']
|
|
121
|
+
? readEmbeddedJsonEntry(exportData.files['.agentxchain/multirepo/state.json'], 'files[".agentxchain/multirepo/state.json"]')
|
|
122
|
+
: null;
|
|
123
|
+
|
|
124
|
+
let restored = 0;
|
|
125
|
+
for (const [repoId, repoEntry] of Object.entries(exportData.repos)) {
|
|
126
|
+
if (!repoEntry || repoEntry.ok !== false) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof repoEntry.path !== 'string' || !repoEntry.path.trim()) {
|
|
131
|
+
throw new Error(`Coordinator repo "${repoId}" failed export but has no path.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const repoRoot = join(tempRoot, repoEntry.path);
|
|
135
|
+
const promptPath = '.agentxchain/prompts/replay.md';
|
|
136
|
+
const repoRun = coordinatorState?.repo_runs?.[repoId] || {};
|
|
137
|
+
const defaultPhase = repoRun.phase || phases[0] || 'planning';
|
|
138
|
+
const routing = Object.fromEntries(
|
|
139
|
+
phases.map((phase) => [
|
|
140
|
+
phase,
|
|
141
|
+
{
|
|
142
|
+
entry_role: 'replay',
|
|
143
|
+
allowed_next_roles: ['replay', 'human'],
|
|
144
|
+
},
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
147
|
+
const config = {
|
|
148
|
+
schema_version: '1.0',
|
|
149
|
+
template: 'generic',
|
|
150
|
+
project: {
|
|
151
|
+
id: `${repoId}-replay-placeholder`,
|
|
152
|
+
name: `${repoId} Replay Placeholder`,
|
|
153
|
+
},
|
|
154
|
+
roles: {
|
|
155
|
+
replay: {
|
|
156
|
+
title: 'Replay Placeholder',
|
|
157
|
+
mandate: 'Preserve coordinator replay continuity when nested repo export is unavailable.',
|
|
158
|
+
write_authority: 'review_only',
|
|
159
|
+
runtime: 'replay-placeholder',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
runtimes: {
|
|
163
|
+
'replay-placeholder': { type: 'manual' },
|
|
164
|
+
},
|
|
165
|
+
routing,
|
|
166
|
+
gates: {},
|
|
167
|
+
prompts: {
|
|
168
|
+
replay: promptPath,
|
|
169
|
+
},
|
|
170
|
+
rules: {
|
|
171
|
+
challenge_required: true,
|
|
172
|
+
max_turn_retries: 2,
|
|
173
|
+
max_deadlock_cycles: 2,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
const state = {
|
|
177
|
+
schema_version: '1.1',
|
|
178
|
+
run_id: repoRun.run_id || null,
|
|
179
|
+
status: repoRun.status || 'blocked',
|
|
180
|
+
phase: defaultPhase,
|
|
181
|
+
active_turns: {},
|
|
182
|
+
turn_sequence: 0,
|
|
183
|
+
retained_turns: {},
|
|
184
|
+
budget_reservations: {},
|
|
185
|
+
phase_gate_status: {},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
mkdirSync(join(repoRoot, '.agentxchain', 'prompts'), { recursive: true });
|
|
189
|
+
writeFileSync(join(repoRoot, 'agentxchain.json'), `${JSON.stringify(config, null, 2)}\n`);
|
|
190
|
+
writeFileSync(join(repoRoot, '.agentxchain', 'state.json'), `${JSON.stringify(state, null, 2)}\n`);
|
|
191
|
+
writeFileSync(join(repoRoot, '.agentxchain', 'history.jsonl'), '');
|
|
192
|
+
writeFileSync(join(repoRoot, '.agentxchain', 'events.jsonl'), '');
|
|
193
|
+
writeFileSync(join(repoRoot, '.agentxchain', 'decision-ledger.jsonl'), '');
|
|
194
|
+
writeFileSync(
|
|
195
|
+
join(repoRoot, promptPath),
|
|
196
|
+
'# Replay Placeholder\n\nThis repo export was unavailable in the coordinator artifact. Replay restores a placeholder so coordinator dashboards remain readable.\n',
|
|
197
|
+
);
|
|
198
|
+
restored += 6;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return restored;
|
|
202
|
+
}
|
|
203
|
+
|
|
20
204
|
export async function replayExportCommand(exportFile, opts = {}) {
|
|
21
205
|
if (!exportFile) {
|
|
22
206
|
console.error(chalk.red('Usage: agentxchain replay export <export-file>'));
|
|
@@ -51,21 +235,22 @@ export async function replayExportCommand(exportFile, opts = {}) {
|
|
|
51
235
|
mkdirSync(tempRoot, { recursive: true });
|
|
52
236
|
mkdirSync(tempAgentxchainDir, { recursive: true });
|
|
53
237
|
|
|
54
|
-
//
|
|
55
|
-
let fileCount =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
fileCount
|
|
238
|
+
// Restore the real exported bytes for top-level files.
|
|
239
|
+
let fileCount = restoreExportFiles(tempRoot, exportData.files, 'files');
|
|
240
|
+
|
|
241
|
+
// Coordinator exports also need successful nested child repo exports
|
|
242
|
+
// rehydrated under their declared repo paths for dashboard replay.
|
|
243
|
+
if (exportData.export_kind === 'agentxchain_coordinator_export') {
|
|
244
|
+
fileCount += restoreCoordinatorRepos(tempRoot, exportData.repos);
|
|
245
|
+
fileCount += restoreFailedCoordinatorRepoStubs(tempRoot, exportData);
|
|
61
246
|
}
|
|
62
247
|
|
|
63
248
|
// Ensure agentxchain.json exists (needed by some dashboard endpoints)
|
|
64
249
|
const configPath = join(tempRoot, 'agentxchain.json');
|
|
65
|
-
if (!existsSync(configPath)) {
|
|
250
|
+
if (exportData.export_kind !== 'agentxchain_coordinator_export' && !existsSync(configPath)) {
|
|
66
251
|
// Synthesize a minimal config from export summary
|
|
67
252
|
const minimalConfig = {
|
|
68
|
-
protocol_version: exportData.summary?.protocol_version ||
|
|
253
|
+
protocol_version: exportData.summary?.protocol_version || null,
|
|
69
254
|
protocol_mode: 'governed',
|
|
70
255
|
version: 4,
|
|
71
256
|
project: { name: exportData.summary?.project_name || 'replay-export' },
|
|
@@ -153,6 +338,10 @@ export async function replayExportCommand(exportFile, opts = {}) {
|
|
|
153
338
|
console.error(chalk.red(`Port ${opts.port || 3847} is already in use. Try --port <number>.`));
|
|
154
339
|
process.exit(1);
|
|
155
340
|
}
|
|
341
|
+
if (err instanceof Error) {
|
|
342
|
+
console.error(chalk.red(err.message));
|
|
343
|
+
process.exit(2);
|
|
344
|
+
}
|
|
156
345
|
throw err;
|
|
157
346
|
}
|
|
158
347
|
}
|
|
@@ -204,6 +204,244 @@ function verifyAggregatedEventsSummary(artifact, errors) {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
function buildExpectedDelegationSummary(files) {
|
|
208
|
+
const historyData = files?.['.agentxchain/history.jsonl']?.data;
|
|
209
|
+
if (!Array.isArray(historyData)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const parentTurns = new Map();
|
|
214
|
+
const childTurns = new Map();
|
|
215
|
+
const reviewTurns = new Map();
|
|
216
|
+
|
|
217
|
+
for (const entry of historyData) {
|
|
218
|
+
if (entry.delegations_issued && Array.isArray(entry.delegations_issued)) {
|
|
219
|
+
parentTurns.set(entry.turn_id, {
|
|
220
|
+
role: entry.role,
|
|
221
|
+
delegations_issued: entry.delegations_issued,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (entry.delegation_context) {
|
|
225
|
+
childTurns.set(entry.delegation_context.delegation_id, {
|
|
226
|
+
turn_id: entry.turn_id,
|
|
227
|
+
status: entry.status || 'completed',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (entry.delegation_review) {
|
|
231
|
+
reviewTurns.set(entry.delegation_review.parent_turn_id, {
|
|
232
|
+
turn_id: entry.turn_id,
|
|
233
|
+
results: entry.delegation_review.results || [],
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let totalDelegationsIssued = 0;
|
|
239
|
+
const delegationChains = [];
|
|
240
|
+
|
|
241
|
+
for (const [parentTurnId, parent] of parentTurns) {
|
|
242
|
+
totalDelegationsIssued += parent.delegations_issued.length;
|
|
243
|
+
|
|
244
|
+
const review = reviewTurns.get(parentTurnId);
|
|
245
|
+
const reviewResultsByDelegation = new Map();
|
|
246
|
+
if (review) {
|
|
247
|
+
for (const r of review.results) {
|
|
248
|
+
if (r.delegation_id) {
|
|
249
|
+
reviewResultsByDelegation.set(r.delegation_id, r);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const delegations = parent.delegations_issued.map((del) => {
|
|
255
|
+
const child = childTurns.get(del.id);
|
|
256
|
+
const reviewResult = reviewResultsByDelegation.get(del.id);
|
|
257
|
+
return {
|
|
258
|
+
delegation_id: del.id,
|
|
259
|
+
to_role: del.to_role,
|
|
260
|
+
charter: del.charter,
|
|
261
|
+
required_decision_ids: Array.isArray(del.required_decision_ids) ? del.required_decision_ids : [],
|
|
262
|
+
satisfied_decision_ids: Array.isArray(reviewResult?.satisfied_decision_ids) ? reviewResult.satisfied_decision_ids : [],
|
|
263
|
+
missing_decision_ids: Array.isArray(reviewResult?.missing_decision_ids) ? reviewResult.missing_decision_ids : [],
|
|
264
|
+
status: reviewResult?.status || child?.status || 'pending',
|
|
265
|
+
child_turn_id: child?.turn_id || null,
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
let outcome;
|
|
270
|
+
if (!review) {
|
|
271
|
+
outcome = 'pending';
|
|
272
|
+
} else {
|
|
273
|
+
const statuses = delegations.map((d) => d.status);
|
|
274
|
+
const allCompleted = statuses.every((s) => s === 'completed');
|
|
275
|
+
const allFailed = statuses.every((s) => s === 'failed');
|
|
276
|
+
if (allCompleted) outcome = 'completed';
|
|
277
|
+
else if (allFailed) outcome = 'failed';
|
|
278
|
+
else outcome = 'mixed';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
delegationChains.push({
|
|
282
|
+
parent_turn_id: parentTurnId,
|
|
283
|
+
parent_role: parent.role,
|
|
284
|
+
delegations,
|
|
285
|
+
review_turn_id: review?.turn_id || null,
|
|
286
|
+
outcome,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
total_delegations_issued: totalDelegationsIssued,
|
|
292
|
+
delegation_chains: delegationChains,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function verifyDelegationSummary(artifact, errors) {
|
|
297
|
+
const summary = artifact.summary?.delegation_summary;
|
|
298
|
+
const expected = buildExpectedDelegationSummary(artifact.files);
|
|
299
|
+
|
|
300
|
+
// Both absent — valid
|
|
301
|
+
if (summary == null && expected == null) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// One present, one absent — mismatch
|
|
306
|
+
if (summary == null && expected != null) {
|
|
307
|
+
if (expected.total_delegations_issued > 0) {
|
|
308
|
+
addError(errors, 'summary.delegation_summary', 'is null but history.jsonl contains delegation entries');
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (summary != null && expected == null) {
|
|
313
|
+
addError(errors, 'summary.delegation_summary', 'claims delegations but no history.jsonl in export');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (summary.total_delegations_issued !== expected.total_delegations_issued) {
|
|
318
|
+
addError(errors, 'summary.delegation_summary.total_delegations_issued', 'must match reconstructed delegation count');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!isDeepStrictEqual(summary.delegation_chains, expected.delegation_chains)) {
|
|
322
|
+
addError(errors, 'summary.delegation_summary.delegation_chains', 'must match reconstructed delegation chains from history.jsonl');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function buildExpectedRepoDecisionsSummary(files) {
|
|
327
|
+
const repoDecisionsData = files?.['.agentxchain/repo-decisions.jsonl']?.data;
|
|
328
|
+
if (!Array.isArray(repoDecisionsData) || repoDecisionsData.length === 0) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const active = repoDecisionsData.filter((d) => d.status === 'active');
|
|
333
|
+
const overridden = repoDecisionsData.filter((d) => d.status === 'overridden');
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
total: repoDecisionsData.length,
|
|
337
|
+
active_count: active.length,
|
|
338
|
+
overridden_count: overridden.length,
|
|
339
|
+
active: active.map((d) => ({
|
|
340
|
+
id: d.id,
|
|
341
|
+
category: d.category,
|
|
342
|
+
statement: d.statement,
|
|
343
|
+
role: d.role,
|
|
344
|
+
run_id: d.run_id,
|
|
345
|
+
})),
|
|
346
|
+
overridden: overridden.map((d) => ({
|
|
347
|
+
id: d.id,
|
|
348
|
+
overridden_by: d.overridden_by,
|
|
349
|
+
statement: d.statement,
|
|
350
|
+
})),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function verifyRepoDecisionsSummary(artifact, errors) {
|
|
355
|
+
const summary = artifact.summary?.repo_decisions;
|
|
356
|
+
const hasFile = '.agentxchain/repo-decisions.jsonl' in (artifact.files || {});
|
|
357
|
+
const expected = buildExpectedRepoDecisionsSummary(artifact.files);
|
|
358
|
+
|
|
359
|
+
if (summary === null && expected === null) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (summary === undefined && expected === null) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (summary !== null && summary !== undefined && !hasFile && expected === null) {
|
|
367
|
+
addError(errors, 'summary.repo_decisions', 'claims repo decisions but no .agentxchain/repo-decisions.jsonl in export');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (summary === null && expected !== null) {
|
|
372
|
+
addError(errors, 'summary.repo_decisions', 'is null but repo-decisions.jsonl contains entries');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (summary !== null && expected === null) {
|
|
377
|
+
addError(errors, 'summary.repo_decisions', 'claims repo decisions but repo-decisions.jsonl is empty');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (summary.total !== expected.total) {
|
|
382
|
+
addError(errors, 'summary.repo_decisions.total', 'must match reconstructed repo decision count');
|
|
383
|
+
}
|
|
384
|
+
if (summary.active_count !== expected.active_count) {
|
|
385
|
+
addError(errors, 'summary.repo_decisions.active_count', 'must match reconstructed active count');
|
|
386
|
+
}
|
|
387
|
+
if (summary.overridden_count !== expected.overridden_count) {
|
|
388
|
+
addError(errors, 'summary.repo_decisions.overridden_count', 'must match reconstructed overridden count');
|
|
389
|
+
}
|
|
390
|
+
if (!isDeepStrictEqual(summary.active, expected.active)) {
|
|
391
|
+
addError(errors, 'summary.repo_decisions.active', 'must match reconstructed active decisions from repo-decisions.jsonl');
|
|
392
|
+
}
|
|
393
|
+
if (!isDeepStrictEqual(summary.overridden, expected.overridden)) {
|
|
394
|
+
addError(errors, 'summary.repo_decisions.overridden', 'must match reconstructed overridden decisions from repo-decisions.jsonl');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const VALID_DASHBOARD_STATUSES = new Set(['running', 'pid_only', 'stale', 'not_running']);
|
|
399
|
+
|
|
400
|
+
function verifyDashboardSessionSummary(artifact, errors) {
|
|
401
|
+
const session = artifact.summary?.dashboard_session;
|
|
402
|
+
if (session === undefined) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (session === null || typeof session !== 'object' || Array.isArray(session)) {
|
|
407
|
+
addError(errors, 'summary.dashboard_session', 'must be an object when present');
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!VALID_DASHBOARD_STATUSES.has(session.status)) {
|
|
412
|
+
addError(errors, 'summary.dashboard_session.status', `must be one of: ${[...VALID_DASHBOARD_STATUSES].join(', ')}`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (session.pid !== null && (!Number.isInteger(session.pid) || session.pid <= 0)) {
|
|
417
|
+
addError(errors, 'summary.dashboard_session.pid', 'must be a positive integer or null');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (session.url !== null && typeof session.url !== 'string') {
|
|
421
|
+
addError(errors, 'summary.dashboard_session.url', 'must be a string or null');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (session.started_at !== null && (typeof session.started_at !== 'string' || Number.isNaN(Date.parse(session.started_at)))) {
|
|
425
|
+
addError(errors, 'summary.dashboard_session.started_at', 'must be a valid ISO timestamp or null');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (session.status === 'not_running') {
|
|
429
|
+
if (session.pid !== null) {
|
|
430
|
+
addError(errors, 'summary.dashboard_session.pid', 'must be null when status is not_running');
|
|
431
|
+
}
|
|
432
|
+
if (session.url !== null) {
|
|
433
|
+
addError(errors, 'summary.dashboard_session.url', 'must be null when status is not_running');
|
|
434
|
+
}
|
|
435
|
+
if (session.started_at !== null) {
|
|
436
|
+
addError(errors, 'summary.dashboard_session.started_at', 'must be null when status is not_running');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (session.status === 'running' && (session.pid === null || !Number.isInteger(session.pid) || session.pid <= 0)) {
|
|
441
|
+
addError(errors, 'summary.dashboard_session.pid', 'must be a positive integer when status is running');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
207
445
|
function countJsonl(files, relPath) {
|
|
208
446
|
return Array.isArray(files?.[relPath]?.data) ? files[relPath].data.length : 0;
|
|
209
447
|
}
|
|
@@ -334,6 +572,10 @@ function verifyRunExport(artifact, errors) {
|
|
|
334
572
|
if (artifact.summary.coordinator_present !== expectedCoordinatorPresent) {
|
|
335
573
|
addError(errors, 'summary.coordinator_present', 'must match multirepo file presence');
|
|
336
574
|
}
|
|
575
|
+
|
|
576
|
+
verifyDelegationSummary(artifact, errors);
|
|
577
|
+
verifyRepoDecisionsSummary(artifact, errors);
|
|
578
|
+
verifyDashboardSessionSummary(artifact, errors);
|
|
337
579
|
}
|
|
338
580
|
|
|
339
581
|
function verifyCoordinatorExport(artifact, errors) {
|
|
@@ -14,6 +14,7 @@ import { finalizeDispatchManifest, verifyDispatchManifest } from './dispatch-man
|
|
|
14
14
|
import { getDispatchTurnDir } from './turn-paths.js';
|
|
15
15
|
import { runHooks } from './hook-runner.js';
|
|
16
16
|
import { validateCoordinatorConfig, normalizeCoordinatorConfig } from './coordinator-config.js';
|
|
17
|
+
import { VALID_RUN_EVENTS, emitRunEvent } from './run-events.js';
|
|
17
18
|
import { projectRepoAcceptance, evaluateBarriers } from './coordinator-acceptance.js';
|
|
18
19
|
import { readBarriers, saveCoordinatorState, readCoordinatorHistory } from './coordinator-state.js';
|
|
19
20
|
|
|
@@ -96,6 +97,11 @@ function validateFixtureConfig(config) {
|
|
|
96
97
|
if (route.exit_gate && !config.gates?.[route.exit_gate]) {
|
|
97
98
|
errors.push(`Routing references unknown gate: "${route.exit_gate}"`);
|
|
98
99
|
}
|
|
100
|
+
if ('max_concurrent_turns' in route) {
|
|
101
|
+
if (!Number.isInteger(route.max_concurrent_turns) || route.max_concurrent_turns < 1 || route.max_concurrent_turns > 4) {
|
|
102
|
+
errors.push(`Routing "${phase}": max_concurrent_turns must be an integer between 1 and 4`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
return errors;
|
|
@@ -853,6 +859,75 @@ function executeFixtureOperation(workspace, fixture) {
|
|
|
853
859
|
};
|
|
854
860
|
}
|
|
855
861
|
|
|
862
|
+
// ── Tier 1: Event Lifecycle ──────────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
case 'validate_event': {
|
|
865
|
+
const event = fixture.input.args.event;
|
|
866
|
+
const errors = [];
|
|
867
|
+
if (!event || typeof event !== 'object') {
|
|
868
|
+
return { result: 'error', error_type: 'invalid_event', errors: ['Event must be an object'] };
|
|
869
|
+
}
|
|
870
|
+
if (typeof event.event_id !== 'string' || !event.event_id.trim()) {
|
|
871
|
+
errors.push('event_id must be a non-empty string');
|
|
872
|
+
}
|
|
873
|
+
if (!VALID_RUN_EVENTS.includes(event.event_type)) {
|
|
874
|
+
errors.push(`event_type must be one of: ${VALID_RUN_EVENTS.join(', ')}`);
|
|
875
|
+
}
|
|
876
|
+
if (typeof event.timestamp !== 'string' || Number.isNaN(Date.parse(event.timestamp))) {
|
|
877
|
+
errors.push('timestamp must be a valid ISO-8601 string');
|
|
878
|
+
}
|
|
879
|
+
// Turn-scoped events must have turn.turn_id
|
|
880
|
+
const turnScopedEvents = ['turn_dispatched', 'turn_accepted', 'turn_rejected'];
|
|
881
|
+
if (turnScopedEvents.includes(event.event_type)) {
|
|
882
|
+
if (!event.turn?.turn_id || typeof event.turn.turn_id !== 'string' || !event.turn.turn_id.trim()) {
|
|
883
|
+
errors.push(`${event.event_type} requires a non-empty turn.turn_id`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (errors.length > 0) {
|
|
887
|
+
return { result: 'error', error_type: 'invalid_event', errors };
|
|
888
|
+
}
|
|
889
|
+
return { result: 'success', errors: [] };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
case 'validate_event_ordering': {
|
|
893
|
+
const events = fixture.input.args.events;
|
|
894
|
+
const errors = [];
|
|
895
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
896
|
+
return { result: 'error', error_type: 'invalid_events', errors: ['Events must be a non-empty array'] };
|
|
897
|
+
}
|
|
898
|
+
// run_started must be first
|
|
899
|
+
if (events[0].event_type !== 'run_started') {
|
|
900
|
+
errors.push('First event must be run_started');
|
|
901
|
+
}
|
|
902
|
+
// run_completed must be last (if present)
|
|
903
|
+
const lastEvent = events[events.length - 1];
|
|
904
|
+
if (events.some((e) => e.event_type === 'run_completed') && lastEvent.event_type !== 'run_completed') {
|
|
905
|
+
errors.push('run_completed must be the last event');
|
|
906
|
+
}
|
|
907
|
+
// turn_dispatched must precede turn_accepted for same turn
|
|
908
|
+
const dispatchedTurns = new Map();
|
|
909
|
+
for (const event of events) {
|
|
910
|
+
if (event.event_type === 'turn_dispatched' && event.turn?.turn_id) {
|
|
911
|
+
dispatchedTurns.set(event.turn.turn_id, true);
|
|
912
|
+
}
|
|
913
|
+
if (event.event_type === 'turn_accepted' && event.turn?.turn_id) {
|
|
914
|
+
if (!dispatchedTurns.has(event.turn.turn_id)) {
|
|
915
|
+
errors.push(`turn_accepted for ${event.turn.turn_id} without preceding turn_dispatched`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
// Timestamps must be monotonically non-decreasing
|
|
920
|
+
for (let i = 1; i < events.length; i++) {
|
|
921
|
+
if (new Date(events[i].timestamp) < new Date(events[i - 1].timestamp)) {
|
|
922
|
+
errors.push(`Event ${i} timestamp is before event ${i - 1}`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (errors.length > 0) {
|
|
926
|
+
return { result: 'error', error_type: 'ordering_violation', errors };
|
|
927
|
+
}
|
|
928
|
+
return { result: 'success', errors: [] };
|
|
929
|
+
}
|
|
930
|
+
|
|
856
931
|
default:
|
|
857
932
|
return { result: 'error', error_type: 'unsupported_operation', operation };
|
|
858
933
|
}
|