agentxchain 2.8.0 → 2.9.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/README.md CHANGED
@@ -110,7 +110,7 @@ agentxchain step
110
110
  | `approve-completion` | Approve a pending human-gated run completion |
111
111
  | `validate` | Validate governed kickoff wiring, a staged turn, or both |
112
112
  | `verify protocol` | Run the shipped protocol conformance suite against a target implementation |
113
- | `dashboard` | Open the read-only governance dashboard in your browser for repo-local runs or multi-repo coordinator initiatives |
113
+ | `dashboard` | Open the local governance dashboard in your browser for repo-local runs or multi-repo coordinator initiatives, including pending gate approvals |
114
114
  | `plugin install|list|remove` | Install, inspect, or remove governed hook plugins backed by `agentxchain-plugin.json` manifests |
115
115
 
116
116
  ### Shared utilities
package/dashboard/app.js CHANGED
@@ -53,6 +53,8 @@ const viewState = {
53
53
 
54
54
  let activeViewName = null;
55
55
  let activeViewData = null;
56
+ let dashboardSession = null;
57
+ let actionInFlight = false;
56
58
 
57
59
  function escapeHtml(str) {
58
60
  if (str == null) return '';
@@ -77,6 +79,15 @@ async function fetchData(keys) {
77
79
  return results;
78
80
  }
79
81
 
82
+ async function loadSession() {
83
+ try {
84
+ const res = await fetch('/api/session');
85
+ dashboardSession = res.ok ? await res.json() : null;
86
+ } catch {
87
+ dashboardSession = null;
88
+ }
89
+ }
90
+
80
91
  function currentView() {
81
92
  return (location.hash || '#timeline').slice(1);
82
93
  }
@@ -126,6 +137,20 @@ function renderView(viewName, data) {
126
137
  container.innerHTML = view.render(buildRenderData(viewName, data));
127
138
  }
128
139
 
140
+ function setActionBanner(message, tone = 'info') {
141
+ const banner = document.getElementById('action-banner');
142
+ if (!banner) return;
143
+
144
+ if (!message) {
145
+ banner.textContent = '';
146
+ banner.className = 'action-banner';
147
+ return;
148
+ }
149
+
150
+ banner.textContent = message;
151
+ banner.className = `action-banner visible ${tone === 'error' ? 'error' : tone === 'success' ? 'success' : ''}`.trim();
152
+ }
153
+
129
154
  async function loadView(viewName, { refresh = true } = {}) {
130
155
  const view = VIEWS[viewName];
131
156
  if (!view) {
@@ -264,6 +289,55 @@ document.addEventListener('click', (event) => {
264
289
  turnCard.toggleAttribute('data-expanded');
265
290
  });
266
291
 
292
+ document.addEventListener('click', async (event) => {
293
+ const button = event.target.closest('[data-dashboard-action="approve-gate"]');
294
+ if (!button) return;
295
+
296
+ event.preventDefault();
297
+ if (actionInFlight) return;
298
+
299
+ if (!dashboardSession?.mutation_token) {
300
+ setActionBanner('Dashboard action token unavailable. Reload the page and try again.', 'error');
301
+ return;
302
+ }
303
+
304
+ const originalLabel = button.textContent;
305
+ const pendingLabel = button.dataset.actionLabel || 'Approve Gate';
306
+ actionInFlight = true;
307
+ button.disabled = true;
308
+ button.textContent = `${pendingLabel}...`;
309
+ setActionBanner('Submitting gate approval...', 'info');
310
+
311
+ try {
312
+ const res = await fetch('/api/actions/approve-gate', {
313
+ method: 'POST',
314
+ headers: {
315
+ 'Content-Type': 'application/json',
316
+ 'X-AgentXchain-Token': dashboardSession.mutation_token,
317
+ },
318
+ body: JSON.stringify({}),
319
+ });
320
+ const payload = await res.json().catch(() => ({
321
+ ok: false,
322
+ error: `Dashboard action failed with HTTP ${res.status}.`,
323
+ }));
324
+
325
+ if (!res.ok || payload.ok === false) {
326
+ setActionBanner(payload.error || `Dashboard action failed with HTTP ${res.status}.`, 'error');
327
+ return;
328
+ }
329
+
330
+ setActionBanner(payload.message || 'Gate approved.', 'success');
331
+ await loadView(currentView());
332
+ } catch (error) {
333
+ setActionBanner(error?.message || 'Dashboard action failed.', 'error');
334
+ } finally {
335
+ actionInFlight = false;
336
+ button.disabled = false;
337
+ button.textContent = originalLabel;
338
+ }
339
+ });
340
+
267
341
  // ── Copy to clipboard ────────────────────────────────────────────────────
268
342
 
269
343
  document.addEventListener('click', (event) => {
@@ -299,7 +373,7 @@ function fallbackSelect(el) {
299
373
 
300
374
  // ── Init ───────────────────────────────────────────────────────────────────
301
375
 
302
- pickInitialView().finally(() => {
376
+ Promise.all([pickInitialView(), loadSession()]).finally(() => {
303
377
  updateNav();
304
378
  connect();
305
379
  });
@@ -2,7 +2,7 @@
2
2
  * Gate Review view — renders pending phase transitions and run completion gates.
3
3
  *
4
4
  * Pure render function: takes data, returns HTML string. Testable in Node.js.
5
- * Per DEC-DASH-002: read-only. Shows the exact CLI command to approve, no buttons.
5
+ * Shows a narrow local approve action plus the exact CLI fallback command.
6
6
  */
7
7
 
8
8
  function esc(str) {
@@ -114,6 +114,17 @@ function renderList(title, items, formatter = (item) => item) {
114
114
  </div>`;
115
115
  }
116
116
 
117
+ function renderApproveControls({ buttonLabel, cliCommand }) {
118
+ return `
119
+ <div class="gate-controls">
120
+ <button class="gate-button" type="button" data-dashboard-action="approve-gate" data-action-label="${esc(buttonLabel)}">${esc(buttonLabel)}</button>
121
+ </div>
122
+ <div class="gate-action">
123
+ <p>CLI fallback:</p>
124
+ <pre class="recovery-command mono" data-copy="${esc(cliCommand)}">${esc(cliCommand)}</pre>
125
+ </div>`;
126
+ }
127
+
117
128
  export { findPostGateTurns, aggregateEvidence };
118
129
 
119
130
  function findCoordinatorGateRequest(history, pendingGate) {
@@ -254,12 +265,11 @@ export function render({
254
265
  )).join('')}</ul></div>`;
255
266
  }
256
267
  }
257
- html += `
258
- <div class="gate-action">
259
- <p>Approve with:</p>
260
- <pre class="recovery-command mono" data-copy="${isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-transition'}">${isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-transition'}</pre>
261
- </div>
262
- </div>`;
268
+ html += renderApproveControls({
269
+ buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Transition',
270
+ cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-transition',
271
+ });
272
+ html += `</div>`;
263
273
  }
264
274
 
265
275
  if (pendingCompletion) {
@@ -298,12 +308,11 @@ export function render({
298
308
  if (evidence.files.length > 0) {
299
309
  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>`;
300
310
  }
301
- html += `
302
- <div class="gate-action">
303
- <p>Approve with:</p>
304
- <pre class="recovery-command mono" data-copy="${isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-completion'}">${isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-completion'}</pre>
305
- </div>
306
- </div>`;
311
+ html += renderApproveControls({
312
+ buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Completion',
313
+ cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-completion',
314
+ });
315
+ html += `</div>`;
307
316
  }
308
317
 
309
318
  html += `</div>`;
@@ -51,6 +51,23 @@
51
51
  background: var(--green);
52
52
  }
53
53
  .status-dot.disconnected { background: var(--red); }
54
+ .action-banner {
55
+ display: none;
56
+ padding: 10px 24px;
57
+ border-bottom: 1px solid var(--border);
58
+ font-size: 13px;
59
+ background: rgba(99, 102, 241, 0.08);
60
+ color: var(--text);
61
+ }
62
+ .action-banner.visible { display: block; }
63
+ .action-banner.success {
64
+ background: rgba(34, 197, 94, 0.08);
65
+ color: var(--green);
66
+ }
67
+ .action-banner.error {
68
+ background: rgba(239, 68, 68, 0.08);
69
+ color: var(--red);
70
+ }
54
71
  nav {
55
72
  display: flex;
56
73
  gap: 0;
@@ -251,6 +268,29 @@
251
268
  margin-bottom: 16px;
252
269
  }
253
270
  .gate-card h3 { font-size: 14px; margin-bottom: 12px; }
271
+ .gate-controls {
272
+ display: flex;
273
+ gap: 12px;
274
+ align-items: center;
275
+ flex-wrap: wrap;
276
+ margin-top: 12px;
277
+ }
278
+ .gate-button {
279
+ background: rgba(99, 102, 241, 0.14);
280
+ color: var(--text);
281
+ border: 1px solid rgba(99, 102, 241, 0.45);
282
+ border-radius: 4px;
283
+ padding: 8px 12px;
284
+ font: inherit;
285
+ font-size: 13px;
286
+ cursor: pointer;
287
+ transition: background 0.15s, border-color 0.15s, opacity 0.15s;
288
+ }
289
+ .gate-button:hover { background: rgba(99, 102, 241, 0.22); }
290
+ .gate-button:disabled {
291
+ cursor: wait;
292
+ opacity: 0.7;
293
+ }
254
294
  .gate-action { margin-top: 12px; }
255
295
  .gate-action p { font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
256
296
  .initiative-grid {
@@ -332,6 +372,7 @@
332
372
  <span id="ws-label">Connecting...</span>
333
373
  </div>
334
374
  </header>
375
+ <div class="action-banner" id="action-banner"></div>
335
376
  <nav>
336
377
  <a href="#initiative">Initiative</a>
337
378
  <a href="#cross-repo">Cross-Repo</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -100,6 +100,13 @@ fi
100
100
 
101
101
  # 3. Tests
102
102
  echo "[3/6] Test suite"
103
+ # Install MCP example deps — tests start example servers as subprocesses
104
+ for example_dir in "${CLI_DIR}/../examples/mcp-echo-agent" "${CLI_DIR}/../examples/mcp-http-echo-agent"; do
105
+ if [[ -f "${example_dir}/package.json" && ! -d "${example_dir}/node_modules" ]]; then
106
+ echo " Installing deps for $(basename "$example_dir")..."
107
+ (cd "$example_dir" && env -u NODE_AUTH_TOKEN -u NPM_CONFIG_USERCONFIG npm install --ignore-scripts --userconfig /dev/null 2>&1) || true
108
+ fi
109
+ done
103
110
  if run_and_capture TEST_OUTPUT npm test; then
104
111
  TEST_STATUS=0
105
112
  else
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * CLI command: agentxchain dashboard
3
3
  *
4
- * Starts the read-only dashboard bridge server and opens a browser.
5
- * See: V2_DASHBOARD_SPEC.md, DEC-DASH-002 (read-only in v2.0).
4
+ * Starts the local dashboard bridge server and opens a browser.
5
+ * The dashboard remains mostly observational, but can approve pending gates.
6
6
  */
7
7
 
8
8
  import { existsSync } from 'fs';
@@ -238,7 +238,7 @@ export function dispatchCoordinatorTurn(workspacePath, state, config, assignment
238
238
  return { ok: false, error: bundleResult.error };
239
239
  }
240
240
 
241
- const turn = assignResult.state.current_turn;
241
+ const turn = assignResult.turn;
242
242
  const contextResult = generateCrossRepoContext(
243
243
  workspacePath,
244
244
  state,
@@ -0,0 +1,154 @@
1
+ import { dirname } from 'path';
2
+ import { loadProjectContext } from '../config.js';
3
+ import { approvePhaseTransition, approveRunCompletion } from '../governed-state.js';
4
+ import { deriveRecoveryDescriptor } from '../blocked-state.js';
5
+ import { loadCoordinatorConfig } from '../coordinator-config.js';
6
+ import { loadCoordinatorState } from '../coordinator-state.js';
7
+ import { buildGatePayload, fireCoordinatorHook } from '../coordinator-hooks.js';
8
+ import { approveCoordinatorCompletion, approveCoordinatorPhaseTransition } from '../coordinator-gates.js';
9
+ import { readJsonFile } from './state-reader.js';
10
+
11
+ function buildError(status, code, error, extra = {}) {
12
+ return {
13
+ status,
14
+ body: {
15
+ ok: false,
16
+ code,
17
+ error,
18
+ ...extra,
19
+ },
20
+ };
21
+ }
22
+
23
+ function normalizeRepoSuccess(result, gateType) {
24
+ if (gateType === 'phase_transition') {
25
+ return {
26
+ status: 200,
27
+ body: {
28
+ ok: true,
29
+ scope: 'repo',
30
+ gate_type: 'phase_transition',
31
+ message: `Phase transition approved: ${result.transition.from} -> ${result.transition.to}`,
32
+ next_action: 'agentxchain step',
33
+ },
34
+ };
35
+ }
36
+
37
+ return {
38
+ status: 200,
39
+ body: {
40
+ ok: true,
41
+ scope: 'repo',
42
+ gate_type: 'run_completion',
43
+ message: 'Run completion approved. Run is now completed.',
44
+ next_action: null,
45
+ },
46
+ };
47
+ }
48
+
49
+ function normalizeRepoFailure(result) {
50
+ const recovery = result.state ? deriveRecoveryDescriptor(result.state) : null;
51
+ const code = result.error_code || 'approval_failed';
52
+ return buildError(409, code, result.error || 'Gate approval failed', {
53
+ next_action: recovery?.recovery_action || null,
54
+ });
55
+ }
56
+
57
+ function approveRepoGate(root, config, state) {
58
+ const gateType = state.pending_phase_transition ? 'phase_transition' : 'run_completion';
59
+ const result = gateType === 'phase_transition'
60
+ ? approvePhaseTransition(root, config)
61
+ : approveRunCompletion(root, config);
62
+
63
+ if (!result.ok) {
64
+ return normalizeRepoFailure(result);
65
+ }
66
+
67
+ return normalizeRepoSuccess(result, gateType);
68
+ }
69
+
70
+ function normalizeCoordinatorSuccess(result, gateType) {
71
+ if (gateType === 'phase_transition') {
72
+ return {
73
+ status: 200,
74
+ body: {
75
+ ok: true,
76
+ scope: 'coordinator',
77
+ gate_type: 'phase_transition',
78
+ message: `Coordinator phase transition approved: ${result.transition.from} -> ${result.transition.to}`,
79
+ next_action: 'agentxchain multi step',
80
+ },
81
+ };
82
+ }
83
+
84
+ return {
85
+ status: 200,
86
+ body: {
87
+ ok: true,
88
+ scope: 'coordinator',
89
+ gate_type: 'run_completion',
90
+ message: 'Coordinator run completion approved. Run is now complete.',
91
+ next_action: null,
92
+ },
93
+ };
94
+ }
95
+
96
+ function approveCoordinatorGate(workspacePath, state, config) {
97
+ const gatePayload = buildGatePayload(state.pending_gate, state);
98
+ const gateHook = fireCoordinatorHook(workspacePath, config, 'before_gate', gatePayload, {
99
+ super_run_id: state.super_run_id,
100
+ });
101
+
102
+ if (gateHook.blocked) {
103
+ const blocker = gateHook.verdicts.find((entry) => entry.verdict === 'block');
104
+ const reason = blocker?.message || 'before_gate hook blocked approval';
105
+ return buildError(409, 'hook_blocked', reason);
106
+ }
107
+
108
+ if (!gateHook.ok) {
109
+ return buildError(409, 'hook_failed', gateHook.error || 'before_gate hook failed');
110
+ }
111
+
112
+ const gateType = state.pending_gate.gate_type;
113
+ let result;
114
+
115
+ if (gateType === 'phase_transition') {
116
+ result = approveCoordinatorPhaseTransition(workspacePath, state, config);
117
+ } else if (gateType === 'run_completion') {
118
+ result = approveCoordinatorCompletion(workspacePath, state, config);
119
+ } else {
120
+ return buildError(400, 'unknown_gate_type', `Unknown gate type: "${gateType}"`);
121
+ }
122
+
123
+ if (!result.ok) {
124
+ return buildError(409, 'approval_failed', result.error || 'Coordinator gate approval failed');
125
+ }
126
+
127
+ return normalizeCoordinatorSuccess(result, gateType);
128
+ }
129
+
130
+ export function approvePendingDashboardGate(agentxchainDir) {
131
+ const workspacePath = dirname(agentxchainDir);
132
+ const repoState = readJsonFile(agentxchainDir, 'state.json');
133
+
134
+ if (repoState?.pending_phase_transition || repoState?.pending_run_completion) {
135
+ const context = loadProjectContext(workspacePath);
136
+ return approveRepoGate(workspacePath, context?.config, repoState);
137
+ }
138
+
139
+ const coordinatorState = readJsonFile(agentxchainDir, 'multirepo/state.json');
140
+ if (coordinatorState?.pending_gate) {
141
+ const configResult = loadCoordinatorConfig(workspacePath);
142
+ if (!configResult.ok) {
143
+ const detail = (configResult.errors || [])
144
+ .map((entry) => typeof entry === 'string' ? entry : entry.message || JSON.stringify(entry))
145
+ .join('; ');
146
+ return buildError(400, 'coordinator_config_error', detail || 'Coordinator config error');
147
+ }
148
+
149
+ const loadedState = loadCoordinatorState(workspacePath) || coordinatorState;
150
+ return approveCoordinatorGate(workspacePath, loadedState, configResult.config);
151
+ }
152
+
153
+ return buildError(409, 'no_pending_gate', 'No pending repo or coordinator gate to approve.');
154
+ }
@@ -1,20 +1,22 @@
1
1
  /**
2
- * Dashboard bridge server — read-only HTTP + WebSocket server.
2
+ * Dashboard bridge server — local HTTP + WebSocket server.
3
3
  *
4
- * Serves dashboard static assets, exposes read-only API endpoints for
5
- * .agentxchain/ state files, and pushes WebSocket invalidation events
6
- * when watched files change.
4
+ * Serves dashboard static assets, exposes state API endpoints for
5
+ * .agentxchain/ files, supports a narrow local gate-approval mutation,
6
+ * and pushes WebSocket invalidation events when watched files change.
7
7
  *
8
- * Security: binds to 127.0.0.1 only. No write RPC. No mutation endpoints.
8
+ * Security: binds to 127.0.0.1 only. WebSocket remains read-only.
9
+ * HTTP mutation is limited to authenticated approve-gate requests.
9
10
  * See: DEC-DASH-002, DEC-DASH-003, AT-DASH-007, AT-DASH-008.
10
11
  */
11
12
 
12
13
  import { createServer } from 'http';
13
- import { createHash } from 'crypto';
14
+ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
14
15
  import { readFileSync, existsSync } from 'fs';
15
16
  import { join, extname, resolve, sep } from 'path';
16
17
  import { readResource } from './state-reader.js';
17
18
  import { FileWatcher } from './file-watcher.js';
19
+ import { approvePendingDashboardGate } from './actions.js';
18
20
 
19
21
  const MIME_TYPES = {
20
22
  '.html': 'text/html; charset=utf-8',
@@ -133,6 +135,55 @@ function sendWsError(socket, error) {
133
135
  }));
134
136
  }
135
137
 
138
+ function writeJson(res, statusCode, payload, extraHeaders = {}) {
139
+ res.writeHead(statusCode, {
140
+ 'Content-Type': 'application/json; charset=utf-8',
141
+ 'Cache-Control': 'no-cache',
142
+ ...extraHeaders,
143
+ });
144
+ res.end(JSON.stringify(payload));
145
+ }
146
+
147
+ function readJsonBody(req) {
148
+ return new Promise((resolve, reject) => {
149
+ let body = '';
150
+ req.on('data', (chunk) => {
151
+ body += chunk;
152
+ if (body.length > 32 * 1024) {
153
+ reject(new Error('Request body too large.'));
154
+ try { req.destroy(); } catch {}
155
+ }
156
+ });
157
+ req.on('end', () => {
158
+ if (!body.trim()) {
159
+ resolve({});
160
+ return;
161
+ }
162
+
163
+ try {
164
+ resolve(JSON.parse(body));
165
+ } catch {
166
+ reject(new Error('Request body must be valid JSON.'));
167
+ }
168
+ });
169
+ req.on('error', reject);
170
+ });
171
+ }
172
+
173
+ function tokenMatches(expectedToken, receivedToken) {
174
+ if (typeof receivedToken !== 'string') {
175
+ return false;
176
+ }
177
+
178
+ const expected = Buffer.from(expectedToken, 'utf8');
179
+ const received = Buffer.from(receivedToken, 'utf8');
180
+ if (expected.length !== received.length) {
181
+ return false;
182
+ }
183
+
184
+ return timingSafeEqual(expected, received);
185
+ }
186
+
136
187
  function resolveDashboardAssetPath(dashboardDir, pathname) {
137
188
  let decodedPath;
138
189
  try {
@@ -159,6 +210,7 @@ function resolveDashboardAssetPath(dashboardDir, pathname) {
159
210
  export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }) {
160
211
  const wsClients = new Set();
161
212
  const watcher = new FileWatcher(agentxchainDir);
213
+ const mutationToken = randomBytes(24).toString('hex');
162
214
 
163
215
  // Broadcast invalidation events to all connected WebSocket clients
164
216
  watcher.on('invalidate', ({ resource }) => {
@@ -168,30 +220,65 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
168
220
  }
169
221
  });
170
222
 
171
- const server = createServer((req, res) => {
172
- // Block all mutation methods (AT-DASH-008)
173
- if (req.method !== 'GET' && req.method !== 'HEAD') {
174
- res.writeHead(405, { 'Content-Type': 'application/json', 'Allow': 'GET, HEAD' });
175
- res.end(JSON.stringify({ error: 'Method not allowed. Dashboard is read-only in v2.0.' }));
223
+ const server = createServer(async (req, res) => {
224
+ const method = req.method || 'GET';
225
+ const isApproveGateRequest = method === 'POST' && req.url && new URL(req.url, `http://${req.headers.host}`).pathname === '/api/actions/approve-gate';
226
+
227
+ if (method !== 'GET' && method !== 'HEAD' && !isApproveGateRequest) {
228
+ writeJson(
229
+ res,
230
+ 405,
231
+ { error: 'Method not allowed. Dashboard mutations are limited to approve-gate.' },
232
+ { Allow: 'GET, HEAD, POST' }
233
+ );
176
234
  return;
177
235
  }
178
236
 
179
237
  const url = new URL(req.url, `http://${req.headers.host}`);
180
238
  const pathname = url.pathname;
181
239
 
240
+ if (pathname === '/api/session') {
241
+ writeJson(res, 200, {
242
+ session_version: '1',
243
+ mutation_token: mutationToken,
244
+ capabilities: {
245
+ approve_gate: true,
246
+ },
247
+ });
248
+ return;
249
+ }
250
+
251
+ if (pathname === '/api/actions/approve-gate') {
252
+ if (method !== 'POST') {
253
+ writeJson(res, 405, { ok: false, code: 'method_not_allowed', error: 'Use POST for dashboard actions.' }, { Allow: 'POST' });
254
+ return;
255
+ }
256
+
257
+ if (!tokenMatches(mutationToken, req.headers['x-agentxchain-token'])) {
258
+ writeJson(res, 403, { ok: false, code: 'invalid_token', error: 'Valid X-AgentXchain-Token is required.' });
259
+ return;
260
+ }
261
+
262
+ try {
263
+ await readJsonBody(req);
264
+ } catch (error) {
265
+ writeJson(res, 400, { ok: false, code: 'invalid_json', error: error.message });
266
+ return;
267
+ }
268
+
269
+ const result = approvePendingDashboardGate(agentxchainDir);
270
+ writeJson(res, result.status, result.body);
271
+ return;
272
+ }
273
+
182
274
  // API routes
183
275
  if (pathname.startsWith('/api/')) {
184
276
  const result = readResource(agentxchainDir, pathname);
185
277
  if (!result) {
186
- res.writeHead(404, { 'Content-Type': 'application/json' });
187
- res.end(JSON.stringify({ error: 'Resource not found' }));
278
+ writeJson(res, 404, { error: 'Resource not found' });
188
279
  return;
189
280
  }
190
- res.writeHead(200, {
191
- 'Content-Type': 'application/json; charset=utf-8',
192
- 'Cache-Control': 'no-cache',
193
- });
194
- res.end(JSON.stringify(result.data));
281
+ writeJson(res, 200, result.data);
195
282
  return;
196
283
  }
197
284
 
@@ -253,7 +340,7 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
253
340
  } else if (frame.opcode === 0x01) {
254
341
  sendWsError(
255
342
  ws,
256
- 'Dashboard is read-only in v2.0. WebSocket commands and mutations are not supported.'
343
+ 'Dashboard WebSocket is read-only. Use the authenticated HTTP approve-gate action instead.'
257
344
  );
258
345
  }
259
346
  });
@@ -1257,7 +1257,8 @@ export function assignGovernedTurn(root, config, roleId) {
1257
1257
  };
1258
1258
 
1259
1259
  writeState(root, updatedState);
1260
- const result = { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
1260
+ const assignedTurn = updatedState.active_turns[turnId];
1261
+ const result = { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), turn: assignedTurn };
1261
1262
  if (warnings.length > 0) {
1262
1263
  result.warnings = warnings;
1263
1264
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Runner Interface — declared contract for governed execution consumers.
3
+ *
4
+ * This module re-exports the library functions that any runner (CLI, CI,
5
+ * hosted, programmatic) needs to orchestrate governed turns. It is the
6
+ * formal boundary described in RUNNER_INTERFACE_SPEC.md.
7
+ *
8
+ * Design rules:
9
+ * - Only protocol-normative operations are exported here
10
+ * - Adapter dispatch is NOT included (runner-specific)
11
+ * - CLI output formatting is NOT included (runner-specific)
12
+ * - Dashboard, export, report, intake are NOT included (runner features)
13
+ *
14
+ * Usage:
15
+ * import { loadContext, loadState, initRun, assignTurn, acceptTurn } from './runner-interface.js';
16
+ * const ctx = loadContext();
17
+ * const state = loadState(ctx.root, ctx.config);
18
+ * // ... orchestrate turns
19
+ */
20
+
21
+ // ── Context ─────────────────────────────────────────────────────────────────
22
+
23
+ export { loadProjectContext as loadContext, loadProjectState as loadState } from './config.js';
24
+
25
+ // ── Governed Lifecycle ──────────────────────────────────────────────────────
26
+
27
+ export {
28
+ initializeGovernedRun as initRun,
29
+ assignGovernedTurn as assignTurn,
30
+ acceptGovernedTurn as acceptTurn,
31
+ rejectGovernedTurn as rejectTurn,
32
+ approvePhaseTransition as approvePhaseGate,
33
+ approveRunCompletion as approveCompletionGate,
34
+ markRunBlocked,
35
+ raiseOperatorEscalation as escalate,
36
+ reactivateGovernedRun as reactivateRun,
37
+ getActiveTurns,
38
+ getActiveTurnCount,
39
+ getActiveTurn,
40
+ acquireAcceptanceLock as acquireLock,
41
+ releaseAcceptanceLock as releaseLock,
42
+ } from './governed-state.js';
43
+
44
+ // ── Dispatch ────────────────────────────────────────────────────────────────
45
+
46
+ export { writeDispatchBundle } from './dispatch-bundle.js';
47
+ export { getTurnStagingResultPath } from './turn-paths.js';
48
+
49
+ // ── Hooks & Notifications ───────────────────────────────────────────────────
50
+
51
+ export { runHooks } from './hook-runner.js';
52
+ export { emitNotifications } from './notification-runner.js';
53
+
54
+ // ── Config Utilities ────────────────────────────────────────────────────────
55
+
56
+ export { getMaxConcurrentTurns } from './normalized-config.js';
57
+
58
+ // ── Interface Version ───────────────────────────────────────────────────────
59
+
60
+ export const RUNNER_INTERFACE_VERSION = '0.2';