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.
@@ -345,9 +345,10 @@ program
345
345
  .action(decisionsCommand);
346
346
 
347
347
  program
348
- .command('diff <left_run_id> <right_run_id>')
349
- .description('Compare two recorded governed runs from run-history')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.96.1",
3
+ "version": "2.98.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- // Write all embedded files from the export
55
- let fileCount = 0;
56
- for (const [relPath, content] of Object.entries(exportData.files)) {
57
- const absPath = join(tempRoot, relPath);
58
- mkdirSync(dirname(absPath), { recursive: true });
59
- writeFileSync(absPath, typeof content === 'string' ? content : JSON.stringify(content, null, 2));
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 || 6,
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
+ }