agentxchain 2.155.73 → 2.157.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.
Files changed (39) hide show
  1. package/bin/agentxchain.js +22 -0
  2. package/dashboard/app.js +54 -0
  3. package/dashboard/components/org-audit-trail.js +161 -0
  4. package/dashboard/components/org-history.js +140 -0
  5. package/dashboard/components/org-overview.js +145 -0
  6. package/dashboard/components/org-runs.js +168 -0
  7. package/dashboard/index.html +4 -0
  8. package/package.json +2 -1
  9. package/src/commands/ci-report.js +80 -0
  10. package/src/commands/doctor.js +22 -1
  11. package/src/commands/intake-approve.js +1 -0
  12. package/src/commands/replay.js +1 -0
  13. package/src/commands/run.js +47 -0
  14. package/src/commands/serve.js +64 -0
  15. package/src/commands/step.js +16 -0
  16. package/src/commands/verify.js +1 -0
  17. package/src/lib/adapters/local-cli-adapter.js +184 -0
  18. package/src/lib/api/execution-worker.js +192 -0
  19. package/src/lib/api/hosted-runner.js +494 -0
  20. package/src/lib/api/job-queue.js +152 -0
  21. package/src/lib/api/org-state-aggregator.js +428 -0
  22. package/src/lib/api/project-registry.js +148 -0
  23. package/src/lib/api/protocol-bridge.js +476 -0
  24. package/src/lib/approval-policy.js +12 -0
  25. package/src/lib/ci-reporter.js +200 -0
  26. package/src/lib/claude-local-auth.js +75 -1
  27. package/src/lib/connector-probe.js +21 -0
  28. package/src/lib/continuous-run.js +75 -4
  29. package/src/lib/dashboard/bridge-server.js +10 -5
  30. package/src/lib/governed-state.js +1 -1
  31. package/src/lib/intake.js +32 -6
  32. package/src/lib/normalized-config.js +2 -0
  33. package/src/lib/scope-overlap.js +214 -0
  34. package/src/lib/stream-json-cost-parser.js +169 -0
  35. package/src/lib/turn-checkpoint.js +21 -0
  36. package/src/lib/turn-result-validator.js +25 -0
  37. package/src/lib/validation.js +11 -3
  38. package/src/lib/verification-replay.js +125 -4
  39. package/src/lib/vision-reader.js +7 -2
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Protocol Bridge — wraps runner-interface.js primitives for HTTP consumption.
3
+ *
4
+ * Design principles:
5
+ * 1. No HTTP objects (no req/res) — pure protocol-to-protocol adapter
6
+ * 2. Typed error classification for deterministic HTTP status mapping
7
+ * 3. @state-provider JSDoc on every filesystem operation
8
+ * 4. No side effects beyond protocol state mutations
9
+ *
10
+ * This module proves the agentxchain protocol is composable by an HTTP server
11
+ * without modifying the protocol layer. The server maps bridge errors to HTTP
12
+ * status codes and bridge results to JSON responses.
13
+ *
14
+ * Note: restartFromCheckpoint is defined in the OpenAPI spec but not exported
15
+ * here because the protocol layer (turn-checkpoint.js) does not yet expose a
16
+ * restart primitive. The bridge will add it when the protocol does.
17
+ */
18
+
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+
22
+ import {
23
+ initRun,
24
+ loadState,
25
+ acceptTurn,
26
+ rejectTurn,
27
+ approvePhaseGate,
28
+ markRunBlocked,
29
+ reissueTurn,
30
+ acquireLock,
31
+ releaseLock,
32
+ getTurnStagingResultPath,
33
+ } from '../runner-interface.js';
34
+
35
+ import { checkpointAcceptedTurn } from '../turn-checkpoint.js';
36
+ import { queryRunHistory } from '../run-history.js';
37
+ import { readRunEvents } from '../run-events.js';
38
+ import { evaluatePhaseExit } from '../gate-evaluator.js';
39
+ import { buildRunExport } from '../export.js';
40
+
41
+ // ── Error Classes ──────────────────────────────────────────────────────────
42
+
43
+ export class ProtocolError extends Error {
44
+ constructor(code, message, details) {
45
+ super(message);
46
+ this.name = 'ProtocolError';
47
+ this.code = code;
48
+ this.details = details || null;
49
+ }
50
+ }
51
+
52
+ export class NotFoundError extends Error {
53
+ constructor(target, id) {
54
+ super(`${target} "${id}" not found`);
55
+ this.name = 'NotFoundError';
56
+ this.code = 'not_found';
57
+ this.target = target;
58
+ this.target_id = id;
59
+ }
60
+ }
61
+
62
+ export class ValidationError extends Error {
63
+ constructor(message, details) {
64
+ super(message);
65
+ this.name = 'ValidationError';
66
+ this.code = 'invalid_request';
67
+ this.details = details || null;
68
+ }
69
+ }
70
+
71
+ export class AuthorizationError extends Error {
72
+ constructor(requiredRole, actualRole) {
73
+ super(`Requires role "${requiredRole}", got "${actualRole}"`);
74
+ this.name = 'AuthorizationError';
75
+ this.code = 'forbidden';
76
+ this.required_role = requiredRole;
77
+ this.actual_role = actualRole;
78
+ }
79
+ }
80
+
81
+ export class ConflictError extends Error {
82
+ constructor(message, details) {
83
+ super(message);
84
+ this.name = 'ConflictError';
85
+ this.code = 'lock_conflict';
86
+ this.details = details || null;
87
+ }
88
+ }
89
+
90
+ // ── Helpers ────────────────────────────────────────────────────────────────
91
+
92
+ const AGENTXCHAIN_DIR = '.agentxchain';
93
+ const HISTORY_FILE = 'history.jsonl';
94
+ const DECISION_LEDGER_FILE = 'decision-ledger.jsonl';
95
+
96
+ function requireState(root) {
97
+ const statePath = join(root, AGENTXCHAIN_DIR, 'state.json');
98
+ if (!existsSync(statePath)) {
99
+ throw new NotFoundError('run state', root);
100
+ }
101
+ }
102
+
103
+ function readJsonl(filePath) {
104
+ if (!existsSync(filePath)) return [];
105
+ const raw = readFileSync(filePath, 'utf8');
106
+ return raw.split('\n').filter(Boolean).map(line => JSON.parse(line));
107
+ }
108
+
109
+ function paginate(items, cursor, limit = 25) {
110
+ const safeLimit = Math.max(1, Math.min(100, limit));
111
+ let startIndex = 0;
112
+ if (cursor) {
113
+ const cursorIndex = parseInt(cursor, 10);
114
+ if (!Number.isNaN(cursorIndex) && cursorIndex >= 0) {
115
+ startIndex = cursorIndex;
116
+ }
117
+ }
118
+ const slice = items.slice(startIndex, startIndex + safeLimit);
119
+ const hasMore = startIndex + safeLimit < items.length;
120
+ return {
121
+ data: slice,
122
+ cursor: hasMore ? String(startIndex + safeLimit) : null,
123
+ has_more: hasMore,
124
+ };
125
+ }
126
+
127
+ // ── Bridge Functions ───────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Create a new governed run.
131
+ *
132
+ * @state-provider writeState
133
+ * Writes: .agentxchain/state.json
134
+ * Cloud replacement: state store PUT (create new run document)
135
+ *
136
+ * @param {string} root - project root
137
+ * @param {object} config - normalized config
138
+ * @param {object} [opts] - options forwarded to initializeGovernedRun
139
+ * @returns {object} created run state
140
+ */
141
+ export function createRun(root, config, opts = {}) {
142
+ const result = initRun(root, config, opts);
143
+ if (!result.ok) {
144
+ throw new ProtocolError('invalid_state', result.error);
145
+ }
146
+ return result.state;
147
+ }
148
+
149
+ /**
150
+ * Get current run state.
151
+ *
152
+ * @state-provider readState
153
+ * Reads: .agentxchain/state.json
154
+ * Cloud replacement: state store GET by run_id
155
+ *
156
+ * @param {string} root - project root
157
+ * @param {object} config - normalized config
158
+ * @returns {object} run state
159
+ */
160
+ export function getRunState(root, config) {
161
+ requireState(root);
162
+ const state = loadState(root, config);
163
+ if (!state) {
164
+ throw new NotFoundError('run', root);
165
+ }
166
+ return state;
167
+ }
168
+
169
+ /**
170
+ * List runs from run history.
171
+ *
172
+ * @state-provider readHistory
173
+ * Reads: .agentxchain/run-history.jsonl
174
+ * Cloud replacement: run history query by project_id with cursor
175
+ *
176
+ * @param {string} root - project root
177
+ * @param {string|null} cursor - pagination cursor
178
+ * @param {number} [limit=25] - page size
179
+ * @returns {{ data: object[], cursor: string|null, has_more: boolean }}
180
+ */
181
+ export function listRuns(root, cursor, limit = 25) {
182
+ const entries = queryRunHistory(root);
183
+ return paginate(entries, cursor, limit);
184
+ }
185
+
186
+ /**
187
+ * Cancel (block) a run.
188
+ *
189
+ * @state-provider writeState
190
+ * Writes: .agentxchain/state.json
191
+ * Cloud replacement: state store PUT (update run status)
192
+ *
193
+ * @param {string} root - project root
194
+ * @param {string} reason - cancellation reason
195
+ * @returns {object} updated run state
196
+ */
197
+ export function cancelRun(root, reason) {
198
+ requireState(root);
199
+ if (!reason) {
200
+ throw new ValidationError('Cancellation reason is required');
201
+ }
202
+ const result = markRunBlocked(root, {
203
+ category: 'operator_cancelled',
204
+ reason,
205
+ });
206
+ if (result && !result.ok) {
207
+ throw new ProtocolError('invalid_state', result.error || 'Failed to cancel run');
208
+ }
209
+ return result;
210
+ }
211
+
212
+ /**
213
+ * List turns from history.
214
+ *
215
+ * @state-provider readHistory
216
+ * Reads: .agentxchain/history.jsonl
217
+ * Cloud replacement: turn history query by run_id with cursor
218
+ *
219
+ * @param {string} root - project root
220
+ * @param {string|null} cursor - pagination cursor
221
+ * @param {number} [limit=25] - page size
222
+ * @returns {{ data: object[], cursor: string|null, has_more: boolean }}
223
+ */
224
+ export function getTurns(root, cursor, limit = 25) {
225
+ const historyPath = join(root, AGENTXCHAIN_DIR, HISTORY_FILE);
226
+ const entries = readJsonl(historyPath);
227
+ return paginate(entries, cursor, limit);
228
+ }
229
+
230
+ /**
231
+ * Get a specific turn by ID.
232
+ *
233
+ * @state-provider readHistory
234
+ * Reads: .agentxchain/history.jsonl, .agentxchain/staging/<turn_id>/turn-result.json
235
+ * Cloud replacement: turn store GET by turn_id
236
+ *
237
+ * @param {string} root - project root
238
+ * @param {string} turnId - turn identifier
239
+ * @returns {object} turn detail
240
+ */
241
+ export function getTurn(root, turnId) {
242
+ if (!turnId) {
243
+ throw new ValidationError('turn_id is required');
244
+ }
245
+ const historyPath = join(root, AGENTXCHAIN_DIR, HISTORY_FILE);
246
+ const entries = readJsonl(historyPath);
247
+ const entry = entries.find(e => e.turn_id === turnId);
248
+ if (entry) return entry;
249
+
250
+ const stagingPath = join(root, getTurnStagingResultPath(turnId));
251
+ if (existsSync(stagingPath)) {
252
+ return JSON.parse(readFileSync(stagingPath, 'utf8'));
253
+ }
254
+ throw new NotFoundError('turn', turnId);
255
+ }
256
+
257
+ /**
258
+ * Accept a turn result.
259
+ *
260
+ * @state-provider writeState
261
+ * Writes: .agentxchain/state.json
262
+ * Cloud replacement: state store PUT (compare-and-swap)
263
+ *
264
+ * @state-provider acquireLock
265
+ * Reads/writes: .agentxchain/lock.json
266
+ * Cloud replacement: distributed lock (lease-based, workspace-scoped)
267
+ *
268
+ * @param {string} root - project root
269
+ * @param {object} config - normalized config
270
+ * @param {string} turnId - target turn ID
271
+ * @param {object} [opts] - additional options
272
+ * @returns {object} acceptance result with updated state
273
+ */
274
+ export function acceptTurnResult(root, config, turnId, opts = {}) {
275
+ requireState(root);
276
+ const result = acceptTurn(root, config, { ...opts, turnId });
277
+ if (!result.ok) {
278
+ if (result.error_code === 'lock_conflict' || (result.error && result.error.includes('lock'))) {
279
+ throw new ConflictError(result.error);
280
+ }
281
+ throw new ProtocolError(
282
+ result.error_code || 'invalid_state',
283
+ result.error || 'Turn acceptance failed',
284
+ );
285
+ }
286
+ return result;
287
+ }
288
+
289
+ /**
290
+ * Reject a turn result.
291
+ *
292
+ * @state-provider writeState
293
+ * Writes: .agentxchain/state.json
294
+ * Cloud replacement: state store PUT (compare-and-swap)
295
+ *
296
+ * @state-provider acquireLock
297
+ * Reads/writes: .agentxchain/lock.json
298
+ * Cloud replacement: distributed lock (lease-based, workspace-scoped)
299
+ *
300
+ * @param {string} root - project root
301
+ * @param {object} config - normalized config
302
+ * @param {string} turnId - target turn ID
303
+ * @param {string} reason - rejection reason
304
+ * @param {object} [opts] - additional options
305
+ * @returns {object} rejection result with updated state
306
+ */
307
+ export function rejectTurnResult(root, config, turnId, reason, opts = {}) {
308
+ requireState(root);
309
+ if (!reason) {
310
+ throw new ValidationError('Rejection reason is required');
311
+ }
312
+ const result = rejectTurn(root, config, null, { ...opts, turnId, reason });
313
+ if (!result.ok) {
314
+ if (result.error_code === 'lock_conflict' || (result.error && result.error.includes('lock'))) {
315
+ throw new ConflictError(result.error);
316
+ }
317
+ throw new ProtocolError(
318
+ result.error_code || 'invalid_state',
319
+ result.error || 'Turn rejection failed',
320
+ );
321
+ }
322
+ return result;
323
+ }
324
+
325
+ /**
326
+ * Approve a pending phase transition.
327
+ *
328
+ * @state-provider writeState
329
+ * Writes: .agentxchain/state.json
330
+ * Cloud replacement: state store PUT (update phase)
331
+ *
332
+ * @param {string} root - project root
333
+ * @param {object} config - normalized config
334
+ * @param {object} [opts] - additional options
335
+ * @returns {object} approval result with transition details
336
+ */
337
+ export function approveTransition(root, config, opts = {}) {
338
+ requireState(root);
339
+ const result = approvePhaseGate(root, config, opts);
340
+ if (!result.ok) {
341
+ throw new ProtocolError(
342
+ result.error_code || 'gate_not_satisfied',
343
+ result.error || 'Phase transition approval failed',
344
+ );
345
+ }
346
+ return result;
347
+ }
348
+
349
+ /**
350
+ * Checkpoint an accepted turn.
351
+ *
352
+ * @state-provider writeState
353
+ * Writes: git commit (checkpoint)
354
+ * Cloud replacement: snapshot store PUT by checkpoint_id
355
+ *
356
+ * @param {string} root - project root
357
+ * @param {string} turnId - turn to checkpoint
358
+ * @returns {object} checkpoint result
359
+ */
360
+ export function checkpointTurn(root, turnId) {
361
+ requireState(root);
362
+ if (!turnId) {
363
+ throw new ValidationError('turn_id is required');
364
+ }
365
+ const result = checkpointAcceptedTurn(root, { turnId });
366
+ if (result && !result.ok) {
367
+ throw new ProtocolError(
368
+ 'checkpoint_not_found',
369
+ result.error || 'Checkpoint failed',
370
+ );
371
+ }
372
+ return result;
373
+ }
374
+
375
+ /**
376
+ * Retry (reissue) a failed turn.
377
+ *
378
+ * @state-provider writeState
379
+ * Writes: .agentxchain/state.json
380
+ * Cloud replacement: state store PUT (reissue turn)
381
+ *
382
+ * @param {string} root - project root
383
+ * @param {object} config - normalized config
384
+ * @param {string} turnId - turn to retry
385
+ * @returns {object} reissue result
386
+ */
387
+ export function retryTurn(root, config, turnId) {
388
+ requireState(root);
389
+ if (!turnId) {
390
+ throw new ValidationError('turn_id is required');
391
+ }
392
+ const result = reissueTurn(root, config, { turnId });
393
+ if (!result.ok) {
394
+ throw new ProtocolError(
395
+ result.error_code || 'invalid_state',
396
+ result.error || 'Turn retry failed',
397
+ );
398
+ }
399
+ return result;
400
+ }
401
+
402
+ /**
403
+ * Get run events.
404
+ *
405
+ * @state-provider readEvents
406
+ * Reads: .agentxchain/events.jsonl
407
+ * Cloud replacement: event store query by run_id with cursor
408
+ *
409
+ * @param {string} root - project root
410
+ * @param {string|null} cursor - pagination cursor
411
+ * @param {number} [limit=25] - page size
412
+ * @returns {{ data: object[], cursor: string|null, has_more: boolean }}
413
+ */
414
+ export function getEvents(root, cursor, limit = 25) {
415
+ const events = readRunEvents(root);
416
+ return paginate(events, cursor, limit);
417
+ }
418
+
419
+ /**
420
+ * Get decision ledger.
421
+ *
422
+ * @state-provider readDecisions
423
+ * Reads: .agentxchain/decision-ledger.jsonl
424
+ * Cloud replacement: decision ledger query by run_id
425
+ *
426
+ * @param {string} root - project root
427
+ * @returns {{ data: object[] }}
428
+ */
429
+ export function getDecisions(root) {
430
+ const ledgerPath = join(root, AGENTXCHAIN_DIR, DECISION_LEDGER_FILE);
431
+ const entries = readJsonl(ledgerPath);
432
+ return { data: entries };
433
+ }
434
+
435
+ /**
436
+ * Get gate states for the current phase.
437
+ *
438
+ * @state-provider readState
439
+ * Reads: .agentxchain/state.json + gate artifacts
440
+ * Cloud replacement: gate state query by run_id
441
+ *
442
+ * @param {string} root - project root
443
+ * @param {object} config - normalized config
444
+ * @returns {{ data: object[] }}
445
+ */
446
+ export function getGates(root, config) {
447
+ requireState(root);
448
+ const state = loadState(root, config);
449
+ if (!state) {
450
+ throw new NotFoundError('run', root);
451
+ }
452
+ const gates = state.gates || {};
453
+ const gateList = Object.entries(gates).map(([name, gate]) => ({
454
+ name,
455
+ ...gate,
456
+ }));
457
+ return { data: gateList };
458
+ }
459
+
460
+ /**
461
+ * Export run as repo-native artifact bundle.
462
+ *
463
+ * @state-provider readState
464
+ * Reads: .agentxchain/ (all state files)
465
+ * Cloud replacement: export service builds bundle from service stores
466
+ *
467
+ * @param {string} root - project root
468
+ * @returns {object} export result with { ok, export }
469
+ */
470
+ export function exportRun(root) {
471
+ const result = buildRunExport(root);
472
+ if (!result.ok) {
473
+ throw new ProtocolError('internal_error', result.error || 'Export failed');
474
+ }
475
+ return result;
476
+ }
@@ -48,6 +48,18 @@ function isCredentialedGate(config, gateId) {
48
48
  return config?.gates?.[gateId]?.credentialed === true;
49
49
  }
50
50
 
51
+ // BUG-59 follow-up — lights-out without blind trust. The --auto-approve /
52
+ // continuous gate path (run.js approveGate) must apply the same credentialed
53
+ // hard-stop as the policy path. Resolve the exit gate for a phase and report
54
+ // whether it is credentialed, so a credentialed / irreversible gate is never
55
+ // auto-approved by the flag path either — it falls through to gate_held and
56
+ // requires a human (approve-transition / approve-completion).
57
+ export function isCredentialedExitGate(config, phase) {
58
+ if (!phase) return false;
59
+ const gateId = config?.routing?.[phase]?.exit_gate;
60
+ return isCredentialedGate(config, gateId);
61
+ }
62
+
51
63
  function evaluateRunCompletionPolicy({ gateResult, state, config, policy }) {
52
64
  if (isCredentialedGate(config, gateResult?.gate_id)) {
53
65
  return {
@@ -0,0 +1,200 @@
1
+ import { appendFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Normalize gate_summary to [gateName, statusString] entries.
5
+ * Handles both array format ([{ gate_id, status }]) from the real report pipeline
6
+ * and object format ({ gateName: statusString }) from legacy/synthetic fixtures.
7
+ */
8
+ function normalizeGateEntries(gates) {
9
+ if (Array.isArray(gates)) {
10
+ return gates
11
+ .filter(g => typeof g?.gate_id === 'string' && typeof g?.status === 'string')
12
+ .map(g => [g.gate_id, g.status]);
13
+ }
14
+ if (gates && typeof gates === 'object') {
15
+ return Object.entries(gates).filter(([, v]) => typeof v === 'string');
16
+ }
17
+ return [];
18
+ }
19
+
20
+ /**
21
+ * Detect CI environment from process.env.
22
+ * @returns {{ provider: string, run_url: string|null, run_id: string|null, ref: string|null, sha: string|null } | null}
23
+ */
24
+ export function detectCIEnvironment() {
25
+ if (process.env.GITHUB_ACTIONS === 'true') {
26
+ return {
27
+ provider: 'github_actions',
28
+ run_url: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
29
+ ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
30
+ : null,
31
+ run_id: process.env.GITHUB_RUN_ID || null,
32
+ ref: process.env.GITHUB_REF || null,
33
+ sha: process.env.GITHUB_SHA || null,
34
+ };
35
+ }
36
+ if (process.env.GITLAB_CI === 'true') {
37
+ return {
38
+ provider: 'gitlab_ci',
39
+ run_url: process.env.CI_PIPELINE_URL || null,
40
+ run_id: process.env.CI_PIPELINE_ID || null,
41
+ ref: process.env.CI_COMMIT_REF_NAME || null,
42
+ sha: process.env.CI_COMMIT_SHA || null,
43
+ };
44
+ }
45
+ if (process.env.CI === 'true') {
46
+ return {
47
+ provider: 'generic',
48
+ run_url: null,
49
+ run_id: null,
50
+ ref: null,
51
+ sha: null,
52
+ };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Format governance report as GitHub Actions annotations.
59
+ * @param {{ overall: string, subject?: object }} report - Governance report from buildGovernanceReport()
60
+ * @returns {string} Newline-separated GitHub Actions workflow commands
61
+ */
62
+ export function formatGitHubAnnotations(report) {
63
+ const lines = [];
64
+
65
+ // Overall run status
66
+ if (report.overall === 'pass') {
67
+ lines.push(`::notice title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — PASS (${report.subject?.run?.phase || 'unknown'} phase)`);
68
+ } else if (report.overall === 'fail') {
69
+ lines.push(`::error title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — FAIL: ${report.message || 'governance check failed'}`);
70
+ } else {
71
+ lines.push(`::warning title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — ${report.overall}: ${report.message || 'review required'}`);
72
+ }
73
+
74
+ // Gate annotations
75
+ for (const [gateName, gateStatus] of normalizeGateEntries(report.subject?.run?.gate_summary)) {
76
+ const normalizedStatus = gateStatus.toLowerCase();
77
+ if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
78
+ lines.push(`::notice title=Gate ${gateName}::satisfied`);
79
+ } else {
80
+ lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
81
+ }
82
+ }
83
+
84
+ // Decision annotations (first 20 to avoid flooding)
85
+ const decisions = report.subject?.run?.decisions || [];
86
+ for (const d of decisions.slice(0, 20)) {
87
+ lines.push(`::notice title=Decision ${d.id || 'unknown'}::${d.statement || d.summary || 'no statement'}`);
88
+ }
89
+
90
+ // Blocked-on annotation
91
+ if (report.subject?.run?.blocked_on) {
92
+ lines.push(`::error title=Blocked::${report.subject.run.blocked_reason || report.subject.run.blocked_on}`);
93
+ }
94
+
95
+ return lines.join('\n');
96
+ }
97
+
98
+ /**
99
+ * Write governance report summary as GitHub Actions output variables.
100
+ * @param {{ overall: string, subject?: object }} report
101
+ * @param {string} outputPath - Path to $GITHUB_OUTPUT file
102
+ * @returns {string[]} Array of key=value pairs written
103
+ */
104
+ export function writeGitHubOutputVars(report, outputPath) {
105
+ const run = report.subject?.run || {};
106
+ const pairs = [
107
+ `run_status=${report.overall || 'unknown'}`,
108
+ `run_id=${run.run_id || ''}`,
109
+ `phase=${run.phase || ''}`,
110
+ `blocked=${run.blocked_on ? 'true' : 'false'}`,
111
+ `turn_count=${(run.turns || []).length}`,
112
+ `decision_count=${(run.decisions || []).length}`,
113
+ ];
114
+
115
+ if (outputPath) {
116
+ appendFileSync(outputPath, pairs.join('\n') + '\n');
117
+ }
118
+
119
+ return pairs;
120
+ }
121
+
122
+ /**
123
+ * Format governance report as JUnit XML.
124
+ * @param {{ overall: string, subject?: object }} report
125
+ * @returns {string} JUnit XML string
126
+ */
127
+ export function formatJUnitXml(report) {
128
+ const run = report.subject?.run || {};
129
+ const turns = run.turns || [];
130
+
131
+ // Escape XML special characters
132
+ const esc = (str) => String(str || '')
133
+ .replace(/&/g, '&amp;')
134
+ .replace(/</g, '&lt;')
135
+ .replace(/>/g, '&gt;')
136
+ .replace(/"/g, '&quot;');
137
+
138
+ // Build gate test cases
139
+ const gateEntries = normalizeGateEntries(run.gate_summary);
140
+ const gateFailures = gateEntries.filter(([, status]) => {
141
+ const s = status.toLowerCase();
142
+ return s !== 'satisfied' && s !== 'pass' && s !== 'passed';
143
+ });
144
+ const gateCases = gateEntries.map(([name, status]) => {
145
+ const s = status.toLowerCase();
146
+ const passed = s === 'satisfied' || s === 'pass' || s === 'passed';
147
+ if (passed) {
148
+ return ` <testcase name="${esc(name)}" classname="agentxchain.gates" time="0" />`;
149
+ }
150
+ return [
151
+ ` <testcase name="${esc(name)}" classname="agentxchain.gates" time="0">`,
152
+ ` <failure message="${esc(status)}">${esc(name)} gate status: ${esc(status)}</failure>`,
153
+ ' </testcase>',
154
+ ].join('\n');
155
+ });
156
+
157
+ // Build turn test cases
158
+ const turnFailures = turns.filter((t) => t.status === 'failed' || t.status === 'blocked');
159
+ const turnCases = turns.map((t) => {
160
+ const name = `${t.turn_id || 'unknown'} (${t.role || 'unknown'})`;
161
+ const time = typeof t.duration_seconds === 'number' ? t.duration_seconds.toFixed(1) : '0';
162
+ if (t.status === 'completed' || t.status === 'accepted') {
163
+ return ` <testcase name="${esc(name)}" classname="agentxchain.turns" time="${time}" />`;
164
+ }
165
+ return [
166
+ ` <testcase name="${esc(name)}" classname="agentxchain.turns" time="${time}">`,
167
+ ` <failure message="${esc(t.status || 'unknown')}">${esc(t.summary || t.status || 'turn did not complete successfully')}</failure>`,
168
+ ' </testcase>',
169
+ ].join('\n');
170
+ });
171
+
172
+ const totalTests = gateEntries.length + turns.length;
173
+ const totalFailures = gateFailures.length + turnFailures.length;
174
+ const totalTime = typeof run.duration_seconds === 'number' ? run.duration_seconds.toFixed(1) : '0';
175
+
176
+ const xml = [
177
+ '<?xml version="1.0" encoding="UTF-8"?>',
178
+ `<testsuites name="AgentXchain Governance" tests="${totalTests}" failures="${totalFailures}" errors="0" time="${totalTime}">`,
179
+ ` <testsuite name="Gates" tests="${gateEntries.length}" failures="${gateFailures.length}" errors="0">`,
180
+ ...gateCases,
181
+ ' </testsuite>',
182
+ ` <testsuite name="Turns" tests="${turns.length}" failures="${turnFailures.length}" errors="0">`,
183
+ ...turnCases,
184
+ ' </testsuite>',
185
+ '</testsuites>',
186
+ ].join('\n');
187
+
188
+ return xml;
189
+ }
190
+
191
+ /**
192
+ * Derive CI-appropriate exit code from governance report.
193
+ * @param {{ overall: string }} report
194
+ * @returns {number} 0 = pass, 1 = fail, 2 = error
195
+ */
196
+ export function deriveCIExitCode(report) {
197
+ if (report.overall === 'pass') return 0;
198
+ if (report.overall === 'fail') return 1;
199
+ return 2;
200
+ }