agentxchain 2.96.1 → 2.98.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/bin/agentxchain.js +3 -2
- package/package.json +1 -1
- package/src/commands/diff.js +94 -10
- package/src/commands/replay-export.js +198 -9
- package/src/lib/export-diff.js +347 -0
package/bin/agentxchain.js
CHANGED
|
@@ -345,9 +345,10 @@ program
|
|
|
345
345
|
.action(decisionsCommand);
|
|
346
346
|
|
|
347
347
|
program
|
|
348
|
-
.command('diff <
|
|
349
|
-
.description('Compare two recorded governed runs
|
|
348
|
+
.command('diff <left_ref> <right_ref>')
|
|
349
|
+
.description('Compare two recorded governed runs or two export artifacts')
|
|
350
350
|
.option('-j, --json', 'Output as JSON')
|
|
351
|
+
.option('--export', 'Compare two export artifacts instead of run-history entries')
|
|
351
352
|
.option('-d, --dir <path>', 'Project directory')
|
|
352
353
|
.action(diffCommand);
|
|
353
354
|
|
package/package.json
CHANGED
package/src/commands/diff.js
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
|
|
3
3
|
import { findProjectRoot } from '../lib/config.js';
|
|
4
|
+
import { buildExportDiff, resolveExportArtifact } from '../lib/export-diff.js';
|
|
4
5
|
import { buildRunDiff, resolveRunHistoryReference } from '../lib/run-diff.js';
|
|
5
6
|
|
|
6
7
|
export async function diffCommand(leftRef, rightRef, opts) {
|
|
8
|
+
if (opts.export) {
|
|
9
|
+
const leftExport = resolveExportArtifact(leftRef);
|
|
10
|
+
if (!leftExport.ok) {
|
|
11
|
+
console.error(chalk.red(leftExport.error));
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const rightExport = resolveExportArtifact(rightRef);
|
|
16
|
+
if (!rightExport.ok) {
|
|
17
|
+
console.error(chalk.red(rightExport.error));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const exportDiff = buildExportDiff(leftExport.artifact, rightExport.artifact, {
|
|
22
|
+
left_ref: leftExport.resolved_ref,
|
|
23
|
+
right_ref: rightExport.resolved_ref,
|
|
24
|
+
});
|
|
25
|
+
if (!exportDiff.ok) {
|
|
26
|
+
console.error(chalk.red(exportDiff.error));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (opts.json) {
|
|
31
|
+
console.log(JSON.stringify(exportDiff.diff, null, 2));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(formatExportDiffText(exportDiff.diff));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
7
39
|
const root = findProjectRoot(opts.dir || process.cwd());
|
|
8
40
|
if (!root) {
|
|
9
41
|
console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
|
|
@@ -53,16 +85,7 @@ function formatRunDiffText(diff) {
|
|
|
53
85
|
|
|
54
86
|
appendChangedSection(lines, 'List changes', Object.values(diff.list_changes)
|
|
55
87
|
.filter((entry) => entry.changed)
|
|
56
|
-
.flatMap((entry) =>
|
|
57
|
-
const items = [];
|
|
58
|
-
if (entry.added.length > 0) {
|
|
59
|
-
items.push(`${entry.label} added: ${entry.added.join(', ')}`);
|
|
60
|
-
}
|
|
61
|
-
if (entry.removed.length > 0) {
|
|
62
|
-
items.push(`${entry.label} removed: ${entry.removed.join(', ')}`);
|
|
63
|
-
}
|
|
64
|
-
return items;
|
|
65
|
-
}));
|
|
88
|
+
.flatMap((entry) => listChangeItems(entry)));
|
|
66
89
|
|
|
67
90
|
appendChangedSection(lines, 'Gate changes', diff.gate_changes
|
|
68
91
|
.filter((entry) => entry.changed)
|
|
@@ -71,6 +94,47 @@ function formatRunDiffText(diff) {
|
|
|
71
94
|
return lines.join('\n');
|
|
72
95
|
}
|
|
73
96
|
|
|
97
|
+
function formatExportDiffText(diff) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
lines.push(chalk.bold('Export Diff'));
|
|
100
|
+
lines.push(`${chalk.dim('Left:')} ${formatExportHeader(diff.left_ref, diff.left)}`);
|
|
101
|
+
lines.push(`${chalk.dim('Right:')} ${formatExportHeader(diff.right_ref, diff.right)}`);
|
|
102
|
+
|
|
103
|
+
if (!diff.changed) {
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push(chalk.green('No differences.'));
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
appendChangedSection(lines, 'Changed fields', Object.values(diff.scalar_changes)
|
|
110
|
+
.filter((entry) => entry.changed)
|
|
111
|
+
.map((entry) => `${entry.label}: ${formatValue(entry.left, entry.label)} -> ${formatValue(entry.right, entry.label)}`));
|
|
112
|
+
|
|
113
|
+
appendChangedSection(lines, 'Numeric deltas', Object.values(diff.numeric_changes)
|
|
114
|
+
.filter((entry) => entry.changed)
|
|
115
|
+
.map((entry) => `${entry.label}: ${formatValue(entry.left, entry.label)} -> ${formatValue(entry.right, entry.label)}${formatDelta(entry.delta, entry.label)}`));
|
|
116
|
+
|
|
117
|
+
appendChangedSection(lines, 'List changes', Object.values(diff.list_changes)
|
|
118
|
+
.filter((entry) => entry.changed)
|
|
119
|
+
.flatMap((entry) => listChangeItems(entry)));
|
|
120
|
+
|
|
121
|
+
if (diff.subject_kind === 'coordinator') {
|
|
122
|
+
appendChangedSection(lines, 'Repo status changes', diff.repo_status_changes
|
|
123
|
+
.filter((entry) => entry.changed)
|
|
124
|
+
.map((entry) => `${entry.key}: ${formatValue(entry.left)} -> ${formatValue(entry.right)}`));
|
|
125
|
+
|
|
126
|
+
appendChangedSection(lines, 'Repo export changes', diff.repo_export_changes
|
|
127
|
+
.filter((entry) => entry.changed)
|
|
128
|
+
.map((entry) => `${entry.key}: ${formatValue(entry.left)} -> ${formatValue(entry.right)}`));
|
|
129
|
+
|
|
130
|
+
appendChangedSection(lines, 'Event type deltas', diff.event_type_changes
|
|
131
|
+
.filter((entry) => entry.changed)
|
|
132
|
+
.map((entry) => `${entry.key}: ${formatValue(entry.left)} -> ${formatValue(entry.right)}${formatDelta(entry.delta, entry.label)}`));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
74
138
|
function appendChangedSection(lines, heading, items) {
|
|
75
139
|
if (items.length === 0) return;
|
|
76
140
|
lines.push('');
|
|
@@ -80,6 +144,17 @@ function appendChangedSection(lines, heading, items) {
|
|
|
80
144
|
}
|
|
81
145
|
}
|
|
82
146
|
|
|
147
|
+
function listChangeItems(entry) {
|
|
148
|
+
const items = [];
|
|
149
|
+
if (entry.added.length > 0) {
|
|
150
|
+
items.push(`${entry.label} added: ${entry.added.join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
if (entry.removed.length > 0) {
|
|
153
|
+
items.push(`${entry.label} removed: ${entry.removed.join(', ')}`);
|
|
154
|
+
}
|
|
155
|
+
return items;
|
|
156
|
+
}
|
|
157
|
+
|
|
83
158
|
function formatRunHeader(entry) {
|
|
84
159
|
const status = entry.status || '—';
|
|
85
160
|
const trigger = entry.trigger || 'legacy';
|
|
@@ -89,6 +164,15 @@ function formatRunHeader(entry) {
|
|
|
89
164
|
return `${entry.run_id} (${status}, ${trigger}, ${recordedAt})`;
|
|
90
165
|
}
|
|
91
166
|
|
|
167
|
+
function formatExportHeader(ref, entry) {
|
|
168
|
+
const exportKind = entry.export_kind === 'agentxchain_coordinator_export'
|
|
169
|
+
? 'coordinator export'
|
|
170
|
+
: 'run export';
|
|
171
|
+
const status = entry.status || '—';
|
|
172
|
+
const identity = entry.run_id || entry.super_run_id || 'unknown run';
|
|
173
|
+
return `${ref} (${exportKind}, ${status}, ${identity})`;
|
|
174
|
+
}
|
|
175
|
+
|
|
92
176
|
function formatValue(value, label = '') {
|
|
93
177
|
if (value == null) return '—';
|
|
94
178
|
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
const RUN_EXPORT_SCALAR_FIELDS = [
|
|
5
|
+
['run_id', 'Run ID'],
|
|
6
|
+
['status', 'Status'],
|
|
7
|
+
['phase', 'Phase'],
|
|
8
|
+
['project_name', 'Project'],
|
|
9
|
+
['project_goal', 'Goal'],
|
|
10
|
+
['provenance_trigger', 'Trigger'],
|
|
11
|
+
['dashboard_status', 'Dashboard'],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const RUN_EXPORT_NUMERIC_FIELDS = [
|
|
15
|
+
['history_entries', 'History entries'],
|
|
16
|
+
['decision_entries', 'Decision entries'],
|
|
17
|
+
['hook_audit_entries', 'Hook audit entries'],
|
|
18
|
+
['notification_audit_entries', 'Notification audit entries'],
|
|
19
|
+
['dispatch_artifact_files', 'Dispatch artifacts'],
|
|
20
|
+
['staging_artifact_files', 'Staging artifacts'],
|
|
21
|
+
['total_delegations_issued', 'Delegations'],
|
|
22
|
+
['active_repo_decision_count', 'Active repo decisions'],
|
|
23
|
+
['overridden_repo_decision_count', 'Overridden repo decisions'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const COORDINATOR_EXPORT_SCALAR_FIELDS = [
|
|
27
|
+
['super_run_id', 'Super run ID'],
|
|
28
|
+
['status', 'Status'],
|
|
29
|
+
['phase', 'Phase'],
|
|
30
|
+
['project_name', 'Project'],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const COORDINATOR_EXPORT_NUMERIC_FIELDS = [
|
|
34
|
+
['barrier_count', 'Barriers'],
|
|
35
|
+
['history_entries', 'History entries'],
|
|
36
|
+
['decision_entries', 'Decision entries'],
|
|
37
|
+
['total_events', 'Aggregated events'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function resolveExportArtifact(ref) {
|
|
41
|
+
const exportPath = resolve(ref);
|
|
42
|
+
if (!existsSync(exportPath)) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: `Export file not found: ${exportPath}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let artifact;
|
|
50
|
+
try {
|
|
51
|
+
artifact = JSON.parse(readFileSync(exportPath, 'utf8'));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `Failed to parse export file ${exportPath}: ${error.message}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: `Export file ${exportPath} must contain a JSON object.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof artifact.export_kind !== 'string' || !artifact.export_kind.trim()) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `Export file ${exportPath} is missing export_kind.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
artifact,
|
|
76
|
+
resolved_ref: exportPath,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildExportDiff(leftArtifact, rightArtifact, refs = {}) {
|
|
81
|
+
if (leftArtifact.export_kind !== rightArtifact.export_kind) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: `Export kinds do not match: ${leftArtifact.export_kind} vs ${rightArtifact.export_kind}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (leftArtifact.export_kind === 'agentxchain_run_export') {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
diff: buildRunExportDiff(leftArtifact, rightArtifact, refs),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (leftArtifact.export_kind === 'agentxchain_coordinator_export') {
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
diff: buildCoordinatorExportDiff(leftArtifact, rightArtifact, refs),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: `Unsupported export kind for diff: ${leftArtifact.export_kind}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildRunExportDiff(leftArtifact, rightArtifact, refs) {
|
|
109
|
+
const left = normalizeRunExport(leftArtifact);
|
|
110
|
+
const right = normalizeRunExport(rightArtifact);
|
|
111
|
+
|
|
112
|
+
const scalar_changes = buildFieldChanges(left, right, RUN_EXPORT_SCALAR_FIELDS);
|
|
113
|
+
const numeric_changes = buildNumericChanges(left, right, RUN_EXPORT_NUMERIC_FIELDS);
|
|
114
|
+
const list_changes = {
|
|
115
|
+
active_turn_ids: buildListChange('Active turns', left.active_turn_ids, right.active_turn_ids),
|
|
116
|
+
retained_turn_ids: buildListChange('Retained turns', left.retained_turn_ids, right.retained_turn_ids),
|
|
117
|
+
active_repo_decision_ids: buildListChange('Active repo decisions', left.active_repo_decision_ids, right.active_repo_decision_ids),
|
|
118
|
+
overridden_repo_decision_ids: buildListChange('Overridden repo decisions', left.overridden_repo_decision_ids, right.overridden_repo_decision_ids),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const changed = hasChanged(scalar_changes, numeric_changes, list_changes);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
comparison_mode: 'export',
|
|
125
|
+
subject_kind: 'run',
|
|
126
|
+
export_kind: leftArtifact.export_kind,
|
|
127
|
+
left_ref: refs.left_ref || null,
|
|
128
|
+
right_ref: refs.right_ref || null,
|
|
129
|
+
changed,
|
|
130
|
+
left,
|
|
131
|
+
right,
|
|
132
|
+
scalar_changes,
|
|
133
|
+
numeric_changes,
|
|
134
|
+
list_changes,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildCoordinatorExportDiff(leftArtifact, rightArtifact, refs) {
|
|
139
|
+
const left = normalizeCoordinatorExport(leftArtifact);
|
|
140
|
+
const right = normalizeCoordinatorExport(rightArtifact);
|
|
141
|
+
|
|
142
|
+
const scalar_changes = buildFieldChanges(left, right, COORDINATOR_EXPORT_SCALAR_FIELDS);
|
|
143
|
+
const numeric_changes = buildNumericChanges(left, right, COORDINATOR_EXPORT_NUMERIC_FIELDS);
|
|
144
|
+
const list_changes = {
|
|
145
|
+
repo_ids: buildListChange('Repos', left.repo_ids, right.repo_ids),
|
|
146
|
+
repos_with_events: buildListChange('Repos with events', left.repos_with_events, right.repos_with_events),
|
|
147
|
+
};
|
|
148
|
+
const repo_status_changes = buildMapChanges('Repo status', left.repo_run_statuses, right.repo_run_statuses);
|
|
149
|
+
const repo_export_changes = buildBooleanMapChanges('Repo export ok', left.repo_export_status, right.repo_export_status);
|
|
150
|
+
const event_type_changes = buildNumericMapChanges('Event type', left.event_type_counts, right.event_type_counts);
|
|
151
|
+
|
|
152
|
+
const changed = hasChanged(scalar_changes, numeric_changes, list_changes)
|
|
153
|
+
|| repo_status_changes.some((entry) => entry.changed)
|
|
154
|
+
|| repo_export_changes.some((entry) => entry.changed)
|
|
155
|
+
|| event_type_changes.some((entry) => entry.changed);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
comparison_mode: 'export',
|
|
159
|
+
subject_kind: 'coordinator',
|
|
160
|
+
export_kind: leftArtifact.export_kind,
|
|
161
|
+
left_ref: refs.left_ref || null,
|
|
162
|
+
right_ref: refs.right_ref || null,
|
|
163
|
+
changed,
|
|
164
|
+
left,
|
|
165
|
+
right,
|
|
166
|
+
scalar_changes,
|
|
167
|
+
numeric_changes,
|
|
168
|
+
list_changes,
|
|
169
|
+
repo_status_changes,
|
|
170
|
+
repo_export_changes,
|
|
171
|
+
event_type_changes,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeRunExport(artifact) {
|
|
176
|
+
const summary = artifact.summary || {};
|
|
177
|
+
const repoDecisions = summary.repo_decisions || {};
|
|
178
|
+
return {
|
|
179
|
+
export_kind: artifact.export_kind,
|
|
180
|
+
run_id: summary.run_id || null,
|
|
181
|
+
status: summary.status || null,
|
|
182
|
+
phase: summary.phase || null,
|
|
183
|
+
project_name: artifact.project?.name || null,
|
|
184
|
+
project_goal: summary.project_goal || artifact.project?.goal || null,
|
|
185
|
+
provenance_trigger: summary.provenance?.trigger || null,
|
|
186
|
+
dashboard_status: summary.dashboard_session?.status || null,
|
|
187
|
+
history_entries: toNumber(summary.history_entries),
|
|
188
|
+
decision_entries: toNumber(summary.decision_entries),
|
|
189
|
+
hook_audit_entries: toNumber(summary.hook_audit_entries),
|
|
190
|
+
notification_audit_entries: toNumber(summary.notification_audit_entries),
|
|
191
|
+
dispatch_artifact_files: toNumber(summary.dispatch_artifact_files),
|
|
192
|
+
staging_artifact_files: toNumber(summary.staging_artifact_files),
|
|
193
|
+
total_delegations_issued: toNumber(summary.delegation_summary?.total_delegations_issued),
|
|
194
|
+
active_repo_decision_count: toNumber(repoDecisions.active_count),
|
|
195
|
+
overridden_repo_decision_count: toNumber(repoDecisions.overridden_count),
|
|
196
|
+
active_turn_ids: normalizeStringArray(summary.active_turn_ids),
|
|
197
|
+
retained_turn_ids: normalizeStringArray(summary.retained_turn_ids),
|
|
198
|
+
active_repo_decision_ids: normalizeStringArray((repoDecisions.active || []).map((entry) => entry?.id)),
|
|
199
|
+
overridden_repo_decision_ids: normalizeStringArray((repoDecisions.overridden || []).map((entry) => entry?.id)),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeCoordinatorExport(artifact) {
|
|
204
|
+
const summary = artifact.summary || {};
|
|
205
|
+
const aggregatedEvents = summary.aggregated_events || {};
|
|
206
|
+
const repoRunStatuses = summary.repo_run_statuses || {};
|
|
207
|
+
const repos = artifact.repos || {};
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
export_kind: artifact.export_kind,
|
|
211
|
+
super_run_id: summary.super_run_id || null,
|
|
212
|
+
status: summary.status || null,
|
|
213
|
+
phase: summary.phase || null,
|
|
214
|
+
project_name: artifact.coordinator?.project_name || null,
|
|
215
|
+
barrier_count: toNumber(summary.barrier_count),
|
|
216
|
+
history_entries: toNumber(summary.history_entries),
|
|
217
|
+
decision_entries: toNumber(summary.decision_entries),
|
|
218
|
+
total_events: toNumber(aggregatedEvents.total_events),
|
|
219
|
+
repo_ids: normalizeStringArray(Object.keys(repos)),
|
|
220
|
+
repos_with_events: normalizeStringArray(aggregatedEvents.repos_with_events),
|
|
221
|
+
repo_run_statuses: normalizeStringMap(repoRunStatuses),
|
|
222
|
+
repo_export_status: normalizeBooleanMap(Object.fromEntries(
|
|
223
|
+
Object.entries(repos).map(([repoId, repoEntry]) => [repoId, repoEntry?.ok === true]),
|
|
224
|
+
)),
|
|
225
|
+
event_type_counts: normalizeNumericMap(aggregatedEvents.event_type_counts),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function buildFieldChanges(left, right, fields) {
|
|
230
|
+
return Object.fromEntries(
|
|
231
|
+
fields.map(([field, label]) => [field, {
|
|
232
|
+
label,
|
|
233
|
+
left: left[field],
|
|
234
|
+
right: right[field],
|
|
235
|
+
changed: !isEqual(left[field], right[field]),
|
|
236
|
+
}]),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildNumericChanges(left, right, fields) {
|
|
241
|
+
return Object.fromEntries(
|
|
242
|
+
fields.map(([field, label]) => [field, {
|
|
243
|
+
label,
|
|
244
|
+
left: left[field],
|
|
245
|
+
right: right[field],
|
|
246
|
+
changed: !isEqual(left[field], right[field]),
|
|
247
|
+
delta: typeof left[field] === 'number' && typeof right[field] === 'number'
|
|
248
|
+
? right[field] - left[field]
|
|
249
|
+
: null,
|
|
250
|
+
}]),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildListChange(label, left, right) {
|
|
255
|
+
const leftSet = new Set(left);
|
|
256
|
+
const rightSet = new Set(right);
|
|
257
|
+
return {
|
|
258
|
+
label,
|
|
259
|
+
left,
|
|
260
|
+
right,
|
|
261
|
+
added: right.filter((value) => !leftSet.has(value)),
|
|
262
|
+
removed: left.filter((value) => !rightSet.has(value)),
|
|
263
|
+
changed: left.length !== right.length || left.some((value, index) => value !== right[index]),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildMapChanges(label, leftMap, rightMap) {
|
|
268
|
+
const keys = normalizeStringArray([...Object.keys(leftMap), ...Object.keys(rightMap)]);
|
|
269
|
+
return keys.map((key) => ({
|
|
270
|
+
label,
|
|
271
|
+
key,
|
|
272
|
+
left: leftMap[key] ?? null,
|
|
273
|
+
right: rightMap[key] ?? null,
|
|
274
|
+
changed: !isEqual(leftMap[key] ?? null, rightMap[key] ?? null),
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildBooleanMapChanges(label, leftMap, rightMap) {
|
|
279
|
+
return buildMapChanges(label, leftMap, rightMap);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function buildNumericMapChanges(label, leftMap, rightMap) {
|
|
283
|
+
const keys = normalizeStringArray([...Object.keys(leftMap), ...Object.keys(rightMap)]);
|
|
284
|
+
return keys.map((key) => {
|
|
285
|
+
const left = typeof leftMap[key] === 'number' ? leftMap[key] : null;
|
|
286
|
+
const right = typeof rightMap[key] === 'number' ? rightMap[key] : null;
|
|
287
|
+
return {
|
|
288
|
+
label,
|
|
289
|
+
key,
|
|
290
|
+
left,
|
|
291
|
+
right,
|
|
292
|
+
changed: !isEqual(left, right),
|
|
293
|
+
delta: typeof left === 'number' && typeof right === 'number' ? right - left : null,
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function hasChanged(scalarChanges, numericChanges, listChanges) {
|
|
299
|
+
return [
|
|
300
|
+
...Object.values(scalarChanges),
|
|
301
|
+
...Object.values(numericChanges),
|
|
302
|
+
].some((entry) => entry.changed)
|
|
303
|
+
|| Object.values(listChanges).some((entry) => entry.changed);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function normalizeStringArray(value) {
|
|
307
|
+
if (!Array.isArray(value)) return [];
|
|
308
|
+
return [...new Set(value
|
|
309
|
+
.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
|
310
|
+
.map((item) => item.trim()))]
|
|
311
|
+
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function normalizeStringMap(value) {
|
|
315
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
316
|
+
return Object.fromEntries(
|
|
317
|
+
Object.entries(value)
|
|
318
|
+
.filter(([key]) => typeof key === 'string' && key.trim().length > 0)
|
|
319
|
+
.map(([key, mapValue]) => [key.trim(), typeof mapValue === 'string' && mapValue.trim() ? mapValue.trim() : null]),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeBooleanMap(value) {
|
|
324
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
325
|
+
return Object.fromEntries(
|
|
326
|
+
Object.entries(value)
|
|
327
|
+
.filter(([key]) => typeof key === 'string' && key.trim().length > 0)
|
|
328
|
+
.map(([key, mapValue]) => [key.trim(), mapValue === true]),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeNumericMap(value) {
|
|
333
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
334
|
+
return Object.fromEntries(
|
|
335
|
+
Object.entries(value)
|
|
336
|
+
.filter(([key, mapValue]) => typeof key === 'string' && key.trim().length > 0 && typeof mapValue === 'number')
|
|
337
|
+
.map(([key, mapValue]) => [key.trim(), mapValue]),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function toNumber(value) {
|
|
342
|
+
return typeof value === 'number' ? value : null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isEqual(left, right) {
|
|
346
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
347
|
+
}
|