agentxchain 2.108.0 → 2.110.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 +44 -4
- package/dashboard/components/blocked.js +57 -0
- package/dashboard/components/gate.js +52 -0
- package/dashboard/components/timeouts.js +21 -3
- package/package.json +1 -1
- package/src/commands/approve-completion.js +45 -1
- package/src/commands/approve-transition.js +49 -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 +263 -0
- package/src/lib/governed-state.js +161 -3
- package/src/lib/normalized-config.js +6 -1
- package/src/lib/notification-runner.js +162 -6
- package/src/lib/report.js +50 -0
- package/src/lib/run-loop.js +3 -0
|
@@ -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,263 @@
|
|
|
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
|
+
export const DEFAULT_GATE_ACTION_TIMEOUT_MS = 15 * 60 * 1000;
|
|
9
|
+
const MIN_GATE_ACTION_TIMEOUT_MS = 1000;
|
|
10
|
+
const MAX_GATE_ACTION_TIMEOUT_MS = 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function normalizeGateActionTimeoutMs(action) {
|
|
13
|
+
if (Number.isInteger(action?.timeout_ms)) {
|
|
14
|
+
return action.timeout_ms;
|
|
15
|
+
}
|
|
16
|
+
return DEFAULT_GATE_ACTION_TIMEOUT_MS;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateGateActionsConfig(gates, errors) {
|
|
20
|
+
if (!gates || typeof gates !== 'object' || Array.isArray(gates)) {
|
|
21
|
+
errors.push('gates must be an object');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const [gateId, gate] of Object.entries(gates)) {
|
|
26
|
+
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
27
|
+
errors.push(`Gate "${gateId}" must be an object`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!('gate_actions' in gate)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!Array.isArray(gate.gate_actions) || gate.gate_actions.length === 0) {
|
|
36
|
+
errors.push(`Gate "${gateId}": gate_actions must be a non-empty array when provided`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (gate.requires_human_approval !== true) {
|
|
41
|
+
errors.push(`Gate "${gateId}": gate_actions require requires_human_approval: true`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < gate.gate_actions.length; i++) {
|
|
45
|
+
const action = gate.gate_actions[i];
|
|
46
|
+
const prefix = `gates.${gateId}.gate_actions[${i}]`;
|
|
47
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
48
|
+
errors.push(`${prefix} must be an object`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (typeof action.run !== 'string' || !action.run.trim()) {
|
|
52
|
+
errors.push(`${prefix}.run must be a non-empty string`);
|
|
53
|
+
}
|
|
54
|
+
if ('label' in action && (typeof action.label !== 'string' || !action.label.trim())) {
|
|
55
|
+
errors.push(`${prefix}.label must be a non-empty string when provided`);
|
|
56
|
+
}
|
|
57
|
+
if ('timeout_ms' in action) {
|
|
58
|
+
if (!Number.isInteger(action.timeout_ms)
|
|
59
|
+
|| action.timeout_ms < MIN_GATE_ACTION_TIMEOUT_MS
|
|
60
|
+
|| action.timeout_ms > MAX_GATE_ACTION_TIMEOUT_MS) {
|
|
61
|
+
errors.push(
|
|
62
|
+
`${prefix}.timeout_ms must be an integer between ${MIN_GATE_ACTION_TIMEOUT_MS} and ${MAX_GATE_ACTION_TIMEOUT_MS} when provided`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getGateActions(config, gateId) {
|
|
71
|
+
const actions = config?.gates?.[gateId]?.gate_actions;
|
|
72
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return actions
|
|
77
|
+
.filter((action) => action && typeof action === 'object' && typeof action.run === 'string' && action.run.trim())
|
|
78
|
+
.map((action, index) => ({
|
|
79
|
+
index: index + 1,
|
|
80
|
+
label: typeof action.label === 'string' && action.label.trim() ? action.label.trim() : null,
|
|
81
|
+
run: action.run.trim(),
|
|
82
|
+
timeout_ms: normalizeGateActionTimeoutMs(action),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function trimOutputTail(value) {
|
|
87
|
+
if (typeof value !== 'string') {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const trimmed = value.trim();
|
|
91
|
+
if (!trimmed) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (trimmed.length <= MAX_OUTPUT_TAIL_CHARS) {
|
|
95
|
+
return trimmed;
|
|
96
|
+
}
|
|
97
|
+
return trimmed.slice(-MAX_OUTPUT_TAIL_CHARS);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildGateActionEntry(action, meta, runtimeResult, status) {
|
|
101
|
+
const timedOut = runtimeResult?.error?.code === 'ETIMEDOUT';
|
|
102
|
+
const stderrTail = trimOutputTail(runtimeResult?.stderr) || (timedOut ? `Timed out after ${action.timeout_ms}ms` : null);
|
|
103
|
+
const spawnError = timedOut
|
|
104
|
+
? `Timed out after ${action.timeout_ms}ms`
|
|
105
|
+
: runtimeResult?.error?.message || null;
|
|
106
|
+
return {
|
|
107
|
+
type: 'gate_action',
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
attempt_id: meta.attemptId,
|
|
110
|
+
gate_id: meta.gateId,
|
|
111
|
+
gate_type: meta.gateType,
|
|
112
|
+
phase: meta.phase || null,
|
|
113
|
+
requested_by_turn: meta.requestedByTurn || null,
|
|
114
|
+
trigger_command: meta.triggerCommand || null,
|
|
115
|
+
action_index: action.index,
|
|
116
|
+
action_label: action.label,
|
|
117
|
+
command: action.run,
|
|
118
|
+
timeout_ms: action.timeout_ms,
|
|
119
|
+
status,
|
|
120
|
+
exit_code: Number.isInteger(runtimeResult?.status) ? runtimeResult.status : null,
|
|
121
|
+
signal: runtimeResult?.signal || null,
|
|
122
|
+
stdout_tail: trimOutputTail(runtimeResult?.stdout),
|
|
123
|
+
stderr_tail: stderrTail,
|
|
124
|
+
spawn_error: spawnError,
|
|
125
|
+
timed_out: timedOut,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function executeGateActions(root, config, meta, opts = {}) {
|
|
130
|
+
const actions = getGateActions(config, meta.gateId);
|
|
131
|
+
if (actions.length === 0) {
|
|
132
|
+
return { ok: true, dry_run: Boolean(opts.dryRun), actions: [] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (opts.dryRun) {
|
|
136
|
+
return { ok: true, dry_run: true, actions };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const attemptId = `ga_${Date.now()}_${randomBytes(4).toString('hex')}`;
|
|
140
|
+
const results = [];
|
|
141
|
+
|
|
142
|
+
for (const action of actions) {
|
|
143
|
+
const runtimeResult = spawnSync('/bin/sh', ['-lc', action.run], {
|
|
144
|
+
cwd: root,
|
|
145
|
+
env: {
|
|
146
|
+
...process.env,
|
|
147
|
+
AGENTXCHAIN_GATE_ID: meta.gateId,
|
|
148
|
+
AGENTXCHAIN_GATE_TYPE: meta.gateType,
|
|
149
|
+
AGENTXCHAIN_PHASE: meta.phase || '',
|
|
150
|
+
AGENTXCHAIN_REQUESTED_BY_TURN: meta.requestedByTurn || '',
|
|
151
|
+
AGENTXCHAIN_TRIGGER_COMMAND: meta.triggerCommand || '',
|
|
152
|
+
},
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
155
|
+
timeout: action.timeout_ms,
|
|
156
|
+
killSignal: 'SIGTERM',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const status = runtimeResult.error || runtimeResult.status !== 0 ? 'failed' : 'succeeded';
|
|
160
|
+
const entry = buildGateActionEntry(action, { ...meta, attemptId }, runtimeResult, status);
|
|
161
|
+
results.push(entry);
|
|
162
|
+
|
|
163
|
+
if (status === 'failed') {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
attempt_id: attemptId,
|
|
167
|
+
actions: results,
|
|
168
|
+
failed_action: entry,
|
|
169
|
+
error: `Gate action failed for "${meta.gateId}": ${action.label || action.run}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
attempt_id: attemptId,
|
|
177
|
+
actions: results,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function normalizeGateActionEntry(entry) {
|
|
182
|
+
if (!entry || entry.type !== 'gate_action') {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
attempt_id: entry.attempt_id || null,
|
|
188
|
+
gate_id: entry.gate_id || null,
|
|
189
|
+
gate_type: entry.gate_type || null,
|
|
190
|
+
phase: entry.phase || null,
|
|
191
|
+
requested_by_turn: entry.requested_by_turn || null,
|
|
192
|
+
trigger_command: entry.trigger_command || null,
|
|
193
|
+
action_index: Number.isInteger(entry.action_index) ? entry.action_index : null,
|
|
194
|
+
action_label: entry.action_label || null,
|
|
195
|
+
command: entry.command || null,
|
|
196
|
+
status: entry.status || 'unknown',
|
|
197
|
+
exit_code: Number.isInteger(entry.exit_code) ? entry.exit_code : null,
|
|
198
|
+
signal: entry.signal || null,
|
|
199
|
+
stdout_tail: entry.stdout_tail || null,
|
|
200
|
+
stderr_tail: entry.stderr_tail || null,
|
|
201
|
+
spawn_error: entry.spawn_error || null,
|
|
202
|
+
timeout_ms: Number.isInteger(entry.timeout_ms) ? entry.timeout_ms : null,
|
|
203
|
+
timed_out: entry.timed_out === true,
|
|
204
|
+
timestamp: entry.timestamp || null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function extractGateActionDigest(entries) {
|
|
209
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return entries
|
|
214
|
+
.map(normalizeGateActionEntry)
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function readGateActionEntries(root) {
|
|
220
|
+
const filePath = join(root, LEDGER_PATH);
|
|
221
|
+
if (!existsSync(filePath)) {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const raw = readFileSync(filePath, 'utf8').trim();
|
|
226
|
+
if (!raw) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const entries = raw
|
|
231
|
+
.split('\n')
|
|
232
|
+
.filter(Boolean)
|
|
233
|
+
.map((line) => {
|
|
234
|
+
try {
|
|
235
|
+
return JSON.parse(line);
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
.filter(Boolean);
|
|
241
|
+
|
|
242
|
+
return extractGateActionDigest(entries);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function summarizeLatestGateActionAttempt(root, gateType, gateId) {
|
|
246
|
+
const entries = readGateActionEntries(root)
|
|
247
|
+
.filter((entry) => entry.gate_type === gateType && entry.gate_id === gateId);
|
|
248
|
+
|
|
249
|
+
if (entries.length === 0) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const latest = entries[entries.length - 1];
|
|
254
|
+
const attemptEntries = entries.filter((entry) => entry.attempt_id === latest.attempt_id);
|
|
255
|
+
return {
|
|
256
|
+
attempt_id: latest.attempt_id,
|
|
257
|
+
gate_id: gateId,
|
|
258
|
+
gate_type: gateType,
|
|
259
|
+
status: attemptEntries.some((entry) => entry.status === 'failed') ? 'failed' : 'succeeded',
|
|
260
|
+
attempted_at: latest.timestamp || null,
|
|
261
|
+
actions: attemptEntries,
|
|
262
|
+
};
|
|
263
|
+
}
|