agentxchain 2.8.0 → 2.10.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 +1 -1
- package/dashboard/app.js +75 -1
- package/dashboard/components/gate.js +22 -13
- package/dashboard/index.html +41 -0
- package/package.json +1 -1
- package/scripts/release-preflight.sh +7 -0
- package/src/commands/dashboard.js +2 -2
- package/src/lib/coordinator-dispatch.js +1 -1
- package/src/lib/dashboard/actions.js +154 -0
- package/src/lib/dashboard/bridge-server.js +106 -19
- package/src/lib/governed-state.js +2 -1
- package/src/lib/runner-interface.js +60 -0
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
|
|
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
|
-
*
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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>`;
|
package/dashboard/index.html
CHANGED
|
@@ -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
|
@@ -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
|
|
5
|
-
*
|
|
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.
|
|
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 —
|
|
2
|
+
* Dashboard bridge server — local HTTP + WebSocket server.
|
|
3
3
|
*
|
|
4
|
-
* Serves dashboard static assets, exposes
|
|
5
|
-
* .agentxchain/
|
|
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.
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
187
|
-
res.end(JSON.stringify({ error: 'Resource not found' }));
|
|
278
|
+
writeJson(res, 404, { error: 'Resource not found' });
|
|
188
279
|
return;
|
|
189
280
|
}
|
|
190
|
-
res
|
|
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
|
|
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
|
|
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';
|