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/src/commands/status.js
CHANGED
|
@@ -14,9 +14,11 @@ import { getConnectorHealth } from '../lib/connector-health.js';
|
|
|
14
14
|
import { readRepoDecisions, summarizeRepoDecisions } from '../lib/repo-decisions.js';
|
|
15
15
|
import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
|
|
16
16
|
import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
|
|
17
|
+
import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
|
|
17
18
|
import { summarizeRunProvenance } from '../lib/run-provenance.js';
|
|
18
19
|
import { readRecentRunEventSummary } from '../lib/recent-event-summary.js';
|
|
19
20
|
import { deriveConflictedTurnResolutionActions } from '../lib/conflict-actions.js';
|
|
21
|
+
import { summarizeLatestGateActionAttempt } from '../lib/gate-actions.js';
|
|
20
22
|
import { getDashboardPid, getDashboardSession } from './dashboard.js';
|
|
21
23
|
|
|
22
24
|
export async function statusCommand(opts) {
|
|
@@ -125,6 +127,14 @@ function renderGovernedStatus(context, opts) {
|
|
|
125
127
|
const repoDecisionSummary = summarizeRepoDecisions(readRepoDecisions(root), config);
|
|
126
128
|
|
|
127
129
|
const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
|
|
130
|
+
const gateActionAttempt = state?.pending_phase_transition
|
|
131
|
+
? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
|
|
132
|
+
: state?.pending_run_completion
|
|
133
|
+
? summarizeLatestGateActionAttempt(root, 'run_completion', state.pending_run_completion.gate)
|
|
134
|
+
: null;
|
|
135
|
+
|
|
136
|
+
// Fire approval SLA reminders as a side effect (webhook-only, no CLI output)
|
|
137
|
+
evaluateApprovalSlaReminders(root, config, state);
|
|
128
138
|
|
|
129
139
|
if (opts.json) {
|
|
130
140
|
const dashPid = getDashboardPid(root);
|
|
@@ -152,6 +162,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
152
162
|
next_actions: nextActions,
|
|
153
163
|
connector_health: connectorHealth,
|
|
154
164
|
recent_event_summary: recentEventSummary,
|
|
165
|
+
gate_action_attempt: gateActionAttempt,
|
|
155
166
|
workflow_kit_artifacts: workflowKitArtifacts,
|
|
156
167
|
dashboard_session: dashboardSessionObj,
|
|
157
168
|
}, null, 2));
|
|
@@ -197,6 +208,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
197
208
|
const activeTurnCount = getActiveTurnCount(state);
|
|
198
209
|
const activeTurns = getActiveTurns(state);
|
|
199
210
|
const singleActiveTurn = getActiveTurn(state);
|
|
211
|
+
const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
|
|
200
212
|
if (activeTurnCount > 1) {
|
|
201
213
|
console.log(` ${chalk.dim('Turns:')} ${activeTurnCount} active`);
|
|
202
214
|
for (const turn of Object.values(activeTurns)) {
|
|
@@ -249,8 +261,14 @@ function renderGovernedStatus(context, opts) {
|
|
|
249
261
|
if (singleActiveTurn.status === 'conflicted' && singleActiveTurn.conflict_state) {
|
|
250
262
|
const cs = singleActiveTurn.conflict_state;
|
|
251
263
|
const files = cs.conflict_error?.conflicting_files || [];
|
|
264
|
+
const count = cs.detection_count || 1;
|
|
252
265
|
const [reassignAction, mergeAction] = deriveConflictedTurnResolutionActions(singleActiveTurn.turn_id);
|
|
253
|
-
console.log(` ${chalk.dim('Conflict:')} ${chalk.red(`${files.length} file(s) conflicting`)} — detection #${
|
|
266
|
+
console.log(` ${chalk.dim('Conflict:')} ${chalk.red(`${files.length} file(s) conflicting`)} — detection #${count}`);
|
|
267
|
+
if (cs.conflict_error?.overlap_ratio != null) {
|
|
268
|
+
console.log(` ${chalk.dim('Overlap:')} ${(cs.conflict_error.overlap_ratio * 100).toFixed(0)}%`);
|
|
269
|
+
}
|
|
270
|
+
const suggestion = cs.conflict_error?.suggested_resolution || 'reject_and_reassign';
|
|
271
|
+
console.log(` ${chalk.dim('Suggest:')} ${suggestion}`);
|
|
254
272
|
console.log(` ${chalk.dim('Resolve:')} ${chalk.cyan(reassignAction.command)}`);
|
|
255
273
|
console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
|
|
256
274
|
}
|
|
@@ -321,12 +339,33 @@ function renderGovernedStatus(context, opts) {
|
|
|
321
339
|
const pt = state.pending_phase_transition;
|
|
322
340
|
console.log(` ${chalk.dim('Pending:')} ${formatGovernedPhase(pt.from)} → ${formatGovernedPhase(pt.to)}`);
|
|
323
341
|
console.log(` ${chalk.dim('Gate:')} ${pt.gate} (requires human approval)`);
|
|
342
|
+
if (pt.requested_at) {
|
|
343
|
+
console.log(` ${chalk.dim('Requested:')} ${pt.requested_at} (${timeSince(pt.requested_at)} ago)`);
|
|
344
|
+
}
|
|
324
345
|
}
|
|
325
346
|
|
|
326
347
|
if (state?.pending_run_completion) {
|
|
327
348
|
const pc = state.pending_run_completion;
|
|
328
349
|
console.log(` ${chalk.dim('Pending:')} ${chalk.bold('Run Completion')}`);
|
|
329
350
|
console.log(` ${chalk.dim('Gate:')} ${pc.gate} (requires human approval)`);
|
|
351
|
+
if (pc.requested_at) {
|
|
352
|
+
console.log(` ${chalk.dim('Requested:')} ${pc.requested_at} (${timeSince(pc.requested_at)} ago)`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (gateActionAttempt) {
|
|
357
|
+
console.log(` ${chalk.dim('Gate actions:')} ${gateActionAttempt.status} at ${gateActionAttempt.attempted_at || 'unknown time'}`);
|
|
358
|
+
for (const action of gateActionAttempt.actions) {
|
|
359
|
+
const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
|
|
360
|
+
const outcome = action.status === 'failed'
|
|
361
|
+
? chalk.red('failed')
|
|
362
|
+
: chalk.green('succeeded');
|
|
363
|
+
const exit = action.exit_code == null ? '' : ` (exit ${action.exit_code})`;
|
|
364
|
+
console.log(` ${action.action_index || '?'}. ${label} — ${outcome}${exit}`);
|
|
365
|
+
if (action.status === 'failed' && action.stderr_tail) {
|
|
366
|
+
console.log(` ${chalk.dim(action.stderr_tail)}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
330
369
|
}
|
|
331
370
|
|
|
332
371
|
if (state?.status === 'completed') {
|
|
@@ -368,14 +407,20 @@ function renderGovernedStatus(context, opts) {
|
|
|
368
407
|
|
|
369
408
|
renderWorkflowKitArtifactsSection(workflowKitArtifacts);
|
|
370
409
|
|
|
371
|
-
if (config.timeouts && state?.status === 'active') {
|
|
372
|
-
const activeTurn = getActiveTurn(state);
|
|
410
|
+
if (config.timeouts && (state?.status === 'active' || approvalPending)) {
|
|
411
|
+
const activeTurn = state?.status === 'active' ? getActiveTurn(state) : null;
|
|
373
412
|
const turnResult = activeTurn ? { role: activeTurn.assigned_role } : undefined;
|
|
374
413
|
const timeoutEval = evaluateTimeouts({ config, state, turn: activeTurn, turnResult, now: new Date().toISOString() });
|
|
375
414
|
const allItems = [...timeoutEval.exceeded, ...timeoutEval.warnings];
|
|
376
|
-
if (allItems.length > 0) {
|
|
415
|
+
if (allItems.length > 0 || approvalPending) {
|
|
377
416
|
console.log('');
|
|
378
417
|
console.log(` ${chalk.dim('Timeouts:')}`);
|
|
418
|
+
if (approvalPending) {
|
|
419
|
+
console.log(` ${chalk.yellow('◷')} approval wait does not mutate timeout state; phase/run clocks keep ticking until the next accepted turn`);
|
|
420
|
+
}
|
|
421
|
+
if (approvalPending && allItems.length === 0) {
|
|
422
|
+
console.log(` ${chalk.dim('No current phase/run timeout pressure.')}`);
|
|
423
|
+
}
|
|
379
424
|
for (const item of allItems) {
|
|
380
425
|
const isExceeded = timeoutEval.exceeded.includes(item);
|
|
381
426
|
const elapsed = item.elapsed_minutes != null ? `${item.elapsed_minutes}m` : '?';
|
|
@@ -637,6 +682,7 @@ function formatRunStatus(status) {
|
|
|
637
682
|
|
|
638
683
|
function timeSince(iso) {
|
|
639
684
|
const ms = Date.now() - new Date(iso).getTime();
|
|
685
|
+
if (!Number.isFinite(ms) || ms < 0) return '0s';
|
|
640
686
|
const sec = Math.floor(ms / 1000);
|
|
641
687
|
if (sec < 60) return `${sec}s`;
|
|
642
688
|
const min = Math.floor(sec / 60);
|
package/src/commands/step.js
CHANGED
|
@@ -67,6 +67,7 @@ import { runHooks } from '../lib/hook-runner.js';
|
|
|
67
67
|
import { finalizeDispatchManifest, verifyDispatchManifest } from '../lib/dispatch-manifest.js';
|
|
68
68
|
import { resolveGovernedRole } from '../lib/role-resolution.js';
|
|
69
69
|
import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
|
|
70
|
+
import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
|
|
70
71
|
|
|
71
72
|
export async function stepCommand(opts) {
|
|
72
73
|
const context = loadProjectContext();
|
|
@@ -169,6 +170,7 @@ export async function stepCommand(opts) {
|
|
|
169
170
|
|
|
170
171
|
if (!skipAssignment) {
|
|
171
172
|
if (state.pending_phase_transition || state.pending_run_completion) {
|
|
173
|
+
evaluateApprovalSlaReminders(root, config, state);
|
|
172
174
|
printRecoverySummary(state, 'This run is awaiting approval.', config);
|
|
173
175
|
process.exit(1);
|
|
174
176
|
}
|
|
@@ -26,6 +26,9 @@ import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
|
|
|
26
26
|
import { readConnectorHealthSnapshot } from './connectors.js';
|
|
27
27
|
import { readTimeoutStatus } from './timeout-status.js';
|
|
28
28
|
import { queryRunHistory } from '../run-history.js';
|
|
29
|
+
import { loadProjectContext, loadProjectState } from '../config.js';
|
|
30
|
+
import { evaluateApprovalSlaReminders } from '../notification-runner.js';
|
|
31
|
+
import { readGateActionSnapshot } from './gate-action-reader.js';
|
|
29
32
|
|
|
30
33
|
const MIME_TYPES = {
|
|
31
34
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -214,6 +217,40 @@ function resolveDashboardAssetPath(dashboardDir, pathname) {
|
|
|
214
217
|
return { blocked: false, filePath };
|
|
215
218
|
}
|
|
216
219
|
|
|
220
|
+
function readDashboardPollStatus(workspacePath, replayMode) {
|
|
221
|
+
const body = {
|
|
222
|
+
ok: true,
|
|
223
|
+
polled_at: new Date().toISOString(),
|
|
224
|
+
replay_mode: replayMode,
|
|
225
|
+
governed_project_detected: false,
|
|
226
|
+
state_available: false,
|
|
227
|
+
reminder_evaluation: {
|
|
228
|
+
reminders_sent: [],
|
|
229
|
+
notifications_emitted: 0,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (replayMode) {
|
|
234
|
+
return { ok: true, status: 200, body };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const context = loadProjectContext(workspacePath);
|
|
238
|
+
if (!context || context.config?.protocol_mode !== 'governed') {
|
|
239
|
+
return { ok: true, status: 200, body };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
body.governed_project_detected = true;
|
|
243
|
+
|
|
244
|
+
const state = loadProjectState(context.root, context.config);
|
|
245
|
+
if (!state) {
|
|
246
|
+
return { ok: true, status: 200, body };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
body.state_available = true;
|
|
250
|
+
body.reminder_evaluation = evaluateApprovalSlaReminders(context.root, context.config, state);
|
|
251
|
+
return { ok: true, status: 200, body };
|
|
252
|
+
}
|
|
253
|
+
|
|
217
254
|
// ── Bridge Server ───────────────────────────────────────────────────────────
|
|
218
255
|
|
|
219
256
|
export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847, replayMode = false }) {
|
|
@@ -315,6 +352,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
|
|
|
315
352
|
return;
|
|
316
353
|
}
|
|
317
354
|
|
|
355
|
+
if (pathname === '/api/poll') {
|
|
356
|
+
const result = readDashboardPollStatus(workspacePath, replayMode);
|
|
357
|
+
writeJson(res, result.status, result.body);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
318
361
|
if (pathname === '/api/actions/approve-gate') {
|
|
319
362
|
if (replayMode) {
|
|
320
363
|
writeJson(res, 403, { ok: false, code: 'replay_mode', error: 'Replay mode: gate approval is not available on exported snapshots.' });
|
|
@@ -419,6 +462,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
|
|
|
419
462
|
return;
|
|
420
463
|
}
|
|
421
464
|
|
|
465
|
+
if (pathname === '/api/gate-actions') {
|
|
466
|
+
const result = readGateActionSnapshot(workspacePath);
|
|
467
|
+
writeJson(res, result.status, result.body);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
422
471
|
// API routes
|
|
423
472
|
if (pathname.startsWith('/api/')) {
|
|
424
473
|
const result = readResource(agentxchainDir, pathname);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate-action reader — reads gate-action config and latest attempt for the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Follows the same pattern as other dashboard readers (connectors.js, timeout-status.js):
|
|
5
|
+
* returns { status, body } for the bridge server to serialize.
|
|
6
|
+
*
|
|
7
|
+
* Per DEC-DASHBOARD-GATE-ACTIONS-001: repo-local only, read-only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { loadProjectContext, loadProjectState } from '../config.js';
|
|
11
|
+
import { getGateActions, summarizeLatestGateActionAttempt } from '../gate-actions.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read gate-action snapshot for the current pending gate (if any).
|
|
15
|
+
*
|
|
16
|
+
* @param {string} workspacePath — project root
|
|
17
|
+
* @returns {{ status: number, body: object }}
|
|
18
|
+
*/
|
|
19
|
+
export function readGateActionSnapshot(workspacePath) {
|
|
20
|
+
const context = loadProjectContext(workspacePath);
|
|
21
|
+
if (!context || context.config?.protocol_mode !== 'governed') {
|
|
22
|
+
return { status: 200, body: { configured: [], latest_attempt: null } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const state = loadProjectState(context.root, context.config);
|
|
26
|
+
if (!state) {
|
|
27
|
+
return { status: 200, body: { configured: [], latest_attempt: null } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pendingTransition = state.pending_phase_transition || null;
|
|
31
|
+
const pendingCompletion = state.pending_run_completion || null;
|
|
32
|
+
|
|
33
|
+
let gateType = null;
|
|
34
|
+
let gateId = null;
|
|
35
|
+
|
|
36
|
+
if (pendingTransition?.gate) {
|
|
37
|
+
gateType = 'phase_transition';
|
|
38
|
+
gateId = pendingTransition.gate;
|
|
39
|
+
} else if (pendingCompletion?.gate) {
|
|
40
|
+
gateType = 'run_completion';
|
|
41
|
+
gateId = pendingCompletion.gate;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!gateId) {
|
|
45
|
+
return { status: 200, body: { configured: [], latest_attempt: null } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const configured = getGateActions(context.config, gateId);
|
|
49
|
+
const latestAttempt = summarizeLatestGateActionAttempt(context.root, gateType, gateId);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
status: 200,
|
|
53
|
+
body: {
|
|
54
|
+
configured,
|
|
55
|
+
latest_attempt: latestAttempt,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -73,6 +73,30 @@ function emptyLiveTimeouts() {
|
|
|
73
73
|
return { exceeded: [], warnings: [] };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function buildLiveContext(state) {
|
|
77
|
+
const pendingPhase = state?.pending_phase_transition;
|
|
78
|
+
const pendingCompletion = state?.pending_run_completion;
|
|
79
|
+
if (pendingPhase) {
|
|
80
|
+
return {
|
|
81
|
+
awaiting_approval: true,
|
|
82
|
+
pending_gate_type: 'phase_transition',
|
|
83
|
+
requested_at: typeof pendingPhase.requested_at === 'string' ? pendingPhase.requested_at : null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (pendingCompletion) {
|
|
87
|
+
return {
|
|
88
|
+
awaiting_approval: true,
|
|
89
|
+
pending_gate_type: 'run_completion',
|
|
90
|
+
requested_at: typeof pendingCompletion.requested_at === 'string' ? pendingCompletion.requested_at : null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
awaiting_approval: false,
|
|
95
|
+
pending_gate_type: null,
|
|
96
|
+
requested_at: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
function getActiveTurns(state) {
|
|
77
101
|
if (!state?.active_turns || typeof state.active_turns !== 'object' || Array.isArray(state.active_turns)) {
|
|
78
102
|
return [];
|
|
@@ -90,7 +114,8 @@ function annotateTurnTimeout(result, state, turn) {
|
|
|
90
114
|
}
|
|
91
115
|
|
|
92
116
|
export function evaluateDashboardTimeoutPressure(config, state, now = new Date()) {
|
|
93
|
-
|
|
117
|
+
const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
|
|
118
|
+
if (!config?.timeouts || (state?.status !== 'active' && !approvalPending)) {
|
|
94
119
|
return emptyLiveTimeouts();
|
|
95
120
|
}
|
|
96
121
|
|
|
@@ -100,16 +125,18 @@ export function evaluateDashboardTimeoutPressure(config, state, now = new Date()
|
|
|
100
125
|
warnings: [...base.warnings],
|
|
101
126
|
};
|
|
102
127
|
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
128
|
+
if (state?.status === 'active') {
|
|
129
|
+
for (const turn of getActiveTurns(state)) {
|
|
130
|
+
const turnEval = evaluateTimeouts({ config, state, turn, now });
|
|
131
|
+
for (const item of turnEval.exceeded) {
|
|
132
|
+
if (item.scope === 'turn') {
|
|
133
|
+
live.exceeded.push(annotateTurnTimeout(item, state, turn));
|
|
134
|
+
}
|
|
108
135
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
for (const item of turnEval.warnings) {
|
|
137
|
+
if (item.scope === 'turn') {
|
|
138
|
+
live.warnings.push(annotateTurnTimeout(item, state, turn));
|
|
139
|
+
}
|
|
113
140
|
}
|
|
114
141
|
}
|
|
115
142
|
}
|
|
@@ -172,6 +199,7 @@ export function readTimeoutStatus(workspacePath) {
|
|
|
172
199
|
configured: false,
|
|
173
200
|
config: null,
|
|
174
201
|
live: null,
|
|
202
|
+
live_context: null,
|
|
175
203
|
events: [],
|
|
176
204
|
},
|
|
177
205
|
};
|
|
@@ -190,12 +218,13 @@ export function readTimeoutStatus(workspacePath) {
|
|
|
190
218
|
return {
|
|
191
219
|
ok: true,
|
|
192
220
|
status: 200,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
221
|
+
body: {
|
|
222
|
+
ok: true,
|
|
223
|
+
configured: true,
|
|
224
|
+
config: configSummary,
|
|
225
|
+
live,
|
|
226
|
+
live_context: buildLiveContext(state),
|
|
227
|
+
events,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
201
230
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
const LEDGER_PATH = '.agentxchain/decision-ledger.jsonl';
|
|
7
|
+
const MAX_OUTPUT_TAIL_CHARS = 1200;
|
|
8
|
+
|
|
9
|
+
export function validateGateActionsConfig(gates, errors) {
|
|
10
|
+
if (!gates || typeof gates !== 'object' || Array.isArray(gates)) {
|
|
11
|
+
errors.push('gates must be an object');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const [gateId, gate] of Object.entries(gates)) {
|
|
16
|
+
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
17
|
+
errors.push(`Gate "${gateId}" must be an object`);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!('gate_actions' in gate)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!Array.isArray(gate.gate_actions) || gate.gate_actions.length === 0) {
|
|
26
|
+
errors.push(`Gate "${gateId}": gate_actions must be a non-empty array when provided`);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (gate.requires_human_approval !== true) {
|
|
31
|
+
errors.push(`Gate "${gateId}": gate_actions require requires_human_approval: true`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < gate.gate_actions.length; i++) {
|
|
35
|
+
const action = gate.gate_actions[i];
|
|
36
|
+
const prefix = `gates.${gateId}.gate_actions[${i}]`;
|
|
37
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
38
|
+
errors.push(`${prefix} must be an object`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (typeof action.run !== 'string' || !action.run.trim()) {
|
|
42
|
+
errors.push(`${prefix}.run must be a non-empty string`);
|
|
43
|
+
}
|
|
44
|
+
if ('label' in action && (typeof action.label !== 'string' || !action.label.trim())) {
|
|
45
|
+
errors.push(`${prefix}.label must be a non-empty string when provided`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getGateActions(config, gateId) {
|
|
52
|
+
const actions = config?.gates?.[gateId]?.gate_actions;
|
|
53
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return actions
|
|
58
|
+
.filter((action) => action && typeof action === 'object' && typeof action.run === 'string' && action.run.trim())
|
|
59
|
+
.map((action, index) => ({
|
|
60
|
+
index: index + 1,
|
|
61
|
+
label: typeof action.label === 'string' && action.label.trim() ? action.label.trim() : null,
|
|
62
|
+
run: action.run.trim(),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function trimOutputTail(value) {
|
|
67
|
+
if (typeof value !== 'string') {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const trimmed = value.trim();
|
|
71
|
+
if (!trimmed) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (trimmed.length <= MAX_OUTPUT_TAIL_CHARS) {
|
|
75
|
+
return trimmed;
|
|
76
|
+
}
|
|
77
|
+
return trimmed.slice(-MAX_OUTPUT_TAIL_CHARS);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildGateActionEntry(action, meta, runtimeResult, status) {
|
|
81
|
+
return {
|
|
82
|
+
type: 'gate_action',
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
attempt_id: meta.attemptId,
|
|
85
|
+
gate_id: meta.gateId,
|
|
86
|
+
gate_type: meta.gateType,
|
|
87
|
+
phase: meta.phase || null,
|
|
88
|
+
requested_by_turn: meta.requestedByTurn || null,
|
|
89
|
+
trigger_command: meta.triggerCommand || null,
|
|
90
|
+
action_index: action.index,
|
|
91
|
+
action_label: action.label,
|
|
92
|
+
command: action.run,
|
|
93
|
+
status,
|
|
94
|
+
exit_code: Number.isInteger(runtimeResult?.status) ? runtimeResult.status : null,
|
|
95
|
+
signal: runtimeResult?.signal || null,
|
|
96
|
+
stdout_tail: trimOutputTail(runtimeResult?.stdout),
|
|
97
|
+
stderr_tail: trimOutputTail(runtimeResult?.stderr),
|
|
98
|
+
spawn_error: runtimeResult?.error?.message || null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function executeGateActions(root, config, meta, opts = {}) {
|
|
103
|
+
const actions = getGateActions(config, meta.gateId);
|
|
104
|
+
if (actions.length === 0) {
|
|
105
|
+
return { ok: true, dry_run: Boolean(opts.dryRun), actions: [] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (opts.dryRun) {
|
|
109
|
+
return { ok: true, dry_run: true, actions };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const attemptId = `ga_${Date.now()}_${randomBytes(4).toString('hex')}`;
|
|
113
|
+
const results = [];
|
|
114
|
+
|
|
115
|
+
for (const action of actions) {
|
|
116
|
+
const runtimeResult = spawnSync('/bin/sh', ['-lc', action.run], {
|
|
117
|
+
cwd: root,
|
|
118
|
+
env: {
|
|
119
|
+
...process.env,
|
|
120
|
+
AGENTXCHAIN_GATE_ID: meta.gateId,
|
|
121
|
+
AGENTXCHAIN_GATE_TYPE: meta.gateType,
|
|
122
|
+
AGENTXCHAIN_PHASE: meta.phase || '',
|
|
123
|
+
AGENTXCHAIN_REQUESTED_BY_TURN: meta.requestedByTurn || '',
|
|
124
|
+
AGENTXCHAIN_TRIGGER_COMMAND: meta.triggerCommand || '',
|
|
125
|
+
},
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const status = runtimeResult.error || runtimeResult.status !== 0 ? 'failed' : 'succeeded';
|
|
131
|
+
const entry = buildGateActionEntry(action, { ...meta, attemptId }, runtimeResult, status);
|
|
132
|
+
results.push(entry);
|
|
133
|
+
|
|
134
|
+
if (status === 'failed') {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
attempt_id: attemptId,
|
|
138
|
+
actions: results,
|
|
139
|
+
failed_action: entry,
|
|
140
|
+
error: `Gate action failed for "${meta.gateId}": ${action.label || action.run}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
attempt_id: attemptId,
|
|
148
|
+
actions: results,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function normalizeGateActionEntry(entry) {
|
|
153
|
+
if (!entry || entry.type !== 'gate_action') {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
attempt_id: entry.attempt_id || null,
|
|
159
|
+
gate_id: entry.gate_id || null,
|
|
160
|
+
gate_type: entry.gate_type || null,
|
|
161
|
+
phase: entry.phase || null,
|
|
162
|
+
requested_by_turn: entry.requested_by_turn || null,
|
|
163
|
+
trigger_command: entry.trigger_command || null,
|
|
164
|
+
action_index: Number.isInteger(entry.action_index) ? entry.action_index : null,
|
|
165
|
+
action_label: entry.action_label || null,
|
|
166
|
+
command: entry.command || null,
|
|
167
|
+
status: entry.status || 'unknown',
|
|
168
|
+
exit_code: Number.isInteger(entry.exit_code) ? entry.exit_code : null,
|
|
169
|
+
signal: entry.signal || null,
|
|
170
|
+
stdout_tail: entry.stdout_tail || null,
|
|
171
|
+
stderr_tail: entry.stderr_tail || null,
|
|
172
|
+
spawn_error: entry.spawn_error || null,
|
|
173
|
+
timestamp: entry.timestamp || null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function extractGateActionDigest(entries) {
|
|
178
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return entries
|
|
183
|
+
.map(normalizeGateActionEntry)
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readGateActionEntries(root) {
|
|
189
|
+
const filePath = join(root, LEDGER_PATH);
|
|
190
|
+
if (!existsSync(filePath)) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const raw = readFileSync(filePath, 'utf8').trim();
|
|
195
|
+
if (!raw) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const entries = raw
|
|
200
|
+
.split('\n')
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.map((line) => {
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(line);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
|
|
211
|
+
return extractGateActionDigest(entries);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function summarizeLatestGateActionAttempt(root, gateType, gateId) {
|
|
215
|
+
const entries = readGateActionEntries(root)
|
|
216
|
+
.filter((entry) => entry.gate_type === gateType && entry.gate_id === gateId);
|
|
217
|
+
|
|
218
|
+
if (entries.length === 0) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const latest = entries[entries.length - 1];
|
|
223
|
+
const attemptEntries = entries.filter((entry) => entry.attempt_id === latest.attempt_id);
|
|
224
|
+
return {
|
|
225
|
+
attempt_id: latest.attempt_id,
|
|
226
|
+
gate_id: gateId,
|
|
227
|
+
gate_type: gateType,
|
|
228
|
+
status: attemptEntries.some((entry) => entry.status === 'failed') ? 'failed' : 'succeeded',
|
|
229
|
+
attempted_at: latest.timestamp || null,
|
|
230
|
+
actions: attemptEntries,
|
|
231
|
+
};
|
|
232
|
+
}
|