agentxchain 2.110.0 → 2.111.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 +31 -0
- package/dashboard/app.js +3 -0
- package/dashboard/components/chain.js +200 -0
- package/dashboard/index.html +1 -0
- package/package.json +1 -1
- package/src/commands/chain.js +252 -0
- package/src/commands/diff.js +19 -0
- package/src/commands/run.js +110 -97
- package/src/lib/chain-reports.js +54 -0
- package/src/lib/dashboard/bridge-server.js +8 -0
- package/src/lib/dashboard/chain-report-reader.js +15 -0
- package/src/lib/dashboard/state-reader.js +6 -1
- package/src/lib/export-diff.js +10 -1
- package/src/lib/run-chain.js +262 -0
package/bin/agentxchain.js
CHANGED
|
@@ -121,6 +121,7 @@ import { diffCommand } from '../src/commands/diff.js';
|
|
|
121
121
|
import { eventsCommand } from '../src/commands/events.js';
|
|
122
122
|
import { connectorCheckCommand } from '../src/commands/connector.js';
|
|
123
123
|
import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
|
|
124
|
+
import { chainLatestCommand, chainListCommand, chainShowCommand } from '../src/commands/chain.js';
|
|
124
125
|
|
|
125
126
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
126
127
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -392,6 +393,32 @@ program
|
|
|
392
393
|
.option('-d, --dir <path>', 'Project directory')
|
|
393
394
|
.action(eventsCommand);
|
|
394
395
|
|
|
396
|
+
const chainCmd = program
|
|
397
|
+
.command('chain')
|
|
398
|
+
.description('Inspect run-chaining history and reports');
|
|
399
|
+
|
|
400
|
+
chainCmd
|
|
401
|
+
.command('latest')
|
|
402
|
+
.description('Show the most recent chain report')
|
|
403
|
+
.option('-j, --json', 'Output as JSON')
|
|
404
|
+
.option('-d, --dir <path>', 'Project directory')
|
|
405
|
+
.action(chainLatestCommand);
|
|
406
|
+
|
|
407
|
+
chainCmd
|
|
408
|
+
.command('list')
|
|
409
|
+
.description('List all chain reports')
|
|
410
|
+
.option('-j, --json', 'Output as JSON')
|
|
411
|
+
.option('-l, --limit <n>', 'Max chain reports to show (default: 20)')
|
|
412
|
+
.option('-d, --dir <path>', 'Project directory')
|
|
413
|
+
.action(chainListCommand);
|
|
414
|
+
|
|
415
|
+
chainCmd
|
|
416
|
+
.command('show <chain_id>')
|
|
417
|
+
.description('Show a specific chain report by ID')
|
|
418
|
+
.option('-j, --json', 'Output as JSON')
|
|
419
|
+
.option('-d, --dir <path>', 'Project directory')
|
|
420
|
+
.action(chainShowCommand);
|
|
421
|
+
|
|
395
422
|
program
|
|
396
423
|
.command('validate')
|
|
397
424
|
.description('Validate project protocol artifacts')
|
|
@@ -516,6 +543,10 @@ program
|
|
|
516
543
|
.option('--continue-from <run_id>', 'Continue from a prior terminal run (sets trigger=continuation)')
|
|
517
544
|
.option('--recover-from <run_id>', 'Recover from a prior blocked run (sets trigger=recovery)')
|
|
518
545
|
.option('--inherit-context', 'Inherit read-only summary context from the parent run (requires --continue-from or --recover-from)')
|
|
546
|
+
.option('--chain', 'Auto-chain runs: when a run completes, start a continuation automatically')
|
|
547
|
+
.option('--max-chains <n>', 'Maximum continuation runs in chain mode (default: 5)', parseInt)
|
|
548
|
+
.option('--chain-on <statuses>', 'Comma-separated terminal statuses that trigger chaining (default: completed)')
|
|
549
|
+
.option('--chain-cooldown <seconds>', 'Seconds to wait between chained runs (default: 5)', parseInt)
|
|
519
550
|
.action(runCommand);
|
|
520
551
|
|
|
521
552
|
program
|
package/dashboard/app.js
CHANGED
|
@@ -15,6 +15,7 @@ import { render as renderCrossRepo } from './components/cross-repo.js';
|
|
|
15
15
|
import { render as renderDelegations } from './components/delegations.js';
|
|
16
16
|
import { render as renderBlockers } from './components/blockers.js';
|
|
17
17
|
import { render as renderArtifacts } from './components/artifacts.js';
|
|
18
|
+
import { render as renderChain } from './components/chain.js';
|
|
18
19
|
import { render as renderRunHistory } from './components/run-history.js';
|
|
19
20
|
import { render as renderTimeouts } from './components/timeouts.js';
|
|
20
21
|
import { render as renderCoordinatorTimeouts } from './components/coordinator-timeouts.js';
|
|
@@ -35,6 +36,7 @@ const VIEWS = {
|
|
|
35
36
|
'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
|
|
36
37
|
blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
|
|
37
38
|
artifacts: { fetch: ['workflowKitArtifacts'], render: renderArtifacts },
|
|
39
|
+
chain: { fetch: ['chainReports'], render: renderChain },
|
|
38
40
|
'run-history': { fetch: ['runHistory'], render: renderRunHistory },
|
|
39
41
|
timeouts: { fetch: ['timeouts'], render: renderTimeouts },
|
|
40
42
|
'coordinator-timeouts': { fetch: ['coordinatorTimeouts'], render: renderCoordinatorTimeouts },
|
|
@@ -58,6 +60,7 @@ const API_MAP = {
|
|
|
58
60
|
coordinatorBlockers: '/api/coordinator/blockers',
|
|
59
61
|
coordinatorRepoStatusRows: '/api/coordinator/repo-status',
|
|
60
62
|
workflowKitArtifacts: '/api/workflow-kit-artifacts',
|
|
63
|
+
chainReports: '/api/chain-reports',
|
|
61
64
|
connectors: '/api/connectors',
|
|
62
65
|
runHistory: '/api/run-history',
|
|
63
66
|
timeouts: '/api/timeouts',
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain view — renders run-chaining visibility from /api/chain-reports.
|
|
3
|
+
*
|
|
4
|
+
* Pure render function: takes snapshot data from the bridge server and returns
|
|
5
|
+
* HTML for latest-chain lineage plus recent chain-session history.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function esc(str) {
|
|
9
|
+
if (str == null) return '';
|
|
10
|
+
return String(str)
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function badge(label, color = 'var(--text-dim)') {
|
|
19
|
+
return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatStatus(status) {
|
|
23
|
+
switch (status) {
|
|
24
|
+
case 'completed':
|
|
25
|
+
return badge('completed', 'var(--green)');
|
|
26
|
+
case 'blocked':
|
|
27
|
+
return badge('blocked', 'var(--yellow)');
|
|
28
|
+
case 'failed':
|
|
29
|
+
return badge('failed', 'var(--red)');
|
|
30
|
+
default:
|
|
31
|
+
return badge(status || 'unknown', 'var(--text-dim)');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatTerminalReason(reason) {
|
|
36
|
+
switch (reason) {
|
|
37
|
+
case 'chain_limit_reached':
|
|
38
|
+
return badge('chain limit reached', '#38bdf8');
|
|
39
|
+
case 'non_chainable_status':
|
|
40
|
+
return badge('non-chainable status', 'var(--yellow)');
|
|
41
|
+
case 'operator_abort':
|
|
42
|
+
return badge('operator abort', 'var(--red)');
|
|
43
|
+
case 'parent_validation_failed':
|
|
44
|
+
return badge('parent validation failed', 'var(--red)');
|
|
45
|
+
default:
|
|
46
|
+
return badge(reason || 'unknown', 'var(--text-dim)');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatDuration(ms) {
|
|
51
|
+
if (ms == null) return '—';
|
|
52
|
+
if (ms < 1000) return `${ms}ms`;
|
|
53
|
+
const seconds = Math.floor(ms / 1000);
|
|
54
|
+
if (seconds < 60) return `${seconds}s`;
|
|
55
|
+
const minutes = Math.floor(seconds / 60);
|
|
56
|
+
const remainingSeconds = seconds % 60;
|
|
57
|
+
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
|
58
|
+
const hours = Math.floor(minutes / 60);
|
|
59
|
+
const remainingMinutes = minutes % 60;
|
|
60
|
+
return `${hours}h ${remainingMinutes}m`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatDate(iso) {
|
|
64
|
+
if (!iso) return '—';
|
|
65
|
+
try {
|
|
66
|
+
const date = new Date(iso);
|
|
67
|
+
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
68
|
+
+ ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
69
|
+
} catch {
|
|
70
|
+
return esc(iso);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function truncateId(value, len = 12) {
|
|
75
|
+
if (!value) return '—';
|
|
76
|
+
return value.length > len ? `${value.slice(0, len)}…` : value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatContextSummary(summary) {
|
|
80
|
+
if (!summary) return '—';
|
|
81
|
+
|
|
82
|
+
const parts = [];
|
|
83
|
+
if (Array.isArray(summary.parent_roles_used) && summary.parent_roles_used.length > 0) {
|
|
84
|
+
parts.push(`${summary.parent_roles_used.length} roles`);
|
|
85
|
+
}
|
|
86
|
+
if (summary.parent_phases_completed_count > 0) {
|
|
87
|
+
parts.push(`${summary.parent_phases_completed_count} phases`);
|
|
88
|
+
}
|
|
89
|
+
if (summary.recent_decisions_count > 0) {
|
|
90
|
+
parts.push(`${summary.recent_decisions_count} decisions`);
|
|
91
|
+
}
|
|
92
|
+
if (summary.recent_accepted_turns_count > 0) {
|
|
93
|
+
parts.push(`${summary.recent_accepted_turns_count} turns`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return parts.length > 0 ? esc(parts.join(', ')) : '—';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderLatestRunsTable(report) {
|
|
100
|
+
if (!Array.isArray(report?.runs) || report.runs.length === 0) {
|
|
101
|
+
return `<p class="section-subtitle">No runs recorded for this chain.</p>`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let html = `<div class="section"><h3>Latest Chain Lineage</h3>
|
|
105
|
+
<table class="data-table">
|
|
106
|
+
<thead>
|
|
107
|
+
<tr>
|
|
108
|
+
<th>#</th>
|
|
109
|
+
<th>Run ID</th>
|
|
110
|
+
<th>Status</th>
|
|
111
|
+
<th>Trigger</th>
|
|
112
|
+
<th>Turns</th>
|
|
113
|
+
<th>Duration</th>
|
|
114
|
+
<th>Parent</th>
|
|
115
|
+
<th>Inherited Context</th>
|
|
116
|
+
</tr>
|
|
117
|
+
</thead>
|
|
118
|
+
<tbody>`;
|
|
119
|
+
|
|
120
|
+
report.runs.forEach((run, index) => {
|
|
121
|
+
html += `<tr>
|
|
122
|
+
<td style="color:var(--text-dim)">${index + 1}</td>
|
|
123
|
+
<td class="mono" title="${esc(run.run_id || '')}">${esc(truncateId(run.run_id))}</td>
|
|
124
|
+
<td>${formatStatus(run.status)}</td>
|
|
125
|
+
<td>${esc(run.provenance_trigger || '—')}</td>
|
|
126
|
+
<td>${run.turns ?? '—'}</td>
|
|
127
|
+
<td>${formatDuration(run.duration_ms)}</td>
|
|
128
|
+
<td class="mono" title="${esc(run.parent_run_id || '')}">${esc(truncateId(run.parent_run_id))}</td>
|
|
129
|
+
<td>${formatContextSummary(run.inherited_context_summary)}</td>
|
|
130
|
+
</tr>`;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
html += '</tbody></table></div>';
|
|
134
|
+
return html;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderRecentChainsTable(reports) {
|
|
138
|
+
let html = `<div class="section"><h3>Recent Chain Sessions</h3>
|
|
139
|
+
<table class="data-table">
|
|
140
|
+
<thead>
|
|
141
|
+
<tr>
|
|
142
|
+
<th>#</th>
|
|
143
|
+
<th>Chain ID</th>
|
|
144
|
+
<th>Runs</th>
|
|
145
|
+
<th>Turns</th>
|
|
146
|
+
<th>Terminal</th>
|
|
147
|
+
<th>Duration</th>
|
|
148
|
+
<th>Started</th>
|
|
149
|
+
</tr>
|
|
150
|
+
</thead>
|
|
151
|
+
<tbody>`;
|
|
152
|
+
|
|
153
|
+
reports.forEach((report, index) => {
|
|
154
|
+
html += `<tr>
|
|
155
|
+
<td style="color:var(--text-dim)">${index + 1}</td>
|
|
156
|
+
<td class="mono" title="${esc(report.chain_id || '')}">${esc(truncateId(report.chain_id, 14))}</td>
|
|
157
|
+
<td>${report.runs?.length || 0}</td>
|
|
158
|
+
<td>${report.total_turns ?? '—'}</td>
|
|
159
|
+
<td>${formatTerminalReason(report.terminal_reason)}</td>
|
|
160
|
+
<td>${formatDuration(report.total_duration_ms)}</td>
|
|
161
|
+
<td>${formatDate(report.started_at)}</td>
|
|
162
|
+
</tr>`;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
html += '</tbody></table></div>';
|
|
166
|
+
return html;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function render({ chainReports }) {
|
|
170
|
+
if (!chainReports || typeof chainReports !== 'object') {
|
|
171
|
+
return `<div class="placeholder"><h2>Chain</h2><p>No chain data available. Run a chained governed session to populate this view.</p></div>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const reports = Array.isArray(chainReports.reports) ? chainReports.reports : [];
|
|
175
|
+
const latest = chainReports.latest || reports[0] || null;
|
|
176
|
+
|
|
177
|
+
if (!latest || reports.length === 0) {
|
|
178
|
+
return `<div class="placeholder"><h2>Chain</h2><p>No chain reports found. Run <code>agentxchain run --chain</code> to record automatic continuation lineage.</p></div>`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let html = `<div class="chain-view"><div class="run-header"><div class="run-meta">`;
|
|
182
|
+
html += `<span class="turn-count">latest chain ${esc(latest.chain_id || '—')}</span>`;
|
|
183
|
+
html += badge(`${latest.runs?.length || 0} runs`, '#38bdf8');
|
|
184
|
+
html += badge(`${latest.total_turns || 0} turns`, 'var(--green)');
|
|
185
|
+
html += formatTerminalReason(latest.terminal_reason);
|
|
186
|
+
html += `</div></div>`;
|
|
187
|
+
|
|
188
|
+
html += `<div class="section"><h3>Latest Chain Summary</h3><dl class="detail-list">`;
|
|
189
|
+
html += `<dt>Started</dt><dd>${esc(latest.started_at || '—')}</dd>`;
|
|
190
|
+
html += `<dt>Completed</dt><dd>${esc(latest.completed_at || '—')}</dd>`;
|
|
191
|
+
html += `<dt>Total Duration</dt><dd>${formatDuration(latest.total_duration_ms)}</dd>`;
|
|
192
|
+
html += `<dt>Total Turns</dt><dd>${latest.total_turns ?? '—'}</dd>`;
|
|
193
|
+
html += `<dt>Terminal Reason</dt><dd>${esc(latest.terminal_reason || '—')}</dd>`;
|
|
194
|
+
html += `</dl></div>`;
|
|
195
|
+
|
|
196
|
+
html += renderLatestRunsTable(latest);
|
|
197
|
+
html += renderRecentChainsTable(reports);
|
|
198
|
+
html += '</div>';
|
|
199
|
+
return html;
|
|
200
|
+
}
|
package/dashboard/index.html
CHANGED
|
@@ -405,6 +405,7 @@
|
|
|
405
405
|
<a href="#gate">Gates</a>
|
|
406
406
|
<a href="#blockers">Blockers</a>
|
|
407
407
|
<a href="#artifacts">Artifacts</a>
|
|
408
|
+
<a href="#chain">Chain</a>
|
|
408
409
|
<a href="#run-history">Run History</a>
|
|
409
410
|
<a href="#timeouts">Timeouts</a>
|
|
410
411
|
<a href="#coordinator-timeouts">Coordinator Timeouts</a>
|
package/package.json
CHANGED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentxchain chain — operator-facing read surface for chain reports.
|
|
3
|
+
*
|
|
4
|
+
* Surfaces chain report metadata so operators can inspect lights-out
|
|
5
|
+
* run-chaining history without opening raw JSON files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { findProjectRoot } from '../lib/config.js';
|
|
10
|
+
import {
|
|
11
|
+
loadAllChainReports,
|
|
12
|
+
loadChainReport,
|
|
13
|
+
loadLatestChainReport,
|
|
14
|
+
} from '../lib/chain-reports.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* agentxchain chain latest — show the most recent chain report.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts - { json?: boolean, dir?: string }
|
|
20
|
+
*/
|
|
21
|
+
export async function chainLatestCommand(opts) {
|
|
22
|
+
const root = findProjectRoot(opts.dir || process.cwd());
|
|
23
|
+
if (!root) {
|
|
24
|
+
console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const report = loadLatestChainReport(root);
|
|
29
|
+
if (!report) {
|
|
30
|
+
console.log(chalk.dim('No chain reports found.'));
|
|
31
|
+
console.log(chalk.dim(' Run `agentxchain run --chain` to enable auto-chaining.'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (opts.json) {
|
|
36
|
+
console.log(JSON.stringify(report, null, 2));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
renderChainReport(report);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* agentxchain chain list — list all chain reports.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} opts - { json?: boolean, limit?: number, dir?: string }
|
|
47
|
+
*/
|
|
48
|
+
export async function chainListCommand(opts) {
|
|
49
|
+
const root = findProjectRoot(opts.dir || process.cwd());
|
|
50
|
+
if (!root) {
|
|
51
|
+
console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reports = loadAllChainReports(root);
|
|
56
|
+
const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
|
|
57
|
+
const limited = reports.slice(0, limit);
|
|
58
|
+
|
|
59
|
+
if (opts.json) {
|
|
60
|
+
console.log(JSON.stringify(limited, null, 2));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (limited.length === 0) {
|
|
65
|
+
console.log(chalk.dim('No chain reports found.'));
|
|
66
|
+
console.log(chalk.dim(' Run `agentxchain run --chain` to enable auto-chaining.'));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Table header
|
|
71
|
+
const header = [
|
|
72
|
+
pad('#', 4),
|
|
73
|
+
pad('Chain ID', 16),
|
|
74
|
+
pad('Runs', 6),
|
|
75
|
+
pad('Turns', 7),
|
|
76
|
+
pad('Terminal Reason', 28),
|
|
77
|
+
pad('Duration', 12),
|
|
78
|
+
pad('Started', 22),
|
|
79
|
+
].join(' ');
|
|
80
|
+
|
|
81
|
+
console.log(chalk.bold(header));
|
|
82
|
+
console.log(chalk.dim('─'.repeat(header.length)));
|
|
83
|
+
|
|
84
|
+
limited.forEach((report, i) => {
|
|
85
|
+
const idx = String(i + 1);
|
|
86
|
+
const chainId = report.chain_id || '—';
|
|
87
|
+
const runs = String(report.runs?.length || 0);
|
|
88
|
+
const turns = String(report.total_turns || 0);
|
|
89
|
+
const terminal = formatTerminalReason(report.terminal_reason);
|
|
90
|
+
const duration = report.total_duration_ms != null
|
|
91
|
+
? formatDuration(report.total_duration_ms)
|
|
92
|
+
: '—';
|
|
93
|
+
const started = report.started_at
|
|
94
|
+
? new Date(report.started_at).toLocaleString()
|
|
95
|
+
: '—';
|
|
96
|
+
|
|
97
|
+
console.log([
|
|
98
|
+
pad(idx, 4),
|
|
99
|
+
pad(chainId, 16),
|
|
100
|
+
pad(runs, 6),
|
|
101
|
+
pad(turns, 7),
|
|
102
|
+
pad(terminal, 28),
|
|
103
|
+
pad(duration, 12),
|
|
104
|
+
pad(started, 22),
|
|
105
|
+
].join(' '));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
console.log(chalk.dim(`\n${limited.length} chain(s) shown${reports.length > limit ? ` (${reports.length} total)` : ''}`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* agentxchain chain show <chain_id> — show a specific chain report.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} chainId
|
|
115
|
+
* @param {object} opts - { json?: boolean, dir?: string }
|
|
116
|
+
*/
|
|
117
|
+
export async function chainShowCommand(chainId, opts) {
|
|
118
|
+
const root = findProjectRoot(opts.dir || process.cwd());
|
|
119
|
+
if (!root) {
|
|
120
|
+
console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const report = loadChainReport(root, chainId);
|
|
125
|
+
if (!report) {
|
|
126
|
+
console.error(chalk.red(`Chain report not found: ${chainId}`));
|
|
127
|
+
console.log(chalk.dim(' Use `agentxchain chain list` to see available chain reports.'));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (opts.json) {
|
|
132
|
+
console.log(JSON.stringify(report, null, 2));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
renderChainReport(report);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function renderChainReport(report) {
|
|
142
|
+
console.log(chalk.bold(`Chain Report: ${report.chain_id}`));
|
|
143
|
+
console.log('');
|
|
144
|
+
console.log(` Total runs: ${report.runs?.length || 0}`);
|
|
145
|
+
console.log(` Total turns: ${report.total_turns || 0}`);
|
|
146
|
+
console.log(` Duration: ${formatDuration(report.total_duration_ms || 0)}`);
|
|
147
|
+
console.log(` Terminal: ${formatTerminalReason(report.terminal_reason)}`);
|
|
148
|
+
console.log(` Started: ${report.started_at || '—'}`);
|
|
149
|
+
console.log(` Completed: ${report.completed_at || '—'}`);
|
|
150
|
+
console.log('');
|
|
151
|
+
|
|
152
|
+
if (!report.runs || report.runs.length === 0) {
|
|
153
|
+
console.log(chalk.dim(' No runs recorded.'));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Run table header
|
|
158
|
+
const runHeader = [
|
|
159
|
+
pad('#', 4),
|
|
160
|
+
pad('Run ID', 14),
|
|
161
|
+
pad('Status', 12),
|
|
162
|
+
pad('Trigger', 14),
|
|
163
|
+
pad('Turns', 7),
|
|
164
|
+
pad('Duration', 12),
|
|
165
|
+
pad('Parent', 14),
|
|
166
|
+
pad('Ctx', 40),
|
|
167
|
+
].join(' ');
|
|
168
|
+
|
|
169
|
+
console.log(chalk.bold(' Runs:'));
|
|
170
|
+
console.log(` ${chalk.dim(runHeader)}`);
|
|
171
|
+
console.log(` ${chalk.dim('─'.repeat(runHeader.length))}`);
|
|
172
|
+
|
|
173
|
+
report.runs.forEach((run, i) => {
|
|
174
|
+
const idx = String(i + 1);
|
|
175
|
+
const runId = (run.run_id || '—').slice(0, 12);
|
|
176
|
+
const status = formatStatus(run.status);
|
|
177
|
+
const trigger = run.provenance_trigger || '—';
|
|
178
|
+
const turns = String(run.turns || 0);
|
|
179
|
+
const duration = run.duration_ms != null ? formatDuration(run.duration_ms) : '—';
|
|
180
|
+
const parent = run.parent_run_id ? run.parent_run_id.slice(0, 12) : '—';
|
|
181
|
+
const ctx = formatInheritedContextSummary(run.inherited_context_summary);
|
|
182
|
+
|
|
183
|
+
console.log(` ${[
|
|
184
|
+
pad(idx, 4),
|
|
185
|
+
pad(runId, 14),
|
|
186
|
+
pad(status, 12),
|
|
187
|
+
pad(trigger, 14),
|
|
188
|
+
pad(turns, 7),
|
|
189
|
+
pad(duration, 12),
|
|
190
|
+
pad(parent, 14),
|
|
191
|
+
pad(ctx, 40),
|
|
192
|
+
].join(' ')}`);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatInheritedContextSummary(summary) {
|
|
197
|
+
if (!summary) return '—';
|
|
198
|
+
|
|
199
|
+
const parts = [];
|
|
200
|
+
if (summary.parent_roles_used?.length) {
|
|
201
|
+
parts.push(`${summary.parent_roles_used.length} roles`);
|
|
202
|
+
}
|
|
203
|
+
if (summary.parent_phases_completed_count > 0) {
|
|
204
|
+
parts.push(`${summary.parent_phases_completed_count} phases`);
|
|
205
|
+
}
|
|
206
|
+
if (summary.recent_decisions_count > 0) {
|
|
207
|
+
parts.push(`${summary.recent_decisions_count} decisions`);
|
|
208
|
+
}
|
|
209
|
+
if (summary.recent_accepted_turns_count > 0) {
|
|
210
|
+
parts.push(`${summary.recent_accepted_turns_count} turns`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return parts.length > 0 ? parts.join(', ') : '—';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function formatTerminalReason(reason) {
|
|
217
|
+
if (!reason) return '—';
|
|
218
|
+
switch (reason) {
|
|
219
|
+
case 'chain_limit_reached': return chalk.cyan('chain limit reached');
|
|
220
|
+
case 'non_chainable_status': return chalk.yellow('non-chainable status');
|
|
221
|
+
case 'operator_abort': return chalk.red('operator abort');
|
|
222
|
+
case 'parent_validation_failed': return chalk.red('parent validation failed');
|
|
223
|
+
case 'completed': return chalk.green('completed');
|
|
224
|
+
case 'blocked': return chalk.yellow('blocked');
|
|
225
|
+
default: return reason;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function formatStatus(status) {
|
|
230
|
+
if (status === 'completed') return chalk.green('completed');
|
|
231
|
+
if (status === 'blocked') return chalk.yellow('blocked');
|
|
232
|
+
if (status === 'failed') return chalk.red('failed');
|
|
233
|
+
return status || '—';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function formatDuration(ms) {
|
|
237
|
+
if (ms < 1000) return `${ms}ms`;
|
|
238
|
+
const seconds = Math.floor(ms / 1000);
|
|
239
|
+
if (seconds < 60) return `${seconds}s`;
|
|
240
|
+
const minutes = Math.floor(seconds / 60);
|
|
241
|
+
const remainingSeconds = seconds % 60;
|
|
242
|
+
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
|
243
|
+
const hours = Math.floor(minutes / 60);
|
|
244
|
+
const remainingMinutes = minutes % 60;
|
|
245
|
+
return `${hours}h ${remainingMinutes}m`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function pad(str, width) {
|
|
249
|
+
return String(str).padEnd(width);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Data Loading ──────────────────────────────────────────────────────────────
|
package/src/commands/diff.js
CHANGED
|
@@ -215,9 +215,28 @@ function formatValue(value, label = '') {
|
|
|
215
215
|
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
|
216
216
|
if (label === 'Cost' || label === 'Budget') return `$${value.toFixed(4)}`;
|
|
217
217
|
if (label === 'Duration') return formatDuration(value);
|
|
218
|
+
if (label === 'Blocked reason' && value && typeof value === 'object') {
|
|
219
|
+
return formatBlockedReason(value);
|
|
220
|
+
}
|
|
221
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
218
222
|
return String(value);
|
|
219
223
|
}
|
|
220
224
|
|
|
225
|
+
function formatBlockedReason(reason) {
|
|
226
|
+
const category = reason.category || 'unknown';
|
|
227
|
+
const gateAction = reason.gate_action;
|
|
228
|
+
if (category === 'gate_action_failed' && gateAction) {
|
|
229
|
+
const actionLabel = gateAction.action_label || gateAction.command || 'unknown action';
|
|
230
|
+
if (gateAction.timed_out) {
|
|
231
|
+
return `gate_action_failed: ${actionLabel} timed out after ${gateAction.timeout_ms}ms`;
|
|
232
|
+
}
|
|
233
|
+
const exit = gateAction.exit_code != null ? ` (exit ${gateAction.exit_code})` : '';
|
|
234
|
+
return `gate_action_failed: ${actionLabel} failed${exit}`;
|
|
235
|
+
}
|
|
236
|
+
const detail = reason.detail || reason.recovery?.detail || '';
|
|
237
|
+
return detail ? `${category}: ${detail}` : category;
|
|
238
|
+
}
|
|
239
|
+
|
|
221
240
|
function formatDelta(delta, label) {
|
|
222
241
|
if (delta == null || delta === 0) return '';
|
|
223
242
|
if (label === 'Cost' || label === 'Budget') {
|
package/src/commands/run.js
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
getDispatchTurnDir,
|
|
45
45
|
getTurnStagingResultPath,
|
|
46
46
|
} from '../lib/turn-paths.js';
|
|
47
|
+
import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
|
|
47
48
|
|
|
48
49
|
export async function runCommand(opts) {
|
|
49
50
|
const context = loadProjectContext();
|
|
@@ -52,6 +53,14 @@ export async function runCommand(opts) {
|
|
|
52
53
|
process.exit(1);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
const chainOpts = resolveChainOptions(opts, context.config);
|
|
57
|
+
if (chainOpts.enabled) {
|
|
58
|
+
console.log(chalk.cyan.bold('agentxchain run --chain'));
|
|
59
|
+
console.log(chalk.dim(` Chain mode: enabled (max ${chainOpts.maxChains} continuations, on: ${chainOpts.chainOn.join(',')}, cooldown: ${chainOpts.cooldownSeconds}s)`));
|
|
60
|
+
const { exitCode } = await executeChainedRun(context, opts, chainOpts, executeGovernedRun);
|
|
61
|
+
process.exit(exitCode);
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
const execution = await executeGovernedRun(context, opts);
|
|
56
65
|
process.exit(execution.exitCode);
|
|
57
66
|
}
|
|
@@ -167,8 +176,7 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
167
176
|
let aborted = false;
|
|
168
177
|
let sigintCount = 0;
|
|
169
178
|
const controller = new AbortController();
|
|
170
|
-
|
|
171
|
-
process.on('SIGINT', () => {
|
|
179
|
+
const onSigint = () => {
|
|
172
180
|
sigintCount++;
|
|
173
181
|
if (sigintCount >= 2) {
|
|
174
182
|
process.exit(130);
|
|
@@ -176,37 +184,39 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
176
184
|
aborted = true;
|
|
177
185
|
controller.abort();
|
|
178
186
|
log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
|
|
179
|
-
}
|
|
187
|
+
};
|
|
188
|
+
process.on('SIGINT', onSigint);
|
|
180
189
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
try {
|
|
191
|
+
// ── Run header ──────────────────────────────────────────────────────────
|
|
192
|
+
log(chalk.cyan.bold('agentxchain run'));
|
|
193
|
+
log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
|
|
194
|
+
if (provenance) {
|
|
195
|
+
const provenanceSummary = summarizeRunProvenance(provenance);
|
|
196
|
+
if (provenanceSummary) {
|
|
197
|
+
log(` ${chalk.dim('Origin:')} ${chalk.magenta(provenanceSummary)}`);
|
|
198
|
+
}
|
|
188
199
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
log('');
|
|
200
|
+
if (inheritedContext) {
|
|
201
|
+
const ic = inheritedContext;
|
|
202
|
+
const phasesCount = ic.parent_phases_completed?.length || 0;
|
|
203
|
+
const decisionsCount = ic.recent_decisions?.length || 0;
|
|
204
|
+
const turnsCount = ic.recent_accepted_turns?.length || 0;
|
|
205
|
+
const parts = [];
|
|
206
|
+
if (phasesCount) parts.push(`${phasesCount} phase${phasesCount !== 1 ? 's' : ''}`);
|
|
207
|
+
if (decisionsCount) parts.push(`${decisionsCount} decision${decisionsCount !== 1 ? 's' : ''}`);
|
|
208
|
+
if (turnsCount) parts.push(`${turnsCount} turn${turnsCount !== 1 ? 's' : ''}`);
|
|
209
|
+
const detail = parts.length ? ` — ${parts.join(', ')}` : '';
|
|
210
|
+
log(` ${chalk.dim('Inherits:')} ${chalk.magenta(`parent ${ic.parent_run_id} (${ic.parent_status || 'unknown'})${detail}`)}`);
|
|
211
|
+
}
|
|
212
|
+
log('');
|
|
203
213
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
214
|
+
// ── Track first-call for --role override ────────────────────────────────
|
|
215
|
+
let firstSelectRole = true;
|
|
216
|
+
let qaMissingCredentialsFallback = null;
|
|
207
217
|
|
|
208
|
-
|
|
209
|
-
|
|
218
|
+
// ── Callbacks ───────────────────────────────────────────────────────────
|
|
219
|
+
const callbacks = {
|
|
210
220
|
selectRole(state, cfg) {
|
|
211
221
|
if (aborted) return null;
|
|
212
222
|
|
|
@@ -407,84 +417,87 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
407
417
|
break;
|
|
408
418
|
}
|
|
409
419
|
},
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// ── Execute ─────────────────────────────────────────────────────────────
|
|
413
|
-
const runLoopOpts = {
|
|
414
|
-
maxTurns,
|
|
415
|
-
startNewRunFromCompleted: true,
|
|
416
|
-
startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
|
|
417
|
-
};
|
|
418
|
-
if (provenance) runLoopOpts.provenance = provenance;
|
|
419
|
-
if (inheritedContext) runLoopOpts.inheritedContext = inheritedContext;
|
|
420
|
-
const result = await runLoop(root, config, callbacks, runLoopOpts);
|
|
420
|
+
};
|
|
421
421
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
422
|
+
// ── Execute ─────────────────────────────────────────────────────────────
|
|
423
|
+
const runLoopOpts = {
|
|
424
|
+
maxTurns,
|
|
425
|
+
startNewRunFromCompleted: true,
|
|
426
|
+
startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
|
|
427
|
+
};
|
|
428
|
+
if (provenance) runLoopOpts.provenance = provenance;
|
|
429
|
+
if (inheritedContext) runLoopOpts.inheritedContext = inheritedContext;
|
|
430
|
+
const result = await runLoop(root, config, callbacks, runLoopOpts);
|
|
431
|
+
|
|
432
|
+
// ── Summary ─────────────────────────────────────────────────────────────
|
|
433
|
+
log('');
|
|
434
|
+
log(chalk.dim('─── Run Summary ───'));
|
|
435
|
+
log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
|
|
436
|
+
log(` Turns: ${result.turns_executed}`);
|
|
437
|
+
log(` Gates: ${result.gates_approved} approved`);
|
|
438
|
+
log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
|
|
439
|
+
|
|
440
|
+
if (result.errors.length) {
|
|
441
|
+
for (const err of result.errors) {
|
|
442
|
+
log(chalk.red(` ${err}`));
|
|
443
|
+
}
|
|
433
444
|
}
|
|
434
|
-
}
|
|
435
445
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
446
|
+
if (qaMissingCredentialsFallback) {
|
|
447
|
+
printManualQaFallback(log);
|
|
448
|
+
}
|
|
439
449
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
450
|
+
// Recovery guidance for blocked/rejected states
|
|
451
|
+
if (result.state && (result.stop_reason === 'blocked' || result.stop_reason === 'reject_exhausted' || result.stop_reason === 'dispatch_error')) {
|
|
452
|
+
const recovery = deriveRecoveryDescriptor(result.state, config);
|
|
453
|
+
if (recovery) {
|
|
454
|
+
log('');
|
|
455
|
+
log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
|
|
456
|
+
log(chalk.dim(` Action: ${recovery.recovery_action}`));
|
|
457
|
+
if (recovery.detail) {
|
|
458
|
+
log(chalk.dim(` Detail: ${recovery.detail}`));
|
|
459
|
+
}
|
|
449
460
|
}
|
|
450
461
|
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// ── Auto governance report ──────────────────────────────────────────────
|
|
454
|
-
if (opts.report !== false && result.state) {
|
|
455
|
-
try {
|
|
456
|
-
const reportsDir = join(root, '.agentxchain', 'reports');
|
|
457
|
-
mkdirSync(reportsDir, { recursive: true });
|
|
458
|
-
|
|
459
|
-
const exportResult = buildRunExport(root);
|
|
460
|
-
if (exportResult.ok) {
|
|
461
|
-
const runId = result.state.run_id || 'unknown';
|
|
462
|
-
const exportPath = join(reportsDir, `export-${runId}.json`);
|
|
463
|
-
writeFileSync(exportPath, JSON.stringify(exportResult.export, null, 2));
|
|
464
|
-
|
|
465
|
-
const reportResult = buildGovernanceReport(exportResult.export, { input: exportPath });
|
|
466
|
-
const reportPath = join(reportsDir, `report-${runId}.md`);
|
|
467
|
-
writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
|
|
468
462
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
463
|
+
// ── Auto governance report ──────────────────────────────────────────────
|
|
464
|
+
if (opts.report !== false && result.state) {
|
|
465
|
+
try {
|
|
466
|
+
const reportsDir = join(root, '.agentxchain', 'reports');
|
|
467
|
+
mkdirSync(reportsDir, { recursive: true });
|
|
468
|
+
|
|
469
|
+
const exportResult = buildRunExport(root);
|
|
470
|
+
if (exportResult.ok) {
|
|
471
|
+
const runId = result.state.run_id || 'unknown';
|
|
472
|
+
const exportPath = join(reportsDir, `export-${runId}.json`);
|
|
473
|
+
writeFileSync(exportPath, JSON.stringify(exportResult.export, null, 2));
|
|
474
|
+
|
|
475
|
+
const reportResult = buildGovernanceReport(exportResult.export, { input: exportPath });
|
|
476
|
+
const reportPath = join(reportsDir, `report-${runId}.md`);
|
|
477
|
+
writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
|
|
478
|
+
|
|
479
|
+
log('');
|
|
480
|
+
log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
|
|
481
|
+
} else {
|
|
482
|
+
log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
|
|
483
|
+
}
|
|
484
|
+
} catch (err) {
|
|
485
|
+
log(chalk.dim(` Governance report failed: ${err.message}`));
|
|
473
486
|
}
|
|
474
|
-
} catch (err) {
|
|
475
|
-
log(chalk.dim(` Governance report failed: ${err.message}`));
|
|
476
487
|
}
|
|
477
|
-
}
|
|
478
488
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
489
|
+
// ── Exit code ───────────────────────────────────────────────────────────
|
|
490
|
+
const successReasons = new Set(['completed', 'gate_held', 'caller_stopped', 'max_turns_reached']);
|
|
491
|
+
return {
|
|
492
|
+
exitCode: result.ok || successReasons.has(result.stop_reason) ? 0 : 1,
|
|
493
|
+
result,
|
|
494
|
+
skipped: false,
|
|
495
|
+
skipReason: null,
|
|
496
|
+
provenance: provenance || null,
|
|
497
|
+
};
|
|
498
|
+
} finally {
|
|
499
|
+
process.removeListener('SIGINT', onSigint);
|
|
500
|
+
}
|
|
488
501
|
}
|
|
489
502
|
|
|
490
503
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function getChainReportsDir(root) {
|
|
5
|
+
return join(root, '.agentxchain', 'reports');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function loadAllChainReports(root) {
|
|
9
|
+
const reportsDir = getChainReportsDir(root);
|
|
10
|
+
if (!existsSync(reportsDir)) return [];
|
|
11
|
+
|
|
12
|
+
const files = readdirSync(reportsDir)
|
|
13
|
+
.filter((file) => file.startsWith('chain-') && file.endsWith('.json'))
|
|
14
|
+
.sort()
|
|
15
|
+
.reverse();
|
|
16
|
+
|
|
17
|
+
const reports = [];
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
try {
|
|
20
|
+
const content = readFileSync(join(reportsDir, file), 'utf8');
|
|
21
|
+
reports.push(JSON.parse(content));
|
|
22
|
+
} catch {
|
|
23
|
+
// Advisory artifact only. Skip malformed files instead of failing the surface.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
reports.sort((a, b) => {
|
|
28
|
+
const aTime = a.started_at ? new Date(a.started_at).getTime() : 0;
|
|
29
|
+
const bTime = b.started_at ? new Date(b.started_at).getTime() : 0;
|
|
30
|
+
return bTime - aTime;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return reports;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadLatestChainReport(root) {
|
|
37
|
+
const reports = loadAllChainReports(root);
|
|
38
|
+
return reports.length > 0 ? reports[0] : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadChainReport(root, chainId) {
|
|
42
|
+
const reportsDir = getChainReportsDir(root);
|
|
43
|
+
const exactPath = join(reportsDir, `${chainId}.json`);
|
|
44
|
+
if (existsSync(exactPath)) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(readFileSync(exactPath, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const reports = loadAllChainReports(root);
|
|
53
|
+
return reports.find((report) => report.chain_id === chainId) || null;
|
|
54
|
+
}
|
|
@@ -29,6 +29,7 @@ import { queryRunHistory } from '../run-history.js';
|
|
|
29
29
|
import { loadProjectContext, loadProjectState } from '../config.js';
|
|
30
30
|
import { evaluateApprovalSlaReminders } from '../notification-runner.js';
|
|
31
31
|
import { readGateActionSnapshot } from './gate-action-reader.js';
|
|
32
|
+
import { readChainReportSnapshot } from './chain-report-reader.js';
|
|
32
33
|
|
|
33
34
|
const MIME_TYPES = {
|
|
34
35
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -462,6 +463,13 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
|
|
|
462
463
|
return;
|
|
463
464
|
}
|
|
464
465
|
|
|
466
|
+
if (pathname === '/api/chain-reports') {
|
|
467
|
+
const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
|
|
468
|
+
const result = readChainReportSnapshot(workspacePath, { limit });
|
|
469
|
+
writeJson(res, result.status, result.body);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
465
473
|
if (pathname === '/api/gate-actions') {
|
|
466
474
|
const result = readGateActionSnapshot(workspacePath);
|
|
467
475
|
writeJson(res, result.status, result.body);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { loadAllChainReports, loadLatestChainReport } from '../chain-reports.js';
|
|
2
|
+
|
|
3
|
+
export function readChainReportSnapshot(workspacePath, { limit } = {}) {
|
|
4
|
+
const reports = loadAllChainReports(workspacePath);
|
|
5
|
+
const effectiveLimit = Number.isInteger(limit) && limit > 0 ? limit : reports.length;
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
ok: true,
|
|
9
|
+
status: 200,
|
|
10
|
+
body: {
|
|
11
|
+
latest: loadLatestChainReport(workspacePath),
|
|
12
|
+
reports: reports.slice(0, effectiveLimit),
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -62,6 +62,7 @@ FILE_TO_RESOURCE[normalizeRelativePath(REPO_DECISIONS_FILE)] = '/api/repo-decisi
|
|
|
62
62
|
export const WATCH_DIRECTORIES = [
|
|
63
63
|
'',
|
|
64
64
|
MULTIREPO_DIR,
|
|
65
|
+
'reports',
|
|
65
66
|
];
|
|
66
67
|
|
|
67
68
|
export function normalizeRelativePath(filePath) {
|
|
@@ -69,7 +70,11 @@ export function normalizeRelativePath(filePath) {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export function resourceForRelativePath(filePath) {
|
|
72
|
-
|
|
73
|
+
const normalized = normalizeRelativePath(filePath);
|
|
74
|
+
if (normalized.startsWith('reports/chain-') && normalized.endsWith('.json')) {
|
|
75
|
+
return '/api/chain-reports';
|
|
76
|
+
}
|
|
77
|
+
return FILE_TO_RESOURCE[normalized] || null;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
/**
|
package/src/lib/export-diff.js
CHANGED
|
@@ -216,6 +216,9 @@ function normalizeRunExport(artifact) {
|
|
|
216
216
|
budget_warn_mode: budgetStatus.warn_mode === true,
|
|
217
217
|
budget_exhausted: budgetStatus.exhausted === true,
|
|
218
218
|
phase_gate_status: normalizeGateStatusMap(phaseGateStatus),
|
|
219
|
+
blocked_category: state.blocked_reason?.category || null,
|
|
220
|
+
blocked_gate_action_timed_out: state.blocked_reason?.gate_action?.timed_out === true,
|
|
221
|
+
blocked_gate_action_timeout_ms: typeof state.blocked_reason?.gate_action?.timeout_ms === 'number' ? state.blocked_reason.gate_action.timeout_ms : null,
|
|
219
222
|
delegation_missing_decisions: normalizeDelegationMissingMap(summary.delegation_summary),
|
|
220
223
|
};
|
|
221
224
|
}
|
|
@@ -563,11 +566,17 @@ function detectRunRegressions(left, right) {
|
|
|
563
566
|
const leftGate = (left.phase_gate_status || {})[gateId] || null;
|
|
564
567
|
const rightGate = (right.phase_gate_status || {})[gateId] || null;
|
|
565
568
|
if (leftGate && rightGate && GATE_PASSED_STATES.has(leftGate) && GATE_FAILED_STATES.has(rightGate)) {
|
|
569
|
+
let causeDetail = '';
|
|
570
|
+
if (right.blocked_category === 'gate_action_failed') {
|
|
571
|
+
causeDetail = right.blocked_gate_action_timed_out
|
|
572
|
+
? ` (gate action timed out after ${right.blocked_gate_action_timeout_ms}ms)`
|
|
573
|
+
: ' (gate action failed)';
|
|
574
|
+
}
|
|
566
575
|
regressions.push({
|
|
567
576
|
id: `REG-GATE-${String(++counter).padStart(3, '0')}`,
|
|
568
577
|
category: 'gate',
|
|
569
578
|
severity: 'error',
|
|
570
|
-
message: `Gate "${gateId}" regressed from ${leftGate} to ${rightGate}`,
|
|
579
|
+
message: `Gate "${gateId}" regressed from ${leftGate} to ${rightGate}${causeDetail}`,
|
|
571
580
|
field: `phase_gate_status.${gateId}`,
|
|
572
581
|
left: leftGate,
|
|
573
582
|
right: rightGate,
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Chain — auto-chaining governed runs for lights-out operation.
|
|
3
|
+
*
|
|
4
|
+
* When a governed run completes (or reaches another chainable terminal status),
|
|
5
|
+
* this module automatically starts a new run that inherits context from the
|
|
6
|
+
* previous one. Removes the manual `--continue-from` step and enables
|
|
7
|
+
* continuous governed delivery.
|
|
8
|
+
*
|
|
9
|
+
* Spec: .planning/RUN_CHAIN_SPEC.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from 'crypto';
|
|
13
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { recordRunHistory, validateParentRun } from './run-history.js';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_CHAINS = 5;
|
|
18
|
+
const DEFAULT_CHAIN_ON = ['completed'];
|
|
19
|
+
const DEFAULT_COOLDOWN_SECONDS = 5;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve chain options from CLI flags and config, with CLI flags taking precedence.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} opts - CLI options
|
|
25
|
+
* @param {object} config - agentxchain.json config
|
|
26
|
+
* @returns {{ enabled: boolean, maxChains: number, chainOn: string[], cooldownSeconds: number }}
|
|
27
|
+
*/
|
|
28
|
+
export function resolveChainOptions(opts, config) {
|
|
29
|
+
const configChain = config?.run_loop?.chain || {};
|
|
30
|
+
|
|
31
|
+
const enabled = opts.chain ?? configChain.enabled ?? false;
|
|
32
|
+
const maxChains = opts.maxChains ?? configChain.max_chains ?? DEFAULT_MAX_CHAINS;
|
|
33
|
+
const cooldownSeconds = opts.chainCooldown ?? configChain.cooldown_seconds ?? DEFAULT_COOLDOWN_SECONDS;
|
|
34
|
+
|
|
35
|
+
let chainOn;
|
|
36
|
+
if (opts.chainOn) {
|
|
37
|
+
chainOn = typeof opts.chainOn === 'string'
|
|
38
|
+
? opts.chainOn.split(',').map(s => s.trim()).filter(Boolean)
|
|
39
|
+
: opts.chainOn;
|
|
40
|
+
} else if (Array.isArray(configChain.chain_on)) {
|
|
41
|
+
chainOn = configChain.chain_on;
|
|
42
|
+
} else {
|
|
43
|
+
chainOn = DEFAULT_CHAIN_ON;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { enabled, maxChains, chainOn, cooldownSeconds };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Execute a chained sequence of governed runs.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} context - { root, config }
|
|
53
|
+
* @param {object} opts - CLI options (passed to executeGovernedRun)
|
|
54
|
+
* @param {object} chainOpts - resolved chain options
|
|
55
|
+
* @param {Function} executeGovernedRun - the run executor function
|
|
56
|
+
* @param {Function} [log] - logging function
|
|
57
|
+
* @returns {Promise<{ exitCode: number, chainReport: object }>}
|
|
58
|
+
*/
|
|
59
|
+
export async function executeChainedRun(context, opts, chainOpts, executeGovernedRun, log = console.log) {
|
|
60
|
+
const chainId = `chain-${randomUUID().slice(0, 8)}`;
|
|
61
|
+
const chainOnSet = new Set(chainOpts.chainOn);
|
|
62
|
+
const maxRuns = chainOpts.maxChains + 1; // initial + continuations
|
|
63
|
+
const startedAt = new Date().toISOString();
|
|
64
|
+
|
|
65
|
+
const chainReport = {
|
|
66
|
+
chain_id: chainId,
|
|
67
|
+
started_at: startedAt,
|
|
68
|
+
runs: [],
|
|
69
|
+
total_turns: 0,
|
|
70
|
+
total_duration_ms: 0,
|
|
71
|
+
terminal_reason: null,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let previousRunId = null;
|
|
75
|
+
let lastExitCode = 0;
|
|
76
|
+
let aborted = false;
|
|
77
|
+
|
|
78
|
+
// Capture SIGINT to prevent chaining after current run
|
|
79
|
+
const onSigint = () => { aborted = true; };
|
|
80
|
+
process.on('SIGINT', onSigint);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
for (let i = 0; i < maxRuns; i++) {
|
|
84
|
+
const runNumber = i + 1;
|
|
85
|
+
log('');
|
|
86
|
+
log(` \u2500\u2500 Chain run ${runNumber}/${maxRuns} ${'─'.repeat(50)}`);
|
|
87
|
+
|
|
88
|
+
// Build continuation options for runs after the first
|
|
89
|
+
const runOpts = { ...opts };
|
|
90
|
+
if (previousRunId) {
|
|
91
|
+
runOpts.continueFrom = previousRunId;
|
|
92
|
+
runOpts.inheritContext = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const runStart = Date.now();
|
|
96
|
+
const execution = await executeGovernedRun(context, runOpts);
|
|
97
|
+
const runDuration = Date.now() - runStart;
|
|
98
|
+
|
|
99
|
+
const runId = execution.result?.state?.run_id || `unknown-${i}`;
|
|
100
|
+
const stopReason = execution.result?.stop_reason || (execution.result?.ok ? 'completed' : 'unknown');
|
|
101
|
+
const turnsExecuted = execution.result?.turns_executed || 0;
|
|
102
|
+
|
|
103
|
+
chainReport.runs.push(buildRunReportEntry(execution, runDuration, {
|
|
104
|
+
fallbackRunId: runId,
|
|
105
|
+
fallbackStatus: stopReason,
|
|
106
|
+
fallbackTurns: turnsExecuted,
|
|
107
|
+
}));
|
|
108
|
+
chainReport.total_turns += turnsExecuted;
|
|
109
|
+
chainReport.total_duration_ms += runDuration;
|
|
110
|
+
|
|
111
|
+
lastExitCode = execution.exitCode;
|
|
112
|
+
previousRunId = runId;
|
|
113
|
+
|
|
114
|
+
// Check abort
|
|
115
|
+
if (aborted) {
|
|
116
|
+
chainReport.terminal_reason = 'operator_abort';
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check if this is the last possible run
|
|
121
|
+
if (i === maxRuns - 1) {
|
|
122
|
+
chainReport.terminal_reason = 'chain_limit_reached';
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check if terminal status is chainable
|
|
127
|
+
if (!chainOnSet.has(stopReason)) {
|
|
128
|
+
chainReport.terminal_reason = 'non_chainable_status';
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate parent run exists for continuation
|
|
133
|
+
let validation = validateParentRun(context.root, runId);
|
|
134
|
+
if (!validation.ok && execution.result?.state && (stopReason === 'completed' || stopReason === 'blocked')) {
|
|
135
|
+
const repair = recordRunHistory(context.root, execution.result.state, context.config, stopReason);
|
|
136
|
+
if (repair.ok) {
|
|
137
|
+
validation = validateParentRun(context.root, runId);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!validation.ok) {
|
|
141
|
+
log(` Chain: cannot continue — ${validation.error}`);
|
|
142
|
+
chainReport.terminal_reason = 'parent_validation_failed';
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Cooldown
|
|
147
|
+
const continuationsRemaining = Math.max(0, maxRuns - (runNumber + 1));
|
|
148
|
+
log('');
|
|
149
|
+
log(` Chain: run ${stopReason} \u2192 starting continuation (${continuationsRemaining} remaining)...`);
|
|
150
|
+
if (chainOpts.cooldownSeconds > 0) {
|
|
151
|
+
log(` Waiting ${chainOpts.cooldownSeconds}s...`);
|
|
152
|
+
await sleep(chainOpts.cooldownSeconds * 1000);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check abort again after cooldown
|
|
156
|
+
if (aborted) {
|
|
157
|
+
chainReport.terminal_reason = 'operator_abort';
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
process.removeListener('SIGINT', onSigint);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If terminal_reason not set, derive from last run
|
|
166
|
+
if (!chainReport.terminal_reason) {
|
|
167
|
+
const lastRun = chainReport.runs[chainReport.runs.length - 1];
|
|
168
|
+
chainReport.terminal_reason = lastRun?.status || 'unknown';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
chainReport.completed_at = new Date().toISOString();
|
|
172
|
+
|
|
173
|
+
// Write chain report
|
|
174
|
+
writeChainReport(context.root, chainReport);
|
|
175
|
+
|
|
176
|
+
// Print chain summary
|
|
177
|
+
printChainSummary(chainReport, log);
|
|
178
|
+
|
|
179
|
+
return { exitCode: lastExitCode, chainReport };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Write chain report to .agentxchain/reports/.
|
|
184
|
+
*/
|
|
185
|
+
function writeChainReport(root, report) {
|
|
186
|
+
try {
|
|
187
|
+
const reportsDir = join(root, '.agentxchain', 'reports');
|
|
188
|
+
mkdirSync(reportsDir, { recursive: true });
|
|
189
|
+
const reportPath = join(reportsDir, `${report.chain_id}.json`);
|
|
190
|
+
writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
191
|
+
} catch {
|
|
192
|
+
// Non-fatal — chain report is advisory
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Print chain summary to terminal.
|
|
198
|
+
*/
|
|
199
|
+
function printChainSummary(report, log) {
|
|
200
|
+
log('');
|
|
201
|
+
log(' \u2500\u2500\u2500 Chain Summary \u2500\u2500\u2500');
|
|
202
|
+
log(` Total runs: ${report.runs.length}`);
|
|
203
|
+
log(` Total turns: ${report.total_turns}`);
|
|
204
|
+
log(` Duration: ${formatDuration(report.total_duration_ms)}`);
|
|
205
|
+
log(` Terminal: ${report.terminal_reason}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Format milliseconds to a human-readable duration.
|
|
210
|
+
*/
|
|
211
|
+
function formatDuration(ms) {
|
|
212
|
+
if (ms < 1000) return `${ms}ms`;
|
|
213
|
+
const seconds = Math.floor(ms / 1000);
|
|
214
|
+
if (seconds < 60) return `${seconds}s`;
|
|
215
|
+
const minutes = Math.floor(seconds / 60);
|
|
216
|
+
const remainingSeconds = seconds % 60;
|
|
217
|
+
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
|
218
|
+
const hours = Math.floor(minutes / 60);
|
|
219
|
+
const remainingMinutes = minutes % 60;
|
|
220
|
+
return `${hours}h ${remainingMinutes}m`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sleep(ms) {
|
|
224
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildRunReportEntry(execution, runDuration, fallback = {}) {
|
|
228
|
+
const state = execution?.result?.state || null;
|
|
229
|
+
const provenance = state?.provenance || null;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
run_id: state?.run_id || fallback.fallbackRunId || 'unknown',
|
|
233
|
+
status: execution?.result?.stop_reason || fallback.fallbackStatus || 'unknown',
|
|
234
|
+
turns: execution?.result?.turns_executed || fallback.fallbackTurns || 0,
|
|
235
|
+
duration_ms: runDuration,
|
|
236
|
+
provenance_trigger: provenance?.trigger || null,
|
|
237
|
+
parent_run_id: provenance?.parent_run_id || null,
|
|
238
|
+
inherited_context_summary: summarizeInheritedContext(state?.inherited_context || null),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function summarizeInheritedContext(inheritedContext) {
|
|
243
|
+
if (!inheritedContext) return null;
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
parent_run_id: inheritedContext.parent_run_id || null,
|
|
247
|
+
parent_status: inheritedContext.parent_status || null,
|
|
248
|
+
inherited_at: inheritedContext.inherited_at || null,
|
|
249
|
+
parent_roles_used: Array.isArray(inheritedContext.parent_roles_used)
|
|
250
|
+
? inheritedContext.parent_roles_used
|
|
251
|
+
: [],
|
|
252
|
+
parent_phases_completed_count: Array.isArray(inheritedContext.parent_phases_completed)
|
|
253
|
+
? inheritedContext.parent_phases_completed.length
|
|
254
|
+
: 0,
|
|
255
|
+
recent_decisions_count: Array.isArray(inheritedContext.recent_decisions)
|
|
256
|
+
? inheritedContext.recent_decisions.length
|
|
257
|
+
: 0,
|
|
258
|
+
recent_accepted_turns_count: Array.isArray(inheritedContext.recent_accepted_turns)
|
|
259
|
+
? inheritedContext.recent_accepted_turns.length
|
|
260
|
+
: 0,
|
|
261
|
+
};
|
|
262
|
+
}
|