agentxchain 2.155.73 → 2.157.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/bin/agentxchain.js +22 -0
- package/dashboard/app.js +54 -0
- package/dashboard/components/org-audit-trail.js +161 -0
- package/dashboard/components/org-history.js +140 -0
- package/dashboard/components/org-overview.js +145 -0
- package/dashboard/components/org-runs.js +168 -0
- package/dashboard/index.html +4 -0
- package/package.json +2 -1
- package/src/commands/ci-report.js +80 -0
- package/src/commands/doctor.js +22 -1
- package/src/commands/intake-approve.js +1 -0
- package/src/commands/replay.js +1 -0
- package/src/commands/run.js +47 -0
- package/src/commands/serve.js +64 -0
- package/src/commands/step.js +16 -0
- package/src/commands/verify.js +1 -0
- package/src/lib/adapters/local-cli-adapter.js +184 -0
- package/src/lib/api/execution-worker.js +192 -0
- package/src/lib/api/hosted-runner.js +494 -0
- package/src/lib/api/job-queue.js +152 -0
- package/src/lib/api/org-state-aggregator.js +428 -0
- package/src/lib/api/project-registry.js +148 -0
- package/src/lib/api/protocol-bridge.js +476 -0
- package/src/lib/approval-policy.js +12 -0
- package/src/lib/ci-reporter.js +200 -0
- package/src/lib/claude-local-auth.js +75 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +75 -4
- package/src/lib/dashboard/bridge-server.js +10 -5
- package/src/lib/governed-state.js +1 -1
- package/src/lib/intake.js +32 -6
- package/src/lib/normalized-config.js +2 -0
- package/src/lib/scope-overlap.js +214 -0
- package/src/lib/stream-json-cost-parser.js +169 -0
- package/src/lib/turn-checkpoint.js +21 -0
- package/src/lib/turn-result-validator.js +25 -0
- package/src/lib/validation.js +11 -3
- package/src/lib/verification-replay.js +125 -4
- package/src/lib/vision-reader.js +7 -2
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org State Aggregator — reads governed state from each registered project
|
|
3
|
+
* and returns aggregated cross-project views.
|
|
4
|
+
*
|
|
5
|
+
* Uses readJsonFile/readJsonlFile from state-reader.js for all file reads.
|
|
6
|
+
* Individual project read failures are isolated — never throws.
|
|
7
|
+
*
|
|
8
|
+
* @module org-state-aggregator
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readJsonFile, readJsonlFile } from '../dashboard/state-reader.js';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read state for a single project. Returns a normalized project state object.
|
|
16
|
+
* Never throws — returns a degraded object with error flag on failure.
|
|
17
|
+
*/
|
|
18
|
+
function readProjectState(project) {
|
|
19
|
+
const axDir = join(project.root, '.agentxchain');
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const state = readJsonFile(axDir, 'state.json');
|
|
23
|
+
const decisions = readJsonlFile(axDir, 'decision-ledger.jsonl') || [];
|
|
24
|
+
const history = readJsonlFile(axDir, 'history.jsonl') || [];
|
|
25
|
+
|
|
26
|
+
if (!state) {
|
|
27
|
+
return {
|
|
28
|
+
id: project.id,
|
|
29
|
+
name: project.name,
|
|
30
|
+
root: project.root,
|
|
31
|
+
state: {
|
|
32
|
+
run_id: null,
|
|
33
|
+
status: null,
|
|
34
|
+
phase: null,
|
|
35
|
+
active_turns: 0,
|
|
36
|
+
budget_spent_usd: 0,
|
|
37
|
+
updated_at: null,
|
|
38
|
+
},
|
|
39
|
+
pending_gates: [],
|
|
40
|
+
decision_count: 0,
|
|
41
|
+
decisions,
|
|
42
|
+
history,
|
|
43
|
+
error: 'state_unreadable',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const activeTurns = state.active_turns
|
|
48
|
+
? Object.keys(state.active_turns).length
|
|
49
|
+
: 0;
|
|
50
|
+
|
|
51
|
+
const budgetSpent = state.cost_tracker?.total_cost_usd || 0;
|
|
52
|
+
|
|
53
|
+
// Extract pending gates
|
|
54
|
+
const pendingGates = [];
|
|
55
|
+
if (state.gates && typeof state.gates === 'object') {
|
|
56
|
+
for (const [gateName, gateStatus] of Object.entries(state.gates)) {
|
|
57
|
+
if (gateStatus === 'pending' || gateStatus?.status === 'pending') {
|
|
58
|
+
pendingGates.push(gateName);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
id: project.id,
|
|
65
|
+
name: project.name,
|
|
66
|
+
root: project.root,
|
|
67
|
+
state: {
|
|
68
|
+
run_id: state.run_id || null,
|
|
69
|
+
status: state.status || null,
|
|
70
|
+
phase: state.phase || null,
|
|
71
|
+
active_turns: activeTurns,
|
|
72
|
+
budget_spent_usd: budgetSpent,
|
|
73
|
+
updated_at: state.updated_at || null,
|
|
74
|
+
},
|
|
75
|
+
pending_gates: pendingGates,
|
|
76
|
+
decision_count: decisions.length,
|
|
77
|
+
decisions,
|
|
78
|
+
history,
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return {
|
|
82
|
+
id: project.id,
|
|
83
|
+
name: project.name,
|
|
84
|
+
root: project.root,
|
|
85
|
+
state: {
|
|
86
|
+
run_id: null,
|
|
87
|
+
status: null,
|
|
88
|
+
phase: null,
|
|
89
|
+
active_turns: 0,
|
|
90
|
+
budget_spent_usd: 0,
|
|
91
|
+
updated_at: null,
|
|
92
|
+
},
|
|
93
|
+
pending_gates: [],
|
|
94
|
+
decision_count: 0,
|
|
95
|
+
decisions: [],
|
|
96
|
+
history: [],
|
|
97
|
+
error: 'state_unreadable',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create an org state aggregator.
|
|
104
|
+
* @param {object} registry - project registry instance
|
|
105
|
+
* @returns {{ getOverview(), getRuns(query?), getDecisions(query?) }}
|
|
106
|
+
*/
|
|
107
|
+
export function createOrgAggregator(registry) {
|
|
108
|
+
|
|
109
|
+
function getOverview() {
|
|
110
|
+
const projects = registry.list();
|
|
111
|
+
const projectStates = projects.map(readProjectState);
|
|
112
|
+
|
|
113
|
+
let activeRuns = 0;
|
|
114
|
+
let pendingGates = 0;
|
|
115
|
+
let totalDecisions = 0;
|
|
116
|
+
let totalCostUsd = 0;
|
|
117
|
+
|
|
118
|
+
for (const ps of projectStates) {
|
|
119
|
+
if (ps.state.status === 'active') activeRuns++;
|
|
120
|
+
pendingGates += ps.pending_gates.length;
|
|
121
|
+
totalDecisions += ps.decision_count;
|
|
122
|
+
totalCostUsd += ps.state.budget_spent_usd;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
total_projects: projects.length,
|
|
127
|
+
active_runs: activeRuns,
|
|
128
|
+
pending_gates: pendingGates,
|
|
129
|
+
total_decisions: totalDecisions,
|
|
130
|
+
total_cost_usd: Math.round(totalCostUsd * 100) / 100,
|
|
131
|
+
projects: projectStates.map(ps => {
|
|
132
|
+
const result = {
|
|
133
|
+
id: ps.id,
|
|
134
|
+
name: ps.name,
|
|
135
|
+
root: ps.root,
|
|
136
|
+
state: ps.state,
|
|
137
|
+
pending_gates: ps.pending_gates,
|
|
138
|
+
decision_count: ps.decision_count,
|
|
139
|
+
};
|
|
140
|
+
if (ps.error) result.error = ps.error;
|
|
141
|
+
return result;
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getRuns(query) {
|
|
147
|
+
const projects = registry.list();
|
|
148
|
+
const runs = [];
|
|
149
|
+
|
|
150
|
+
for (const project of projects) {
|
|
151
|
+
const axDir = join(project.root, '.agentxchain');
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const state = readJsonFile(axDir, 'state.json');
|
|
155
|
+
const history = readJsonlFile(axDir, 'history.jsonl') || [];
|
|
156
|
+
const runHistory = readJsonlFile(axDir, 'run-history.jsonl') || [];
|
|
157
|
+
|
|
158
|
+
// Active run from state.json
|
|
159
|
+
if (state && state.run_id) {
|
|
160
|
+
runs.push({
|
|
161
|
+
project_id: project.id,
|
|
162
|
+
project_name: project.name,
|
|
163
|
+
run_id: state.run_id,
|
|
164
|
+
status: state.status || 'unknown',
|
|
165
|
+
phase: state.phase || 'unknown',
|
|
166
|
+
turns_completed: history.length,
|
|
167
|
+
cost_usd: state.cost_tracker?.total_cost_usd || 0,
|
|
168
|
+
started_at: state.created_at || null,
|
|
169
|
+
updated_at: state.updated_at || null,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Historical runs
|
|
174
|
+
for (const entry of runHistory) {
|
|
175
|
+
if (entry.run_id === state?.run_id) continue; // skip active run
|
|
176
|
+
runs.push({
|
|
177
|
+
project_id: project.id,
|
|
178
|
+
project_name: project.name,
|
|
179
|
+
run_id: entry.run_id || 'unknown',
|
|
180
|
+
status: entry.status || 'completed',
|
|
181
|
+
phase: entry.phase || entry.final_phase || 'unknown',
|
|
182
|
+
turns_completed: entry.turns_completed || 0,
|
|
183
|
+
cost_usd: entry.cost_usd || 0,
|
|
184
|
+
started_at: entry.started_at || null,
|
|
185
|
+
updated_at: entry.completed_at || entry.updated_at || null,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Skip unreadable projects
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Apply filters
|
|
194
|
+
let filtered = runs;
|
|
195
|
+
if (query?.project) {
|
|
196
|
+
filtered = filtered.filter(r => r.project_id === query.project);
|
|
197
|
+
}
|
|
198
|
+
if (query?.phase) {
|
|
199
|
+
filtered = filtered.filter(r => r.phase === query.phase);
|
|
200
|
+
}
|
|
201
|
+
if (query?.status) {
|
|
202
|
+
filtered = filtered.filter(r => r.status === query.status);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Sort by updated_at descending
|
|
206
|
+
filtered.sort((a, b) => {
|
|
207
|
+
const ta = a.updated_at || '';
|
|
208
|
+
const tb = b.updated_at || '';
|
|
209
|
+
return tb.localeCompare(ta);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const limit = parseInt(query?.limit, 10) || 50;
|
|
213
|
+
return { data: filtered.slice(0, limit) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getDecisions(query) {
|
|
217
|
+
const projects = registry.list();
|
|
218
|
+
const decisions = [];
|
|
219
|
+
|
|
220
|
+
for (const project of projects) {
|
|
221
|
+
const axDir = join(project.root, '.agentxchain');
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const entries = readJsonlFile(axDir, 'decision-ledger.jsonl') || [];
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
decisions.push({
|
|
227
|
+
project_id: project.id,
|
|
228
|
+
project_name: project.name,
|
|
229
|
+
id: entry.id || entry.decision_id || 'unknown',
|
|
230
|
+
phase: entry.phase || 'unknown',
|
|
231
|
+
role: entry.role || 'unknown',
|
|
232
|
+
runtime_id: entry.runtime_id || null,
|
|
233
|
+
category: entry.category || 'unknown',
|
|
234
|
+
statement: entry.statement || '',
|
|
235
|
+
rationale: entry.rationale || '',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
// Skip unreadable projects
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Apply filters
|
|
244
|
+
let filtered = decisions;
|
|
245
|
+
if (query?.project) {
|
|
246
|
+
filtered = filtered.filter(d => d.project_id === query.project);
|
|
247
|
+
}
|
|
248
|
+
if (query?.phase) {
|
|
249
|
+
filtered = filtered.filter(d => d.phase === query.phase);
|
|
250
|
+
}
|
|
251
|
+
if (query?.role) {
|
|
252
|
+
filtered = filtered.filter(d => d.role === query.role);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const limit = parseInt(query?.limit, 10) || 100;
|
|
256
|
+
return { data: filtered.slice(0, limit) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Run History (full-fidelity) ──────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function getRunHistory(query) {
|
|
262
|
+
const projects = registry.list();
|
|
263
|
+
const records = [];
|
|
264
|
+
|
|
265
|
+
for (const project of projects) {
|
|
266
|
+
const axDir = join(project.root, '.agentxchain');
|
|
267
|
+
try {
|
|
268
|
+
const entries = readJsonlFile(axDir, 'run-history.jsonl') || [];
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
records.push({
|
|
271
|
+
...entry,
|
|
272
|
+
project_id: project.id,
|
|
273
|
+
project_name: project.name,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
} catch { /* skip unreadable */ }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let filtered = records;
|
|
280
|
+
if (query?.project) filtered = filtered.filter(r => r.project_id === query.project);
|
|
281
|
+
if (query?.status) filtered = filtered.filter(r => r.status === query.status);
|
|
282
|
+
|
|
283
|
+
filtered.sort((a, b) => {
|
|
284
|
+
const ta = a.completed_at || a.recorded_at || '';
|
|
285
|
+
const tb = b.completed_at || b.recorded_at || '';
|
|
286
|
+
return tb.localeCompare(ta);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const limit = parseInt(query?.limit, 10) || 50;
|
|
290
|
+
const offset = parseInt(query?.offset, 10) || 0;
|
|
291
|
+
return { data: filtered.slice(offset, offset + limit), total: filtered.length };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Governance Audit Trail ─────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
const GOVERNANCE_EVENT_TYPES = new Set([
|
|
297
|
+
'escalation_raised', 'escalation_resolved',
|
|
298
|
+
'gate_pending', 'gate_approved', 'gate_failed',
|
|
299
|
+
'run_blocked', 'human_escalation_raised',
|
|
300
|
+
'budget_exceeded_warn',
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
const GOVERNANCE_DECISION_TYPES = new Set([
|
|
304
|
+
'policy_escalation', 'conflict_detected', 'conflict_rejected',
|
|
305
|
+
'conflict_resolution_selected', 'operator_escalated',
|
|
306
|
+
'escalation_resolved', 'timeout_turn_level',
|
|
307
|
+
'timeout_phase_level', 'timeout_run_level',
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
function classifySeverity(eventType) {
|
|
311
|
+
if (['run_blocked', 'timeout_run_level', 'policy_escalation', 'hook_block'].includes(eventType)) return 'high';
|
|
312
|
+
if (['escalation_raised', 'human_escalation_raised', 'gate_failed', 'conflict_detected', 'timeout_phase_level', 'timeout_turn_level', 'hook_warn', 'budget_exceeded_warn'].includes(eventType)) return 'medium';
|
|
313
|
+
return 'low';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildDecisionSummary(entry) {
|
|
317
|
+
const type = entry.decision || entry.type || 'unknown';
|
|
318
|
+
if (type === 'policy_escalation') return `Policy violation: ${(entry.violations || []).map(v => v.message).join('; ') || 'unknown'}`;
|
|
319
|
+
if (type === 'conflict_detected') return `File conflict detected: ${(entry.conflict?.conflicting_files || []).join(', ')}`;
|
|
320
|
+
if (type.startsWith('timeout_')) return `Timeout (${entry.scope || type}): ${entry.elapsed_minutes || '?'}m elapsed vs ${entry.limit_minutes || '?'}m limit`;
|
|
321
|
+
if (type === 'operator_escalated') return `Operator escalated: ${entry.escalation?.reason || entry.blocked_on || 'unknown'}`;
|
|
322
|
+
if (type === 'escalation_resolved') return `Escalation resolved: ${entry.resolution || 'resolved'}`;
|
|
323
|
+
if (type.startsWith('conflict_')) return `Conflict ${type.replace('conflict_', '')}: ${(entry.conflict?.conflicting_files || []).join(', ')}`;
|
|
324
|
+
return type;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function buildEventSummary(entry) {
|
|
328
|
+
const type = entry.event_type || 'unknown';
|
|
329
|
+
const payload = entry.payload || {};
|
|
330
|
+
if (type === 'escalation_raised') return `Escalation: ${payload.reason || payload.blocked_on || 'unknown'}`;
|
|
331
|
+
if (type === 'gate_pending') return `Gate pending: ${payload.gate_id || 'unknown'}`;
|
|
332
|
+
if (type === 'gate_approved') return `Gate approved: ${payload.gate_id || 'unknown'}`;
|
|
333
|
+
if (type === 'gate_failed') return `Gate failed: ${payload.gate_id || 'unknown'}`;
|
|
334
|
+
if (type === 'run_blocked') return `Run blocked: ${payload.reason || payload.blocked_on || 'unknown'}`;
|
|
335
|
+
if (type === 'human_escalation_raised') return `Human escalation: ${payload.reason || 'operator required'}`;
|
|
336
|
+
if (type === 'budget_exceeded_warn') return `Budget warning: ${payload.message || 'approaching limit'}`;
|
|
337
|
+
if (type === 'escalation_resolved') return `Escalation resolved`;
|
|
338
|
+
return type;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function getAuditTrail(query) {
|
|
342
|
+
const projects = registry.list();
|
|
343
|
+
const events = [];
|
|
344
|
+
|
|
345
|
+
for (const project of projects) {
|
|
346
|
+
const axDir = join(project.root, '.agentxchain');
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const ledger = readJsonlFile(axDir, 'decision-ledger.jsonl') || [];
|
|
350
|
+
for (const entry of ledger) {
|
|
351
|
+
const decisionType = entry.decision || entry.type || '';
|
|
352
|
+
if (!GOVERNANCE_DECISION_TYPES.has(decisionType)) continue;
|
|
353
|
+
events.push({
|
|
354
|
+
timestamp: entry.timestamp || null,
|
|
355
|
+
event_type: decisionType,
|
|
356
|
+
severity: classifySeverity(decisionType),
|
|
357
|
+
source: 'decision_ledger',
|
|
358
|
+
project_id: project.id,
|
|
359
|
+
project_name: project.name,
|
|
360
|
+
run_id: entry.run_id || null,
|
|
361
|
+
phase: entry.phase || null,
|
|
362
|
+
role: entry.role || null,
|
|
363
|
+
summary: buildDecisionSummary(entry),
|
|
364
|
+
detail: entry,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
} catch { /* skip */ }
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const hooks = readJsonlFile(axDir, 'hook-audit.jsonl') || [];
|
|
371
|
+
for (const entry of hooks) {
|
|
372
|
+
if (entry.verdict !== 'block' && entry.verdict !== 'warn') continue;
|
|
373
|
+
events.push({
|
|
374
|
+
timestamp: entry.timestamp || null,
|
|
375
|
+
event_type: `hook_${entry.verdict}`,
|
|
376
|
+
severity: entry.verdict === 'block' ? 'high' : 'medium',
|
|
377
|
+
source: 'hook_audit',
|
|
378
|
+
project_id: project.id,
|
|
379
|
+
project_name: project.name,
|
|
380
|
+
run_id: entry.run_id || null,
|
|
381
|
+
phase: entry.phase || null,
|
|
382
|
+
role: null,
|
|
383
|
+
summary: `Hook "${entry.hook_name || 'unknown'}" ${entry.verdict}: ${entry.message || entry.event || ''}`,
|
|
384
|
+
detail: entry,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
} catch { /* skip */ }
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const evts = readJsonlFile(axDir, 'events.jsonl') || [];
|
|
391
|
+
for (const entry of evts) {
|
|
392
|
+
if (!GOVERNANCE_EVENT_TYPES.has(entry.event_type)) continue;
|
|
393
|
+
events.push({
|
|
394
|
+
timestamp: entry.timestamp || null,
|
|
395
|
+
event_type: entry.event_type,
|
|
396
|
+
severity: classifySeverity(entry.event_type),
|
|
397
|
+
source: 'events',
|
|
398
|
+
project_id: project.id,
|
|
399
|
+
project_name: project.name,
|
|
400
|
+
run_id: entry.run_id || null,
|
|
401
|
+
phase: entry.phase || null,
|
|
402
|
+
role: entry.turn?.role_id || null,
|
|
403
|
+
summary: buildEventSummary(entry),
|
|
404
|
+
detail: entry,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
} catch { /* skip */ }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let filtered = events;
|
|
411
|
+
if (query?.project) filtered = filtered.filter(e => e.project_id === query.project);
|
|
412
|
+
if (query?.severity) filtered = filtered.filter(e => e.severity === query.severity);
|
|
413
|
+
if (query?.event_type) filtered = filtered.filter(e => e.event_type === query.event_type);
|
|
414
|
+
if (query?.source) filtered = filtered.filter(e => e.source === query.source);
|
|
415
|
+
|
|
416
|
+
filtered.sort((a, b) => {
|
|
417
|
+
const ta = a.timestamp || '';
|
|
418
|
+
const tb = b.timestamp || '';
|
|
419
|
+
return tb.localeCompare(ta);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const limit = parseInt(query?.limit, 10) || 50;
|
|
423
|
+
const offset = parseInt(query?.offset, 10) || 0;
|
|
424
|
+
return { data: filtered.slice(offset, offset + limit), total: filtered.length };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return { getOverview, getRuns, getDecisions, getRunHistory, getAuditTrail };
|
|
428
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Registry — file-backed registry mapping project IDs to root directories.
|
|
3
|
+
*
|
|
4
|
+
* Persists to <primaryRoot>/.agentxchain/org-registry.json.
|
|
5
|
+
* The primary project is always registered and cannot be unregistered.
|
|
6
|
+
*
|
|
7
|
+
* @module project-registry
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { join, resolve, basename } from 'node:path';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Derive a deterministic project ID from a normalized absolute path.
|
|
16
|
+
* Uses first 12 hex chars of SHA-256.
|
|
17
|
+
*/
|
|
18
|
+
function deriveProjectId(absolutePath) {
|
|
19
|
+
const normalized = resolve(absolutePath);
|
|
20
|
+
return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a project registry persisted in the primary project's .agentxchain/ directory.
|
|
25
|
+
* @param {string} primaryRoot - primary project root (always registered)
|
|
26
|
+
* @returns {ProjectRegistry}
|
|
27
|
+
*/
|
|
28
|
+
export function createProjectRegistry(primaryRoot) {
|
|
29
|
+
const normalizedPrimary = resolve(primaryRoot);
|
|
30
|
+
const axDir = join(normalizedPrimary, '.agentxchain');
|
|
31
|
+
const registryPath = join(axDir, 'org-registry.json');
|
|
32
|
+
|
|
33
|
+
/** @type {Map<string, RegistryEntry>} */
|
|
34
|
+
const entries = new Map();
|
|
35
|
+
|
|
36
|
+
// Always register the primary project
|
|
37
|
+
const primaryId = deriveProjectId(normalizedPrimary);
|
|
38
|
+
entries.set(primaryId, {
|
|
39
|
+
id: primaryId,
|
|
40
|
+
name: basename(normalizedPrimary),
|
|
41
|
+
root: normalizedPrimary,
|
|
42
|
+
is_primary: true,
|
|
43
|
+
registered_at: Date.now(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Try to load existing registry from disk
|
|
47
|
+
load();
|
|
48
|
+
|
|
49
|
+
function load() {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(registryPath)) return;
|
|
52
|
+
const raw = readFileSync(registryPath, 'utf8').trim();
|
|
53
|
+
if (!raw) return;
|
|
54
|
+
const data = JSON.parse(raw);
|
|
55
|
+
if (data.version !== 1 || !Array.isArray(data.projects)) return;
|
|
56
|
+
for (const proj of data.projects) {
|
|
57
|
+
if (!proj.id || !proj.root) continue;
|
|
58
|
+
entries.set(proj.id, {
|
|
59
|
+
id: proj.id,
|
|
60
|
+
name: proj.name || basename(proj.root),
|
|
61
|
+
root: proj.root,
|
|
62
|
+
is_primary: proj.id === primaryId,
|
|
63
|
+
registered_at: proj.registered_at || Date.now(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Ensure primary is always present
|
|
67
|
+
if (!entries.has(primaryId)) {
|
|
68
|
+
entries.set(primaryId, {
|
|
69
|
+
id: primaryId,
|
|
70
|
+
name: basename(normalizedPrimary),
|
|
71
|
+
root: normalizedPrimary,
|
|
72
|
+
is_primary: true,
|
|
73
|
+
registered_at: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Graceful: corrupt file → keep primary-only registry
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function save() {
|
|
82
|
+
try {
|
|
83
|
+
if (!existsSync(axDir)) {
|
|
84
|
+
mkdirSync(axDir, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
const data = {
|
|
87
|
+
version: 1,
|
|
88
|
+
projects: Array.from(entries.values()),
|
|
89
|
+
};
|
|
90
|
+
writeFileSync(registryPath, JSON.stringify(data, null, 2));
|
|
91
|
+
} catch {
|
|
92
|
+
// Best-effort persistence
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function register(root, name) {
|
|
97
|
+
const normalizedRoot = resolve(root);
|
|
98
|
+
|
|
99
|
+
// Validate: must be a governed project
|
|
100
|
+
if (!existsSync(join(normalizedRoot, 'agentxchain.json'))) {
|
|
101
|
+
throw new Error(`no agentxchain.json found at ${normalizedRoot}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const id = deriveProjectId(normalizedRoot);
|
|
105
|
+
const existing = entries.get(id);
|
|
106
|
+
|
|
107
|
+
if (existing) {
|
|
108
|
+
// Idempotent: update name only
|
|
109
|
+
if (name) existing.name = name;
|
|
110
|
+
save();
|
|
111
|
+
return { ...existing };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const entry = {
|
|
115
|
+
id,
|
|
116
|
+
name: name || basename(normalizedRoot),
|
|
117
|
+
root: normalizedRoot,
|
|
118
|
+
is_primary: id === primaryId,
|
|
119
|
+
registered_at: Date.now(),
|
|
120
|
+
};
|
|
121
|
+
entries.set(id, entry);
|
|
122
|
+
save();
|
|
123
|
+
return { ...entry };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function unregister(projectId) {
|
|
127
|
+
const entry = entries.get(projectId);
|
|
128
|
+
if (!entry) return false;
|
|
129
|
+
// Primary project cannot be unregistered
|
|
130
|
+
if (entry.is_primary) return false;
|
|
131
|
+
entries.delete(projectId);
|
|
132
|
+
save();
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function list() {
|
|
137
|
+
return Array.from(entries.values())
|
|
138
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
139
|
+
.map(e => ({ ...e }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function get(projectId) {
|
|
143
|
+
const entry = entries.get(projectId);
|
|
144
|
+
return entry ? { ...entry } : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { register, unregister, list, get, save, load };
|
|
148
|
+
}
|