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.
@@ -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
- if (!config?.timeouts || state?.status !== 'active') {
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
- for (const turn of getActiveTurns(state)) {
104
- const turnEval = evaluateTimeouts({ config, state, turn, now });
105
- for (const item of turnEval.exceeded) {
106
- if (item.scope === 'turn') {
107
- live.exceeded.push(annotateTurnTimeout(item, state, turn));
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
- for (const item of turnEval.warnings) {
111
- if (item.scope === 'turn') {
112
- live.warnings.push(annotateTurnTimeout(item, state, turn));
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
- body: {
194
- ok: true,
195
- configured: true,
196
- config: configSummary,
197
- live,
198
- events,
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
+ }