agentxchain 2.107.0 → 2.109.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 +2 -0
- package/dashboard/app.js +46 -5
- package/dashboard/components/blocked.js +55 -0
- package/dashboard/components/gate.js +49 -0
- package/dashboard/components/timeline.js +100 -1
- package/dashboard/components/timeouts.js +21 -3
- package/package.json +1 -1
- package/src/commands/approve-completion.js +40 -1
- package/src/commands/approve-transition.js +44 -1
- package/src/commands/events.js +33 -1
- package/src/commands/status.js +50 -4
- package/src/commands/step.js +2 -0
- package/src/lib/dashboard/bridge-server.js +49 -0
- package/src/lib/dashboard/gate-action-reader.js +58 -0
- package/src/lib/dashboard/timeout-status.js +47 -18
- package/src/lib/gate-actions.js +232 -0
- package/src/lib/governed-state.js +178 -3
- package/src/lib/normalized-config.js +6 -1
- package/src/lib/notification-runner.js +162 -6
- package/src/lib/report.js +131 -3
- package/src/lib/run-events.js +1 -0
- package/src/lib/run-loop.js +50 -2
package/bin/agentxchain.js
CHANGED
|
@@ -521,11 +521,13 @@ program
|
|
|
521
521
|
program
|
|
522
522
|
.command('approve-transition')
|
|
523
523
|
.description('Approve a pending phase transition that requires human sign-off')
|
|
524
|
+
.option('--dry-run', 'Show configured gate actions without executing approval')
|
|
524
525
|
.action(approveTransitionCommand);
|
|
525
526
|
|
|
526
527
|
program
|
|
527
528
|
.command('approve-completion')
|
|
528
529
|
.description('Approve a pending run completion that requires human sign-off')
|
|
530
|
+
.option('--dry-run', 'Show configured gate actions without executing approval')
|
|
529
531
|
.action(approveCompletionCommand);
|
|
530
532
|
|
|
531
533
|
program
|
package/dashboard/app.js
CHANGED
|
@@ -25,12 +25,12 @@ import {
|
|
|
25
25
|
} from './live-observer.js';
|
|
26
26
|
|
|
27
27
|
const VIEWS = {
|
|
28
|
-
timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations', 'connectors', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderTimeline },
|
|
28
|
+
timeline: { fetch: ['state', 'continuity', 'history', 'events', 'audit', 'annotations', 'connectors', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderTimeline },
|
|
29
29
|
delegations: { fetch: ['state', 'history'], render: renderDelegations },
|
|
30
30
|
ledger: { fetch: ['state', 'ledger', 'coordinatorState', 'coordinatorLedger', 'repoDecisionsSummary'], render: renderLedger },
|
|
31
31
|
hooks: { fetch: ['audit', 'annotations', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderHooks },
|
|
32
|
-
blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit', 'coordinatorBlockers', 'coordinatorRepoStatusRows'], render: renderBlocked },
|
|
33
|
-
gate: { fetch: ['state', 'history', 'coordinatorState', 'coordinatorHistory', 'coordinatorBarriers'], render: renderGate },
|
|
32
|
+
blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit', 'coordinatorBlockers', 'coordinatorRepoStatusRows', 'gateActions'], render: renderBlocked },
|
|
33
|
+
gate: { fetch: ['state', 'history', 'coordinatorState', 'coordinatorHistory', 'coordinatorBarriers', 'gateActions'], render: renderGate },
|
|
34
34
|
initiative: { fetch: ['coordinatorState', 'coordinatorBarriers', 'barrierLedger', 'coordinatorBlockers', 'coordinatorRepoStatusRows'], render: renderInitiative },
|
|
35
35
|
'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
|
|
36
36
|
blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
|
|
@@ -62,6 +62,8 @@ const API_MAP = {
|
|
|
62
62
|
runHistory: '/api/run-history',
|
|
63
63
|
timeouts: '/api/timeouts',
|
|
64
64
|
coordinatorTimeouts: '/api/coordinator/timeouts',
|
|
65
|
+
gateActions: '/api/gate-actions',
|
|
66
|
+
events: '/api/events?type=turn_conflicted&limit=10',
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
const viewState = {
|
|
@@ -78,11 +80,14 @@ const viewState = {
|
|
|
78
80
|
hookName: 'all',
|
|
79
81
|
},
|
|
80
82
|
};
|
|
83
|
+
const DASHBOARD_POLL_INTERVAL_MS = 60 * 1000;
|
|
81
84
|
|
|
82
85
|
let activeViewName = null;
|
|
83
86
|
let activeViewData = null;
|
|
84
87
|
let dashboardSession = null;
|
|
85
88
|
let actionInFlight = false;
|
|
89
|
+
let pollInFlight = false;
|
|
90
|
+
let pollTimer = null;
|
|
86
91
|
const liveObserverState = {
|
|
87
92
|
connected: false,
|
|
88
93
|
lastRefreshAt: null,
|
|
@@ -145,7 +150,7 @@ async function pickInitialView() {
|
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
function buildRenderData(viewName, data) {
|
|
148
|
-
const liveMeta = viewName === 'timeline'
|
|
153
|
+
const liveMeta = (viewName === 'timeline' || viewName === 'timeouts')
|
|
149
154
|
? buildLiveMeta({
|
|
150
155
|
connected: liveObserverState.connected,
|
|
151
156
|
lastRefreshAt: liveObserverState.lastRefreshAt,
|
|
@@ -273,6 +278,39 @@ function rerenderActiveView() {
|
|
|
273
278
|
renderView(activeViewName, activeViewData);
|
|
274
279
|
}
|
|
275
280
|
|
|
281
|
+
async function pollDashboard({ refreshView = false } = {}) {
|
|
282
|
+
if (pollInFlight) return;
|
|
283
|
+
pollInFlight = true;
|
|
284
|
+
try {
|
|
285
|
+
await fetch('/api/poll', { cache: 'no-store' });
|
|
286
|
+
if (refreshView) {
|
|
287
|
+
await loadView(currentView());
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
// Best-effort heartbeat only
|
|
291
|
+
} finally {
|
|
292
|
+
pollInFlight = false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function startDashboardPolling() {
|
|
297
|
+
if (pollTimer) return;
|
|
298
|
+
|
|
299
|
+
const tick = () => {
|
|
300
|
+
if (document.visibilityState === 'hidden') return;
|
|
301
|
+
if (actionInFlight) return;
|
|
302
|
+
void pollDashboard({ refreshView: true });
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
pollTimer = setInterval(tick, DASHBOARD_POLL_INTERVAL_MS);
|
|
306
|
+
|
|
307
|
+
document.addEventListener('visibilitychange', () => {
|
|
308
|
+
if (document.visibilityState === 'visible') {
|
|
309
|
+
void pollDashboard({ refreshView: true });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
276
314
|
// ── WebSocket connection ──────────────────────────────────────────────────
|
|
277
315
|
|
|
278
316
|
let ws = null;
|
|
@@ -290,7 +328,9 @@ function connect() {
|
|
|
290
328
|
statusLabel.textContent = 'Connected';
|
|
291
329
|
reconnectDelay = 1000;
|
|
292
330
|
liveObserverState.connected = true;
|
|
293
|
-
|
|
331
|
+
void pollDashboard().finally(() => {
|
|
332
|
+
loadView(currentView());
|
|
333
|
+
});
|
|
294
334
|
};
|
|
295
335
|
|
|
296
336
|
ws.onmessage = (event) => {
|
|
@@ -500,5 +540,6 @@ function fallbackSelect(el) {
|
|
|
500
540
|
|
|
501
541
|
Promise.all([pickInitialView(), loadSession()]).finally(() => {
|
|
502
542
|
updateNav();
|
|
543
|
+
startDashboardPolling();
|
|
503
544
|
connect();
|
|
504
545
|
});
|
|
@@ -92,6 +92,54 @@ function formatCoordinatorRepoCardMeta(row) {
|
|
|
92
92
|
return parts.join(' | ') || '-';
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
function getGateActionDryRunCommand(gateActions, state) {
|
|
96
|
+
const gateType = gateActions?.latest_attempt?.gate_type
|
|
97
|
+
|| (state?.pending_run_completion ? 'run_completion' : null)
|
|
98
|
+
|| (state?.pending_phase_transition ? 'phase_transition' : null);
|
|
99
|
+
|
|
100
|
+
return gateType === 'run_completion'
|
|
101
|
+
? 'agentxchain approve-completion --dry-run'
|
|
102
|
+
: 'agentxchain approve-transition --dry-run';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderGateActionFailure(gateActions, state) {
|
|
106
|
+
if (!gateActions?.latest_attempt || gateActions.latest_attempt.status !== 'failed') return '';
|
|
107
|
+
|
|
108
|
+
const attempt = gateActions.latest_attempt;
|
|
109
|
+
const actions = Array.isArray(attempt.actions) ? attempt.actions : [];
|
|
110
|
+
const dryRunCommand = getGateActionDryRunCommand(gateActions, state);
|
|
111
|
+
|
|
112
|
+
let html = `<div class="section"><h3>Gate Action Failure</h3>`;
|
|
113
|
+
html += `<dl class="detail-list">`;
|
|
114
|
+
html += `<dt>Attempt</dt><dd class="mono">${esc(attempt.attempt_id || '-')}</dd>`;
|
|
115
|
+
html += `<dt>Gate</dt><dd class="mono">${esc(attempt.gate_id || '-')}</dd>`;
|
|
116
|
+
html += `<dt>Attempted At</dt><dd class="mono">${esc(attempt.attempted_at || '-')}</dd>`;
|
|
117
|
+
html += `</dl>`;
|
|
118
|
+
|
|
119
|
+
if (actions.length > 0) {
|
|
120
|
+
html += `<div class="annotation-list">`;
|
|
121
|
+
for (const action of actions) {
|
|
122
|
+
const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
|
|
123
|
+
const outcome = action.status === 'failed' ? '❌ failed' : '✅ succeeded';
|
|
124
|
+
const exitStr = action.exit_code != null ? ` (exit ${action.exit_code})` : '';
|
|
125
|
+
html += `<div class="annotation-card">
|
|
126
|
+
<span class="mono">${esc(String(action.action_index || '?'))}.</span>
|
|
127
|
+
<span>${esc(label)}</span>
|
|
128
|
+
<span>${esc(outcome)}${esc(exitStr)}</span>
|
|
129
|
+
</div>`;
|
|
130
|
+
if (action.status === 'failed' && action.stderr_tail) {
|
|
131
|
+
html += `<pre class="recovery-command mono">${esc(action.stderr_tail)}</pre>`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
html += `</div>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
html += `<p class="recovery-hint">Re-run with dry-run first:</p>`;
|
|
138
|
+
html += `<pre class="recovery-command mono" data-copy="${esc(dryRunCommand)}">${esc(dryRunCommand)}</pre>`;
|
|
139
|
+
html += `</div>`;
|
|
140
|
+
return html;
|
|
141
|
+
}
|
|
142
|
+
|
|
95
143
|
export function render({
|
|
96
144
|
state,
|
|
97
145
|
audit = [],
|
|
@@ -99,6 +147,7 @@ export function render({
|
|
|
99
147
|
coordinatorAudit = [],
|
|
100
148
|
coordinatorBlockers = null,
|
|
101
149
|
coordinatorRepoStatusRows = null,
|
|
150
|
+
gateActions = null,
|
|
102
151
|
}) {
|
|
103
152
|
const activeState = state?.status === 'blocked' ? state : coordinatorState;
|
|
104
153
|
const activeAudit = activeState === state ? audit : coordinatorAudit;
|
|
@@ -168,6 +217,12 @@ export function render({
|
|
|
168
217
|
</div>`;
|
|
169
218
|
}
|
|
170
219
|
|
|
220
|
+
// Gate-action failure detail (only for gate_action_failed blocks)
|
|
221
|
+
const category = String(reason).toLowerCase();
|
|
222
|
+
if (category.includes('gate_action_failed') && !isCoordinator) {
|
|
223
|
+
html += renderGateActionFailure(gateActions, activeState);
|
|
224
|
+
}
|
|
225
|
+
|
|
171
226
|
if (runtimeGuidance.length > 0) {
|
|
172
227
|
html += `<div class="section"><h3>Runtime Guidance</h3><div class="annotation-list">`;
|
|
173
228
|
for (const entry of runtimeGuidance) {
|
|
@@ -210,12 +210,55 @@ function aggregateCoordinatorEvidence(entries) {
|
|
|
210
210
|
return { summaries, decisions, objections: [], risks: [], files };
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
function renderGateActionsSection(gateActions) {
|
|
214
|
+
if (!gateActions) return '';
|
|
215
|
+
|
|
216
|
+
const configured = Array.isArray(gateActions.configured) ? gateActions.configured : [];
|
|
217
|
+
const attempt = gateActions.latest_attempt || null;
|
|
218
|
+
|
|
219
|
+
if (configured.length === 0 && !attempt) return '';
|
|
220
|
+
|
|
221
|
+
let html = `<div class="gate-support"><p><strong>Gate Actions:</strong></p>`;
|
|
222
|
+
|
|
223
|
+
if (configured.length > 0) {
|
|
224
|
+
html += `<ul>`;
|
|
225
|
+
for (const action of configured) {
|
|
226
|
+
const label = action.label || action.run || `action ${action.index || '?'}`;
|
|
227
|
+
html += `<li><div><span class="mono">${esc(String(action.index || '?'))}.</span> ${esc(label)}</div>`;
|
|
228
|
+
if (action.run) {
|
|
229
|
+
html += `<div class="mono">${esc(action.run)}</div>`;
|
|
230
|
+
}
|
|
231
|
+
html += `</li>`;
|
|
232
|
+
}
|
|
233
|
+
html += `</ul>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (attempt) {
|
|
237
|
+
const statusLabel = attempt.status === 'failed' ? '❌ Failed' : '✅ Succeeded';
|
|
238
|
+
html += `<p><strong>Last Attempt:</strong> ${esc(statusLabel)} at ${esc(attempt.attempted_at || 'unknown')}</p>`;
|
|
239
|
+
if (Array.isArray(attempt.actions) && attempt.actions.length > 0) {
|
|
240
|
+
html += `<ul>`;
|
|
241
|
+
for (const a of attempt.actions) {
|
|
242
|
+
const aLabel = a.action_label || a.command || `action ${a.action_index || '?'}`;
|
|
243
|
+
const outcome = a.status === 'failed' ? '❌' : '✅';
|
|
244
|
+
const exitStr = a.exit_code != null ? ` (exit ${a.exit_code})` : '';
|
|
245
|
+
html += `<li>${outcome} ${esc(aLabel)}${esc(exitStr)}</li>`;
|
|
246
|
+
}
|
|
247
|
+
html += `</ul>`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
html += `</div>`;
|
|
252
|
+
return html;
|
|
253
|
+
}
|
|
254
|
+
|
|
213
255
|
export function render({
|
|
214
256
|
state,
|
|
215
257
|
history = [],
|
|
216
258
|
coordinatorState = null,
|
|
217
259
|
coordinatorHistory = [],
|
|
218
260
|
coordinatorBarriers = {},
|
|
261
|
+
gateActions = null,
|
|
219
262
|
}) {
|
|
220
263
|
const repoPendingTransition = state?.pending_phase_transition || null;
|
|
221
264
|
const repoPendingCompletion = state?.pending_run_completion || null;
|
|
@@ -284,6 +327,9 @@ export function render({
|
|
|
284
327
|
)).join('')}</ul></div>`;
|
|
285
328
|
}
|
|
286
329
|
}
|
|
330
|
+
if (!isCoordinator) {
|
|
331
|
+
html += renderGateActionsSection(gateActions);
|
|
332
|
+
}
|
|
287
333
|
html += renderApproveControls({
|
|
288
334
|
buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Transition',
|
|
289
335
|
cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-transition',
|
|
@@ -329,6 +375,9 @@ export function render({
|
|
|
329
375
|
if (evidence.files.length > 0) {
|
|
330
376
|
html += `<div class="gate-support"><p><strong>Files Changed:</strong></p><ul>${evidence.files.map(f => `<li class="mono">${esc(f)}</li>`).join('')}</ul></div>`;
|
|
331
377
|
}
|
|
378
|
+
if (!isCoordinator) {
|
|
379
|
+
html += renderGateActionsSection(gateActions);
|
|
380
|
+
}
|
|
332
381
|
html += renderApproveControls({
|
|
333
382
|
buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Completion',
|
|
334
383
|
cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-completion',
|
|
@@ -90,6 +90,11 @@ function formatTimestamp(iso) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function formatPercent(value) {
|
|
94
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
95
|
+
return `${Math.round(value * 100)}%`;
|
|
96
|
+
}
|
|
97
|
+
|
|
93
98
|
function statusBadge(status) {
|
|
94
99
|
const colors = {
|
|
95
100
|
running: 'var(--green)',
|
|
@@ -206,6 +211,99 @@ function renderDelegationReview(review) {
|
|
|
206
211
|
return `<div class="turn-detail"><span class="detail-label">Delegation Review:</span> <span class="mono">${esc(review.parent_turn_id || 'unknown')}</span> with ${esc(resultCount)} result${resultCount === 1 ? '' : 's'}</div>`;
|
|
207
212
|
}
|
|
208
213
|
|
|
214
|
+
function collectConflictCards(state, events) {
|
|
215
|
+
const latestByTurn = new Map();
|
|
216
|
+
|
|
217
|
+
if (Array.isArray(events)) {
|
|
218
|
+
for (const event of [...events].reverse()) {
|
|
219
|
+
if (event?.event_type !== 'turn_conflicted') continue;
|
|
220
|
+
const turnId = event?.turn?.turn_id;
|
|
221
|
+
if (!turnId || latestByTurn.has(turnId)) continue;
|
|
222
|
+
latestByTurn.set(turnId, {
|
|
223
|
+
turn_id: turnId,
|
|
224
|
+
role_id: event?.turn?.role_id || 'unknown',
|
|
225
|
+
detected_at: event?.timestamp || null,
|
|
226
|
+
state_label: event?.status === 'blocked' ? 'conflict loop blocked run' : 'recent conflict',
|
|
227
|
+
detection_count: typeof event?.payload?.detection_count === 'number' ? event.payload.detection_count : null,
|
|
228
|
+
conflicting_files: Array.isArray(event?.payload?.conflicting_files) ? event.payload.conflicting_files : [],
|
|
229
|
+
accepted_since_turn_ids: Array.isArray(event?.payload?.accepted_since_turn_ids) ? event.payload.accepted_since_turn_ids : [],
|
|
230
|
+
overlap_ratio: typeof event?.payload?.overlap_ratio === 'number' ? event.payload.overlap_ratio : null,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const activeTurns = state?.active_turns ? Object.values(state.active_turns) : [];
|
|
236
|
+
for (const turn of activeTurns) {
|
|
237
|
+
if (turn?.status !== 'conflicted') continue;
|
|
238
|
+
if (latestByTurn.has(turn.turn_id)) {
|
|
239
|
+
const existing = latestByTurn.get(turn.turn_id);
|
|
240
|
+
latestByTurn.set(turn.turn_id, {
|
|
241
|
+
...existing,
|
|
242
|
+
state_label: state?.blocked_reason?.category === 'conflict_loop' && state?.blocked_on?.includes(turn.turn_id)
|
|
243
|
+
? 'conflict loop blocked run'
|
|
244
|
+
: 'active conflict',
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const conflictError = turn?.conflict_state?.conflict_error || {};
|
|
250
|
+
latestByTurn.set(turn.turn_id, {
|
|
251
|
+
turn_id: turn.turn_id,
|
|
252
|
+
role_id: getRole(turn),
|
|
253
|
+
detected_at: turn?.conflict_state?.detected_at || null,
|
|
254
|
+
state_label: state?.blocked_reason?.category === 'conflict_loop' && state?.blocked_on?.includes(turn.turn_id)
|
|
255
|
+
? 'conflict loop blocked run'
|
|
256
|
+
: 'active conflict',
|
|
257
|
+
detection_count: typeof turn?.conflict_state?.detection_count === 'number' ? turn.conflict_state.detection_count : null,
|
|
258
|
+
conflicting_files: Array.isArray(conflictError.conflicting_files) ? conflictError.conflicting_files : [],
|
|
259
|
+
accepted_since_turn_ids: Array.isArray(conflictError.accepted_since)
|
|
260
|
+
? conflictError.accepted_since.map((entry) => entry?.turn_id).filter(Boolean)
|
|
261
|
+
: [],
|
|
262
|
+
overlap_ratio: typeof conflictError.overlap_ratio === 'number' ? conflictError.overlap_ratio : null,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return [...latestByTurn.values()].slice(0, 5);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderConflictPanel(state, events) {
|
|
270
|
+
const conflicts = collectConflictCards(state, events);
|
|
271
|
+
if (conflicts.length === 0) return '';
|
|
272
|
+
|
|
273
|
+
let html = `<div class="section"><h3>Conflicts</h3><div class="turn-list">`;
|
|
274
|
+
for (const conflict of conflicts) {
|
|
275
|
+
const detectedAt = formatTimestamp(conflict.detected_at);
|
|
276
|
+
const overlap = formatPercent(conflict.overlap_ratio);
|
|
277
|
+
html += `<div class="turn-card">
|
|
278
|
+
<div class="turn-header">
|
|
279
|
+
${roleBadge(conflict.role_id)}
|
|
280
|
+
<span class="mono">${esc(conflict.turn_id)}</span>
|
|
281
|
+
${statusBadge('conflicted')}
|
|
282
|
+
</div>
|
|
283
|
+
<div class="turn-detail"><span class="detail-label">Scope:</span> ${esc(conflict.state_label)}</div>`;
|
|
284
|
+
|
|
285
|
+
if (detectedAt) {
|
|
286
|
+
html += `<div class="turn-detail"><span class="detail-label">Detected:</span> ${esc(detectedAt)}</div>`;
|
|
287
|
+
}
|
|
288
|
+
if (conflict.conflicting_files.length > 0) {
|
|
289
|
+
html += `<div class="turn-detail"><span class="detail-label">Files:</span> <span class="mono">${conflict.conflicting_files.map((file) => esc(file)).join(', ')}</span></div>`;
|
|
290
|
+
}
|
|
291
|
+
if (conflict.accepted_since_turn_ids.length > 0) {
|
|
292
|
+
html += `<div class="turn-detail"><span class="detail-label">Accepted since:</span> <span class="mono">${conflict.accepted_since_turn_ids.map((turnId) => esc(turnId)).join(', ')}</span></div>`;
|
|
293
|
+
}
|
|
294
|
+
if (overlap) {
|
|
295
|
+
html += `<div class="turn-detail"><span class="detail-label">Overlap:</span> ${esc(overlap)}</div>`;
|
|
296
|
+
}
|
|
297
|
+
if (conflict.detection_count != null) {
|
|
298
|
+
html += `<div class="turn-detail"><span class="detail-label">Detection count:</span> ${esc(conflict.detection_count)}</div>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
html += `</div>`;
|
|
302
|
+
}
|
|
303
|
+
html += `</div></div>`;
|
|
304
|
+
return html;
|
|
305
|
+
}
|
|
306
|
+
|
|
209
307
|
function renderContinuityPanel(continuity) {
|
|
210
308
|
if (!continuity) return '';
|
|
211
309
|
|
|
@@ -306,7 +404,7 @@ function renderConnectorHealthPanel(connectorsPayload) {
|
|
|
306
404
|
|
|
307
405
|
export { formatDuration, computeElapsed, formatTimestamp };
|
|
308
406
|
|
|
309
|
-
export function render({ state, continuity, history, annotations, audit, connectors, coordinatorAudit = null, coordinatorAnnotations = null, liveMeta = null }) {
|
|
407
|
+
export function render({ state, continuity, history, events = null, annotations, audit, connectors, coordinatorAudit = null, coordinatorAnnotations = null, liveMeta = null }) {
|
|
310
408
|
if (!state) {
|
|
311
409
|
return `<div class="placeholder"><h2>No Run</h2><p>No governed run found. Start one with <code class="mono">agentxchain init --governed</code></p></div>`;
|
|
312
410
|
}
|
|
@@ -330,6 +428,7 @@ export function render({ state, continuity, history, annotations, audit, connect
|
|
|
330
428
|
|
|
331
429
|
html += renderContinuityPanel(continuity);
|
|
332
430
|
html += renderConnectorHealthPanel(connectors);
|
|
431
|
+
html += renderConflictPanel(state, events);
|
|
333
432
|
|
|
334
433
|
// Active turns
|
|
335
434
|
if (activeTurns.length > 0) {
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
* Pure render function: takes data from /api/timeouts, returns HTML.
|
|
5
5
|
* All evaluation is server-side. This view renders the snapshot.
|
|
6
6
|
*
|
|
7
|
+
* Receives liveMeta from the live-observer for websocket freshness display.
|
|
8
|
+
*
|
|
7
9
|
* See: TIMEOUT_DASHBOARD_SURFACE_SPEC.md
|
|
8
10
|
*/
|
|
9
11
|
|
|
12
|
+
import { renderLiveStatus } from './live-status.js';
|
|
13
|
+
|
|
10
14
|
function esc(str) {
|
|
11
15
|
if (!str) return '';
|
|
12
16
|
return String(str)
|
|
@@ -82,14 +86,22 @@ function renderConfigTable(config) {
|
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
function renderLivePressure(live) {
|
|
89
|
+
const liveContext = live?.context || null;
|
|
85
90
|
const hasExceeded = live.exceeded && live.exceeded.length > 0;
|
|
86
91
|
const hasWarnings = live.warnings && live.warnings.length > 0;
|
|
92
|
+
const approvalNote = liveContext?.awaiting_approval
|
|
93
|
+
? `<p style="color:var(--yellow)">Approval wait does not mutate timeout state, but phase/run clocks continue until the next accepted turn.${liveContext.requested_at ? ` Requested: <code>${esc(liveContext.requested_at)}</code>.` : ''}</p>`
|
|
94
|
+
: '';
|
|
87
95
|
|
|
88
96
|
if (!hasExceeded && !hasWarnings) {
|
|
89
|
-
|
|
97
|
+
const message = liveContext?.awaiting_approval
|
|
98
|
+
? `<p style="color:var(--text-dim)">No current phase/run timeout pressure during this approval wait.</p>`
|
|
99
|
+
: `<p style="color:var(--green)">No timeouts exceeded or approaching limits.</p>`;
|
|
100
|
+
return `<div class="section"><h3>Live Pressure</h3>${approvalNote}${message}</div>`;
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
let html = `<div class="section"><h3>Live Pressure</h3>
|
|
104
|
+
${approvalNote}
|
|
93
105
|
<table class="data-table">
|
|
94
106
|
<thead><tr><th>Status</th><th>Scope</th><th>Turn</th><th>Phase</th><th>Elapsed</th><th>Limit</th><th>Exceeded By</th><th>Action</th></tr></thead>
|
|
95
107
|
<tbody>`;
|
|
@@ -157,7 +169,7 @@ function renderEvents(events) {
|
|
|
157
169
|
return html;
|
|
158
170
|
}
|
|
159
171
|
|
|
160
|
-
export function render({ timeouts }) {
|
|
172
|
+
export function render({ timeouts, liveMeta }) {
|
|
161
173
|
if (!timeouts) {
|
|
162
174
|
return `<div class="placeholder"><h2>Timeouts</h2><p>No timeout data available. Ensure a governed run is active.</p></div>`;
|
|
163
175
|
}
|
|
@@ -175,6 +187,9 @@ export function render({ timeouts }) {
|
|
|
175
187
|
|
|
176
188
|
let html = `<div class="timeouts-view">`;
|
|
177
189
|
|
|
190
|
+
// Freshness banner — timeout data is time-sensitive so operators need visibility
|
|
191
|
+
html += renderLiveStatus(liveMeta);
|
|
192
|
+
|
|
178
193
|
// Header
|
|
179
194
|
html += `<div class="run-header"><div class="run-meta">`;
|
|
180
195
|
html += `<span class="phase-label"><strong>Timeouts</strong></span>`;
|
|
@@ -190,7 +205,10 @@ export function render({ timeouts }) {
|
|
|
190
205
|
|
|
191
206
|
// Live pressure
|
|
192
207
|
if (timeouts.live) {
|
|
193
|
-
html += renderLivePressure(
|
|
208
|
+
html += renderLivePressure({
|
|
209
|
+
...timeouts.live,
|
|
210
|
+
context: timeouts.live_context || null,
|
|
211
|
+
});
|
|
194
212
|
}
|
|
195
213
|
|
|
196
214
|
// Persisted events
|
package/package.json
CHANGED
|
@@ -37,11 +37,30 @@ export async function approveCompletionCommand(opts) {
|
|
|
37
37
|
console.log(` ${chalk.dim('Turn:')} ${pc.requested_by_turn}`);
|
|
38
38
|
console.log('');
|
|
39
39
|
|
|
40
|
-
const result = approveRunCompletion(root, config);
|
|
40
|
+
const result = approveRunCompletion(root, config, { dryRun: opts.dryRun });
|
|
41
|
+
|
|
42
|
+
if (result.dry_run) {
|
|
43
|
+
console.log(chalk.cyan(' Dry Run: gate approval preview only'));
|
|
44
|
+
if (result.gate_actions?.length > 0) {
|
|
45
|
+
console.log(` ${chalk.dim('Gate actions:')} ${result.gate_actions.length}`);
|
|
46
|
+
for (const action of result.gate_actions) {
|
|
47
|
+
console.log(` ${action.index}. ${action.label || action.run}`);
|
|
48
|
+
if (action.label) {
|
|
49
|
+
console.log(` ${chalk.dim(action.run)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
console.log(` ${chalk.dim('Gate actions:')} none configured`);
|
|
54
|
+
}
|
|
55
|
+
console.log('');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
41
58
|
|
|
42
59
|
if (!result.ok) {
|
|
43
60
|
if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
|
|
44
61
|
printGateHookFailure(result, 'run_completion', pc);
|
|
62
|
+
} else if (result.error_code === 'gate_action_failed') {
|
|
63
|
+
printGateActionFailure(result, pc);
|
|
45
64
|
} else {
|
|
46
65
|
console.log(chalk.red(` Failed: ${result.error}`));
|
|
47
66
|
}
|
|
@@ -49,6 +68,9 @@ export async function approveCompletionCommand(opts) {
|
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
console.log(chalk.green(' \u2713 Run completed'));
|
|
71
|
+
if (result.gateActionRun?.actions?.length > 0) {
|
|
72
|
+
console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
|
|
73
|
+
}
|
|
52
74
|
console.log(chalk.dim(` Completed at: ${result.state.completed_at}`));
|
|
53
75
|
console.log('');
|
|
54
76
|
}
|
|
@@ -78,3 +100,20 @@ function printGateHookFailure(result, gateType, gateInfo) {
|
|
|
78
100
|
}
|
|
79
101
|
console.log('');
|
|
80
102
|
}
|
|
103
|
+
|
|
104
|
+
function printGateActionFailure(result, gateInfo) {
|
|
105
|
+
const failure = result.gateActionRun?.failed_action;
|
|
106
|
+
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(chalk.yellow(' Run Completion Blocked By Gate Action'));
|
|
109
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
110
|
+
console.log('');
|
|
111
|
+
console.log(` ${chalk.dim('Gate:')} ${gateInfo.gate}`);
|
|
112
|
+
console.log(` ${chalk.dim('Action:')} ${failure?.action_label || failure?.command || '(unknown)'}`);
|
|
113
|
+
console.log(` ${chalk.dim('Exit:')} ${failure?.exit_code ?? failure?.signal ?? 'unknown'}`);
|
|
114
|
+
if (failure?.stderr_tail) {
|
|
115
|
+
console.log(` ${chalk.dim('stderr:')} ${failure.stderr_tail}`);
|
|
116
|
+
}
|
|
117
|
+
console.log(` ${chalk.dim('Retry:')} agentxchain approve-completion`);
|
|
118
|
+
console.log('');
|
|
119
|
+
}
|
|
@@ -36,11 +36,30 @@ export async function approveTransitionCommand(opts) {
|
|
|
36
36
|
console.log(` ${chalk.dim('Turn:')} ${pt.requested_by_turn}`);
|
|
37
37
|
console.log('');
|
|
38
38
|
|
|
39
|
-
const result = approvePhaseTransition(root, config);
|
|
39
|
+
const result = approvePhaseTransition(root, config, { dryRun: opts.dryRun });
|
|
40
|
+
|
|
41
|
+
if (result.dry_run) {
|
|
42
|
+
console.log(chalk.cyan(' Dry Run: gate approval preview only'));
|
|
43
|
+
if (result.gate_actions?.length > 0) {
|
|
44
|
+
console.log(` ${chalk.dim('Gate actions:')} ${result.gate_actions.length}`);
|
|
45
|
+
for (const action of result.gate_actions) {
|
|
46
|
+
console.log(` ${action.index}. ${action.label || action.run}`);
|
|
47
|
+
if (action.label) {
|
|
48
|
+
console.log(` ${chalk.dim(action.run)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
console.log(` ${chalk.dim('Gate actions:')} none configured`);
|
|
53
|
+
}
|
|
54
|
+
console.log('');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
40
57
|
|
|
41
58
|
if (!result.ok) {
|
|
42
59
|
if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
|
|
43
60
|
printGateHookFailure(result, 'phase_transition', pt);
|
|
61
|
+
} else if (result.error_code === 'gate_action_failed') {
|
|
62
|
+
printGateActionFailure(result, 'phase_transition', pt);
|
|
44
63
|
} else {
|
|
45
64
|
console.log(chalk.red(` Failed: ${result.error}`));
|
|
46
65
|
}
|
|
@@ -48,6 +67,9 @@ export async function approveTransitionCommand(opts) {
|
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
console.log(chalk.green(` ✓ Phase advanced: ${pt.from} → ${pt.to}`));
|
|
70
|
+
if (result.gateActionRun?.actions?.length > 0) {
|
|
71
|
+
console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
|
|
72
|
+
}
|
|
51
73
|
console.log(chalk.dim(` Run status: ${result.state.status}`));
|
|
52
74
|
console.log('');
|
|
53
75
|
console.log(chalk.dim(` Next: agentxchain step (to run the first turn in ${pt.to} phase)`));
|
|
@@ -83,3 +105,24 @@ function printGateHookFailure(result, gateType, gateInfo) {
|
|
|
83
105
|
}
|
|
84
106
|
console.log('');
|
|
85
107
|
}
|
|
108
|
+
|
|
109
|
+
function printGateActionFailure(result, gateType, gateInfo) {
|
|
110
|
+
const failure = result.gateActionRun?.failed_action;
|
|
111
|
+
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(chalk.yellow(` ${gateType === 'phase_transition' ? 'Phase Transition' : 'Run Completion'} Blocked By Gate Action`));
|
|
114
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
115
|
+
console.log('');
|
|
116
|
+
if (gateType === 'phase_transition') {
|
|
117
|
+
console.log(` ${chalk.dim('From:')} ${gateInfo.from}`);
|
|
118
|
+
console.log(` ${chalk.dim('To:')} ${gateInfo.to}`);
|
|
119
|
+
}
|
|
120
|
+
console.log(` ${chalk.dim('Gate:')} ${gateInfo.gate}`);
|
|
121
|
+
console.log(` ${chalk.dim('Action:')} ${failure?.action_label || failure?.command || '(unknown)'}`);
|
|
122
|
+
console.log(` ${chalk.dim('Exit:')} ${failure?.exit_code ?? failure?.signal ?? 'unknown'}`);
|
|
123
|
+
if (failure?.stderr_tail) {
|
|
124
|
+
console.log(` ${chalk.dim('stderr:')} ${failure.stderr_tail}`);
|
|
125
|
+
}
|
|
126
|
+
console.log(` ${chalk.dim('Retry:')} ${gateType === 'phase_transition' ? 'agentxchain approve-transition' : 'agentxchain approve-completion'}`);
|
|
127
|
+
console.log('');
|
|
128
|
+
}
|
package/src/commands/events.js
CHANGED
|
@@ -61,6 +61,9 @@ function printEvent(evt) {
|
|
|
61
61
|
const runId = evt.run_id ? evt.run_id.slice(0, 12) : '—';
|
|
62
62
|
const phase = evt.phase || '—';
|
|
63
63
|
const turnInfo = evt.turn?.role_id ? ` [${evt.turn.role_id}]` : '';
|
|
64
|
+
const conflictDetail = evt.event_type === 'turn_conflicted'
|
|
65
|
+
? ` — ${formatConflictDetail(evt)}`
|
|
66
|
+
: '';
|
|
64
67
|
const rejectionDetail = evt.event_type === 'turn_rejected' && evt.payload?.reason
|
|
65
68
|
? ` — ${evt.payload.reason}${evt.payload.failed_stage ? ` (${evt.payload.failed_stage})` : ''}`
|
|
66
69
|
: '';
|
|
@@ -70,7 +73,34 @@ function printEvent(evt) {
|
|
|
70
73
|
const gateFailedDetail = evt.event_type === 'gate_failed' && evt.payload?.from_phase
|
|
71
74
|
? ` ${evt.payload.from_phase} → ${evt.payload.to_phase || '?'}${evt.payload.reasons?.length ? ` — ${evt.payload.reasons[0]}` : ''}${evt.payload.gate_id ? ` (${evt.payload.gate_id})` : ''}`
|
|
72
75
|
: '';
|
|
73
|
-
console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${rejectionDetail}${phaseTransitionDetail}${gateFailedDetail}`);
|
|
76
|
+
console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${conflictDetail}${rejectionDetail}${phaseTransitionDetail}${gateFailedDetail}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatConflictDetail(evt) {
|
|
80
|
+
const payload = evt.payload || {};
|
|
81
|
+
const fileSummary = summarizeList(payload.conflicting_files, 3) || 'unknown files';
|
|
82
|
+
const overlapRatio = typeof payload.overlap_ratio === 'number'
|
|
83
|
+
? `${Math.round(payload.overlap_ratio * 100)}% overlap`
|
|
84
|
+
: null;
|
|
85
|
+
const detectionCount = Number.isInteger(payload.detection_count)
|
|
86
|
+
? `detection ${payload.detection_count}`
|
|
87
|
+
: null;
|
|
88
|
+
const turnSummary = summarizeList(payload.accepted_since_turn_ids, 2);
|
|
89
|
+
const parts = [fileSummary, overlapRatio, detectionCount];
|
|
90
|
+
if (turnSummary) {
|
|
91
|
+
parts.push(`accepted since ${turnSummary}`);
|
|
92
|
+
}
|
|
93
|
+
if (evt.status === 'blocked') {
|
|
94
|
+
parts.push('run blocked');
|
|
95
|
+
}
|
|
96
|
+
return parts.filter(Boolean).join(' | ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function summarizeList(items, limit) {
|
|
100
|
+
if (!Array.isArray(items) || items.length === 0) return '';
|
|
101
|
+
const shown = items.slice(0, limit).join(', ');
|
|
102
|
+
if (items.length <= limit) return shown;
|
|
103
|
+
return `${shown} +${items.length - limit} more`;
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
function colorEventType(type) {
|
|
@@ -81,12 +111,14 @@ function colorEventType(type) {
|
|
|
81
111
|
turn_dispatched: chalk.blue,
|
|
82
112
|
turn_accepted: chalk.green,
|
|
83
113
|
turn_rejected: chalk.yellow,
|
|
114
|
+
turn_conflicted: chalk.redBright,
|
|
84
115
|
phase_entered: chalk.magenta,
|
|
85
116
|
escalation_raised: chalk.red.bold,
|
|
86
117
|
escalation_resolved: chalk.green,
|
|
87
118
|
gate_pending: chalk.yellow,
|
|
88
119
|
gate_approved: chalk.green,
|
|
89
120
|
gate_failed: chalk.red,
|
|
121
|
+
budget_exceeded_warn: chalk.yellowBright,
|
|
90
122
|
};
|
|
91
123
|
const colorFn = colors[type] || chalk.white;
|
|
92
124
|
return colorFn(pad(type, 22));
|