@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.
- package/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- 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
|
+
});
|