@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single persistent clip-projection worker thread. It exposes the `ClipCompute`
|
|
3
|
+
* seam the service drives: `run(job)` returns a promise plus a `cancel()` the
|
|
4
|
+
* service calls on a wall-clock overrun — cancel terminates the stuck worker
|
|
5
|
+
* (a running diff cannot be interrupted from inside) and spawns a replacement,
|
|
6
|
+
* so a single pathological change cannot wedge the worker. The service's
|
|
7
|
+
* admission gate guarantees at most one concurrent `run` call (B1 runs exactly
|
|
8
|
+
* one worker), so the worker is always free synchronously.
|
|
9
|
+
*/
|
|
10
|
+
import { Worker } from 'node:worker_threads';
|
|
11
|
+
import { siblingModuleUrl } from "./sibling-module.js";
|
|
12
|
+
import { CLIP_PROJECTION_VERSION } from "./clip-projection.js";
|
|
13
|
+
import { emitProjectionPhase, emitProjectionTrace, traceProcessId } from "./projection-trace.js";
|
|
14
|
+
const WORKER_URL = siblingModuleUrl('clip-projection-worker', import.meta.url);
|
|
15
|
+
function workerErrorResult() {
|
|
16
|
+
return {
|
|
17
|
+
change_seq: '',
|
|
18
|
+
projection_version: CLIP_PROJECTION_VERSION,
|
|
19
|
+
status: 'skipped',
|
|
20
|
+
fallback_reason: 'worker-error',
|
|
21
|
+
clips: [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function createClipWorkerPool(trace) {
|
|
25
|
+
let current = null;
|
|
26
|
+
let jobId = 0;
|
|
27
|
+
let closed = false;
|
|
28
|
+
// Terminations started by cancel()/error (fire-and-forget from the caller's
|
|
29
|
+
// point of view) but not yet complete. close() must await these too — the
|
|
30
|
+
// worker they belong to is no longer `current` by the time close() runs
|
|
31
|
+
// (cancel() has already spawned and installed its replacement), so awaiting
|
|
32
|
+
// only the current worker would let shutdown finish while the actual
|
|
33
|
+
// CPU-heavy worker is still mid-terminate.
|
|
34
|
+
const pendingTerminations = new Set();
|
|
35
|
+
const identities = new WeakMap();
|
|
36
|
+
const trackTermination = (w, traceUnitId) => {
|
|
37
|
+
const processId = identities.get(w);
|
|
38
|
+
if (processId !== undefined && traceUnitId !== undefined)
|
|
39
|
+
emitProjectionTrace(trace, { kind: 'process-retire', processId, unitId: traceUnitId, atNs: process.hrtime.bigint() });
|
|
40
|
+
const p = w.terminate().catch(() => { });
|
|
41
|
+
pendingTerminations.add(p);
|
|
42
|
+
p.finally(() => pendingTerminations.delete(p));
|
|
43
|
+
};
|
|
44
|
+
const spawn = () => {
|
|
45
|
+
const startedAtNs = trace ? process.hrtime.bigint() : undefined;
|
|
46
|
+
const w = new Worker(WORKER_URL);
|
|
47
|
+
const processId = trace ? traceProcessId() : undefined;
|
|
48
|
+
if (processId !== undefined) {
|
|
49
|
+
identities.set(w, processId);
|
|
50
|
+
emitProjectionTrace(trace, { kind: 'process-start', processId, process: 'clip-worker', atNs: process.hrtime.bigint() });
|
|
51
|
+
w.once('online', () => emitProjectionPhase(trace, 'worker-startup', startedAtNs, { scope: 'clip', processId }));
|
|
52
|
+
w.on('exit', code => emitProjectionTrace(trace, { kind: 'process-exit', processId, code, atNs: process.hrtime.bigint() }));
|
|
53
|
+
}
|
|
54
|
+
w.on('message', (resp) => {
|
|
55
|
+
const cur = current;
|
|
56
|
+
if (!cur || cur.id !== resp.id)
|
|
57
|
+
return; // stale message from before a cancel
|
|
58
|
+
current = null;
|
|
59
|
+
emitProjectionPhase(trace, 'worker-roundtrip', cur.startedAtNs, { scope: 'clip', unitId: cur.traceUnitId, processId });
|
|
60
|
+
if (resp.ok && trace && processId !== undefined && resp.traceTimings)
|
|
61
|
+
for (const timing of resp.traceTimings)
|
|
62
|
+
emitProjectionTrace(trace, { kind: 'phase', scope: 'clip', phase: timing.phase, durationNs: timing.durationNs,
|
|
63
|
+
atNs: process.hrtime.bigint(), unitId: cur.traceUnitId, processId });
|
|
64
|
+
cur.resolve(resp.ok ? resp.result : workerErrorResult());
|
|
65
|
+
});
|
|
66
|
+
w.on('error', () => {
|
|
67
|
+
const cur = current;
|
|
68
|
+
current = null;
|
|
69
|
+
trackTermination(w, cur?.traceUnitId);
|
|
70
|
+
if (!closed)
|
|
71
|
+
worker = spawn();
|
|
72
|
+
if (cur)
|
|
73
|
+
cur.resolve(workerErrorResult());
|
|
74
|
+
});
|
|
75
|
+
return w;
|
|
76
|
+
};
|
|
77
|
+
let worker = spawn();
|
|
78
|
+
const run = (job, traceUnitId) => {
|
|
79
|
+
if (closed || current !== null) {
|
|
80
|
+
// Admission should prevent this; fail safe rather than block.
|
|
81
|
+
return { promise: Promise.resolve(workerErrorResult()), cancel: () => { } };
|
|
82
|
+
}
|
|
83
|
+
let settled = false;
|
|
84
|
+
const processId = identities.get(worker);
|
|
85
|
+
if (processId !== undefined && traceUnitId !== undefined)
|
|
86
|
+
emitProjectionTrace(trace, { kind: 'process-use', processId, unitId: traceUnitId, atNs: process.hrtime.bigint() });
|
|
87
|
+
const promise = new Promise((resolve) => {
|
|
88
|
+
const id = ++jobId;
|
|
89
|
+
current = { id, traceUnitId, startedAtNs: trace ? process.hrtime.bigint() : undefined,
|
|
90
|
+
resolve: (v) => { if (!settled) {
|
|
91
|
+
settled = true;
|
|
92
|
+
resolve(v);
|
|
93
|
+
} } };
|
|
94
|
+
worker.postMessage({ id, job, ...(trace ? { traceTimings: true } : {}) });
|
|
95
|
+
});
|
|
96
|
+
const cancel = () => {
|
|
97
|
+
if (settled || current === null)
|
|
98
|
+
return;
|
|
99
|
+
current.resolve(workerErrorResult());
|
|
100
|
+
current = null;
|
|
101
|
+
const stale = worker;
|
|
102
|
+
trackTermination(stale, traceUnitId);
|
|
103
|
+
if (!closed)
|
|
104
|
+
worker = spawn();
|
|
105
|
+
};
|
|
106
|
+
return { promise, cancel };
|
|
107
|
+
};
|
|
108
|
+
const close = async () => {
|
|
109
|
+
closed = true;
|
|
110
|
+
const cur = current;
|
|
111
|
+
current = null;
|
|
112
|
+
if (cur)
|
|
113
|
+
cur.resolve(workerErrorResult());
|
|
114
|
+
await worker.terminate().catch(() => { });
|
|
115
|
+
await Promise.all(pendingTerminations);
|
|
116
|
+
};
|
|
117
|
+
return { run, close };
|
|
118
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public configuration surface (Fork 4): a JSON daemon config file plus CLI
|
|
3
|
+
* overrides, resolved into the effective enrichment policy and transcript
|
|
4
|
+
* discovery settings. Precedence is CLI > file > built-in defaults, applied
|
|
5
|
+
* field by field so an override never wipes a sibling.
|
|
6
|
+
*
|
|
7
|
+
* Configuration is enrichment-only: a bad or missing config warns and falls back
|
|
8
|
+
* to defaults, never throws — enrichment must never block capture (CLAUDE.md).
|
|
9
|
+
*/
|
|
10
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
11
|
+
import { DEFAULT_ENRICHMENT_POLICY } from "./attribution-producer.js";
|
|
12
|
+
const HARNESSES = ['claude-code', 'codex'];
|
|
13
|
+
const COVERAGE_VALUES = ['unconfigured', 'configured'];
|
|
14
|
+
/** Max transcripts a bounded Codex scan reads before disclosing discovery-limited. */
|
|
15
|
+
export const DEFAULT_CODEX_SCAN_LIMIT = 2000;
|
|
16
|
+
export function defaultConfig(homeDir) {
|
|
17
|
+
return {
|
|
18
|
+
policy: {
|
|
19
|
+
window_ms: DEFAULT_ENRICHMENT_POLICY.window_ms,
|
|
20
|
+
grace_ms: DEFAULT_ENRICHMENT_POLICY.grace_ms,
|
|
21
|
+
sources: { ...DEFAULT_ENRICHMENT_POLICY.sources },
|
|
22
|
+
},
|
|
23
|
+
transcript: {
|
|
24
|
+
homes: {
|
|
25
|
+
'claude-code': resolve(homeDir, '.claude'),
|
|
26
|
+
codex: resolve(homeDir, '.codex'),
|
|
27
|
+
},
|
|
28
|
+
codexScanLimit: DEFAULT_CODEX_SCAN_LIMIT,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function isHarness(v) {
|
|
33
|
+
return HARNESSES.includes(v);
|
|
34
|
+
}
|
|
35
|
+
function positiveInt(v) {
|
|
36
|
+
return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : undefined;
|
|
37
|
+
}
|
|
38
|
+
/** Parse a JSON config file into an override layer. Never throws: malformed JSON
|
|
39
|
+
* or bad fields produce warnings and are skipped, so capture is never blocked. */
|
|
40
|
+
export function parseConfigFile(raw) {
|
|
41
|
+
const warnings = [];
|
|
42
|
+
let obj;
|
|
43
|
+
try {
|
|
44
|
+
obj = JSON.parse(raw);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { overrides: {}, warnings: ['config: file is not valid JSON; using defaults'] };
|
|
48
|
+
}
|
|
49
|
+
if (typeof obj !== 'object' || obj === null) {
|
|
50
|
+
return { overrides: {}, warnings: ['config: top level is not an object; using defaults'] };
|
|
51
|
+
}
|
|
52
|
+
const root = obj;
|
|
53
|
+
const overrides = {};
|
|
54
|
+
const enrichment = root.enrichment;
|
|
55
|
+
if (typeof enrichment === 'object' && enrichment !== null) {
|
|
56
|
+
const e = enrichment;
|
|
57
|
+
if ('window_ms' in e) {
|
|
58
|
+
const n = positiveInt(e.window_ms);
|
|
59
|
+
if (n === undefined)
|
|
60
|
+
warnings.push('config: enrichment.window_ms must be a positive integer; ignored');
|
|
61
|
+
else
|
|
62
|
+
overrides.windowMs = n;
|
|
63
|
+
}
|
|
64
|
+
if ('grace_ms' in e) {
|
|
65
|
+
const n = positiveInt(e.grace_ms);
|
|
66
|
+
if (n === undefined)
|
|
67
|
+
warnings.push('config: enrichment.grace_ms must be a positive integer; ignored');
|
|
68
|
+
else
|
|
69
|
+
overrides.graceMs = n;
|
|
70
|
+
}
|
|
71
|
+
if (typeof e.sources === 'object' && e.sources !== null) {
|
|
72
|
+
const sources = {};
|
|
73
|
+
for (const [k, v] of Object.entries(e.sources)) {
|
|
74
|
+
if (!isHarness(k))
|
|
75
|
+
warnings.push(`config: unknown harness "${k}" in enrichment.sources; ignored`);
|
|
76
|
+
else if (typeof v !== 'string' || !COVERAGE_VALUES.includes(v)) {
|
|
77
|
+
warnings.push(`config: enrichment.sources.${k} must be one of ${COVERAGE_VALUES.join('/')}; ignored`);
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
sources[k] = v;
|
|
81
|
+
}
|
|
82
|
+
if (Object.keys(sources).length > 0)
|
|
83
|
+
overrides.sources = sources;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const transcripts = root.transcripts;
|
|
87
|
+
if (typeof transcripts === 'object' && transcripts !== null) {
|
|
88
|
+
const t = transcripts;
|
|
89
|
+
if (typeof t.homes === 'object' && t.homes !== null) {
|
|
90
|
+
const homes = {};
|
|
91
|
+
for (const [k, v] of Object.entries(t.homes)) {
|
|
92
|
+
if (!isHarness(k))
|
|
93
|
+
warnings.push(`config: unknown harness "${k}" in transcripts.homes; ignored`);
|
|
94
|
+
else if (typeof v !== 'string' || v.length === 0)
|
|
95
|
+
warnings.push(`config: transcripts.homes.${k} must be a path; ignored`);
|
|
96
|
+
else
|
|
97
|
+
homes[k] = v;
|
|
98
|
+
}
|
|
99
|
+
if (Object.keys(homes).length > 0)
|
|
100
|
+
overrides.homes = homes;
|
|
101
|
+
}
|
|
102
|
+
if ('codex_scan_limit' in t) {
|
|
103
|
+
const n = positiveInt(t.codex_scan_limit);
|
|
104
|
+
if (n === undefined)
|
|
105
|
+
warnings.push('config: transcripts.codex_scan_limit must be a positive integer; ignored');
|
|
106
|
+
else
|
|
107
|
+
overrides.codexScanLimit = n;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { overrides, warnings };
|
|
111
|
+
}
|
|
112
|
+
function applyLayer(base, o, homeDir) {
|
|
113
|
+
const homes = { ...base.transcript.homes };
|
|
114
|
+
if (o.homes) {
|
|
115
|
+
for (const h of HARNESSES) {
|
|
116
|
+
const v = o.homes[h];
|
|
117
|
+
if (v !== undefined)
|
|
118
|
+
homes[h] = isAbsolute(v) ? v : resolve(homeDir, v);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
policy: {
|
|
123
|
+
window_ms: o.windowMs ?? base.policy.window_ms,
|
|
124
|
+
grace_ms: o.graceMs ?? base.policy.grace_ms,
|
|
125
|
+
sources: { ...base.policy.sources, ...(o.sources ?? {}) },
|
|
126
|
+
},
|
|
127
|
+
transcript: {
|
|
128
|
+
homes,
|
|
129
|
+
codexScanLimit: o.codexScanLimit ?? base.transcript.codexScanLimit,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Layer file then CLI overrides onto the defaults; CLI wins field by field. */
|
|
134
|
+
export function resolveConfig(file, cli, homeDir) {
|
|
135
|
+
const withFile = applyLayer(defaultConfig(homeDir), file, homeDir);
|
|
136
|
+
return applyLayer(withFile, cli, homeDir);
|
|
137
|
+
}
|
|
138
|
+
export async function loadConfig(opts) {
|
|
139
|
+
const warnings = [];
|
|
140
|
+
let fileOverrides = {};
|
|
141
|
+
if (opts.path !== undefined) {
|
|
142
|
+
const raw = await opts.io.readFile(opts.path);
|
|
143
|
+
if (raw === undefined) {
|
|
144
|
+
warnings.push(`config: file not found at ${opts.path}; using defaults`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const parsed = parseConfigFile(raw);
|
|
148
|
+
fileOverrides = parsed.overrides;
|
|
149
|
+
warnings.push(...parsed.warnings);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const config = resolveConfig(fileOverrides, opts.cli ?? {}, opts.homeDir);
|
|
153
|
+
return { config, warnings };
|
|
154
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
import { createLineDecoder, encodeMessage, } from "./control-protocol.js";
|
|
3
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 2000;
|
|
4
|
+
const DEFAULT_RESPONSE_TIMEOUT_MS = 15000;
|
|
5
|
+
/** The request reached the wire but its outcome is unknowable — the mutation may
|
|
6
|
+
* or may not have committed. The caller must not retry blindly. */
|
|
7
|
+
export class OutcomeUnknownError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'OutcomeUnknownError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function daemonUnavailable(message) {
|
|
14
|
+
return { v: 1, ok: false, code: 'DAEMON_UNAVAILABLE', message };
|
|
15
|
+
}
|
|
16
|
+
/** A reply is only usable if it is a well-formed response envelope. Anything else
|
|
17
|
+
* on the wire (a bare `null`, a success object missing `ok`, a wrong protocol
|
|
18
|
+
* version) means we cannot read the daemon's answer — ambiguous after send, never
|
|
19
|
+
* a silent success. */
|
|
20
|
+
function isResponseEnvelope(value) {
|
|
21
|
+
if (typeof value !== 'object' || value === null)
|
|
22
|
+
return false;
|
|
23
|
+
const v = value;
|
|
24
|
+
if (v.v !== 1 || typeof v.ok !== 'boolean')
|
|
25
|
+
return false;
|
|
26
|
+
if (v.ok === false)
|
|
27
|
+
return typeof v.code === 'string' && typeof v.message === 'string';
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
export function sendControlRequest(opts) {
|
|
31
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
32
|
+
const responseTimeoutMs = opts.responseTimeoutMs ?? DEFAULT_RESPONSE_TIMEOUT_MS;
|
|
33
|
+
if (opts.deadlineAtMs !== undefined && opts.deadlineAtMs <= Date.now()) {
|
|
34
|
+
return Promise.resolve(daemonUnavailable('daemon request deadline elapsed before connecting'));
|
|
35
|
+
}
|
|
36
|
+
const remaining = (phaseTimeoutMs) => opts.deadlineAtMs === undefined
|
|
37
|
+
? phaseTimeoutMs : Math.min(phaseTimeoutMs, Math.max(0, opts.deadlineAtMs - Date.now()));
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const decoder = createLineDecoder();
|
|
40
|
+
let sent = false;
|
|
41
|
+
let settled = false;
|
|
42
|
+
let timer;
|
|
43
|
+
const sock = connect(opts.socketPath);
|
|
44
|
+
const finish = (act) => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
sock.removeAllListeners();
|
|
50
|
+
sock.destroy();
|
|
51
|
+
act();
|
|
52
|
+
};
|
|
53
|
+
// Before the request is on the wire, a fault means the daemon is unreachable;
|
|
54
|
+
// after, the outcome is unknowable. `sent` is the sole switch between them.
|
|
55
|
+
const failTransport = (message) => sent
|
|
56
|
+
? finish(() => reject(new OutcomeUnknownError(message)))
|
|
57
|
+
: finish(() => resolve(daemonUnavailable(message)));
|
|
58
|
+
timer = setTimeout(() => failTransport('daemon did not respond before the deadline'), remaining(connectTimeoutMs));
|
|
59
|
+
sock.on('connect', () => {
|
|
60
|
+
// The connection is up: from here on, bytes may reach the daemon, so any
|
|
61
|
+
// later fault is ambiguous, not proof of nothing sent. Flip `sent` before
|
|
62
|
+
// the write starts and re-arm the timer as a response deadline.
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
if (opts.deadlineAtMs !== undefined && opts.deadlineAtMs <= Date.now()) {
|
|
65
|
+
failTransport('daemon request deadline elapsed before transmission');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
sent = true;
|
|
69
|
+
timer = setTimeout(() => failTransport('no response before the deadline; the request may have committed'), remaining(responseTimeoutMs));
|
|
70
|
+
sock.write(encodeMessage(opts.request), (err) => {
|
|
71
|
+
if (err)
|
|
72
|
+
failTransport(`control write failed: ${err.message}`);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
sock.on('data', (chunk) => {
|
|
76
|
+
let messages;
|
|
77
|
+
try {
|
|
78
|
+
messages = decoder.push(chunk);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// A malformed reply after send: the daemon acted but we cannot read its
|
|
82
|
+
// answer — ambiguous, not "unavailable".
|
|
83
|
+
finish(() => reject(new OutcomeUnknownError(`unreadable control response: ${err.message}`)));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (messages.length === 0)
|
|
87
|
+
return; // response not yet complete
|
|
88
|
+
const reply = messages[0];
|
|
89
|
+
if (!isResponseEnvelope(reply)) {
|
|
90
|
+
finish(() => reject(new OutcomeUnknownError('daemon reply was not a valid control response')));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
finish(() => resolve(reply));
|
|
94
|
+
});
|
|
95
|
+
sock.on('error', (err) => failTransport(err.message));
|
|
96
|
+
sock.on('close', () => failTransport('connection closed before a response arrived'));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Slipstream daemon control protocol: newline-delimited JSON over an
|
|
3
|
+
* owner-only `node:net` unix socket. This is a PRIVATE CONTROL channel — its
|
|
4
|
+
* verbs (attach / detach / status / begin_task / delete_session / gc) WRITE
|
|
5
|
+
* public events or maintain the store and never read or serve the feed. Readers
|
|
6
|
+
* keep serving the public HTTP/disk view; there is no privileged back channel
|
|
7
|
+
* for reading.
|
|
8
|
+
*
|
|
9
|
+
* Framing is one JSON object per line. A length prefix would buy nothing here:
|
|
10
|
+
* the real hazards are unbounded buffering of an unterminated line, a UTF-8
|
|
11
|
+
* character split across socket chunks, and several frames arriving in one
|
|
12
|
+
* chunk. The decoder below closes all three, and refuses a line past a byte cap
|
|
13
|
+
* before it can accumulate.
|
|
14
|
+
*/
|
|
15
|
+
/** A framing/parse fault at the transport layer. The server answers a
|
|
16
|
+
* best-effort `PROTOCOL` error then closes; the client surfaces it as-is. */
|
|
17
|
+
export class ProtocolError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'ProtocolError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** One control line (the JSON object, excluding its trailing newline) must fit
|
|
24
|
+
* in this many bytes. Generous for a title but far short of anything that could
|
|
25
|
+
* exhaust memory. */
|
|
26
|
+
export const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
27
|
+
const NEWLINE = 0x0a;
|
|
28
|
+
export function encodeMessage(value) {
|
|
29
|
+
return Buffer.from(JSON.stringify(value) + '\n', 'utf8');
|
|
30
|
+
}
|
|
31
|
+
export function createLineDecoder(maxBytes = MAX_MESSAGE_BYTES) {
|
|
32
|
+
let buf = Buffer.alloc(0);
|
|
33
|
+
return {
|
|
34
|
+
push(chunk) {
|
|
35
|
+
// Keep raw bytes until a line is complete: a multibyte character split
|
|
36
|
+
// across chunks is only decoded once all its bytes have arrived.
|
|
37
|
+
buf = buf.length === 0 ? chunk : Buffer.concat([buf, chunk]);
|
|
38
|
+
const out = [];
|
|
39
|
+
let start = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const nl = buf.indexOf(NEWLINE, start);
|
|
42
|
+
if (nl === -1)
|
|
43
|
+
break;
|
|
44
|
+
const line = buf.subarray(start, nl);
|
|
45
|
+
start = nl + 1;
|
|
46
|
+
if (line.length === 0)
|
|
47
|
+
continue; // tolerate blank separators
|
|
48
|
+
if (line.length > maxBytes)
|
|
49
|
+
throw new ProtocolError('control line exceeds byte cap');
|
|
50
|
+
const text = line.toString('utf8');
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(text);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new ProtocolError('control line is not valid JSON');
|
|
57
|
+
}
|
|
58
|
+
out.push(parsed);
|
|
59
|
+
}
|
|
60
|
+
buf = start === 0 ? buf : buf.subarray(start);
|
|
61
|
+
// Refuse an unterminated line that has already outgrown the cap, before it
|
|
62
|
+
// can accumulate without bound across future chunks.
|
|
63
|
+
if (buf.length > maxBytes)
|
|
64
|
+
throw new ProtocolError('control line exceeds byte cap');
|
|
65
|
+
return out;
|
|
66
|
+
},
|
|
67
|
+
end() {
|
|
68
|
+
if (buf.length > 0)
|
|
69
|
+
throw new ProtocolError('stream ended mid-line');
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the forwarder finds the daemon's control socket.
|
|
3
|
+
*
|
|
4
|
+
* The forwarder is a control client, not the daemon: it never creates the
|
|
5
|
+
* store, it only needs to know which store the shared daemon owns so it can
|
|
6
|
+
* connect to `<store>/control.sock`. That store defaults to `~/.slipstream`
|
|
7
|
+
* (matching the CLI) and may be overridden with an explicit `--store <dir>`
|
|
8
|
+
* shared with the CLI, so both point at the same daemon.
|
|
9
|
+
*/
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
export const CONTROL_SOCKET_NAME = 'control.sock';
|
|
13
|
+
export function defaultDaemonStore(home = homedir()) {
|
|
14
|
+
return join(home, '.slipstream');
|
|
15
|
+
}
|
|
16
|
+
export function controlSocketPath(storeDir) {
|
|
17
|
+
return join(storeDir, CONTROL_SOCKET_NAME);
|
|
18
|
+
}
|
|
19
|
+
/** Resolve the store dir the forwarder should talk to from its argv: an
|
|
20
|
+
* explicit `--store <dir>` (resolved to an absolute path), else the default
|
|
21
|
+
* home store. Fails fast when `--store` is given without a value. */
|
|
22
|
+
export function resolveStoreDir(argv, home = homedir()) {
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
if (argv[i] === '--store') {
|
|
25
|
+
const value = argv[i + 1];
|
|
26
|
+
if (value === undefined || value.startsWith('--')) {
|
|
27
|
+
throw new Error('--store requires a directory argument');
|
|
28
|
+
}
|
|
29
|
+
return resolve(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return defaultDaemonStore(home);
|
|
33
|
+
}
|