agentxchain 2.155.72 → 2.156.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.
Files changed (49) hide show
  1. package/README.md +4 -8
  2. package/bin/agentxchain.js +22 -0
  3. package/dashboard/app.js +54 -0
  4. package/dashboard/components/org-audit-trail.js +161 -0
  5. package/dashboard/components/org-history.js +140 -0
  6. package/dashboard/components/org-overview.js +145 -0
  7. package/dashboard/components/org-runs.js +168 -0
  8. package/dashboard/index.html +4 -0
  9. package/package.json +4 -5
  10. package/scripts/migrate-node-test-to-vitest.mjs +98 -0
  11. package/scripts/release-postflight.sh +1 -1
  12. package/scripts/release-preflight.sh +5 -5
  13. package/scripts/verify-post-publish.sh +1 -1
  14. package/src/commands/ci-report.js +80 -0
  15. package/src/commands/doctor.js +22 -1
  16. package/src/commands/intake-approve.js +1 -0
  17. package/src/commands/replay.js +1 -0
  18. package/src/commands/run.js +50 -0
  19. package/src/commands/serve.js +64 -0
  20. package/src/commands/step.js +63 -1
  21. package/src/commands/verify.js +1 -0
  22. package/src/lib/adapters/local-cli-adapter.js +326 -2
  23. package/src/lib/api/execution-worker.js +192 -0
  24. package/src/lib/api/hosted-runner.js +494 -0
  25. package/src/lib/api/job-queue.js +152 -0
  26. package/src/lib/api/org-state-aggregator.js +428 -0
  27. package/src/lib/api/project-registry.js +148 -0
  28. package/src/lib/api/protocol-bridge.js +476 -0
  29. package/src/lib/approval-policy.js +12 -0
  30. package/src/lib/ci-reporter.js +188 -0
  31. package/src/lib/claude-local-auth.js +89 -1
  32. package/src/lib/connector-probe.js +21 -0
  33. package/src/lib/continuous-run.js +51 -3
  34. package/src/lib/dashboard/bridge-server.js +10 -5
  35. package/src/lib/dispatch-bundle.js +7 -3
  36. package/src/lib/dispatch-progress.js +9 -0
  37. package/src/lib/governed-state.js +5 -4
  38. package/src/lib/intake.js +32 -6
  39. package/src/lib/normalized-config.js +4 -0
  40. package/src/lib/recovery-classification.js +158 -0
  41. package/src/lib/report.js +91 -0
  42. package/src/lib/run-events.js +7 -1
  43. package/src/lib/schemas/agentxchain-config.schema.json +10 -0
  44. package/src/lib/scope-overlap.js +214 -0
  45. package/src/lib/turn-checkpoint.js +33 -3
  46. package/src/lib/turn-result-validator.js +47 -6
  47. package/src/lib/validation.js +11 -3
  48. package/src/lib/verification-replay.js +125 -4
  49. package/src/lib/vision-reader.js +16 -1
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Job Queue — in-memory FIFO queue with lease management.
3
+ *
4
+ * Implements execution plane spec behavior rules:
5
+ * - Rule 2: FIFO ordering within project
6
+ * - Rule 3: Exclusive leases with heartbeat-based liveness
7
+ * - Rule 4: Explicit finalization, crash-closed recovery (no auto-retry)
8
+ *
9
+ * @module job-queue
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto';
13
+
14
+ const DEFAULT_LEASE_DURATION_MS = 600_000; // 10min (api_proxy)
15
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
16
+ const DEFAULT_STALE_THRESHOLD_MULTIPLIER = 2;
17
+
18
+ /**
19
+ * Create an in-memory job queue.
20
+ * @param {object} [options]
21
+ * @param {number} [options.defaultLeaseDurationMs=600000]
22
+ * @param {number} [options.heartbeatIntervalMs=30000]
23
+ * @param {number} [options.staleThresholdMultiplier=2]
24
+ * @returns {JobQueue}
25
+ */
26
+ export function createJobQueue(options = {}) {
27
+ const leaseDurationMs = options.defaultLeaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
28
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
29
+ const staleMultiplier = options.staleThresholdMultiplier ?? DEFAULT_STALE_THRESHOLD_MULTIPLIER;
30
+ const staleThresholdMs = heartbeatIntervalMs * staleMultiplier;
31
+
32
+ /** @type {Map<string, Job>} */
33
+ const jobs = new Map();
34
+
35
+ /** @type {Map<string, ExecutionLease>} */
36
+ const leases = new Map();
37
+
38
+ function getLeaseDuration(runtimeClass) {
39
+ if (runtimeClass === 'local_cli') return 1_800_000; // 30min
40
+ return leaseDurationMs; // 10min default (api_proxy)
41
+ }
42
+
43
+ function enqueue(jobDef) {
44
+ const job = {
45
+ job_id: jobDef.job_id || randomUUID(),
46
+ project_id: jobDef.project_id || null,
47
+ run_id: jobDef.run_id,
48
+ turn_id: jobDef.turn_id || null,
49
+ role: jobDef.role,
50
+ runtime_id: jobDef.runtime_id || null,
51
+ runtime_class: jobDef.runtime_class || 'api_proxy',
52
+ enqueued_at: Date.now(),
53
+ status: 'waiting',
54
+ lease: null,
55
+ };
56
+ jobs.set(job.job_id, job);
57
+ return job.job_id;
58
+ }
59
+
60
+ function claim(workerId, runtimeClass) {
61
+ for (const job of jobs.values()) {
62
+ if (job.status !== 'waiting') continue;
63
+ if (runtimeClass && job.runtime_class !== runtimeClass) continue;
64
+
65
+ const now = Date.now();
66
+ const duration = getLeaseDuration(job.runtime_class);
67
+ const lease = {
68
+ lease_id: randomUUID(),
69
+ job_id: job.job_id,
70
+ worker_id: workerId,
71
+ claimed_at: now,
72
+ expires_at: now + duration,
73
+ heartbeat_at: now,
74
+ attempt: (job.lease?.attempt || 0) + 1,
75
+ };
76
+
77
+ job.status = 'claimed';
78
+ job.lease = lease;
79
+ leases.set(lease.lease_id, lease);
80
+
81
+ return { job, lease };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function heartbeat(leaseId) {
87
+ const lease = leases.get(leaseId);
88
+ if (!lease) return false;
89
+ const job = jobs.get(lease.job_id);
90
+ if (!job || job.status !== 'claimed') return false;
91
+ if (Date.now() > lease.expires_at) return false;
92
+ lease.heartbeat_at = Date.now();
93
+ return true;
94
+ }
95
+
96
+ function finalize(leaseId, result) {
97
+ const lease = leases.get(leaseId);
98
+ if (!lease) return false;
99
+ const job = jobs.get(lease.job_id);
100
+ if (!job) return false;
101
+
102
+ job.status = result === 'completed' ? 'completed' : 'failed';
103
+ leases.delete(leaseId);
104
+ return true;
105
+ }
106
+
107
+ function expireStaleLeases() {
108
+ const now = Date.now();
109
+ const expired = [];
110
+ for (const [leaseId, lease] of leases) {
111
+ const elapsed = now - lease.heartbeat_at;
112
+ if (elapsed > staleThresholdMs) {
113
+ const job = jobs.get(lease.job_id);
114
+ if (job && job.status === 'claimed') {
115
+ job.status = 'needs_recovery';
116
+ expired.push(lease.job_id);
117
+ }
118
+ leases.delete(leaseId);
119
+ }
120
+ }
121
+ return expired;
122
+ }
123
+
124
+ function getJobs(filter) {
125
+ const result = [];
126
+ for (const job of jobs.values()) {
127
+ if (filter && filter.status && job.status !== filter.status) continue;
128
+ if (filter && filter.run_id && job.run_id !== filter.run_id) continue;
129
+ result.push(job);
130
+ }
131
+ return result;
132
+ }
133
+
134
+ function getStatus() {
135
+ const counts = { total: 0, waiting: 0, claimed: 0, completed: 0, failed: 0, needs_recovery: 0 };
136
+ for (const job of jobs.values()) {
137
+ counts.total++;
138
+ counts[job.status] = (counts[job.status] || 0) + 1;
139
+ }
140
+ return counts;
141
+ }
142
+
143
+ return {
144
+ enqueue,
145
+ claim,
146
+ heartbeat,
147
+ finalize,
148
+ expireStaleLeases,
149
+ getJobs,
150
+ getStatus,
151
+ };
152
+ }
@@ -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
+ }