@playola-radio/slipstream 0.1.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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. package/schemas/slipstream.task.started.v1.json +72 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The I/O-scoped clip module: resolve a change's before/after snapshots to raw
3
+ * blob bytes from the content-addressed store and run the pure
4
+ * {@link projectClips} core. This is the reusable entry the TUI / any
5
+ * independent client calls directly against on-disk artifacts, and the same
6
+ * function the projection worker runs off the main thread. It reads RAW bytes
7
+ * (not decoded text): the core owns UTF-8 validation and needs bytes for
8
+ * byte-offset spans.
9
+ */
10
+ import { constants } from 'node:fs';
11
+ import { open } from 'node:fs/promises';
12
+ import { blobPath, isValidHex } from "./store-reader.js";
13
+ import { projectClips, MAX_UTF8_BYTES, } from "./clip-projection.js";
14
+ /** Parse an event snapshot (`data.before` / `data.after`) into a
15
+ * {@link ClipSnapshot}, or null if malformed. Mirrors change-view's parser. */
16
+ export function parseClipSnapshot(v) {
17
+ if (typeof v !== 'object' || v === null)
18
+ return null;
19
+ const s = v;
20
+ if (s.kind === 'content') {
21
+ if (typeof s.sha256 === 'string' && isValidHex(s.sha256)
22
+ && typeof s.size === 'number' && Number.isInteger(s.size) && s.size >= 0) {
23
+ return { kind: 'content', sha256: s.sha256, size: s.size };
24
+ }
25
+ return null;
26
+ }
27
+ if (s.kind === 'absent')
28
+ return { kind: 'absent' };
29
+ if (s.kind === 'unavailable' && typeof s.reason === 'string') {
30
+ return { kind: 'unavailable', reason: s.reason };
31
+ }
32
+ return null;
33
+ }
34
+ /** Resolve one snapshot to a {@link SideInput}. A content blob larger than
35
+ * `maxBytes`, or one that under-reports its size, becomes `oversize`; a blob
36
+ * that cannot be opened/read (GC'd, missing, unreadable) becomes `missing` with
37
+ * an explicit reason — never faked content. */
38
+ export async function resolveClipSide(storeDir, snap, maxBytes, onPhase) {
39
+ const startedAtNs = onPhase ? process.hrtime.bigint() : undefined;
40
+ let readOccurred = false;
41
+ try {
42
+ if (snap.kind === 'absent')
43
+ return { kind: 'absent' };
44
+ if (snap.kind === 'unavailable')
45
+ return { kind: 'unavailable', reason: snap.reason };
46
+ if (snap.size > maxBytes)
47
+ return { kind: 'oversize' };
48
+ if (!isValidHex(snap.sha256))
49
+ return { kind: 'missing', reason: 'invalid-hex' };
50
+ // O_NOFOLLOW: a symlink planted at a valid CAS path must serve nothing but the
51
+ // blob it names, never the link target's bytes.
52
+ let handle;
53
+ try {
54
+ handle = await open(blobPath(storeDir, snap.sha256), constants.O_RDONLY | constants.O_NOFOLLOW);
55
+ }
56
+ catch (err) {
57
+ return { kind: 'missing', reason: err.code ?? 'open-failed' };
58
+ }
59
+ try {
60
+ const { size } = await handle.stat();
61
+ if (size > maxBytes)
62
+ return { kind: 'oversize' };
63
+ const bytes = await handle.readFile();
64
+ readOccurred = true;
65
+ return { kind: 'bytes', bytes };
66
+ }
67
+ catch (err) {
68
+ return { kind: 'missing', reason: err.code ?? 'read-failed' };
69
+ }
70
+ finally {
71
+ await handle.close();
72
+ }
73
+ }
74
+ finally {
75
+ if (readOccurred && startedAtNs !== undefined)
76
+ onPhase?.('cas-read', process.hrtime.bigint() - startedAtNs);
77
+ }
78
+ }
79
+ /** Resolve both sides and run the pure projection. Successful spans are determined by
80
+ * bytes, language and version; timeouts/availability remain explicit transient
81
+ * outcomes. The caller decides where it runs (worker or directly). */
82
+ export async function computeClipProjection(job, onPhase) {
83
+ const maxBytes = job.opts.maxBytes ?? MAX_UTF8_BYTES;
84
+ const [before, after] = await Promise.all([
85
+ resolveClipSide(job.storeDir, job.before, maxBytes, onPhase),
86
+ resolveClipSide(job.storeDir, job.after, maxBytes, onPhase),
87
+ ]);
88
+ const language = job.opts.language ?? 'unsupported';
89
+ if (language === 'unsupported' || (before.kind !== 'bytes' && after.kind !== 'bytes')) {
90
+ return projectClips(before, after, job.opts, () => ({ functions: [], errors: [], reason: 'unsupported-language' }));
91
+ }
92
+ // Only supported-content computations load the parser. HTTP snapshot
93
+ // validation and honest unsupported/unavailable results need no WASM startup.
94
+ const loadStartedAtNs = onPhase ? process.hrtime.bigint() : undefined;
95
+ const { createFunctionIndexer } = await import("./clip-function-parser.js");
96
+ const indexer = await createFunctionIndexer(language);
97
+ if (loadStartedAtNs !== undefined)
98
+ onPhase?.('grammar-load', process.hrtime.bigint() - loadStartedAtNs);
99
+ const parseStartedAtNs = onPhase ? process.hrtime.bigint() : undefined;
100
+ const result = projectClips(before, after, job.opts, indexer);
101
+ if (parseStartedAtNs !== undefined)
102
+ onPhase?.('parse-compare', process.hrtime.bigint() - parseStartedAtNs);
103
+ return result;
104
+ }
@@ -0,0 +1,83 @@
1
+ import { createRequire } from 'node:module';
2
+ import { Parser, Language } from 'web-tree-sitter';
3
+ // Computing callers prepare only the requested grammar. Initialization remains
4
+ // inside the existing job/deadline; unused grammars do not tax cold requests.
5
+ const require = createRequire(import.meta.url);
6
+ let initialization;
7
+ const grammars = new Map();
8
+ /** Return a synchronous extractor bound to one language. Promise caching joins
9
+ * concurrent direct callers; JS/JSX share one grammar. Failed loads can retry. */
10
+ export async function createFunctionIndexer(language) {
11
+ if (language === 'unsupported')
12
+ return () => ({ functions: [], errors: [], reason: 'unsupported-language' });
13
+ const name = language === 'jsx' ? 'javascript' : language;
14
+ let pending = grammars.get(name);
15
+ if (!pending) {
16
+ pending = (async () => {
17
+ initialization ??= Parser.init().catch(error => { initialization = undefined; throw error; });
18
+ await initialization;
19
+ return Language.load(require.resolve(`tree-sitter-wasms/out/tree-sitter-${name}.wasm`));
20
+ })().catch(error => { grammars.delete(name); throw error; });
21
+ grammars.set(name, pending);
22
+ }
23
+ const grammar = await pending;
24
+ return text => indexFunctions(text, grammar);
25
+ }
26
+ const FUNCTION_TYPES = new Set([
27
+ 'function_declaration', 'function_expression', 'generator_function_declaration',
28
+ 'generator_function', 'arrow_function', 'method_definition',
29
+ ]);
30
+ const MAX_NODES = 20_000;
31
+ // Cooperative per-side parse limit; the existing worker deadline bounds the
32
+ // whole job, including startup, syntax indexing and diffing.
33
+ const PARSE_TIMEOUT_MS = 20;
34
+ /** Parse each immutable side once. Positions are converted by ROW, never by
35
+ * parser startIndex (UTF-16, not a raw-blob byte offset). Trees/nodes are local
36
+ * to this call; no retained parse state or incremental edits.
37
+ */
38
+ function indexFunctions(text, grammar) {
39
+ const empty = (reason) => ({ functions: [], errors: [], reason });
40
+ const parser = new Parser();
41
+ let tree = null;
42
+ try {
43
+ parser.setLanguage(grammar);
44
+ const deadline = performance.now() + PARSE_TIMEOUT_MS;
45
+ tree = parser.parse(text, null, { progressCallback: () => performance.now() > deadline });
46
+ if (!tree)
47
+ return empty('timeout');
48
+ const result = { functions: [], errors: [] };
49
+ const stack = [
50
+ { node: tree.rootNode, unreliable: false },
51
+ ];
52
+ let visited = 0;
53
+ while (stack.length) {
54
+ if (++visited > MAX_NODES)
55
+ return empty('extraction-budget-exhausted');
56
+ const { node, unreliable } = stack.pop();
57
+ const damaged = unreliable || node.isError || node.isMissing;
58
+ const range = {
59
+ s0: node.startPosition.row,
60
+ e0: node.endPosition.row + (node.endPosition.column > 0 ? 1 : 0),
61
+ c0: node.startPosition.column,
62
+ c1: node.endPosition.column,
63
+ };
64
+ if (node.isError || node.isMissing) {
65
+ result.errors.push({ ...range, e0: Math.max(range.e0, range.s0 + 1) });
66
+ }
67
+ if (FUNCTION_TYPES.has(node.type) && !damaged && !node.hasError) {
68
+ result.functions.push(range);
69
+ }
70
+ // Include anonymous missing tokens as well: namedChildren would miss them.
71
+ for (let i = node.childCount - 1; i >= 0; i--) {
72
+ const child = node.child(i);
73
+ if (child)
74
+ stack.push({ node: child, unreliable: damaged });
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+ finally {
80
+ tree?.delete();
81
+ parser.delete();
82
+ }
83
+ }
@@ -0,0 +1,15 @@
1
+ import { extname } from 'node:path';
2
+ /** Filename selection is public input, never an inference from captured bytes. */
3
+ export function languageForPath(path) {
4
+ switch (extname(path).toLowerCase()) {
5
+ case '.js':
6
+ case '.mjs':
7
+ case '.cjs': return 'javascript';
8
+ case '.jsx': return 'jsx';
9
+ case '.ts':
10
+ case '.mts':
11
+ case '.cts': return 'typescript';
12
+ case '.tsx': return 'tsx';
13
+ default: return 'unsupported';
14
+ }
15
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Main-thread orchestrator for the clip projection. It turns a change's
3
+ * before/after snapshots into a {@link ClipProjection}, computed on demand from
4
+ * the immutable blobs and cached disposably (no log, no persistence). It layers
5
+ * four concerns on top of the pure core:
6
+ *
7
+ * - Content-addressed LRU cache: keyed by (before tag, after tag,
8
+ * projection_version, language) so identical inputs across changes reuse one result,
9
+ * bounded by entry count AND estimated bytes. change_seq is response-only and
10
+ * is stamped on the way out, never part of the key.
11
+ * - Bounded admission via the shared projection budget: at most `concurrency`
12
+ * computes run at once behind a bounded queue; excess is returned immediately
13
+ * as `skipped`/`overloaded` (never a silent stall), so a burst of cold-cache
14
+ * requests cannot starve capture. The deadline is measured from admission
15
+ * (queue wait included). Coalescing and the deadline live in the budget so
16
+ * the same bound covers a future interface-projection workload too. The CPU
17
+ * itself runs off the shared event loop in a worker.
18
+ * - Revalidate-on-hit: a cache hit is dropped if a referenced blob is gone
19
+ * (GC'd), so a stale result never masquerades as available.
20
+ *
21
+ * Availability, overload, and timeout dispositions are NOT pure functions of
22
+ * content, so they are never cached.
23
+ */
24
+ import { constants } from 'node:fs';
25
+ import { access } from 'node:fs/promises';
26
+ import { blobPath } from "./store-reader.js";
27
+ import { CLIP_PROJECTION_VERSION, } from "./clip-projection.js";
28
+ import {} from "./clip-blob-reader.js";
29
+ import { createClipWorkerPool } from "./clip-worker-pool.js";
30
+ import { createProjectionAdmission } from "./projection-admission.js";
31
+ import { emitProjectionPhase, emitProjectionTrace } from "./projection-trace.js";
32
+ // B1 runs exactly one clip worker (CPU stays off the capture path); concurrency
33
+ // is a fixed design constant, not a knob.
34
+ const CONCURRENCY = 1;
35
+ const DEFAULTS = {
36
+ queueLimit: 8,
37
+ cacheEntries: 512,
38
+ cacheBytes: 4 * 1024 * 1024,
39
+ deadlineMs: 100,
40
+ };
41
+ /** Reasons that are transient or availability-dependent — never cached. */
42
+ const UNCACHEABLE_REASONS = new Set(['overloaded', 'timeout', 'worker-error']);
43
+ function sideTag(s) {
44
+ if (s.kind === 'content')
45
+ return `content:${s.sha256}`;
46
+ if (s.kind === 'absent')
47
+ return 'absent';
48
+ return `unavailable:${s.reason}`;
49
+ }
50
+ function cacheKey(req) {
51
+ return `${sideTag(req.before)}|${sideTag(req.after)}|${CLIP_PROJECTION_VERSION}|${req.language ?? 'unsupported'}`;
52
+ }
53
+ function contentShas(req) {
54
+ const out = [];
55
+ if (req.before.kind === 'content')
56
+ out.push(req.before.sha256);
57
+ if (req.after.kind === 'content')
58
+ out.push(req.after.sha256);
59
+ return out;
60
+ }
61
+ function transient(changeSeq, reason) {
62
+ return {
63
+ change_seq: changeSeq,
64
+ projection_version: CLIP_PROJECTION_VERSION,
65
+ status: 'skipped',
66
+ fallback_reason: reason,
67
+ clips: [],
68
+ };
69
+ }
70
+ function cacheable(value) {
71
+ if (value.status === 'unavailable')
72
+ return false;
73
+ if (value.fallback_reason !== undefined && UNCACHEABLE_REASONS.has(value.fallback_reason))
74
+ return false;
75
+ // A per-side `unavailable` method reflects blob presence at compute time, not
76
+ // content, so a later restore must recompute rather than serve the stale
77
+ // "before-missing" disposition. Revalidate-on-hit can't catch this: the
78
+ // snapshot still names a content sha whose blob is now back.
79
+ for (const clip of value.clips) {
80
+ if (clip.before.method === 'unavailable' || clip.after.method === 'unavailable')
81
+ return false;
82
+ }
83
+ return true;
84
+ }
85
+ /** Insertion-ordered LRU bounded by entry count and estimated bytes. */
86
+ class LruCache {
87
+ map = new Map();
88
+ bytes = 0;
89
+ maxEntries;
90
+ maxBytes;
91
+ constructor(maxEntries, maxBytes) {
92
+ this.maxEntries = maxEntries;
93
+ this.maxBytes = maxBytes;
94
+ }
95
+ get(key) {
96
+ const entry = this.map.get(key);
97
+ if (!entry)
98
+ return undefined;
99
+ this.map.delete(key);
100
+ this.map.set(key, entry); // move to most-recently-used
101
+ return entry.value;
102
+ }
103
+ set(key, value) {
104
+ const existing = this.map.get(key);
105
+ if (existing) {
106
+ this.bytes -= existing.bytes;
107
+ this.map.delete(key);
108
+ }
109
+ const bytes = JSON.stringify(value).length;
110
+ this.map.set(key, { value, bytes });
111
+ this.bytes += bytes;
112
+ while (this.map.size > this.maxEntries || this.bytes > this.maxBytes) {
113
+ const oldest = this.map.keys().next().value;
114
+ if (oldest === undefined)
115
+ break;
116
+ this.bytes -= this.map.get(oldest).bytes;
117
+ this.map.delete(oldest);
118
+ }
119
+ }
120
+ delete(key) {
121
+ const entry = this.map.get(key);
122
+ if (entry) {
123
+ this.bytes -= entry.bytes;
124
+ this.map.delete(key);
125
+ }
126
+ }
127
+ }
128
+ export function createClipProjectionService(opts) {
129
+ if (opts.admission && (opts.queueLimit !== undefined || opts.deadlineMs !== undefined)) {
130
+ throw new Error('shared clip admission cannot also set private queue or deadline');
131
+ }
132
+ const queueLimit = opts.queueLimit ?? DEFAULTS.queueLimit;
133
+ const deadlineMs = opts.deadlineMs ?? DEFAULTS.deadlineMs;
134
+ const pool = opts.compute ? null : createClipWorkerPool(opts.projectionTrace);
135
+ const compute = opts.compute ?? pool.run;
136
+ const hasBlob = opts.hasBlob ?? ((sha256) => access(blobPath(opts.storeDir, sha256), constants.F_OK).then(() => true, () => false));
137
+ const cache = new LruCache(opts.cacheEntries ?? DEFAULTS.cacheEntries, opts.cacheBytes ?? DEFAULTS.cacheBytes);
138
+ // Concurrency, queue, coalesced-waiter bound, and the admission-time deadline
139
+ // live in the shared budget. Clip's own budget uses C = CONCURRENCY (1) and
140
+ // W = Q = queueLimit, so the outstanding ceiling stays C + queueLimit exactly
141
+ // (coalesced waiters counted), reproducing the prior `maxPending` bound. The
142
+ // budget is workload-agnostic so a future interface service can share one
143
+ // instance; wiring that shared instance is a later step (see ADMISSION.md).
144
+ const budget = opts.admission ?? createProjectionAdmission({
145
+ C: CONCURRENCY,
146
+ Q: queueLimit,
147
+ W: queueLimit,
148
+ D: deadlineMs,
149
+ }, opts.projectionTrace);
150
+ let closed = false;
151
+ const stamp = (value, changeSeq) => ({ ...value, change_seq: changeSeq });
152
+ const blobsPresent = async (req) => {
153
+ for (const sha of contentShas(req))
154
+ if (!(await hasBlob(sha)))
155
+ return false;
156
+ return true;
157
+ };
158
+ const get = async (req) => {
159
+ if (closed)
160
+ return transient(req.changeSeq, 'worker-error');
161
+ const key = cacheKey(req);
162
+ const hit = cache.get(key);
163
+ if (hit) {
164
+ if (await blobsPresent(req)) {
165
+ if (req.traceRouteKey !== undefined)
166
+ emitProjectionTrace(opts.projectionTrace, { kind: 'clip-cache-bypass', routeKey: req.traceRouteKey, atNs: process.hrtime.bigint() });
167
+ return stamp(hit, req.changeSeq);
168
+ }
169
+ cache.delete(key); // referenced blob GC'd — never serve a stale hit
170
+ }
171
+ // Only the coalescing leader's `run` is ever invoked (the budget calls it
172
+ // once per flight); a coalesced waiter never sees this flip, so only the
173
+ // leader writes the cache entry — waiters would otherwise redundantly
174
+ // re-store the identical value the leader already cached.
175
+ let isLeader = false;
176
+ const outcome = await budget.admit({
177
+ workload: 'clip',
178
+ localConcurrency: CONCURRENCY,
179
+ key,
180
+ traceRouteKey: req.traceRouteKey,
181
+ run: traceUnitId => {
182
+ isLeader = true;
183
+ const start = () => {
184
+ if (traceUnitId !== undefined)
185
+ emitProjectionTrace(opts.projectionTrace, { kind: 'parser-request', unitId: traceUnitId, atNs: process.hrtime.bigint() });
186
+ return compute({
187
+ storeDir: opts.storeDir,
188
+ before: req.before,
189
+ after: req.after,
190
+ opts: { changeSeq: req.changeSeq, language: req.language },
191
+ }, traceUnitId);
192
+ };
193
+ if (!opts.dispatchBarrier)
194
+ return start();
195
+ const beganAtNs = opts.projectionTrace ? process.hrtime.bigint() : undefined;
196
+ let cancelBarrier;
197
+ const cancelled = new Promise((_resolve, reject) => { cancelBarrier = reject; });
198
+ let inner;
199
+ let wasCancelled = false;
200
+ const promise = Promise.race([Promise.resolve().then(opts.dispatchBarrier), cancelled]).then(() => {
201
+ if (wasCancelled)
202
+ throw new Error('clip dispatch cancelled');
203
+ emitProjectionPhase(opts.projectionTrace, 'dispatch-barrier', beganAtNs, { scope: 'clip', unitId: traceUnitId });
204
+ inner = start();
205
+ return inner.promise;
206
+ });
207
+ return { promise, cancel: () => {
208
+ wasCancelled = true;
209
+ cancelBarrier(new Error('clip dispatch cancelled'));
210
+ inner?.cancel();
211
+ } };
212
+ },
213
+ });
214
+ switch (outcome.kind) {
215
+ case 'ok':
216
+ // Availability, overload, and timeout dispositions are never cached.
217
+ if (isLeader && cacheable(outcome.value))
218
+ cache.set(key, outcome.value);
219
+ return stamp(outcome.value, req.changeSeq);
220
+ case 'timeout':
221
+ return transient(req.changeSeq, 'timeout');
222
+ case 'overloaded':
223
+ return transient(req.changeSeq, 'overloaded');
224
+ // A closed budget or a failed compute both surface as worker-error, the
225
+ // reason the service has always used for "no result from the worker".
226
+ default:
227
+ return transient(req.changeSeq, 'worker-error');
228
+ }
229
+ };
230
+ const close = async () => {
231
+ closed = true;
232
+ // Settle everything admitted to this service's budget, then terminate the
233
+ // worker. (The budget here is private to this service; closing it never
234
+ // affects another workload.)
235
+ if (!opts.admission)
236
+ await budget.close();
237
+ await pool?.close();
238
+ };
239
+ return { get, close };
240
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Worker-thread entry for clip projection. It runs the CPU-bound diff off the
3
+ * shared daemon event loop so a burst of cold-cache projection requests cannot
4
+ * starve capture. It is a thin wrapper: resolve blob bytes and run the pure
5
+ * core via {@link computeClipProjection}. One request/response per message,
6
+ * keyed by id; the service owns pooling, admission, and the wall-clock deadline
7
+ * (it terminates and replaces this worker on overrun — a stuck diff cannot be
8
+ * interrupted from inside).
9
+ */
10
+ import { parentPort } from 'node:worker_threads';
11
+ import { computeClipProjection } from "./clip-blob-reader.js";
12
+ if (!parentPort)
13
+ throw new Error('clip-projection-worker must run as a worker thread');
14
+ const port = parentPort;
15
+ port.on('message', (msg) => {
16
+ const traceTimings = msg.traceTimings ? [] : undefined;
17
+ computeClipProjection(msg.job, traceTimings ? (phase, durationNs) => traceTimings.push({ phase, durationNs }) : undefined).then((result) => port.postMessage({ id: msg.id, ok: true, result, traceTimings }), () => port.postMessage({ id: msg.id, ok: false }));
18
+ });