agentxchain 2.39.0 → 2.41.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/dashboard/app.js CHANGED
@@ -16,7 +16,7 @@ import { render as renderBlockers } from './components/blockers.js';
16
16
  import { render as renderArtifacts } from './components/artifacts.js';
17
17
 
18
18
  const VIEWS = {
19
- timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations'], render: renderTimeline },
19
+ timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations', 'connectors'], render: renderTimeline },
20
20
  ledger: { fetch: ['ledger'], render: renderLedger },
21
21
  hooks: { fetch: ['audit', 'annotations'], render: renderHooks },
22
22
  blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit'], render: renderBlocked },
@@ -41,6 +41,7 @@ const API_MAP = {
41
41
  coordinatorAudit: '/api/coordinator/hooks/audit',
42
42
  coordinatorBlockers: '/api/coordinator/blockers',
43
43
  workflowKitArtifacts: '/api/workflow-kit-artifacts',
44
+ connectors: '/api/connectors',
44
45
  };
45
46
 
46
47
  const viewState = {
@@ -82,7 +82,8 @@ function aggregateEvidence(turns) {
82
82
  allDecisions.push(dec);
83
83
  }
84
84
  }
85
- const files = turn.observed_artifact?.files_changed || turn.files_changed || [];
85
+ const observed = turn.observed_artifact?.files_changed;
86
+ const files = (Array.isArray(observed) && observed.length > 0) ? observed : (turn.files_changed || []);
86
87
  for (const f of files) {
87
88
  if (!allFiles.includes(f)) {
88
89
  allFiles.push(f);
@@ -177,7 +177,60 @@ function renderContinuityPanel(continuity) {
177
177
  return html;
178
178
  }
179
179
 
180
- export function render({ state, continuity, history, annotations, audit }) {
180
+ function connectorBadge(state) {
181
+ const colors = {
182
+ healthy: 'var(--green)',
183
+ failing: 'var(--red)',
184
+ active: 'var(--yellow)',
185
+ never_used: 'var(--text-dim)',
186
+ };
187
+ const color = colors[state] || 'var(--text-dim)';
188
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(state || 'unknown')}</span>`;
189
+ }
190
+
191
+ function renderConnectorHealthPanel(connectorsPayload) {
192
+ const connectors = Array.isArray(connectorsPayload?.connectors)
193
+ ? connectorsPayload.connectors
194
+ : [];
195
+ if (connectors.length === 0) return '';
196
+
197
+ let html = `<div class="section"><h3>Connector Health</h3><div class="turn-list">`;
198
+ for (const connector of connectors) {
199
+ html += `<div class="turn-card">
200
+ <div class="turn-header">
201
+ <span class="mono">${esc(connector.runtime_id)}</span>
202
+ ${connectorBadge(connector.state)}
203
+ </div>
204
+ <div class="turn-detail"><span class="detail-label">Type:</span> ${esc(connector.type || 'unknown')}</div>
205
+ <div class="turn-detail"><span class="detail-label">Target:</span> <span class="mono">${esc(connector.target || 'unknown')}</span></div>
206
+ <div class="turn-detail"><span class="detail-label">Reachable:</span> ${esc(connector.reachable || 'unknown')}</div>`;
207
+
208
+ if (Array.isArray(connector.active_turn_ids) && connector.active_turn_ids.length > 0) {
209
+ html += `<div class="turn-detail"><span class="detail-label">Active turns:</span> <span class="mono">${esc(connector.active_turn_ids.join(', '))}</span></div>`;
210
+ }
211
+
212
+ if (connector.last_success_at) {
213
+ html += `<div class="turn-detail"><span class="detail-label">Last success:</span> ${esc(connector.last_success_at)}</div>`;
214
+ }
215
+ if (connector.last_failure_at) {
216
+ html += `<div class="turn-detail"><span class="detail-label">Last failure:</span> ${esc(connector.last_failure_at)}</div>`;
217
+ }
218
+ if (connector.last_error) {
219
+ html += `<div class="turn-detail risks"><span class="detail-label">Last error:</span> ${esc(connector.last_error)}</div>`;
220
+ }
221
+ if (connector.attempts_made != null || connector.latency_ms != null) {
222
+ const attempts = connector.attempts_made != null ? connector.attempts_made : 'n/a';
223
+ const latency = connector.latency_ms != null ? `${connector.latency_ms}ms` : 'n/a';
224
+ html += `<div class="turn-detail"><span class="detail-label">Attempt telemetry:</span> attempts ${esc(attempts)} / latency ${esc(latency)}</div>`;
225
+ }
226
+
227
+ html += `</div>`;
228
+ }
229
+ html += `</div></div>`;
230
+ return html;
231
+ }
232
+
233
+ export function render({ state, continuity, history, annotations, audit, connectors }) {
181
234
  if (!state) {
182
235
  return `<div class="placeholder"><h2>No Run</h2><p>No governed run found. Start one with <code class="mono">agentxchain init --governed</code></p></div>`;
183
236
  }
@@ -198,6 +251,7 @@ export function render({ state, continuity, history, annotations, audit }) {
198
251
  </div>`;
199
252
 
200
253
  html += renderContinuityPanel(continuity);
254
+ html += renderConnectorHealthPanel(connectors);
201
255
 
202
256
  // Active turns
203
257
  if (activeTurns.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.39.0",
3
+ "version": "2.41.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "postflight:downstream": "bash scripts/release-downstream-truth.sh",
31
31
  "bump:release": "bash scripts/release-bump.sh",
32
32
  "sync:homebrew": "bash scripts/sync-homebrew.sh",
33
+ "verify:post-publish": "bash scripts/verify-post-publish.sh",
33
34
  "build:macos": "bun build bin/agentxchain.js --compile --target=bun-darwin-arm64 --outfile=dist/agentxchain-macos-arm64",
34
35
  "build:linux": "bun build bin/agentxchain.js --compile --target=bun-linux-x64 --outfile=dist/agentxchain-linux-x64",
35
36
  "publish:npm": "bash scripts/publish-npm.sh"
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # Post-publish repo-mirror verification — the executable contract that
3
+ # ensures main is green after a release publish completes.
4
+ #
5
+ # Three-phase Homebrew lifecycle:
6
+ # Phase 1 (pre-publish): formula URL updated, SHA carried from previous version
7
+ # Phase 2 (post-publish): npm live, but repo mirror SHA is stale
8
+ # Phase 3 (post-sync): repo mirror SHA matches published tarball — main is green
9
+ #
10
+ # This script transitions from Phase 2 → Phase 3 and verifies the result.
11
+ #
12
+ # Usage: bash scripts/verify-post-publish.sh [--target-version <semver>]
13
+ # If --target-version is omitted, reads from package.json.
14
+ set -euo pipefail
15
+
16
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
+ CLI_DIR="${SCRIPT_DIR}/.."
18
+ cd "$CLI_DIR"
19
+
20
+ TARGET_VERSION=""
21
+
22
+ while [[ $# -gt 0 ]]; do
23
+ case "$1" in
24
+ --target-version)
25
+ if [[ -z "${2:-}" ]]; then
26
+ echo "Error: --target-version requires a semver argument" >&2
27
+ exit 1
28
+ fi
29
+ TARGET_VERSION="$2"
30
+ shift 2
31
+ ;;
32
+ *)
33
+ echo "Unknown argument: $1" >&2
34
+ exit 1
35
+ ;;
36
+ esac
37
+ done
38
+
39
+ if [[ -z "$TARGET_VERSION" ]]; then
40
+ TARGET_VERSION="$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")"
41
+ fi
42
+
43
+ echo "Post-Publish Verification: v${TARGET_VERSION}"
44
+ echo "============================================="
45
+ echo ""
46
+
47
+ # Step 1: Verify npm serves the version
48
+ echo "[1/4] Checking npm registry..."
49
+ NPM_VERSION="$(npm view "agentxchain@${TARGET_VERSION}" version 2>/dev/null || echo "")"
50
+ if [[ "$NPM_VERSION" != "$TARGET_VERSION" ]]; then
51
+ echo " FAIL: npm does not serve agentxchain@${TARGET_VERSION} (got: '${NPM_VERSION}')"
52
+ echo " This script must run AFTER npm publish completes."
53
+ exit 1
54
+ fi
55
+ echo " OK: npm serves v${TARGET_VERSION}"
56
+
57
+ # Step 2: Sync the repo mirror to the published tarball
58
+ echo "[2/4] Syncing repo mirror to published tarball..."
59
+ bash "${SCRIPT_DIR}/sync-homebrew.sh" --target-version "$TARGET_VERSION"
60
+ echo " OK: repo mirror synced"
61
+
62
+ # Step 3: Run the full test suite WITHOUT the preflight skip
63
+ echo "[3/4] Running full test suite (no preflight skip)..."
64
+ echo " This verifies the Homebrew mirror contract passes with the real SHA."
65
+ npm test
66
+ echo " OK: full test suite green"
67
+
68
+ # Step 4: Summary
69
+ echo ""
70
+ echo "============================================="
71
+ echo "Post-publish verification PASSED."
72
+ echo " - npm: agentxchain@${TARGET_VERSION} live"
73
+ echo " - repo mirror: SHA synced to published tarball"
74
+ echo " - test suite: green without preflight skip"
75
+ echo ""
76
+ echo "Main is now in Phase 3 (post-sync). Commit and push the mirror update."
@@ -3,6 +3,8 @@ import { loadConfig, loadLock, loadProjectContext, loadProjectState, loadState }
3
3
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
4
4
  import { getActiveTurn, getActiveTurnCount, getActiveTurns } from '../lib/governed-state.js';
5
5
  import { getContinuityStatus } from '../lib/continuity-status.js';
6
+ import { getConnectorHealth } from '../lib/connector-health.js';
7
+ import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
6
8
 
7
9
  export async function statusCommand(opts) {
8
10
  const context = loadProjectContext();
@@ -76,6 +78,9 @@ function renderGovernedStatus(context, opts) {
76
78
  const { root, config, version } = context;
77
79
  const state = loadProjectState(root, config);
78
80
  const continuity = getContinuityStatus(root, state);
81
+ const connectorHealth = getConnectorHealth(root, config, state);
82
+
83
+ const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
79
84
 
80
85
  if (opts.json) {
81
86
  console.log(JSON.stringify({
@@ -85,6 +90,8 @@ function renderGovernedStatus(context, opts) {
85
90
  config,
86
91
  state,
87
92
  continuity,
93
+ connector_health: connectorHealth,
94
+ workflow_kit_artifacts: workflowKitArtifacts,
88
95
  }, null, 2));
89
96
  return;
90
97
  }
@@ -105,6 +112,7 @@ function renderGovernedStatus(context, opts) {
105
112
  console.log('');
106
113
 
107
114
  renderContinuityStatus(continuity, state);
115
+ renderConnectorHealthStatus(connectorHealth);
108
116
 
109
117
  const activeTurnCount = getActiveTurnCount(state);
110
118
  const activeTurns = getActiveTurns(state);
@@ -227,6 +235,8 @@ function renderGovernedStatus(context, opts) {
227
235
  }
228
236
  }
229
237
 
238
+ renderWorkflowKitArtifactsSection(workflowKitArtifacts);
239
+
230
240
  if (state?.budget_status) {
231
241
  console.log('');
232
242
  console.log(` ${chalk.dim('Budget:')} spent $${formatUsd(state.budget_status.spent_usd)} / remaining $${formatUsd(state.budget_status.remaining_usd)}`);
@@ -243,6 +253,48 @@ function renderGovernedStatus(context, opts) {
243
253
  console.log('');
244
254
  }
245
255
 
256
+ function renderConnectorHealthStatus(connectorHealth) {
257
+ const connectors = Array.isArray(connectorHealth?.connectors)
258
+ ? connectorHealth.connectors
259
+ : [];
260
+ if (connectors.length === 0) {
261
+ return;
262
+ }
263
+
264
+ console.log(` ${chalk.dim('Connectors:')}`);
265
+ for (const connector of connectors) {
266
+ const stateLabel = formatConnectorState(connector.state);
267
+ console.log(` ${stateLabel} ${chalk.bold(connector.runtime_id)} — ${connector.type} (${connector.target})`);
268
+
269
+ if (connector.active_turn_ids.length > 0) {
270
+ console.log(` ${chalk.dim('Active turns:')} ${connector.active_turn_ids.join(', ')}`);
271
+ }
272
+
273
+ if (connector.last_error) {
274
+ console.log(` ${chalk.dim('Last error:')} ${connector.last_error}`);
275
+ } else if (connector.last_success_at) {
276
+ console.log(` ${chalk.dim('Last success:')} ${connector.last_success_at}`);
277
+ } else if (connector.last_attempt_at) {
278
+ console.log(` ${chalk.dim('Last attempt:')} ${connector.last_attempt_at}`);
279
+ }
280
+ }
281
+ console.log('');
282
+ }
283
+
284
+ function formatConnectorState(state) {
285
+ switch (state) {
286
+ case 'healthy':
287
+ return chalk.green('● healthy');
288
+ case 'failing':
289
+ return chalk.red('✗ failing');
290
+ case 'active':
291
+ return chalk.yellow('● active');
292
+ case 'never_used':
293
+ default:
294
+ return chalk.dim('○ never_used');
295
+ }
296
+ }
297
+
246
298
  function renderContinuityStatus(continuity, state) {
247
299
  if (!continuity) return;
248
300
 
@@ -292,6 +344,25 @@ function renderContinuityStatus(continuity, state) {
292
344
  console.log('');
293
345
  }
294
346
 
347
+ function renderWorkflowKitArtifactsSection(wkData) {
348
+ if (!wkData || !wkData.artifacts || wkData.artifacts.length === 0) return;
349
+
350
+ const artifacts = wkData.artifacts;
351
+ console.log('');
352
+ console.log(` ${chalk.dim('Artifacts:')} (${wkData.phase})`);
353
+ for (const a of artifacts) {
354
+ const icon = a.exists ? chalk.green('✓') : (a.required ? chalk.red('✗') : chalk.yellow('○'));
355
+ const reqLabel = a.required ? '' : chalk.dim(' (optional)');
356
+ const ownerLabel = a.owned_by
357
+ ? chalk.dim(` [${a.owned_by}${a.owner_resolution === 'entry_role' ? '*' : ''}]`)
358
+ : '';
359
+ console.log(` ${icon} ${a.path}${ownerLabel}${reqLabel}`);
360
+ }
361
+ if (artifacts.some(a => a.owner_resolution === 'entry_role')) {
362
+ console.log(` ${chalk.dim('* = ownership inferred from entry_role')}`);
363
+ }
364
+ }
365
+
295
366
  function formatPhase(phase) {
296
367
  const colors = { discovery: chalk.blue, build: chalk.green, qa: chalk.yellow, deploy: chalk.magenta, blocked: chalk.red };
297
368
  return (colors[phase] || chalk.white)(phase);
@@ -0,0 +1,319 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const HISTORY_PATH = '.agentxchain/history.jsonl';
5
+ const STAGING_DIR = '.agentxchain/staging';
6
+ const CONNECTOR_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
7
+
8
+ function safeReadJson(absPath) {
9
+ try {
10
+ if (!existsSync(absPath)) return null;
11
+ return JSON.parse(readFileSync(absPath, 'utf8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function safeReadJsonl(absPath) {
18
+ try {
19
+ if (!existsSync(absPath)) return [];
20
+ const raw = readFileSync(absPath, 'utf8').trim();
21
+ if (!raw) return [];
22
+ return raw.split('\n').filter(Boolean).map((line) => JSON.parse(line));
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ function toMillis(value) {
29
+ if (typeof value !== 'string' || value.length === 0) return null;
30
+ const ms = Date.parse(value);
31
+ return Number.isFinite(ms) ? ms : null;
32
+ }
33
+
34
+ function chooseLatest(left, right) {
35
+ if (!left) return right || null;
36
+ if (!right) return left;
37
+ const leftMs = toMillis(left.at);
38
+ const rightMs = toMillis(right.at);
39
+ if (leftMs === null) return right;
40
+ if (rightMs === null) return left;
41
+ return rightMs >= leftMs ? right : left;
42
+ }
43
+
44
+ function chooseLatestDefined(left, right) {
45
+ if (right == null) return left ?? null;
46
+ if (left == null) return right;
47
+ return right;
48
+ }
49
+
50
+ function formatCommand(command, args = []) {
51
+ if (Array.isArray(command)) {
52
+ return command.join(' ');
53
+ }
54
+ if (typeof command === 'string' && command.length > 0) {
55
+ return [command, ...(Array.isArray(args) ? args : [])].join(' ');
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function formatTarget(runtime) {
61
+ switch (runtime?.type) {
62
+ case 'api_proxy':
63
+ return [runtime.provider, runtime.model].filter(Boolean).join(' / ') || 'unknown target';
64
+ case 'remote_agent':
65
+ return runtime.url || 'unknown target';
66
+ case 'mcp':
67
+ return runtime.transport === 'streamable_http'
68
+ ? (runtime.url || 'unknown target')
69
+ : (formatCommand(runtime.command, runtime.args) || 'unknown target');
70
+ case 'local_cli':
71
+ return formatCommand(runtime.command, runtime.args) || 'unknown target';
72
+ default:
73
+ return 'unknown target';
74
+ }
75
+ }
76
+
77
+ function getLatestAttemptFromTrace(trace) {
78
+ if (!trace || typeof trace !== 'object' || Array.isArray(trace)) return null;
79
+ const attempts = Array.isArray(trace.attempts) ? trace.attempts : [];
80
+ if (attempts.length === 0) return null;
81
+ const last = attempts[attempts.length - 1];
82
+ if (!last || typeof last !== 'object') return null;
83
+ const startedAt = typeof last.started_at === 'string' ? last.started_at : null;
84
+ const completedAt = typeof last.completed_at === 'string' ? last.completed_at : startedAt;
85
+ let latencyMs = null;
86
+ const startedMs = toMillis(startedAt);
87
+ const completedMs = toMillis(completedAt);
88
+ if (startedMs !== null && completedMs !== null && completedMs >= startedMs) {
89
+ latencyMs = completedMs - startedMs;
90
+ }
91
+ return {
92
+ at: completedAt,
93
+ turn_id: trace.turn_id || null,
94
+ runtime_id: trace.runtime_id || null,
95
+ attempts_made: Number.isInteger(trace.attempts_made) ? trace.attempts_made : attempts.length,
96
+ final_outcome: trace.final_outcome || null,
97
+ latency_ms: latencyMs,
98
+ };
99
+ }
100
+
101
+ function buildConnectorEntry(runtimeId, runtime) {
102
+ return {
103
+ runtime_id: runtimeId,
104
+ type: runtime.type,
105
+ target: formatTarget(runtime),
106
+ state: 'never_used',
107
+ reachable: 'unknown',
108
+ active_turn_ids: [],
109
+ active_roles: [],
110
+ last_turn_id: null,
111
+ last_role: null,
112
+ last_phase: null,
113
+ last_attempt_at: null,
114
+ last_success_at: null,
115
+ last_failure_at: null,
116
+ latency_ms: null,
117
+ attempts_made: null,
118
+ last_error: null,
119
+ _latest_success: null,
120
+ _latest_failure: null,
121
+ _latest_attempt: null,
122
+ _latest_identity: null,
123
+ };
124
+ }
125
+
126
+ function applyIdentityTarget(entry, candidate) {
127
+ if (!candidate) return;
128
+ entry.last_turn_id = candidate.turn_id || entry.last_turn_id;
129
+ entry.last_role = candidate.role || entry.last_role;
130
+ entry.last_phase = candidate.phase || entry.last_phase;
131
+ }
132
+
133
+ function finalizeConnectorEntry(entry) {
134
+ entry.last_attempt_at = entry._latest_attempt?.at || null;
135
+ entry.latency_ms = entry._latest_attempt?.latency_ms ?? null;
136
+ entry.attempts_made = entry._latest_attempt?.attempts_made ?? null;
137
+ entry.last_success_at = entry._latest_success?.at || null;
138
+ entry.last_failure_at = entry._latest_failure?.at || null;
139
+ entry.last_error = entry._latest_failure?.error || null;
140
+
141
+ const hasActive = entry.active_turn_ids.length > 0;
142
+ const hasFailure = Boolean(entry._latest_failure);
143
+ const hasSuccess = Boolean(entry._latest_success);
144
+
145
+ if (hasActive && !hasFailure) {
146
+ entry.state = 'active';
147
+ } else {
148
+ const failureMs = toMillis(entry.last_failure_at);
149
+ const successMs = toMillis(entry.last_success_at);
150
+ if (failureMs !== null && (successMs === null || failureMs >= successMs)) {
151
+ entry.state = 'failing';
152
+ } else if (hasSuccess || entry._latest_attempt?.final_outcome === 'success') {
153
+ entry.state = 'healthy';
154
+ } else if (hasActive) {
155
+ entry.state = 'active';
156
+ } else {
157
+ entry.state = 'never_used';
158
+ }
159
+ }
160
+
161
+ if (entry.type === 'local_cli') {
162
+ entry.reachable = 'unknown';
163
+ } else if (entry._latest_attempt?.final_outcome === 'success' || entry.state === 'healthy') {
164
+ entry.reachable = 'yes';
165
+ } else if (entry._latest_failure) {
166
+ entry.reachable = 'no';
167
+ } else {
168
+ entry.reachable = 'unknown';
169
+ }
170
+
171
+ delete entry._latest_success;
172
+ delete entry._latest_failure;
173
+ delete entry._latest_attempt;
174
+ delete entry._latest_identity;
175
+ return entry;
176
+ }
177
+
178
+ export function getConnectorHealth(root, config, state) {
179
+ const runtimeEntries = Object.entries(config?.runtimes || {})
180
+ .filter(([, runtime]) => CONNECTOR_RUNTIME_TYPES.has(runtime?.type))
181
+ .sort(([left], [right]) => left.localeCompare(right, 'en'));
182
+
183
+ const connectors = runtimeEntries.map(([runtimeId, runtime]) => buildConnectorEntry(runtimeId, runtime));
184
+ const connectorMap = Object.fromEntries(connectors.map((entry) => [entry.runtime_id, entry]));
185
+
186
+ const turnRuntimeIndex = {};
187
+
188
+ for (const turn of Object.values(state?.active_turns || {})) {
189
+ if (!turn || typeof turn !== 'object') continue;
190
+ const entry = connectorMap[turn.runtime_id];
191
+ if (!entry) continue;
192
+ if (typeof turn.turn_id === 'string') {
193
+ turnRuntimeIndex[turn.turn_id] = turn.runtime_id;
194
+ entry.active_turn_ids.push(turn.turn_id);
195
+ }
196
+ if (typeof turn.assigned_role === 'string' && !entry.active_roles.includes(turn.assigned_role)) {
197
+ entry.active_roles.push(turn.assigned_role);
198
+ }
199
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, {
200
+ turn_id: turn.turn_id || null,
201
+ role: turn.assigned_role || null,
202
+ phase: state?.phase || null,
203
+ });
204
+ }
205
+
206
+ for (const entry of connectors) {
207
+ entry.active_turn_ids.sort((a, b) => a.localeCompare(b, 'en'));
208
+ entry.active_roles.sort((a, b) => a.localeCompare(b, 'en'));
209
+ }
210
+
211
+ const history = safeReadJsonl(join(root, HISTORY_PATH));
212
+ for (const item of history) {
213
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
214
+ const runtimeId = item.runtime_id;
215
+ const entry = connectorMap[runtimeId];
216
+ if (!entry) continue;
217
+
218
+ if (typeof item.turn_id === 'string') {
219
+ turnRuntimeIndex[item.turn_id] = runtimeId;
220
+ }
221
+
222
+ const acceptedAt = typeof item.accepted_at === 'string' ? item.accepted_at : null;
223
+ if (!acceptedAt) continue;
224
+
225
+ const successCandidate = {
226
+ at: acceptedAt,
227
+ turn_id: item.turn_id || null,
228
+ role: item.role || null,
229
+ phase: item.phase || null,
230
+ };
231
+ entry._latest_success = chooseLatest(entry._latest_success, successCandidate);
232
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, successCandidate);
233
+ }
234
+
235
+ const stagingRoot = join(root, STAGING_DIR);
236
+ if (existsSync(stagingRoot)) {
237
+ for (const child of readdirSync(stagingRoot, { withFileTypes: true })) {
238
+ if (!child.isDirectory()) continue;
239
+ const turnId = child.name;
240
+ const turnDir = join(stagingRoot, turnId);
241
+ const trace = safeReadJson(join(turnDir, 'retry-trace.json'));
242
+ const apiError = safeReadJson(join(turnDir, 'api-error.json'));
243
+ const runtimeId = trace?.runtime_id || turnRuntimeIndex[turnId] || null;
244
+ const entry = runtimeId ? connectorMap[runtimeId] : null;
245
+ if (!entry) continue;
246
+
247
+ const latestAttempt = getLatestAttemptFromTrace(trace);
248
+ if (latestAttempt) {
249
+ entry._latest_attempt = chooseLatest(entry._latest_attempt, latestAttempt);
250
+ const identity = {
251
+ turn_id: latestAttempt.turn_id || turnId,
252
+ role: null,
253
+ phase: null,
254
+ };
255
+ if (latestAttempt.final_outcome === 'success') {
256
+ entry._latest_success = chooseLatest(entry._latest_success, {
257
+ at: latestAttempt.at,
258
+ turn_id: latestAttempt.turn_id || turnId,
259
+ role: null,
260
+ phase: null,
261
+ });
262
+ } else if (latestAttempt.final_outcome === 'failure' || latestAttempt.final_outcome === 'aborted') {
263
+ entry._latest_failure = chooseLatest(entry._latest_failure, {
264
+ at: latestAttempt.at,
265
+ turn_id: latestAttempt.turn_id || turnId,
266
+ role: null,
267
+ phase: null,
268
+ error: apiError?.message || latestAttempt.final_outcome,
269
+ });
270
+ }
271
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, identity);
272
+ }
273
+
274
+ if (apiError && !latestAttempt) {
275
+ const stats = statSync(turnDir);
276
+ entry._latest_failure = chooseLatest(entry._latest_failure, {
277
+ at: stats.mtime.toISOString(),
278
+ turn_id: turnId,
279
+ role: null,
280
+ phase: null,
281
+ error: apiError.message || apiError.error_class || 'runtime_error',
282
+ });
283
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, {
284
+ turn_id: turnId,
285
+ role: null,
286
+ phase: null,
287
+ });
288
+ } else if (apiError && entry._latest_failure && entry._latest_failure.turn_id === turnId) {
289
+ entry._latest_failure.error = apiError.message || apiError.error_class || entry._latest_failure.error;
290
+ }
291
+ }
292
+ }
293
+
294
+ const blockedTurnId = state?.blocked_reason?.turn_id || null;
295
+ if (blockedTurnId && turnRuntimeIndex[blockedTurnId] && connectorMap[turnRuntimeIndex[blockedTurnId]]) {
296
+ const entry = connectorMap[turnRuntimeIndex[blockedTurnId]];
297
+ const detail = state?.blocked_reason?.recovery?.detail || state?.blocked_on || 'runtime_blocked';
298
+ const blockedAt = state?.blocked_reason?.blocked_at || null;
299
+ entry._latest_failure = chooseLatest(entry._latest_failure, {
300
+ at: blockedAt,
301
+ turn_id: blockedTurnId,
302
+ role: null,
303
+ phase: state?.phase || null,
304
+ error: detail,
305
+ });
306
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, {
307
+ turn_id: blockedTurnId,
308
+ role: null,
309
+ phase: state?.phase || null,
310
+ });
311
+ }
312
+
313
+ return {
314
+ connectors: connectors.map((entry) => {
315
+ applyIdentityTarget(entry, entry._latest_failure || entry._latest_success || entry._latest_identity);
316
+ return finalizeConnectorEntry(entry);
317
+ }),
318
+ };
319
+ }
@@ -19,6 +19,7 @@ import { FileWatcher } from './file-watcher.js';
19
19
  import { approvePendingDashboardGate } from './actions.js';
20
20
  import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
21
21
  import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
22
+ import { readConnectorHealthSnapshot } from './connectors.js';
22
23
 
23
24
  const MIME_TYPES = {
24
25
  '.html': 'text/html; charset=utf-8',
@@ -286,6 +287,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
286
287
  return;
287
288
  }
288
289
 
290
+ if (pathname === '/api/connectors') {
291
+ const result = readConnectorHealthSnapshot(workspacePath);
292
+ writeJson(res, result.status, result.body);
293
+ return;
294
+ }
295
+
289
296
  // API routes
290
297
  if (pathname.startsWith('/api/')) {
291
298
  const result = readResource(agentxchainDir, pathname);
@@ -0,0 +1,41 @@
1
+ import { loadProjectContext } from '../config.js';
2
+ import { readJsonFile } from './state-reader.js';
3
+ import { getConnectorHealth } from '../connector-health.js';
4
+ import { join } from 'path';
5
+
6
+ export function readConnectorHealthSnapshot(workspacePath) {
7
+ const context = loadProjectContext(workspacePath);
8
+ if (!context || context.config.protocol_mode !== 'governed') {
9
+ return {
10
+ ok: false,
11
+ status: 404,
12
+ body: {
13
+ ok: false,
14
+ code: 'config_missing',
15
+ error: 'Project config not found. Run `agentxchain init --governed` first.',
16
+ },
17
+ };
18
+ }
19
+
20
+ const state = readJsonFile(join(workspacePath, '.agentxchain'), 'state.json');
21
+ if (!state) {
22
+ return {
23
+ ok: false,
24
+ status: 404,
25
+ body: {
26
+ ok: false,
27
+ code: 'state_missing',
28
+ error: 'Run state not found. Run `agentxchain init --governed` first.',
29
+ },
30
+ };
31
+ }
32
+
33
+ return {
34
+ ok: true,
35
+ status: 200,
36
+ body: {
37
+ ok: true,
38
+ ...getConnectorHealth(workspacePath, context.config, state),
39
+ },
40
+ };
41
+ }
@@ -10,9 +10,9 @@
10
10
  * See: WORKFLOW_KIT_DASHBOARD_SPEC.md
11
11
  */
12
12
 
13
- import { existsSync } from 'fs';
14
13
  import { join } from 'path';
15
- import { loadConfig } from '../config.js';
14
+ import { loadConfig, loadProjectContext, loadProjectState } from '../config.js';
15
+ import { deriveWorkflowKitArtifacts } from '../workflow-kit-artifacts.js';
16
16
  import { readJsonFile } from './state-reader.js';
17
17
 
18
18
  /**
@@ -22,8 +22,10 @@ import { readJsonFile } from './state-reader.js';
22
22
  * @returns {{ ok: boolean, status: number, body: object }}
23
23
  */
24
24
  export function readWorkflowKitArtifacts(workspacePath) {
25
- const configResult = loadConfig(workspacePath);
26
- if (!configResult) {
25
+ const context = loadProjectContext(workspacePath);
26
+ const governedContext = context?.config?.protocol_mode === 'governed' ? context : null;
27
+ const legacyConfigResult = governedContext ? null : loadConfig(workspacePath);
28
+ if (!governedContext && !legacyConfigResult) {
27
29
  return {
28
30
  ok: false,
29
31
  status: 404,
@@ -35,8 +37,11 @@ export function readWorkflowKitArtifacts(workspacePath) {
35
37
  };
36
38
  }
37
39
 
38
- const agentxchainDir = join(workspacePath, '.agentxchain');
39
- const state = readJsonFile(agentxchainDir, 'state.json');
40
+ const root = governedContext?.root || legacyConfigResult.root;
41
+ const config = governedContext?.config || legacyConfigResult.config;
42
+ const state = governedContext
43
+ ? loadProjectState(root, config)
44
+ : readJsonFile(join(root, '.agentxchain'), 'state.json');
40
45
  if (!state) {
41
46
  return {
42
47
  ok: false,
@@ -49,7 +54,6 @@ export function readWorkflowKitArtifacts(workspacePath) {
49
54
  };
50
55
  }
51
56
 
52
- const config = configResult.config;
53
57
  const phase = state.phase || null;
54
58
 
55
59
  if (!config.workflow_kit) {
@@ -89,8 +93,8 @@ export function readWorkflowKitArtifacts(workspacePath) {
89
93
  };
90
94
  }
91
95
 
92
- const artifacts = Array.isArray(phaseConfig.artifacts) ? phaseConfig.artifacts : [];
93
- if (artifacts.length === 0) {
96
+ const snapshot = deriveWorkflowKitArtifacts(root, config, state);
97
+ if (!snapshot) {
94
98
  return {
95
99
  ok: true,
96
100
  status: 200,
@@ -102,30 +106,13 @@ export function readWorkflowKitArtifacts(workspacePath) {
102
106
  };
103
107
  }
104
108
 
105
- const entryRole = config.routing?.[phase]?.entry_role || null;
106
-
107
- const result = artifacts
108
- .filter((a) => a && typeof a.path === 'string')
109
- .map((a) => {
110
- const hasExplicitOwner = typeof a.owned_by === 'string' && a.owned_by.length > 0;
111
- return {
112
- path: a.path,
113
- required: a.required !== false,
114
- semantics: a.semantics || null,
115
- owned_by: hasExplicitOwner ? a.owned_by : entryRole,
116
- owner_resolution: hasExplicitOwner ? 'explicit' : 'entry_role',
117
- exists: existsSync(join(workspacePath, a.path)),
118
- };
119
- })
120
- .sort((a, b) => a.path.localeCompare(b.path, 'en'));
121
-
122
109
  return {
123
110
  ok: true,
124
111
  status: 200,
125
112
  body: {
126
113
  ok: true,
127
114
  phase,
128
- artifacts: result,
115
+ artifacts: snapshot.artifacts,
129
116
  },
130
117
  };
131
118
  }
@@ -571,6 +571,23 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
571
571
  continue;
572
572
  }
573
573
 
574
+ if (Array.isArray(phaseConfig.artifacts)) {
575
+ const explicitSeenPaths = new Set();
576
+ for (const artifact of phaseConfig.artifacts) {
577
+ if (!artifact || typeof artifact !== 'object') {
578
+ continue;
579
+ }
580
+ if (typeof artifact.path !== 'string' || !artifact.path.trim()) {
581
+ continue;
582
+ }
583
+ if (explicitSeenPaths.has(artifact.path)) {
584
+ errors.push(`duplicate artifact path "${artifact.path}" in phase "${phase}"`);
585
+ continue;
586
+ }
587
+ explicitSeenPaths.add(artifact.path);
588
+ }
589
+ }
590
+
574
591
  if (phaseConfig.template === undefined && phaseConfig.artifacts === undefined) {
575
592
  errors.push(`workflow_kit.phases.${phase} must declare template, artifacts, or both`);
576
593
  continue;
@@ -0,0 +1,36 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export function deriveWorkflowKitArtifacts(root, config, state) {
5
+ if (!config?.workflow_kit) return null;
6
+
7
+ const phase = state?.phase || null;
8
+ if (!phase) return null;
9
+
10
+ const phaseConfig = config.workflow_kit.phases?.[phase];
11
+ if (!phaseConfig) return null;
12
+
13
+ const artifacts = Array.isArray(phaseConfig.artifacts) ? phaseConfig.artifacts : [];
14
+ if (artifacts.length === 0) return null;
15
+
16
+ const entryRole = config.routing?.[phase]?.entry_role || null;
17
+
18
+ return {
19
+ ok: true,
20
+ phase,
21
+ artifacts: artifacts
22
+ .filter((artifact) => artifact && typeof artifact.path === 'string')
23
+ .map((artifact) => {
24
+ const hasExplicitOwner = typeof artifact.owned_by === 'string' && artifact.owned_by.length > 0;
25
+ return {
26
+ path: artifact.path,
27
+ required: artifact.required !== false,
28
+ semantics: artifact.semantics || null,
29
+ owned_by: hasExplicitOwner ? artifact.owned_by : entryRole,
30
+ owner_resolution: hasExplicitOwner ? 'explicit' : 'entry_role',
31
+ exists: existsSync(join(root, artifact.path)),
32
+ };
33
+ })
34
+ .sort((a, b) => a.path.localeCompare(b.path, 'en')),
35
+ };
36
+ }
@@ -93,7 +93,33 @@ export function expandWorkflowKitPhaseArtifacts(phaseConfig = {}) {
93
93
  const explicitArtifacts = Array.isArray(phaseConfig.artifacts)
94
94
  ? cloneJsonCompatible(phaseConfig.artifacts)
95
95
  : [];
96
- return [...templateArtifacts, ...explicitArtifacts];
96
+ const mergedArtifacts = templateArtifacts.map((artifact) => cloneJsonCompatible(artifact));
97
+ const indexByPath = new Map(
98
+ mergedArtifacts
99
+ .filter((artifact) => artifact && typeof artifact.path === 'string')
100
+ .map((artifact, index) => [artifact.path, index]),
101
+ );
102
+
103
+ for (const artifact of explicitArtifacts) {
104
+ if (!artifact || typeof artifact.path !== 'string') {
105
+ mergedArtifacts.push(artifact);
106
+ continue;
107
+ }
108
+
109
+ const existingIndex = indexByPath.get(artifact.path);
110
+ if (existingIndex === undefined) {
111
+ indexByPath.set(artifact.path, mergedArtifacts.length);
112
+ mergedArtifacts.push(artifact);
113
+ continue;
114
+ }
115
+
116
+ mergedArtifacts[existingIndex] = {
117
+ ...mergedArtifacts[existingIndex],
118
+ ...artifact,
119
+ };
120
+ }
121
+
122
+ return mergedArtifacts;
97
123
  }
98
124
 
99
125
  export function buildDefaultWorkflowKitArtifactsForPhase(phase) {
@@ -145,49 +145,33 @@
145
145
  "workflow_kit": {
146
146
  "phases": {
147
147
  "planning": {
148
- "artifacts": [
149
- { "path": ".planning/PM_SIGNOFF.md", "semantics": "pm_signoff", "required": true },
150
- { "path": ".planning/SYSTEM_SPEC.md", "semantics": "system_spec", "required": true },
151
- { "path": ".planning/ROADMAP.md", "semantics": null, "required": true }
152
- ]
148
+ "template": "planning-default"
153
149
  },
154
150
  "architecture": {
151
+ "template": "architecture-review",
155
152
  "artifacts": [
156
153
  {
157
154
  "path": ".planning/ARCHITECTURE.md",
158
- "semantics": "section_check",
159
155
  "owned_by": "architect",
160
- "semantics_config": {
161
- "required_sections": ["## Context", "## Proposed Design", "## Trade-offs", "## Risks"]
162
- },
163
156
  "required": true
164
157
  }
165
158
  ]
166
159
  },
167
160
  "implementation": {
168
- "artifacts": [
169
- { "path": ".planning/IMPLEMENTATION_NOTES.md", "semantics": "implementation_notes", "required": true }
170
- ]
161
+ "template": "implementation-default"
171
162
  },
172
163
  "security_review": {
164
+ "template": "security-review",
173
165
  "artifacts": [
174
166
  {
175
167
  "path": ".planning/SECURITY_REVIEW.md",
176
- "semantics": "section_check",
177
168
  "owned_by": "security_reviewer",
178
- "semantics_config": {
179
- "required_sections": ["## Threat Model", "## Findings", "## Verdict"]
180
- },
181
169
  "required": true
182
170
  }
183
171
  ]
184
172
  },
185
173
  "qa": {
186
- "artifacts": [
187
- { "path": ".planning/acceptance-matrix.md", "semantics": "acceptance_matrix", "required": true },
188
- { "path": ".planning/ship-verdict.md", "semantics": "ship_verdict", "required": true },
189
- { "path": ".planning/RELEASE_NOTES.md", "semantics": "release_notes", "required": true }
190
- ]
174
+ "template": "qa-default"
191
175
  }
192
176
  }
193
177
  }