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 +2 -1
- package/dashboard/components/gate.js +2 -1
- package/dashboard/components/timeline.js +55 -1
- package/package.json +2 -1
- package/scripts/verify-post-publish.sh +76 -0
- package/src/commands/status.js +71 -0
- package/src/lib/connector-health.js +319 -0
- package/src/lib/dashboard/bridge-server.js +7 -0
- package/src/lib/dashboard/connectors.js +41 -0
- package/src/lib/dashboard/workflow-kit-artifacts.js +14 -27
- package/src/lib/normalized-config.js +17 -0
- package/src/lib/workflow-kit-artifacts.js +36 -0
- package/src/lib/workflow-kit-phase-templates.js +27 -1
- package/src/templates/governed/enterprise-app.json +5 -21
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
|
|
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
|
-
|
|
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.
|
|
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."
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
|
26
|
-
|
|
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
|
|
39
|
-
const
|
|
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
|
|
93
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
}
|