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.
- package/bin/agentxchain.js +22 -0
- package/dashboard/app.js +54 -0
- package/dashboard/components/org-audit-trail.js +161 -0
- package/dashboard/components/org-history.js +140 -0
- package/dashboard/components/org-overview.js +145 -0
- package/dashboard/components/org-runs.js +168 -0
- package/dashboard/index.html +4 -0
- package/package.json +2 -1
- package/src/commands/ci-report.js +80 -0
- package/src/commands/doctor.js +22 -1
- package/src/commands/intake-approve.js +1 -0
- package/src/commands/replay.js +1 -0
- package/src/commands/run.js +47 -0
- package/src/commands/serve.js +64 -0
- package/src/commands/step.js +16 -0
- package/src/commands/verify.js +1 -0
- package/src/lib/adapters/local-cli-adapter.js +184 -0
- package/src/lib/api/execution-worker.js +192 -0
- package/src/lib/api/hosted-runner.js +494 -0
- package/src/lib/api/job-queue.js +152 -0
- package/src/lib/api/org-state-aggregator.js +428 -0
- package/src/lib/api/project-registry.js +148 -0
- package/src/lib/api/protocol-bridge.js +476 -0
- package/src/lib/approval-policy.js +12 -0
- package/src/lib/ci-reporter.js +200 -0
- package/src/lib/claude-local-auth.js +75 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +75 -4
- package/src/lib/dashboard/bridge-server.js +10 -5
- package/src/lib/governed-state.js +1 -1
- package/src/lib/intake.js +32 -6
- package/src/lib/normalized-config.js +2 -0
- package/src/lib/scope-overlap.js +214 -0
- package/src/lib/stream-json-cost-parser.js +169 -0
- package/src/lib/turn-checkpoint.js +21 -0
- package/src/lib/turn-result-validator.js +25 -0
- package/src/lib/validation.js +11 -3
- package/src/lib/verification-replay.js +125 -4
- 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, '&')
|
|
134
|
+
.replace(/</g, '<')
|
|
135
|
+
.replace(/>/g, '>')
|
|
136
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|