hoi4-agent-tools 1.1.0 → 1.2.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.
@@ -0,0 +1,1147 @@
1
+ import path from 'node:path';
2
+ import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
3
+ import { boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
4
+ import { ServiceError } from '../core/result.js';
5
+ import { RenderBudget } from '../core/render-budget.js';
6
+ import { SOURCE_MAX_BYTES } from '../core/source/index.js';
7
+ import { isPortablePathSegment } from '../core/workspace.js';
8
+ import { PACKAGE_VERSION } from '../version.js';
9
+ import { buildEventGraph } from './graph.js';
10
+ import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_PROPOSED_SOURCE_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_FILES, } from './limits.js';
11
+ import { analyzeEventImpact, discoverEventRoots, explainEventPath, inspectEventStateFlow, lintEventGraph, traceSelectedEvents, } from './queries.js';
12
+ import { compareEventGraphs, eventGraphHash } from './compare.js';
13
+ import { eventFlowEdges } from './algorithms.js';
14
+ import { validateEventGraphArtifact } from './artifact-validation.js';
15
+ import { renderEventGraph, } from './render.js';
16
+ class BoundedEventFragmentCache {
17
+ #entries = new Map();
18
+ #sourceBytes = 0;
19
+ get(key) {
20
+ const cached = this.#entries.get(key);
21
+ if (cached === undefined)
22
+ return undefined;
23
+ this.#entries.delete(key);
24
+ this.#entries.set(key, cached);
25
+ return cached.fragment;
26
+ }
27
+ set(key, fragment, sourceBytes) {
28
+ const previous = this.#entries.get(key);
29
+ if (previous !== undefined) {
30
+ this.#sourceBytes -= previous.sourceBytes;
31
+ this.#entries.delete(key);
32
+ }
33
+ this.#entries.set(key, { fragment, sourceBytes });
34
+ this.#sourceBytes += sourceBytes;
35
+ while (this.#entries.size > EVENT_FRAGMENT_CACHE_MAX_ENTRIES ||
36
+ this.#sourceBytes > EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES) {
37
+ const oldest = this.#entries.entries().next().value;
38
+ if (oldest === undefined)
39
+ break;
40
+ this.#entries.delete(oldest[0]);
41
+ this.#sourceBytes -= oldest[1].sourceBytes;
42
+ }
43
+ }
44
+ }
45
+ const serviceState = new WeakMap();
46
+ const EVENT_GRAPH_ARTIFACT_MAX_BYTES = 67_108_864;
47
+ const EVENT_GRAPH_ARTIFACT_MAX_CHUNKS = 1_024;
48
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
49
+ const graphHashCache = new WeakMap();
50
+ function cachedEventGraphHash(graph, signal) {
51
+ signal?.throwIfAborted();
52
+ const cached = graphHashCache.get(graph);
53
+ if (cached !== undefined)
54
+ return cached;
55
+ const computed = eventGraphHash(graph, signal);
56
+ graphHashCache.set(graph, computed);
57
+ return computed;
58
+ }
59
+ function sharedState(engine) {
60
+ let state = serviceState.get(engine);
61
+ if (state === undefined) {
62
+ state = {
63
+ current: new Map(),
64
+ history: new Map(),
65
+ fragments: new BoundedEventFragmentCache(),
66
+ };
67
+ serviceState.set(engine, state);
68
+ }
69
+ return state;
70
+ }
71
+ function engineGeneration(engine, workspaceId) {
72
+ return engine.generation(workspaceId);
73
+ }
74
+ function rememberGraph(state, workspaceId, graph) {
75
+ const history = state.history.get(workspaceId) ?? new Map();
76
+ history.delete(graph.revision);
77
+ history.set(graph.revision, graph);
78
+ while (history.size > 8) {
79
+ const oldest = history.keys().next().value;
80
+ if (oldest === undefined)
81
+ break;
82
+ history.delete(oldest);
83
+ }
84
+ state.history.set(workspaceId, history);
85
+ }
86
+ function eventIssueDiagnostic(issue) {
87
+ return {
88
+ code: issue.code,
89
+ severity: issue.severity,
90
+ category: issue.classification === 'confirmed_error' ? 'reference' : 'design',
91
+ message: issue.message,
92
+ ...(issue.location === undefined ? {} : { location: issue.location }),
93
+ ...(issue.related === undefined ? {} : { related: issue.related }),
94
+ details: {
95
+ classification: issue.classification,
96
+ confidence: issue.confidence,
97
+ blockers: issue.blockers,
98
+ ...issue.details,
99
+ },
100
+ };
101
+ }
102
+ export function eventDiagnostics(graph) {
103
+ const existing = new Set(graph.diagnostics.map(({ code, location }) => `${code}:${location?.path ?? ''}:${location?.start.offset ?? -1}`));
104
+ return [
105
+ ...graph.diagnostics,
106
+ ...graph.issues
107
+ .map(eventIssueDiagnostic)
108
+ .filter(({ code, location }) => !existing.has(`${code}:${location?.path ?? ''}:${location?.start.offset ?? -1}`)),
109
+ ];
110
+ }
111
+ function safeSlug(value) {
112
+ return (value
113
+ .replace(/[^A-Za-z0-9._-]+/gu, '-')
114
+ .replace(/^-+|-+$/gu, '')
115
+ .slice(0, 48) || 'event-chain');
116
+ }
117
+ function eventProvenance(graph, kind, metadata = {}, signal) {
118
+ const sourceEvidence = boundedSourceHashEvidence(graph.sourceHashes);
119
+ return {
120
+ kind,
121
+ toolVersion: PACKAGE_VERSION,
122
+ schemaVersion: 'event-chain-viewer.v1',
123
+ sourceHashes: sourceEvidence.sourceHashes,
124
+ renderProfile: {
125
+ graphRevision: graph.revision,
126
+ graphHash: cachedEventGraphHash(graph, signal),
127
+ complete: graph.complete,
128
+ parserVersion: graph.parserVersion,
129
+ },
130
+ metadata: {
131
+ ...metadata,
132
+ sourceHashInventory: sourceEvidence.inventory,
133
+ unresolvedCount: graph.unresolved.length,
134
+ },
135
+ };
136
+ }
137
+ function validRelativeSourcePath(relativePath) {
138
+ if (relativePath.length === 0 ||
139
+ relativePath.includes('\0') ||
140
+ path.win32.isAbsolute(relativePath) ||
141
+ path.posix.isAbsolute(relativePath)) {
142
+ throw new ServiceError('EVENT_PROPOSED_PATH_INVALID', 'Proposed source path must be workspace-relative');
143
+ }
144
+ const normalized = path.posix.normalize(relativePath.replaceAll('\\', '/')).replace(/^\.\//u, '');
145
+ const segments = normalized.split('/');
146
+ if (normalized === '.' ||
147
+ segments.some((segment) => segment === '..' || !isPortablePathSegment(segment)) ||
148
+ normalized.startsWith('.hoi4-agent/')) {
149
+ throw new ServiceError('EVENT_PROPOSED_PATH_INVALID', 'Proposed source path is not portable or escapes the workspace');
150
+ }
151
+ if (!/\.(?:txt|yml|yaml)$/iu.test(normalized)) {
152
+ throw new ServiceError('EVENT_PROPOSED_SOURCE_UNSUPPORTED', 'Event comparison overlays support Clausewitz and localisation text only');
153
+ }
154
+ return normalized;
155
+ }
156
+ function assertProposedSourceBounds(sources) {
157
+ if (sources.length > EVENT_PROPOSED_SOURCE_MAX_FILES) {
158
+ throw new ServiceError('EVENT_PROPOSED_SOURCE_COUNT_LIMIT', 'Proposed event comparison exceeds the fixed source-file ceiling', { count: sources.length, maximumFiles: EVENT_PROPOSED_SOURCE_MAX_FILES });
159
+ }
160
+ const sourceBytes = sources.reduce((total, { source }) => total + (source === null ? 0 : Buffer.byteLength(source, 'utf8')), 0);
161
+ if (sourceBytes > EVENT_PROPOSED_SOURCE_MAX_BYTES) {
162
+ throw new ServiceError('EVENT_PROPOSED_SOURCE_TOTAL_LIMIT', 'Proposed event comparison exceeds the fixed total source-byte ceiling', { sourceBytes, maximumBytes: EVENT_PROPOSED_SOURCE_MAX_BYTES });
163
+ }
164
+ }
165
+ function recomputeShadowing(files) {
166
+ const groups = new Map();
167
+ for (const file of files) {
168
+ delete file.shadowedBy;
169
+ const key = file.relativePath.replaceAll('\\', '/').toLowerCase();
170
+ const values = groups.get(key) ?? [];
171
+ values.push(file);
172
+ groups.set(key, values);
173
+ }
174
+ for (const values of groups.values()) {
175
+ values.sort((left, right) => right.loadOrder - left.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
176
+ const active = values[0];
177
+ if (active === undefined)
178
+ continue;
179
+ for (const shadowed of values.slice(1))
180
+ shadowed.shadowedBy = active.displayPath;
181
+ }
182
+ }
183
+ function snapshotRevision(files) {
184
+ return hashCanonical([...files]
185
+ .sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath))
186
+ .map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 })));
187
+ }
188
+ async function readLogicalArtifact(engine, workspaceId, uri, principal, signal) {
189
+ const workspace = engine.resolver.get(workspaceId, principal);
190
+ const probe = await engine.artifacts.read(workspace, uri, { offset: 0, length: 1 }, signal);
191
+ if (probe.mimeType !== 'application/json') {
192
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Event graph snapshot must be JSON');
193
+ }
194
+ if (probe.totalSize > EVENT_GRAPH_ARTIFACT_MAX_BYTES) {
195
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_LIMIT', 'Event graph snapshot exceeds the fixed comparison byte ceiling', { size: probe.totalSize, maximumBytes: EVENT_GRAPH_ARTIFACT_MAX_BYTES });
196
+ }
197
+ const read = await engine.artifacts.read(workspace, uri, undefined, signal);
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(read.bytes.toString('utf8'));
201
+ }
202
+ catch {
203
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Event graph artifact is not valid JSON');
204
+ }
205
+ if (parsed !== null &&
206
+ typeof parsed === 'object' &&
207
+ parsed.type === 'hoi4-agent.chunked-artifact') {
208
+ const index = parsed;
209
+ if (index.schemaVersion !== 1) {
210
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked graph index uses an unsupported schema version');
211
+ }
212
+ const chunks = index.chunks;
213
+ if (!Array.isArray(chunks))
214
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked graph index is malformed');
215
+ if (chunks.length === 0 || chunks.length > EVENT_GRAPH_ARTIFACT_MAX_CHUNKS) {
216
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_LIMIT', 'Chunked graph index exceeds the fixed chunk-count ceiling', { count: chunks.length, maximumChunks: EVENT_GRAPH_ARTIFACT_MAX_CHUNKS });
217
+ }
218
+ const original = index.original;
219
+ const originalSize = original?.size;
220
+ if (typeof originalSize !== 'number' ||
221
+ !Number.isSafeInteger(originalSize) ||
222
+ originalSize < 0 ||
223
+ originalSize > EVENT_GRAPH_ARTIFACT_MAX_BYTES) {
224
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_LIMIT', 'Chunked event graph exceeds the fixed comparison byte ceiling', { size: originalSize, maximumBytes: EVENT_GRAPH_ARTIFACT_MAX_BYTES });
225
+ }
226
+ if (original?.mimeType !== 'application/json' ||
227
+ typeof original.name !== 'string' ||
228
+ original.name.length === 0 ||
229
+ original.name.length > 255 ||
230
+ typeof original.sha256 !== 'string' ||
231
+ !SHA256_PATTERN.test(original.sha256)) {
232
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked graph index has invalid original-content metadata');
233
+ }
234
+ const buffers = [];
235
+ const chunkUris = new Set();
236
+ let reconstructedSize = 0;
237
+ for (const [chunkIndex, candidate] of chunks.entries()) {
238
+ signal?.throwIfAborted();
239
+ if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
240
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked graph entry is malformed');
241
+ }
242
+ const chunk = candidate;
243
+ const validLength = typeof chunk.length === 'number' && Number.isSafeInteger(chunk.length) && chunk.length > 0;
244
+ if (chunk.index !== chunkIndex ||
245
+ chunk.offset !== reconstructedSize ||
246
+ !validLength ||
247
+ chunk.size !== chunk.length ||
248
+ typeof chunk.uri !== 'string' ||
249
+ chunk.uri.length > 8_192 ||
250
+ typeof chunk.name !== 'string' ||
251
+ chunk.name.length === 0 ||
252
+ chunk.name.length > 255 ||
253
+ chunk.mimeType !== 'application/octet-stream' ||
254
+ typeof chunk.sha256 !== 'string' ||
255
+ !SHA256_PATTERN.test(chunk.sha256) ||
256
+ chunkUris.has(chunk.uri)) {
257
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked graph entry is malformed');
258
+ }
259
+ const chunkLength = chunk.length;
260
+ if (reconstructedSize + chunkLength > originalSize) {
261
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_LIMIT', 'Chunked graph entries exceed the declared original size');
262
+ }
263
+ chunkUris.add(chunk.uri);
264
+ const chunkProbe = await engine.artifacts.read(workspace, chunk.uri, { offset: 0, length: 1 }, signal);
265
+ if (chunkProbe.mimeType !== 'application/octet-stream' ||
266
+ chunkProbe.totalSize !== chunkLength) {
267
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked graph content does not match its bounded index entry');
268
+ }
269
+ const chunkRead = await engine.artifacts.read(workspace, chunk.uri, undefined, signal);
270
+ if (sha256Bytes(chunkRead.bytes) !== chunk.sha256) {
271
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked graph content hash does not match its index entry');
272
+ }
273
+ buffers.push(chunkRead.bytes);
274
+ reconstructedSize += chunkRead.bytes.length;
275
+ }
276
+ if (reconstructedSize !== originalSize) {
277
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked graph entries do not cover the declared original size');
278
+ }
279
+ const reconstructed = Buffer.concat(buffers, originalSize);
280
+ if (reconstructed.length !== originalSize || sha256Bytes(reconstructed) !== original.sha256) {
281
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Chunked event graph does not reconstruct the declared artifact');
282
+ }
283
+ return reconstructed;
284
+ }
285
+ return read.bytes;
286
+ }
287
+ function parseGraphArtifact(bytes, signal) {
288
+ signal?.throwIfAborted();
289
+ let parsed;
290
+ try {
291
+ parsed = JSON.parse(bytes.toString('utf8'));
292
+ }
293
+ catch {
294
+ throw new ServiceError('EVENT_GRAPH_ARTIFACT_INVALID', 'Event graph snapshot is not valid JSON');
295
+ }
296
+ const record = parsed !== null && typeof parsed === 'object' ? parsed : undefined;
297
+ const report = record !== undefined && 'report' in record && record.report !== null
298
+ ? record.report
299
+ : undefined;
300
+ const graphCandidate = record !== undefined && 'graph' in record
301
+ ? record.graph
302
+ : report !== undefined && typeof report === 'object' && 'graph' in report
303
+ ? report.graph
304
+ : parsed;
305
+ return validateEventGraphArtifact(graphCandidate, signal);
306
+ }
307
+ function comparisonRenderProjection(before, after, comparison, signal) {
308
+ signal?.throwIfAborted();
309
+ const addedNodes = new Set(comparison.addedNodeIds);
310
+ const changedNodes = new Set(comparison.changedNodeIds);
311
+ const removedNodes = new Set(comparison.removedNodeIds);
312
+ const addedEdges = new Set(comparison.addedEdgeIds);
313
+ const changedEdges = new Set(comparison.changedEdgeIds);
314
+ const removedEdges = new Set(comparison.removedEdgeIds);
315
+ const beforeNodes = new Map();
316
+ for (const [index, node] of before.nodes.entries()) {
317
+ if ((index & 255) === 0)
318
+ signal?.throwIfAborted();
319
+ beforeNodes.set(node.id, node);
320
+ }
321
+ const nodes = new Map();
322
+ for (const [index, node] of after.nodes.entries()) {
323
+ if ((index & 255) === 0)
324
+ signal?.throwIfAborted();
325
+ const status = addedNodes.has(node.id)
326
+ ? 'added'
327
+ : changedNodes.has(node.id)
328
+ ? 'changed'
329
+ : undefined;
330
+ nodes.set(node.id, {
331
+ ...node,
332
+ ...(status === undefined ? {} : { metadata: { ...node.metadata, comparisonStatus: status } }),
333
+ });
334
+ }
335
+ for (const nodeId of removedNodes) {
336
+ signal?.throwIfAborted();
337
+ const node = beforeNodes.get(nodeId);
338
+ if (node !== undefined) {
339
+ nodes.set(nodeId, {
340
+ ...node,
341
+ metadata: { ...node.metadata, comparisonStatus: 'removed' },
342
+ });
343
+ }
344
+ }
345
+ const edges = new Map();
346
+ for (const [index, edge] of after.edges.entries()) {
347
+ if ((index & 255) === 0)
348
+ signal?.throwIfAborted();
349
+ const status = addedEdges.has(edge.id)
350
+ ? 'added'
351
+ : changedEdges.has(edge.id)
352
+ ? 'changed'
353
+ : undefined;
354
+ edges.set(edge.id, {
355
+ ...edge,
356
+ ...(status === undefined ? {} : { metadata: { ...edge.metadata, comparisonStatus: status } }),
357
+ });
358
+ }
359
+ const beforeEdges = new Map();
360
+ for (const [index, edge] of before.edges.entries()) {
361
+ if ((index & 255) === 0)
362
+ signal?.throwIfAborted();
363
+ beforeEdges.set(edge.id, edge);
364
+ }
365
+ for (const edgeId of removedEdges) {
366
+ signal?.throwIfAborted();
367
+ const edge = beforeEdges.get(edgeId);
368
+ if (edge === undefined)
369
+ continue;
370
+ edges.set(edge.id, {
371
+ ...edge,
372
+ metadata: { ...edge.metadata, comparisonStatus: 'removed' },
373
+ });
374
+ for (const nodeId of [edge.from, edge.to]) {
375
+ if (nodes.has(nodeId))
376
+ continue;
377
+ const node = beforeNodes.get(nodeId);
378
+ if (node !== undefined)
379
+ nodes.set(nodeId, node);
380
+ }
381
+ }
382
+ const selectedNodeIds = new Set([
383
+ ...comparison.addedNodeIds,
384
+ ...comparison.removedNodeIds,
385
+ ...comparison.changedNodeIds,
386
+ ...comparison.newlyDisconnectedRootIds,
387
+ ...comparison.newlyDisconnectedBranchIds,
388
+ ...comparison.newlyDisconnectedTerminalIds,
389
+ ]);
390
+ for (const edgeId of [
391
+ ...comparison.addedEdgeIds,
392
+ ...comparison.removedEdgeIds,
393
+ ...comparison.changedEdgeIds,
394
+ ]) {
395
+ signal?.throwIfAborted();
396
+ const edge = edges.get(edgeId) ?? beforeEdges.get(edgeId);
397
+ if (edge === undefined)
398
+ continue;
399
+ selectedNodeIds.add(edge.from);
400
+ selectedNodeIds.add(edge.to);
401
+ }
402
+ const removedStateAccessIds = new Set(comparison.removedStateAccessIds);
403
+ const stateAccesses = [...after.stateAccesses];
404
+ for (const [index, access] of before.stateAccesses.entries()) {
405
+ if ((index & 255) === 0)
406
+ signal?.throwIfAborted();
407
+ if (!removedStateAccessIds.has(access.id))
408
+ continue;
409
+ stateAccesses.push({
410
+ ...access,
411
+ metadata: { ...access.metadata, comparisonStatus: 'removed' },
412
+ });
413
+ }
414
+ signal?.throwIfAborted();
415
+ return {
416
+ graph: {
417
+ ...after,
418
+ revision: hashCanonical({
419
+ kind: 'event-comparison-render',
420
+ before: before.revision,
421
+ after: after.revision,
422
+ }),
423
+ complete: before.complete && after.complete,
424
+ sourceHashes: { ...before.sourceHashes, ...after.sourceHashes },
425
+ filesScanned: [...new Set([...before.filesScanned, ...after.filesScanned])].sort(compareCodeUnits),
426
+ nodes: [...nodes.values()].sort((left, right) => compareCodeUnits(left.id, right.id)),
427
+ edges: [...edges.values()].sort((left, right) => compareCodeUnits(left.id, right.id)),
428
+ stateAccesses: stateAccesses.sort((left, right) => compareCodeUnits(left.id, right.id)),
429
+ },
430
+ selectedNodeIds: [...selectedNodeIds].sort(compareCodeUnits),
431
+ };
432
+ }
433
+ function comparisonEntityEvidence(before, after, comparison, signal) {
434
+ const select = (values, ids) => {
435
+ const selected = [];
436
+ for (const [index, value] of values.entries()) {
437
+ if ((index & 255) === 0)
438
+ signal?.throwIfAborted();
439
+ if (ids.has(value.id))
440
+ selected.push(value);
441
+ }
442
+ return selected;
443
+ };
444
+ const beforeNodeIds = new Set([...comparison.removedNodeIds, ...comparison.changedNodeIds]);
445
+ const afterNodeIds = new Set([...comparison.addedNodeIds, ...comparison.changedNodeIds]);
446
+ const beforeEdgeIds = new Set([...comparison.removedEdgeIds, ...comparison.changedEdgeIds]);
447
+ const afterEdgeIds = new Set([...comparison.addedEdgeIds, ...comparison.changedEdgeIds]);
448
+ const beforeStateAccessIds = new Set([
449
+ ...comparison.removedStateAccessIds,
450
+ ...comparison.changedStateAccessIds,
451
+ ]);
452
+ const afterStateAccessIds = new Set([
453
+ ...comparison.addedStateAccessIds,
454
+ ...comparison.changedStateAccessIds,
455
+ ]);
456
+ const beforeStateLinkIds = new Set([
457
+ ...comparison.removedStateLinkIds,
458
+ ...comparison.changedStateLinkIds,
459
+ ]);
460
+ const afterStateLinkIds = new Set([
461
+ ...comparison.addedStateLinkIds,
462
+ ...comparison.changedStateLinkIds,
463
+ ]);
464
+ return {
465
+ before: {
466
+ nodes: select(before.nodes, beforeNodeIds),
467
+ edges: select(before.edges, beforeEdgeIds),
468
+ stateAccesses: select(before.stateAccesses, beforeStateAccessIds),
469
+ stateLinks: select(before.stateLinks, beforeStateLinkIds),
470
+ },
471
+ after: {
472
+ nodes: select(after.nodes, afterNodeIds),
473
+ edges: select(after.edges, afterEdgeIds),
474
+ stateAccesses: select(after.stateAccesses, afterStateAccessIds),
475
+ stateLinks: select(after.stateLinks, afterStateLinkIds),
476
+ },
477
+ };
478
+ }
479
+ function coherentBranchPartitions(graph, nodeLimit, maximumGroups, signal) {
480
+ signal?.throwIfAborted();
481
+ const outgoing = new Map();
482
+ const incoming = new Map();
483
+ for (const [index, edge] of eventFlowEdges(graph, false).entries()) {
484
+ if ((index & 255) === 0)
485
+ signal?.throwIfAborted();
486
+ const next = outgoing.get(edge.from) ?? [];
487
+ next.push(edge.to);
488
+ outgoing.set(edge.from, next);
489
+ const previous = incoming.get(edge.to) ?? [];
490
+ previous.push(edge.from);
491
+ incoming.set(edge.to, previous);
492
+ }
493
+ for (const values of [...outgoing.values(), ...incoming.values()]) {
494
+ values.sort(compareCodeUnits);
495
+ }
496
+ const roots = discoverEventRoots(graph, signal);
497
+ const seeds = [
498
+ ...roots.entryPoints.map(({ id }) => id),
499
+ ...roots.automaticEvents.map(({ id }) => id),
500
+ ...roots.callerlessTriggeredEvents.map(({ id }) => id),
501
+ ...graph.nodes.map(({ id }) => id).sort(compareCodeUnits),
502
+ ];
503
+ const visited = new Set();
504
+ const partitions = [];
505
+ const isolatedNodeIds = [];
506
+ let visits = 0;
507
+ for (const seed of seeds) {
508
+ if (visited.has(seed))
509
+ continue;
510
+ const component = [];
511
+ const queue = [seed];
512
+ visited.add(seed);
513
+ for (const current of queue) {
514
+ if ((visits++ & 255) === 0)
515
+ signal?.throwIfAborted();
516
+ component.push(current);
517
+ const neighbors = [...(outgoing.get(current) ?? []), ...(incoming.get(current) ?? [])].sort(compareCodeUnits);
518
+ for (const next of neighbors) {
519
+ if (visited.has(next))
520
+ continue;
521
+ visited.add(next);
522
+ queue.push(next);
523
+ }
524
+ }
525
+ if (component.length === 1 &&
526
+ (outgoing.get(seed)?.length ?? 0) === 0 &&
527
+ (incoming.get(seed)?.length ?? 0) === 0) {
528
+ isolatedNodeIds.push(seed);
529
+ continue;
530
+ }
531
+ for (let offset = 0; offset < component.length; offset += nodeLimit) {
532
+ partitions.push(component.slice(offset, offset + nodeLimit));
533
+ }
534
+ }
535
+ isolatedNodeIds.sort(compareCodeUnits);
536
+ for (let offset = 0; offset < isolatedNodeIds.length; offset += nodeLimit) {
537
+ partitions.push(isolatedNodeIds.slice(offset, offset + nodeLimit));
538
+ }
539
+ const groups = partitions.slice(0, maximumGroups);
540
+ const omittedNodeIds = partitions.slice(maximumGroups).flat().sort(compareCodeUnits);
541
+ return { groups, omittedNodeIds: omittedNodeIds.sort(compareCodeUnits) };
542
+ }
543
+ export class EventChainViewer {
544
+ engine;
545
+ #state;
546
+ constructor(engine) {
547
+ this.engine = engine;
548
+ this.#state = sharedState(engine);
549
+ }
550
+ async scan(workspaceId, options = {}) {
551
+ options.signal?.throwIfAborted();
552
+ // Authorization is request-scoped. Never let a graph cached by one
553
+ // principal bypass the resolver check for a later caller.
554
+ const workspace = this.engine.resolver.get(workspaceId, options.principal);
555
+ const generation = engineGeneration(this.engine, workspaceId);
556
+ const cached = this.#state.current.get(workspaceId);
557
+ if (options.refresh !== true && cached?.generation === generation) {
558
+ return cached.graph;
559
+ }
560
+ const snapshot = await this.engine.scan(workspaceId, {}, options.principal, options.signal);
561
+ options.signal?.throwIfAborted();
562
+ if (cached?.snapshot.revision === snapshot.revision && cached.generation === generation) {
563
+ return cached.graph;
564
+ }
565
+ const graph = buildEventGraph(snapshot, {
566
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
567
+ cache: this.#state.fragments,
568
+ workspaceIdentity: workspace.workspaceIdentity,
569
+ });
570
+ this.#state.current.set(workspaceId, { generation, snapshot, graph });
571
+ rememberGraph(this.#state, workspaceId, graph);
572
+ return graph;
573
+ }
574
+ async inspect(input) {
575
+ input.signal?.throwIfAborted();
576
+ const graph = await this.scan(input.workspaceId, {
577
+ refresh: input.refresh ?? input.mode === 'scan',
578
+ ...(input.principal === undefined ? {} : { principal: input.principal }),
579
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
580
+ });
581
+ const maxDepth = input.maxDepth ?? 8;
582
+ const maxNodes = input.maxNodes ?? 500;
583
+ const maxEdges = input.maxEdges ?? 2_000;
584
+ const analysisFilters = {
585
+ ...(input.selector === undefined ? {} : { selector: input.selector }),
586
+ ...(input.from === undefined ? {} : { from: input.from }),
587
+ ...(input.to === undefined ? {} : { to: input.to }),
588
+ direction: input.direction ?? 'both',
589
+ maxDepth,
590
+ maxNodes,
591
+ maxEdges,
592
+ expandHelpers: input.expandHelpers ?? input.mode === 'explain_path',
593
+ ...(input.stateSubject === undefined ? {} : { stateSubject: input.stateSubject }),
594
+ ...(input.impactSubject === undefined ? {} : { impactSubject: input.impactSubject }),
595
+ refresh: input.refresh ?? input.mode === 'scan',
596
+ };
597
+ let report;
598
+ switch (input.mode) {
599
+ case 'scan':
600
+ report = { graph };
601
+ break;
602
+ case 'roots':
603
+ report = {
604
+ graphRevision: graph.revision,
605
+ sourceHashes: graph.sourceHashes,
606
+ roots: discoverEventRoots(graph, input.signal),
607
+ unresolved: graph.unresolved,
608
+ };
609
+ break;
610
+ case 'trace':
611
+ if (input.selector === undefined)
612
+ throw new ServiceError('EVENT_SELECTOR_REQUIRED', 'Trace requires a selector');
613
+ report = {
614
+ graphRevision: graph.revision,
615
+ sourceHashes: graph.sourceHashes,
616
+ trace: traceSelectedEvents(graph, input.selector, {
617
+ maxDepth,
618
+ maxNodes,
619
+ maxEdges,
620
+ direction: input.direction ?? 'both',
621
+ expandHelpers: input.expandHelpers ?? false,
622
+ }, input.signal),
623
+ unresolved: graph.unresolved,
624
+ };
625
+ break;
626
+ case 'explain_path':
627
+ if (input.from === undefined || input.to === undefined)
628
+ throw new ServiceError('EVENT_PATH_SELECTORS_REQUIRED', 'Path explanation requires from and to selectors');
629
+ report = {
630
+ graphRevision: graph.revision,
631
+ sourceHashes: graph.sourceHashes,
632
+ explanation: explainEventPath(graph, input.from, input.to, { maxDepth, maxNodes, expandHelpers: input.expandHelpers ?? true }, input.signal),
633
+ };
634
+ break;
635
+ case 'state_flow':
636
+ report = {
637
+ graphRevision: graph.revision,
638
+ sourceHashes: graph.sourceHashes,
639
+ flow: inspectEventStateFlow(graph, input.selector, input.stateSubject, input.signal),
640
+ issues: lintEventGraph(graph, input.selector, input.signal).filter(({ code }) => /(?:STATE|FLAG|VARIABLE|ARRAY|TARGET|SCOPE)/u.test(code)),
641
+ };
642
+ break;
643
+ case 'lint':
644
+ report = {
645
+ graphRevision: graph.revision,
646
+ sourceHashes: graph.sourceHashes,
647
+ issues: lintEventGraph(graph, input.selector, input.signal),
648
+ complete: graph.complete,
649
+ unresolved: graph.unresolved,
650
+ };
651
+ break;
652
+ case 'impact':
653
+ if (input.impactSubject === undefined)
654
+ throw new ServiceError('EVENT_IMPACT_SUBJECT_REQUIRED', 'Impact analysis requires a subject');
655
+ report = {
656
+ graphRevision: graph.revision,
657
+ sourceHashes: graph.sourceHashes,
658
+ impact: analyzeEventImpact(graph, input.impactSubject, input.signal),
659
+ unresolved: graph.unresolved,
660
+ };
661
+ break;
662
+ }
663
+ const graphHash = cachedEventGraphHash(graph, input.signal);
664
+ const name = `${safeSlug(`event-${input.mode}`)}-${graph.revision.slice(0, 12)}.json`;
665
+ const reportJson = `${canonicalJson({
666
+ schemaVersion: 'event-analysis.v1',
667
+ graphSchemaVersion: graph.schemaVersion,
668
+ parserVersion: graph.parserVersion,
669
+ workspaceId: graph.workspaceId,
670
+ mode: input.mode,
671
+ workspaceIdentity: graph.workspaceIdentity,
672
+ graphRevision: graph.revision,
673
+ graphHash,
674
+ sourceHashes: graph.sourceHashes,
675
+ complete: graph.complete,
676
+ filters: analysisFilters,
677
+ unresolved: graph.unresolved,
678
+ resources: [{ name, mimeType: 'application/json' }],
679
+ report,
680
+ })}\n`;
681
+ const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
682
+ const artifact = await this.engine.artifacts.putChunked(workspace, name, 'application/json', reportJson, eventProvenance(graph, `event-${input.mode}`, {
683
+ mode: input.mode,
684
+ ...(input.selector === undefined ? {} : { selector: input.selector }),
685
+ }, input.signal), `Authoritative Event Chain Viewer ${input.mode.replaceAll('_', ' ')} report`, input.signal);
686
+ return {
687
+ graph,
688
+ graphHash,
689
+ mode: input.mode,
690
+ report,
691
+ reportJson,
692
+ artifacts: [artifact],
693
+ };
694
+ }
695
+ async renderAndStore(input) {
696
+ const graph = await this.scan(input.workspaceId, {
697
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
698
+ ...(input.principal === undefined ? {} : { principal: input.principal }),
699
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
700
+ });
701
+ const budget = new RenderBudget();
702
+ const renderOptions = {
703
+ view: input.view,
704
+ budget,
705
+ ...(input.selector === undefined ? {} : { selector: input.selector }),
706
+ ...(input.direction === undefined ? {} : { direction: input.direction }),
707
+ ...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }),
708
+ ...(input.maxNodes === undefined ? {} : { maxNodes: input.maxNodes }),
709
+ ...(input.expandHelpers === undefined ? {} : { expandHelpers: input.expandHelpers }),
710
+ includeHtml: input.includeHtml ?? false,
711
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
712
+ };
713
+ const render = await renderEventGraph(graph, renderOptions);
714
+ const branches = [];
715
+ const branchNodeLimit = 240;
716
+ const partitions = input.view === 'overview' &&
717
+ input.selector === undefined &&
718
+ graph.nodes.length > render.selectedNodeIds.length
719
+ ? coherentBranchPartitions(graph, branchNodeLimit, 64, input.signal)
720
+ : { groups: [], omittedNodeIds: [] };
721
+ const branchGroups = partitions.groups;
722
+ for (const [branchIndex, nodeIds] of branchGroups.entries()) {
723
+ input.signal?.throwIfAborted();
724
+ branches.push(await renderEventGraph(graph, {
725
+ view: 'neighborhood',
726
+ selector: {
727
+ kind: 'manifest',
728
+ manifest: { id: `overview-branch-${branchIndex + 1}`, nodeIds },
729
+ },
730
+ direction: 'both',
731
+ maxDepth: 0,
732
+ maxNodes: branchNodeLimit,
733
+ expandHelpers: input.expandHelpers ?? false,
734
+ includeHtml: false,
735
+ compactLayout: true,
736
+ budget,
737
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
738
+ }));
739
+ }
740
+ const prefix = safeSlug(`event-${input.view}-${graph.revision.slice(0, 12)}`);
741
+ const manifestName = `${prefix}-manifest.json`;
742
+ const resourceNames = {
743
+ manifest: manifestName,
744
+ overview: {
745
+ json: `${prefix}.json`,
746
+ svg: `${prefix}.svg`,
747
+ png: `${prefix}.png`,
748
+ ...(render.html === undefined ? {} : { html: `${prefix}.html` }),
749
+ },
750
+ branches: branches.map((branch, index) => {
751
+ const branchPrefix = `${prefix}-branch-${String(index + 1).padStart(2, '0')}`;
752
+ return {
753
+ index: index + 1,
754
+ json: `${branchPrefix}.json`,
755
+ svg: `${branchPrefix}.svg`,
756
+ png: `${branchPrefix}.png`,
757
+ ...(branch.html === undefined ? {} : { html: `${branchPrefix}.html` }),
758
+ };
759
+ }),
760
+ };
761
+ const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
762
+ const provenance = eventProvenance(graph, 'event-render', {
763
+ view: input.view,
764
+ selectedNodeCount: render.selectedNodeIds.length,
765
+ omittedNodeCount: render.omittedNodeCount,
766
+ layoutHash: render.layout.layoutHash,
767
+ filters: {
768
+ ...(input.selector === undefined ? {} : { selector: input.selector }),
769
+ direction: input.direction ?? 'both',
770
+ maxDepth: input.maxDepth ?? 4,
771
+ maxNodes: input.maxNodes ?? 120,
772
+ expandHelpers: input.expandHelpers ?? false,
773
+ },
774
+ }, input.signal);
775
+ const dataWrites = [
776
+ {
777
+ name: `${prefix}.json`,
778
+ mimeType: 'application/json',
779
+ content: render.json,
780
+ provenance,
781
+ description: `Authoritative ${input.view} event-chain render data`,
782
+ },
783
+ {
784
+ name: `${prefix}.svg`,
785
+ mimeType: 'image/svg+xml',
786
+ content: render.svg,
787
+ provenance,
788
+ description: `Source-linked ${input.view} event-chain diagram`,
789
+ },
790
+ {
791
+ name: `${prefix}.png`,
792
+ mimeType: 'image/png',
793
+ content: render.png,
794
+ provenance,
795
+ description: `${input.view} event-chain image`,
796
+ },
797
+ ...(render.html === undefined
798
+ ? []
799
+ : [
800
+ {
801
+ name: `${prefix}.html`,
802
+ mimeType: 'text/html',
803
+ content: render.html,
804
+ provenance,
805
+ description: `Static ${input.view} event-chain evidence`,
806
+ },
807
+ ]),
808
+ ...branches.flatMap((branch, index) => {
809
+ const branchPrefix = `${prefix}-branch-${String(index + 1).padStart(2, '0')}`;
810
+ return [
811
+ {
812
+ name: `${branchPrefix}.json`,
813
+ mimeType: 'application/json',
814
+ content: branch.json,
815
+ provenance,
816
+ description: `Authoritative bounded event-chain branch ${index + 1}`,
817
+ },
818
+ {
819
+ name: `${branchPrefix}.svg`,
820
+ mimeType: 'image/svg+xml',
821
+ content: branch.svg,
822
+ provenance,
823
+ description: `Source-linked event-chain branch ${index + 1}`,
824
+ },
825
+ {
826
+ name: `${branchPrefix}.png`,
827
+ mimeType: 'image/png',
828
+ content: branch.png,
829
+ provenance,
830
+ description: `Event-chain branch ${index + 1} image`,
831
+ },
832
+ ];
833
+ }),
834
+ ];
835
+ const dataArtifacts = await this.engine.artifacts.withAtomicChunkedWrites(workspace, dataWrites, (stored) => Promise.resolve([...stored]), input.signal);
836
+ const resourceLinks = dataArtifacts
837
+ .map(publicArtifactLink)
838
+ .sort((left, right) => compareCodeUnits(left.name, right.name) || compareCodeUnits(left.uri, right.uri));
839
+ const manifestJson = `${canonicalJson({
840
+ schemaVersion: 'event-render-manifest.v1',
841
+ graphSchemaVersion: graph.schemaVersion,
842
+ parserVersion: graph.parserVersion,
843
+ workspaceId: graph.workspaceId,
844
+ workspaceIdentity: graph.workspaceIdentity,
845
+ graphRevision: graph.revision,
846
+ graphHash: cachedEventGraphHash(graph, input.signal),
847
+ sourceHashes: graph.sourceHashes,
848
+ complete: graph.complete,
849
+ filters: {
850
+ view: input.view,
851
+ ...(input.selector === undefined ? {} : { selector: input.selector }),
852
+ direction: input.direction ?? 'both',
853
+ maxDepth: input.maxDepth ?? 4,
854
+ maxNodes: input.maxNodes ?? 120,
855
+ expandHelpers: input.expandHelpers ?? false,
856
+ includeHtml: input.includeHtml ?? false,
857
+ refresh: input.refresh ?? false,
858
+ },
859
+ unresolved: graph.unresolved,
860
+ resources: { ...resourceNames, artifacts: resourceLinks },
861
+ overview: {
862
+ selectedNodeIds: render.selectedNodeIds,
863
+ hashes: render.hashes,
864
+ },
865
+ branches: branches.map((branch, index) => ({
866
+ index: index + 1,
867
+ selectedNodeIds: branch.selectedNodeIds,
868
+ omittedNodeCount: branch.omittedNodeCount,
869
+ hashes: branch.hashes,
870
+ })),
871
+ coverage: {
872
+ totalNodes: graph.nodes.length,
873
+ retainedBranchSourceNodes: branchGroups.reduce((total, group) => total + group.length, 0),
874
+ omittedNodeCount: partitions.omittedNodeIds.length,
875
+ omittedNodeIds: partitions.omittedNodeIds,
876
+ omittedNodeHash: hashCanonical(partitions.omittedNodeIds),
877
+ truncated: partitions.omittedNodeIds.length > 0,
878
+ },
879
+ })}\n`;
880
+ const manifestArtifact = await this.engine.artifacts.putChunked(workspace, manifestName, 'application/json', manifestJson, provenance, `Event-chain ${input.view} render and bounded branch manifest`, input.signal);
881
+ const artifacts = [manifestArtifact, ...dataArtifacts];
882
+ return {
883
+ graph,
884
+ graphHash: cachedEventGraphHash(graph, input.signal),
885
+ render,
886
+ branches,
887
+ manifestJson,
888
+ artifacts,
889
+ };
890
+ }
891
+ #proposedGraph(workspaceId, sources, principal, signal) {
892
+ if (sources.length === 0)
893
+ throw new ServiceError('EVENT_PROPOSED_SOURCE_REQUIRED', 'Proposed comparison source list is empty');
894
+ assertProposedSourceBounds(sources);
895
+ const cached = this.#state.current.get(workspaceId);
896
+ if (cached === undefined)
897
+ throw new ServiceError('EVENT_BASELINE_MISSING', 'Scan the workspace before comparing proposed source');
898
+ const paths = sources.map(({ relativePath }) => validRelativeSourcePath(relativePath));
899
+ if (new Set(paths.map((value) => value.toLowerCase())).size !== paths.length) {
900
+ throw new ServiceError('EVENT_PROPOSED_PATH_DUPLICATE', 'Proposed source paths must be unique');
901
+ }
902
+ const files = cached.snapshot.files.map((file) => ({
903
+ ...file,
904
+ bytes: Buffer.from(file.bytes),
905
+ }));
906
+ const workspace = this.engine.resolver.get(workspaceId, principal);
907
+ for (const [sourceIndex, source] of sources.entries()) {
908
+ signal?.throwIfAborted();
909
+ const relativePath = paths[sourceIndex];
910
+ const candidates = files
911
+ .map((file, index) => ({ file, index }))
912
+ .filter(({ file }) => file.relativePath.toLowerCase() === relativePath.toLowerCase())
913
+ .sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
914
+ compareCodeUnits(left.file.displayPath, right.file.displayPath));
915
+ const logicalActive = candidates.find(({ file }) => file.shadowedBy === undefined) ?? candidates[0];
916
+ const modCandidate = candidates.find(({ file }) => file.rootKind === 'mod');
917
+ if (source.expectedSourceHash !== undefined &&
918
+ logicalActive?.file.sha256 !== source.expectedSourceHash) {
919
+ throw new ServiceError('EVENT_PROPOSED_SOURCE_STALE', 'Proposed source baseline hash is stale', {
920
+ relativePath,
921
+ expectedSourceHash: source.expectedSourceHash,
922
+ actualSourceHash: logicalActive?.file.sha256 ?? null,
923
+ });
924
+ }
925
+ if (source.source === null) {
926
+ if (modCandidate === undefined)
927
+ throw new ServiceError('EVENT_PROPOSED_SOURCE_MISSING', 'Cannot delete a source absent from the active mod root', { relativePath });
928
+ files.splice(modCandidate.index, 1);
929
+ continue;
930
+ }
931
+ const bytes = Buffer.from(source.source, 'utf8');
932
+ if (bytes.length > SOURCE_MAX_BYTES)
933
+ throw new ServiceError('SOURCE_FILE_LIMIT', 'Proposed event source exceeds the parser byte ceiling', { relativePath, size: bytes.length, limit: SOURCE_MAX_BYTES });
934
+ if (modCandidate !== undefined) {
935
+ modCandidate.file.bytes = bytes;
936
+ modCandidate.file.size = bytes.length;
937
+ modCandidate.file.sha256 = sha256Bytes(bytes);
938
+ modCandidate.file.modifiedMs = 0;
939
+ }
940
+ else {
941
+ const modRoot = [...workspace.roots]
942
+ .filter(({ kind }) => kind === 'mod')
943
+ .sort((left, right) => right.loadOrder - left.loadOrder)[0];
944
+ if (modRoot === undefined)
945
+ throw new ServiceError('EVENT_PROPOSED_ROOT_MISSING', 'Workspace has no mod root for a proposed new source');
946
+ files.push({
947
+ absolutePath: path.join(modRoot.path, relativePath),
948
+ displayPath: `mod:${relativePath}`,
949
+ relativePath,
950
+ rootKind: 'mod',
951
+ loadOrder: modRoot.loadOrder,
952
+ size: bytes.length,
953
+ modifiedMs: 0,
954
+ sha256: sha256Bytes(bytes),
955
+ bytes,
956
+ });
957
+ }
958
+ }
959
+ recomputeShadowing(files);
960
+ files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
961
+ const index = this.engine.indexFiles(files);
962
+ const revision = snapshotRevision(files);
963
+ const snapshot = {
964
+ workspaceId,
965
+ revision,
966
+ files,
967
+ index,
968
+ complete: index.complete,
969
+ skippedSourceCount: index.skippedSourceCount,
970
+ skippedSources: index.skippedSources,
971
+ diagnostics: index.diagnostics,
972
+ };
973
+ return buildEventGraph(snapshot, {
974
+ ...(signal === undefined ? {} : { signal }),
975
+ cache: this.#state.fragments,
976
+ workspaceIdentity: workspace.workspaceIdentity,
977
+ });
978
+ }
979
+ async #resolveGraphReference(workspaceId, reference, current, principal, signal) {
980
+ if (reference?.artifactUri !== undefined) {
981
+ const selected = parseGraphArtifact(await readLogicalArtifact(this.engine, workspaceId, reference.artifactUri, principal, signal), signal);
982
+ if (selected.workspaceId !== workspaceId) {
983
+ throw new ServiceError('EVENT_GRAPH_WORKSPACE_MISMATCH', 'Event graph snapshot names a different workspace');
984
+ }
985
+ if (selected.workspaceIdentity !== current.workspaceIdentity) {
986
+ throw new ServiceError('EVENT_GRAPH_WORKSPACE_MISMATCH', 'Event graph snapshot was produced for a different workspace topology');
987
+ }
988
+ return selected;
989
+ }
990
+ if (reference?.revision !== undefined) {
991
+ const selected = this.#state.history.get(workspaceId)?.get(reference.revision);
992
+ if (selected === undefined)
993
+ throw new ServiceError('EVENT_REVISION_NOT_CACHED', 'Requested event graph revision is not cached');
994
+ return selected;
995
+ }
996
+ return current;
997
+ }
998
+ async compareAndStore(input) {
999
+ input.signal?.throwIfAborted();
1000
+ if (input.proposedSources !== undefined)
1001
+ assertProposedSourceBounds(input.proposedSources);
1002
+ const refresh = input.refresh ??
1003
+ (input.proposedSources !== undefined ||
1004
+ input.before === undefined ||
1005
+ input.after === undefined);
1006
+ const current = await this.scan(input.workspaceId, {
1007
+ refresh,
1008
+ ...(input.principal === undefined ? {} : { principal: input.principal }),
1009
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
1010
+ });
1011
+ let before = await this.#resolveGraphReference(input.workspaceId, input.before, current, input.principal, input.signal);
1012
+ let after = input.proposedSources === undefined
1013
+ ? await this.#resolveGraphReference(input.workspaceId, input.after, current, input.principal, input.signal)
1014
+ : this.#proposedGraph(input.workspaceId, input.proposedSources, input.principal, input.signal);
1015
+ if (input.before === undefined &&
1016
+ input.after === undefined &&
1017
+ input.proposedSources === undefined) {
1018
+ const history = [...(this.#state.history.get(input.workspaceId)?.values() ?? [])];
1019
+ const previous = history.filter(({ revision }) => revision !== current.revision).at(-1);
1020
+ if (previous === undefined)
1021
+ throw new ServiceError('EVENT_COMPARISON_BASELINE_REQUIRED', 'Provide a cached revision, graph artifact, or proposed source overlay');
1022
+ before = previous;
1023
+ after = current;
1024
+ }
1025
+ const comparison = compareEventGraphs(before, after, input.signal);
1026
+ graphHashCache.set(before, comparison.beforeGraphHash);
1027
+ graphHashCache.set(after, comparison.afterGraphHash);
1028
+ let render;
1029
+ if (input.render !== false) {
1030
+ const projection = comparisonRenderProjection(before, after, comparison, input.signal);
1031
+ const selector = {
1032
+ kind: 'manifest',
1033
+ manifest: {
1034
+ id: 'comparison',
1035
+ nodeIds: projection.selectedNodeIds,
1036
+ },
1037
+ };
1038
+ render = await renderEventGraph(projection.graph, {
1039
+ view: 'neighborhood',
1040
+ selector,
1041
+ direction: 'both',
1042
+ maxDepth: 2,
1043
+ maxNodes: input.maxRenderNodes ?? 120,
1044
+ includeHtml: false,
1045
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
1046
+ });
1047
+ }
1048
+ const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
1049
+ const prefix = safeSlug(`event-compare-${before.revision.slice(0, 8)}-${after.revision.slice(0, 8)}`);
1050
+ const resourceNames = {
1051
+ json: `${prefix}.json`,
1052
+ ...(render === undefined
1053
+ ? {}
1054
+ : {
1055
+ svg: `${prefix}.svg`,
1056
+ png: `${prefix}.png`,
1057
+ ...(render.html === undefined ? {} : { html: `${prefix}.html` }),
1058
+ }),
1059
+ };
1060
+ const comparisonJson = `${canonicalJson({
1061
+ schemaVersion: 'event-comparison-artifact.v1',
1062
+ graphSchemaVersion: after.schemaVersion,
1063
+ parserVersion: after.parserVersion,
1064
+ workspaceId: current.workspaceId,
1065
+ workspaceIdentity: current.workspaceIdentity,
1066
+ beforeRevision: before.revision,
1067
+ afterRevision: after.revision,
1068
+ beforeComplete: before.complete,
1069
+ afterComplete: after.complete,
1070
+ beforeSourceHashes: before.sourceHashes,
1071
+ afterSourceHashes: after.sourceHashes,
1072
+ filters: {
1073
+ ...(input.before === undefined ? {} : { before: input.before }),
1074
+ ...(input.after === undefined ? {} : { after: input.after }),
1075
+ proposedSources: input.proposedSources?.map(({ relativePath, source, expectedSourceHash }) => ({
1076
+ relativePath,
1077
+ operation: source === null ? 'delete' : 'overlay',
1078
+ ...(source === null ? {} : { sourceHash: sha256Bytes(source) }),
1079
+ ...(expectedSourceHash === undefined ? {} : { expectedSourceHash }),
1080
+ })) ?? [],
1081
+ render: input.render ?? true,
1082
+ maxRenderNodes: input.maxRenderNodes ?? 120,
1083
+ refresh,
1084
+ },
1085
+ beforeUnresolved: before.unresolved,
1086
+ afterUnresolved: after.unresolved,
1087
+ beforeIssues: before.issues,
1088
+ afterIssues: after.issues,
1089
+ evidence: comparisonEntityEvidence(before, after, comparison, input.signal),
1090
+ resources: resourceNames,
1091
+ comparison,
1092
+ })}\n`;
1093
+ const provenance = eventProvenance(after, 'event-comparison', {
1094
+ beforeRevision: before.revision,
1095
+ afterRevision: after.revision,
1096
+ changeCount: comparison.changes.length,
1097
+ }, input.signal);
1098
+ const writes = [
1099
+ {
1100
+ name: `${prefix}.json`,
1101
+ mimeType: 'application/json',
1102
+ content: comparisonJson,
1103
+ provenance,
1104
+ description: 'Authoritative Event Chain Viewer structural comparison',
1105
+ },
1106
+ ...(render === undefined
1107
+ ? []
1108
+ : [
1109
+ {
1110
+ name: `${prefix}.svg`,
1111
+ mimeType: 'image/svg+xml',
1112
+ content: render.svg,
1113
+ provenance,
1114
+ description: 'Source-linked event-chain comparison diagram',
1115
+ },
1116
+ {
1117
+ name: `${prefix}.png`,
1118
+ mimeType: 'image/png',
1119
+ content: render.png,
1120
+ provenance,
1121
+ description: 'Event-chain comparison image',
1122
+ },
1123
+ ...(render.html === undefined
1124
+ ? []
1125
+ : [
1126
+ {
1127
+ name: `${prefix}.html`,
1128
+ mimeType: 'text/html',
1129
+ content: render.html,
1130
+ provenance,
1131
+ description: 'Static event-chain comparison evidence',
1132
+ },
1133
+ ]),
1134
+ ]),
1135
+ ];
1136
+ const artifacts = await this.engine.artifacts.withAtomicChunkedWrites(workspace, writes, (stored) => Promise.resolve([...stored]), input.signal);
1137
+ return {
1138
+ before,
1139
+ after,
1140
+ refresh,
1141
+ comparison,
1142
+ comparisonJson,
1143
+ ...(render === undefined ? {} : { render }),
1144
+ artifacts,
1145
+ };
1146
+ }
1147
+ }