@playola-radio/slipstream 0.1.0 → 0.3.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/README.md +427 -101
- package/SKILL.md +4 -1
- package/contracts/interface/v2/schema.json +3 -3
- package/dist/attach-workflow.js +557 -0
- package/dist/attribution-engine.js +2 -3
- package/dist/attribution-scoring.js +70 -13
- package/dist/boundary-registry.js +25 -0
- package/dist/capture-intent.js +70 -0
- package/dist/capture-scope.js +135 -0
- package/dist/cas.js +61 -14
- package/dist/claude-root-transcript.js +18 -11
- package/dist/cli.js +426 -26
- package/dist/clip-function-parser.js +9 -2
- package/dist/clip-projection-service.js +28 -0
- package/dist/clip-projection-worker.js +4 -0
- package/dist/clip-worker-pool.js +13 -1
- package/dist/codex-root-transcript.js +46 -0
- package/dist/conductor-settings.js +178 -0
- package/dist/conductor.js +313 -0
- package/dist/control-protocol.js +2 -1
- package/dist/daemon.js +1718 -470
- package/dist/engine.js +26 -10
- package/dist/ensure-daemon.js +30 -0
- package/dist/http-reader.js +20 -8
- package/dist/intent-store.js +196 -0
- package/dist/interface-range-resolver.js +28 -1
- package/dist/launch-agent.js +179 -0
- package/dist/lock.js +1 -1
- package/dist/public-events.js +26 -2
- package/dist/question-hook.js +11 -1
- package/dist/questions.js +102 -3
- package/dist/recovery.js +62 -6
- package/dist/session.js +604 -104
- package/dist/store-reader.js +112 -7
- package/dist/task-forwarder.js +8 -8
- package/dist/transcript/discovery.js +242 -111
- package/dist/transcript/fs-io.js +10 -1
- package/dist/transcript/scheduler.js +114 -0
- package/dist/transcript/watcher.js +5 -3
- package/dist/withdraw-workflow.js +106 -0
- package/dist/workspace.js +133 -0
- package/package.json +3 -2
- package/schemas/slipstream.agent.binding.v1.json +90 -0
- package/schemas/slipstream.agent.connection.v1.json +34 -0
- package/schemas/slipstream.agent.unlinked.v1.json +68 -0
- package/schemas/slipstream.capture.gap.v1.json +1 -0
- package/schemas/slipstream.capture.ready.v1.json +55 -0
- package/schemas/slipstream.capture.scope.v1.json +85 -0
- package/schemas/slipstream.capture.stopped.v1.json +65 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +11 -1
- package/schemas/slipstream.question.exchange_recorded.v1.json +65 -0
- package/schemas/slipstream.question.relay_updated.v1.json +64 -0
- package/schemas/slipstream.session.resumed.v1.json +4 -1
- package/schemas/slipstream.session.started.v1.json +4 -1
- package/dist/transcript/runner.js +0 -62
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { parentPort } from 'node:worker_threads';
|
|
11
11
|
import { computeClipProjection } from "./clip-blob-reader.js";
|
|
12
|
+
import { warmFunctionIndexers } from "./clip-function-parser.js";
|
|
12
13
|
if (!parentPort)
|
|
13
14
|
throw new Error('clip-projection-worker must run as a worker thread');
|
|
14
15
|
const port = parentPort;
|
|
16
|
+
// A replacement worker would otherwise pay parser startup inside its first job's
|
|
17
|
+
// deadline. Warm failures are not retained; a job's own load reports them.
|
|
18
|
+
void warmFunctionIndexers().catch(() => { }).then(() => port.postMessage({ kind: 'ready' }));
|
|
15
19
|
port.on('message', (msg) => {
|
|
16
20
|
const traceTimings = msg.traceTimings ? [] : undefined;
|
|
17
21
|
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 }));
|
package/dist/clip-worker-pool.js
CHANGED
|
@@ -11,6 +11,9 @@ import { Worker } from 'node:worker_threads';
|
|
|
11
11
|
import { siblingModuleUrl } from "./sibling-module.js";
|
|
12
12
|
import { CLIP_PROJECTION_VERSION } from "./clip-projection.js";
|
|
13
13
|
import { emitProjectionPhase, emitProjectionTrace, traceProcessId } from "./projection-trace.js";
|
|
14
|
+
function isReadyMessage(message) {
|
|
15
|
+
return 'kind' in message && message.kind === 'ready';
|
|
16
|
+
}
|
|
14
17
|
const WORKER_URL = siblingModuleUrl('clip-projection-worker', import.meta.url);
|
|
15
18
|
function workerErrorResult() {
|
|
16
19
|
return {
|
|
@@ -33,6 +36,7 @@ export function createClipWorkerPool(trace) {
|
|
|
33
36
|
// CPU-heavy worker is still mid-terminate.
|
|
34
37
|
const pendingTerminations = new Set();
|
|
35
38
|
const identities = new WeakMap();
|
|
39
|
+
let workerReady = Promise.resolve();
|
|
36
40
|
const trackTermination = (w, traceUnitId) => {
|
|
37
41
|
const processId = identities.get(w);
|
|
38
42
|
if (processId !== undefined && traceUnitId !== undefined)
|
|
@@ -42,6 +46,8 @@ export function createClipWorkerPool(trace) {
|
|
|
42
46
|
p.finally(() => pendingTerminations.delete(p));
|
|
43
47
|
};
|
|
44
48
|
const spawn = () => {
|
|
49
|
+
let resolveReady;
|
|
50
|
+
workerReady = new Promise(resolve => { resolveReady = resolve; });
|
|
45
51
|
const startedAtNs = trace ? process.hrtime.bigint() : undefined;
|
|
46
52
|
const w = new Worker(WORKER_URL);
|
|
47
53
|
const processId = trace ? traceProcessId() : undefined;
|
|
@@ -52,6 +58,10 @@ export function createClipWorkerPool(trace) {
|
|
|
52
58
|
w.on('exit', code => emitProjectionTrace(trace, { kind: 'process-exit', processId, code, atNs: process.hrtime.bigint() }));
|
|
53
59
|
}
|
|
54
60
|
w.on('message', (resp) => {
|
|
61
|
+
if (isReadyMessage(resp)) {
|
|
62
|
+
resolveReady();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
55
65
|
const cur = current;
|
|
56
66
|
if (!cur || cur.id !== resp.id)
|
|
57
67
|
return; // stale message from before a cancel
|
|
@@ -64,6 +74,7 @@ export function createClipWorkerPool(trace) {
|
|
|
64
74
|
cur.resolve(resp.ok ? resp.result : workerErrorResult());
|
|
65
75
|
});
|
|
66
76
|
w.on('error', () => {
|
|
77
|
+
resolveReady();
|
|
67
78
|
const cur = current;
|
|
68
79
|
current = null;
|
|
69
80
|
trackTermination(w, cur?.traceUnitId);
|
|
@@ -72,6 +83,7 @@ export function createClipWorkerPool(trace) {
|
|
|
72
83
|
if (cur)
|
|
73
84
|
cur.resolve(workerErrorResult());
|
|
74
85
|
});
|
|
86
|
+
w.once('exit', () => resolveReady());
|
|
75
87
|
return w;
|
|
76
88
|
};
|
|
77
89
|
let worker = spawn();
|
|
@@ -114,5 +126,5 @@ export function createClipWorkerPool(trace) {
|
|
|
114
126
|
await worker.terminate().catch(() => { });
|
|
115
127
|
await Promise.all(pendingTerminations);
|
|
116
128
|
};
|
|
117
|
-
return { run, close };
|
|
129
|
+
return { run, get ready() { return workerReady; }, close };
|
|
118
130
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, realpath } from 'node:fs/promises';
|
|
3
|
+
const SUPPORTED_CODEX_VERSIONS = new Set(['0.154.0', '0.155.1']);
|
|
4
|
+
const MAX_CODEX_META_BYTES = 64 * 1024;
|
|
5
|
+
/** Verify the selected transcript's own first record. It binds the reported
|
|
6
|
+
* session and worktree without treating either as authorship. Any missing,
|
|
7
|
+
* mismatched, or unobserved runtime metadata is `false`. */
|
|
8
|
+
export async function verifyCodexRootTranscript(path, sessionId, worktree) {
|
|
9
|
+
try {
|
|
10
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
11
|
+
let bytes;
|
|
12
|
+
try {
|
|
13
|
+
if (!(await handle.stat()).isFile())
|
|
14
|
+
return false;
|
|
15
|
+
const buffer = Buffer.alloc(MAX_CODEX_META_BYTES + 1);
|
|
16
|
+
let total = 0;
|
|
17
|
+
let end = -1;
|
|
18
|
+
while (total < buffer.length && end < 0) {
|
|
19
|
+
const { bytesRead } = await handle.read(buffer, total, buffer.length - total, total);
|
|
20
|
+
if (bytesRead === 0)
|
|
21
|
+
break;
|
|
22
|
+
end = buffer.subarray(total, total + bytesRead).indexOf(0x0a);
|
|
23
|
+
if (end >= 0)
|
|
24
|
+
end += total;
|
|
25
|
+
total += bytesRead;
|
|
26
|
+
}
|
|
27
|
+
if (end < 0 || end > MAX_CODEX_META_BYTES)
|
|
28
|
+
return false;
|
|
29
|
+
bytes = buffer.subarray(0, end);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
const record = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
35
|
+
const payload = record.payload;
|
|
36
|
+
return record.type === 'session_meta' && !!payload
|
|
37
|
+
&& payload.originator === 'codex_sdk_ts'
|
|
38
|
+
&& (payload.source === 'exec' || payload.source === 'vscode')
|
|
39
|
+
&& SUPPORTED_CODEX_VERSIONS.has(String(payload.cli_version))
|
|
40
|
+
&& payload.session_id === sessionId && typeof payload.cwd === 'string'
|
|
41
|
+
&& await realpath(payload.cwd) === worktree;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read and edit the `[scripts] setup` / `archive` entries of a Conductor
|
|
3
|
+
* settings file, keeping every other line as written. Only the plain forms are
|
|
4
|
+
* handled: a plainly written `[scripts]` table with single-line string values.
|
|
5
|
+
* Anything else (dotted keys, inline tables, a quoted `["scripts"]` header or key, a
|
|
6
|
+
* multi-line string anywhere in the file) is refused, so a file is never
|
|
7
|
+
* rewritten on a guess.
|
|
8
|
+
*/
|
|
9
|
+
export const SCRIPT_KINDS = ['setup', 'archive'];
|
|
10
|
+
export class SettingsError extends Error {
|
|
11
|
+
}
|
|
12
|
+
const HEADER = /^\s*\[\s*([^\[\]]+?)\s*\]\s*(?:#.*)?$/;
|
|
13
|
+
const ARRAY_HEADER = /^\s*\[\[/;
|
|
14
|
+
const KEY = /^\s*(setup|archive)\s*=\s*(.*)$/;
|
|
15
|
+
function unsupported(path, line, why) {
|
|
16
|
+
return new SettingsError(`${path}:${line} ${why}; Slipstream edits only a [scripts] table with single-line string values. `
|
|
17
|
+
+ 'Nothing was changed.');
|
|
18
|
+
}
|
|
19
|
+
function parseValue(raw, path, line) {
|
|
20
|
+
const text = raw.trim();
|
|
21
|
+
let value;
|
|
22
|
+
let rest;
|
|
23
|
+
if (text.startsWith('"')) {
|
|
24
|
+
let end = 1;
|
|
25
|
+
while (end < text.length && text[end] !== '"')
|
|
26
|
+
end += text[end] === '\\' ? 2 : 1;
|
|
27
|
+
if (end >= text.length)
|
|
28
|
+
throw unsupported(path, line, 'has an unterminated string');
|
|
29
|
+
try {
|
|
30
|
+
value = JSON.parse(text.slice(0, end + 1));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw unsupported(path, line, 'has a string escape Slipstream does not read');
|
|
34
|
+
}
|
|
35
|
+
rest = text.slice(end + 1);
|
|
36
|
+
}
|
|
37
|
+
else if (text.startsWith("'")) {
|
|
38
|
+
const end = text.indexOf("'", 1);
|
|
39
|
+
if (end < 0)
|
|
40
|
+
throw unsupported(path, line, 'has an unterminated string');
|
|
41
|
+
value = text.slice(1, end);
|
|
42
|
+
rest = text.slice(end + 1);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
throw unsupported(path, line, 'sets a script to something other than a string');
|
|
46
|
+
}
|
|
47
|
+
if (rest.trim() !== '' && !rest.trim().startsWith('#'))
|
|
48
|
+
throw unsupported(path, line, 'has text after the string');
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function usesMultilineString(line) {
|
|
52
|
+
let quote;
|
|
53
|
+
for (let i = 0; i < line.length; i++) {
|
|
54
|
+
const char = line[i];
|
|
55
|
+
if (!quote) {
|
|
56
|
+
if (char === '#')
|
|
57
|
+
return false;
|
|
58
|
+
if ((char === '"' || char === "'") && line.slice(i, i + 3) === char.repeat(3))
|
|
59
|
+
return true;
|
|
60
|
+
if (char === '"' || char === "'")
|
|
61
|
+
quote = char;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (quote === '"' && char === '\\') {
|
|
65
|
+
i += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (char === quote)
|
|
69
|
+
quote = undefined;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
/** Whether a line's leading quoted key could name `scripts`. A key whose
|
|
74
|
+
* escapes don't decode is assumed to, so it is refused rather than guessed. */
|
|
75
|
+
function mayQuoteScripts(line) {
|
|
76
|
+
const key = /^\s*("(?:[^"\\]|\\.)*"|'[^']*')/.exec(line)?.[1];
|
|
77
|
+
if (key === undefined)
|
|
78
|
+
return true;
|
|
79
|
+
if (key.startsWith("'"))
|
|
80
|
+
return key.slice(1, -1) === 'scripts';
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(key) === 'scripts';
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function locate(text, path) {
|
|
89
|
+
const lines = text.split('\n');
|
|
90
|
+
const scripts = {};
|
|
91
|
+
const at = {};
|
|
92
|
+
let table = '';
|
|
93
|
+
let found;
|
|
94
|
+
for (const [i, line] of lines.entries()) {
|
|
95
|
+
if (usesMultilineString(line))
|
|
96
|
+
throw unsupported(path, i + 1, 'uses a multi-line string');
|
|
97
|
+
if (ARRAY_HEADER.test(line)) {
|
|
98
|
+
table = null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const header = HEADER.exec(line);
|
|
102
|
+
if (header) {
|
|
103
|
+
table = header[1];
|
|
104
|
+
if (/^["']/.test(table))
|
|
105
|
+
throw unsupported(path, i + 1, 'quotes a table name');
|
|
106
|
+
if (table === 'scripts') {
|
|
107
|
+
if (found !== undefined)
|
|
108
|
+
throw unsupported(path, i + 1, 'repeats the [scripts] table');
|
|
109
|
+
found = i;
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (table === '' && /^\s*["']/.test(line) && mayQuoteScripts(line)) {
|
|
114
|
+
throw unsupported(path, i + 1, 'quotes a top-level scripts key');
|
|
115
|
+
}
|
|
116
|
+
if (table === '' && /^\s*scripts\s*[.=]/.test(line))
|
|
117
|
+
throw unsupported(path, i + 1, 'sets scripts outside a [scripts] table');
|
|
118
|
+
if (table !== 'scripts')
|
|
119
|
+
continue;
|
|
120
|
+
if (/^\s*["']/.test(line))
|
|
121
|
+
throw unsupported(path, i + 1, 'quotes a script key');
|
|
122
|
+
const key = KEY.exec(line);
|
|
123
|
+
if (!key)
|
|
124
|
+
continue;
|
|
125
|
+
const kind = key[1];
|
|
126
|
+
scripts[kind] = parseValue(key[2], path, i + 1);
|
|
127
|
+
at[kind] = i;
|
|
128
|
+
}
|
|
129
|
+
return { scripts, lines, header: found, at };
|
|
130
|
+
}
|
|
131
|
+
export function readTomlScripts(text, path) {
|
|
132
|
+
return locate(text, path).scripts;
|
|
133
|
+
}
|
|
134
|
+
/** `text` with these scripts set, every other line kept. */
|
|
135
|
+
export function writeTomlScripts(text, scripts, path) {
|
|
136
|
+
const { lines, header, at } = locate(text, path);
|
|
137
|
+
const entries = SCRIPT_KINDS.filter((k) => scripts[k] !== undefined).map((k) => [k, `${k} = ${JSON.stringify(scripts[k])}`]);
|
|
138
|
+
const missing = [];
|
|
139
|
+
for (const [kind, line] of entries) {
|
|
140
|
+
if (at[kind] !== undefined)
|
|
141
|
+
lines[at[kind]] = line;
|
|
142
|
+
else
|
|
143
|
+
missing.push(line);
|
|
144
|
+
}
|
|
145
|
+
if (missing.length === 0)
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
if (header !== undefined) {
|
|
148
|
+
lines.splice(header + 1, 0, ...missing);
|
|
149
|
+
return lines.join('\n');
|
|
150
|
+
}
|
|
151
|
+
const body = lines.join('\n');
|
|
152
|
+
const sep = body === '' ? '' : body.endsWith('\n\n') ? '' : body.endsWith('\n') ? '\n' : '\n\n';
|
|
153
|
+
return `${body}${sep}[scripts]\n${missing.join('\n')}\n`;
|
|
154
|
+
}
|
|
155
|
+
/** `text` with generated script entries removed, retaining all other text. */
|
|
156
|
+
export function removeTomlScripts(text, kinds, path) {
|
|
157
|
+
const { lines, at } = locate(text, path);
|
|
158
|
+
const remove = kinds.map((kind) => at[kind]).filter((line) => line !== undefined).sort((a, b) => b - a);
|
|
159
|
+
for (const line of remove)
|
|
160
|
+
lines.splice(line, 1);
|
|
161
|
+
return lines.join('\n');
|
|
162
|
+
}
|
|
163
|
+
/** The scripts of a legacy `conductor.json`. */
|
|
164
|
+
export function readLegacyScripts(text, path) {
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(text);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
throw new SettingsError(`${path} is not valid JSON. Nothing was changed.`);
|
|
171
|
+
}
|
|
172
|
+
const scripts = parsed?.scripts;
|
|
173
|
+
const out = {};
|
|
174
|
+
for (const kind of SCRIPT_KINDS)
|
|
175
|
+
if (typeof scripts?.[kind] === 'string')
|
|
176
|
+
out[kind] = scripts[kind];
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conductor integration: `slipstream conductor install | setup | archive | enroll`.
|
|
3
|
+
*
|
|
4
|
+
* Install chains `slipstream conductor setup` / `archive` into the personal
|
|
5
|
+
* Conductor scripts (`~/.conductor/settings.toml`) and, for a repo that defines
|
|
6
|
+
* its own scripts and so overrides the personal ones, into that repo's
|
|
7
|
+
* `.conductor/settings.local.toml`, ahead of the repo's own command. Conductor
|
|
8
|
+
* runs setup after it creates a workspace and archive before it archives one,
|
|
9
|
+
* but it was observed (README, "Capture every Conductor workspace") starting the
|
|
10
|
+
* agent without waiting for setup to finish, so capture can begin after an
|
|
11
|
+
* agent's first edits. The first baseline is then the first state observed,
|
|
12
|
+
* never a claim of the original one.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { shellWord } from "./attach-workflow.js";
|
|
18
|
+
import { SCRIPT_KINDS, SettingsError, readLegacyScripts, readTomlScripts, removeTomlScripts, writeTomlScripts, } from "./conductor-settings.js";
|
|
19
|
+
import { InstallError, entryPaths, installLaunchAgent, readManagedInstall, writeManagedInstall, } from "./launch-agent.js";
|
|
20
|
+
export function slipstreamCommand(kind, p) {
|
|
21
|
+
return [p.node, p.cli, 'conductor', kind, '--store', p.store].map(shellWord).join(' ');
|
|
22
|
+
}
|
|
23
|
+
/** Slipstream first, then the repo's own command whatever Slipstream returned, so
|
|
24
|
+
* Slipstream never stops a workspace being set up or archived. The script fails
|
|
25
|
+
* with the repo's code if its command fails, otherwise with Slipstream's. The
|
|
26
|
+
* subshell keeps an `exit` in the repo's command from skipping that. */
|
|
27
|
+
export function chainScript(ours, own) {
|
|
28
|
+
if (own === null || own.trim() === '')
|
|
29
|
+
return ours;
|
|
30
|
+
return `slipstream_status=0\n${ours} || slipstream_status=$?\n(\n${own}\n) || exit $?\nexit $slipstream_status`;
|
|
31
|
+
}
|
|
32
|
+
const WORD = String.raw `(?:[\w@%+=:,./-]+|'[^']*'(?:\\''[^']*')*)`;
|
|
33
|
+
const OURS = String.raw `${WORD} ${WORD} conductor (?:setup|archive) --store ${WORD}`;
|
|
34
|
+
const CHAINED = new RegExp(String.raw `^(?:${OURS}|slipstream_status=0\n${OURS} \|\| slipstream_status=\$\?\n\(\n([\s\S]*)\n\) \|\| exit \$\?\nexit \$slipstream_status)$`);
|
|
35
|
+
/** The command a Slipstream chain runs after its own, or undefined when `value`
|
|
36
|
+
* is not a Slipstream chain. */
|
|
37
|
+
export function chainedOwn(value) {
|
|
38
|
+
const m = CHAINED.exec(value);
|
|
39
|
+
return m ? { own: m[1] ?? null } : undefined;
|
|
40
|
+
}
|
|
41
|
+
async function readText(path) {
|
|
42
|
+
try {
|
|
43
|
+
return await readFile(path, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err.code === 'ENOENT')
|
|
47
|
+
return null;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function writeAtomic(path, text) {
|
|
52
|
+
await mkdir(dirname(path), { recursive: true });
|
|
53
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
54
|
+
try {
|
|
55
|
+
await writeFile(tmp, text, { flag: 'wx' });
|
|
56
|
+
await rename(tmp, path);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
await unlink(tmp).catch(() => { });
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function localSettingsPath(root) {
|
|
64
|
+
return join(root, '.conductor', 'settings.local.toml');
|
|
65
|
+
}
|
|
66
|
+
/** A repo's shared scripts: `settings.toml`, then legacy `conductor.json`, per key. */
|
|
67
|
+
export async function sharedScripts(root) {
|
|
68
|
+
const tomlPath = join(root, '.conductor', 'settings.toml');
|
|
69
|
+
const jsonPath = join(root, 'conductor.json');
|
|
70
|
+
const toml = await readText(tomlPath);
|
|
71
|
+
const json = await readText(jsonPath);
|
|
72
|
+
return { ...(json === null ? {} : readLegacyScripts(json, jsonPath)), ...(toml === null ? {} : readTomlScripts(toml, tomlPath)) };
|
|
73
|
+
}
|
|
74
|
+
/** Where the shared scripts no longer match what the install chained. */
|
|
75
|
+
export function driftOf(recorded, shared) {
|
|
76
|
+
return SCRIPT_KINDS.filter((k) => (shared[k] ?? null) !== recorded[k]);
|
|
77
|
+
}
|
|
78
|
+
export async function runConductorInstall(deps) {
|
|
79
|
+
try {
|
|
80
|
+
return await install(deps);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
if (!(err instanceof InstallError) && !(err instanceof SettingsError) && !(err instanceof InventoryError))
|
|
84
|
+
throw err;
|
|
85
|
+
deps.err(`slipstream: ${err.message}`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function install(deps) {
|
|
90
|
+
const paths = await entryPaths(deps.execPath, deps.nodeVersion, deps.cliPath);
|
|
91
|
+
const ours = (kind) => slipstreamCommand(kind, { ...paths, store: deps.store });
|
|
92
|
+
const prior = await readManagedInstall(deps.store);
|
|
93
|
+
const writes = [];
|
|
94
|
+
const notes = [];
|
|
95
|
+
// Plan every edit before writing any, so a settings file Slipstream cannot
|
|
96
|
+
// read leaves the machine as it was.
|
|
97
|
+
const personalPath = join(deps.home, '.conductor', 'settings.toml');
|
|
98
|
+
const personalText = (await readText(personalPath)) ?? '';
|
|
99
|
+
const personal = readTomlScripts(personalText, personalPath);
|
|
100
|
+
const personalNext = {};
|
|
101
|
+
for (const kind of SCRIPT_KINDS) {
|
|
102
|
+
const current = personal[kind];
|
|
103
|
+
const chain = current === undefined ? undefined : chainedOwn(current);
|
|
104
|
+
const own = chain ? chain.own : current ?? null;
|
|
105
|
+
personalNext[kind] = chainScript(ours(kind), own);
|
|
106
|
+
}
|
|
107
|
+
const personalOut = writeTomlScripts(personalText, personalNext, personalPath);
|
|
108
|
+
if (personalOut !== personalText)
|
|
109
|
+
writes.push({ path: personalPath, text: personalOut });
|
|
110
|
+
const managedPath = join(deps.home, '.conductor', 'settings.managed.toml');
|
|
111
|
+
const managedText = await readText(managedPath);
|
|
112
|
+
if (managedText !== null && Object.keys(readTomlScripts(managedText, managedPath)).length > 0) {
|
|
113
|
+
notes.push(`warning: ${managedPath} sets Conductor scripts, which override Slipstream's on this machine; `
|
|
114
|
+
+ 'workspaces will not be captured until those scripts run `slipstream conductor setup`.');
|
|
115
|
+
}
|
|
116
|
+
const repos = [];
|
|
117
|
+
const chained = [];
|
|
118
|
+
for (const root of [...new Set(await deps.listRepos())].sort()) {
|
|
119
|
+
if (!(await stat(root).catch(() => null))?.isDirectory()) {
|
|
120
|
+
notes.push(`skipped ${root}: Conductor lists it, but it is not a directory here.`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const shared = await sharedScripts(root);
|
|
124
|
+
const localPath = localSettingsPath(root);
|
|
125
|
+
const localText = await readText(localPath);
|
|
126
|
+
const local = localText === null ? {} : readTomlScripts(localText, localPath);
|
|
127
|
+
const before = prior?.repos.find((r) => r.root === root);
|
|
128
|
+
const next = {};
|
|
129
|
+
const remove = [];
|
|
130
|
+
for (const kind of SCRIPT_KINDS) {
|
|
131
|
+
const value = local[kind];
|
|
132
|
+
const ourChain = value === undefined ? undefined : chainedOwn(value);
|
|
133
|
+
let own;
|
|
134
|
+
if (ourChain) {
|
|
135
|
+
// Follow a shared script that changed since the last install, unless the
|
|
136
|
+
// chained command was edited by hand.
|
|
137
|
+
if (before && ourChain.own === before[kind] && shared[kind] === undefined) {
|
|
138
|
+
remove.push(kind);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
own = before && ourChain.own === before[kind] ? shared[kind] ?? null : ourChain.own;
|
|
142
|
+
}
|
|
143
|
+
else if (value !== undefined)
|
|
144
|
+
own = value;
|
|
145
|
+
else if (shared[kind] !== undefined)
|
|
146
|
+
own = shared[kind];
|
|
147
|
+
else
|
|
148
|
+
continue;
|
|
149
|
+
next[kind] = chainScript(ours(kind), own);
|
|
150
|
+
}
|
|
151
|
+
if (Object.keys(next).length === 0 && remove.length === 0)
|
|
152
|
+
continue;
|
|
153
|
+
let out = localText ?? '';
|
|
154
|
+
if (remove.length > 0)
|
|
155
|
+
out = removeTomlScripts(out, remove, localPath);
|
|
156
|
+
if (Object.keys(next).length > 0)
|
|
157
|
+
out = writeTomlScripts(out, next, localPath);
|
|
158
|
+
if (out !== localText)
|
|
159
|
+
writes.push({ path: localPath, text: out });
|
|
160
|
+
repos.push({ root, setup: shared.setup ?? null, archive: shared.archive ?? null });
|
|
161
|
+
chained.push(localPath);
|
|
162
|
+
const ignored = await deps.run('git', ['-C', root, 'check-ignore', '-q', join('.conductor', 'settings.local.toml')]);
|
|
163
|
+
if (ignored.code === 1) {
|
|
164
|
+
notes.push(`warning: ${localPath} is not ignored by git. It holds paths for this machine only; do not commit it.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const upgraded = prior !== null && (prior.package_version !== deps.packageVersion || prior.node !== paths.node);
|
|
168
|
+
const agent = await installLaunchAgent({ ...paths, home: deps.home, store: deps.store, uid: deps.uid, restart: upgraded }, deps.run);
|
|
169
|
+
for (const w of writes)
|
|
170
|
+
await writeAtomic(w.path, w.text);
|
|
171
|
+
await writeManagedInstall(deps.store, { version: 1, package_version: deps.packageVersion, ...paths, log: agent.log, repos });
|
|
172
|
+
deps.out(`LaunchAgent: ${agent.plist}${agent.reloaded ? ' (loaded; the daemon was started on these paths)' : ' (unchanged)'}`);
|
|
173
|
+
deps.out(`Daemon log: ${agent.log}`);
|
|
174
|
+
deps.out(`Node: ${paths.node}`);
|
|
175
|
+
deps.out(`Slipstream: ${paths.cli} (${deps.packageVersion})`);
|
|
176
|
+
deps.out(`Personal Conductor scripts: ${personalPath}`);
|
|
177
|
+
for (const path of chained)
|
|
178
|
+
deps.out(`Repo override: ${path}`);
|
|
179
|
+
for (const note of notes)
|
|
180
|
+
deps.out(note);
|
|
181
|
+
deps.out('Conductor runs setup after it creates a workspace but does not wait for it to finish before starting '
|
|
182
|
+
+ 'the agent, so a capture may begin after the agent\'s first edits; Slipstream then records the first state it '
|
|
183
|
+
+ 'sees as the baseline.');
|
|
184
|
+
deps.out('Run `slipstream conductor enroll` to capture workspaces that already exist. '
|
|
185
|
+
+ 'Run this install again after upgrading Node or Slipstream.');
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
export async function runConductorSetup(deps) {
|
|
189
|
+
const dir = deps.env.CONDUCTOR_WORKSPACE_PATH;
|
|
190
|
+
if (!dir) {
|
|
191
|
+
deps.err('slipstream: CONDUCTOR_WORKSPACE_PATH is not set; `slipstream conductor setup` runs as a Conductor setup script.');
|
|
192
|
+
return 2;
|
|
193
|
+
}
|
|
194
|
+
await warnOfDrift(deps);
|
|
195
|
+
try {
|
|
196
|
+
await deps.ensureDaemon();
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
deps.err(`slipstream: ${err.message}`);
|
|
200
|
+
deps.err(`slipstream: ${dir} is NOT recording.`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
return deps.attach(dir);
|
|
204
|
+
}
|
|
205
|
+
async function warnOfDrift(deps) {
|
|
206
|
+
let install;
|
|
207
|
+
try {
|
|
208
|
+
install = await readManagedInstall(deps.store);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
deps.err(`slipstream: warning: ${err.message}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!install)
|
|
215
|
+
return;
|
|
216
|
+
if (install.package_version !== deps.packageVersion) {
|
|
217
|
+
deps.err(`slipstream: warning: the install was recorded by Slipstream ${install.package_version}, but this is `
|
|
218
|
+
+ `${deps.packageVersion}. Run \`slipstream conductor install\` to refresh the daemon and scripts.`);
|
|
219
|
+
}
|
|
220
|
+
const root = deps.env.CONDUCTOR_ROOT_PATH;
|
|
221
|
+
const recorded = root ? install.repos.find((r) => r.root === root) : undefined;
|
|
222
|
+
if (!recorded)
|
|
223
|
+
return;
|
|
224
|
+
let shared;
|
|
225
|
+
try {
|
|
226
|
+
shared = await sharedScripts(recorded.root);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
deps.err(`slipstream: warning: ${err.message}`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
for (const kind of driftOf(recorded, shared)) {
|
|
233
|
+
deps.err(`slipstream: warning: this repo's own ${kind} script changed since \`slipstream conductor install\` chained it; `
|
|
234
|
+
+ `${localSettingsPath(recorded.root)} still runs the old one. Run \`slipstream conductor install\` to update it.`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
export async function runConductorArchive(deps) {
|
|
238
|
+
const dir = deps.env.CONDUCTOR_WORKSPACE_PATH;
|
|
239
|
+
if (!dir) {
|
|
240
|
+
deps.err('slipstream: CONDUCTOR_WORKSPACE_PATH is not set; `slipstream conductor archive` runs as a Conductor archive script.');
|
|
241
|
+
return 2;
|
|
242
|
+
}
|
|
243
|
+
return deps.withdraw(dir);
|
|
244
|
+
}
|
|
245
|
+
export class InventoryError extends Error {
|
|
246
|
+
}
|
|
247
|
+
/** Conductor's own record of its workspaces and repos, opened read-only. Its
|
|
248
|
+
* columns are checked, not assumed: a schema this does not recognize fails. */
|
|
249
|
+
export async function readConductorInventory(dbPath) {
|
|
250
|
+
if (!(await stat(dbPath).catch(() => null))?.isFile())
|
|
251
|
+
throw new InventoryError(`Conductor's database ${dbPath} was not found`);
|
|
252
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
253
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
254
|
+
try {
|
|
255
|
+
const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => String(r.name)));
|
|
256
|
+
const ws = columns('workspaces');
|
|
257
|
+
const repos = columns('repos');
|
|
258
|
+
const missing = [...['state', 'workspace_path'].filter((c) => !ws.has(c)).map((c) => `workspaces.${c}`),
|
|
259
|
+
...(repos.has('root_path') ? [] : ['repos.root_path'])];
|
|
260
|
+
if (missing.length > 0) {
|
|
261
|
+
throw new InventoryError(`Conductor's database ${dbPath} has no ${missing.join(', ')}; this Conductor version is not one `
|
|
262
|
+
+ 'Slipstream can read. Nothing was enrolled.');
|
|
263
|
+
}
|
|
264
|
+
const byActivity = ws.has('last_activity_at');
|
|
265
|
+
const order = byActivity ? 'last_activity_at IS NULL, last_activity_at DESC, rowid' : 'rowid';
|
|
266
|
+
const workspaces = db.prepare(`SELECT workspace_path FROM workspaces WHERE state != 'archived' AND workspace_path IS NOT NULL `
|
|
267
|
+
+ `AND workspace_path != '' ORDER BY ${order}`).all().map((r) => String(r.workspace_path));
|
|
268
|
+
const roots = db.prepare(`SELECT root_path FROM repos WHERE root_path IS NOT NULL AND root_path != ''`).all()
|
|
269
|
+
.map((r) => String(r.root_path));
|
|
270
|
+
return { workspaces, byActivity, repos: roots };
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
db.close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Ask for a watch-only capture of every workspace, in order. A workspace that
|
|
277
|
+
* already has one keeps it. */
|
|
278
|
+
export async function runConductorEnroll(inventory, deps) {
|
|
279
|
+
try {
|
|
280
|
+
await deps.ensureDaemon();
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
deps.err(`slipstream: ${err.message}`);
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
deps.out(`Enrolling ${inventory.workspaces.length} Conductor workspace(s), ${inventory.byActivity
|
|
287
|
+
? 'most recently active first (Conductor\'s last activity time)'
|
|
288
|
+
: 'in Conductor\'s inventory order (it records no activity time)'}.`);
|
|
289
|
+
let recording = 0;
|
|
290
|
+
let starting = 0;
|
|
291
|
+
let failed = 0;
|
|
292
|
+
for (const dir of inventory.workspaces) {
|
|
293
|
+
const res = await deps.control({ verb: 'attach', mode: 'watch-only', worktree: dir });
|
|
294
|
+
if (!res.ok) {
|
|
295
|
+
failed++;
|
|
296
|
+
deps.err(`slipstream: ${dir}: NOT recording (${res.code}: ${res.message})`);
|
|
297
|
+
}
|
|
298
|
+
else if (res.state === 'recording') {
|
|
299
|
+
recording++;
|
|
300
|
+
deps.out(`${dir}: recording`);
|
|
301
|
+
}
|
|
302
|
+
else if (res.state === 'queued' || res.state === 'baselining') {
|
|
303
|
+
starting++;
|
|
304
|
+
deps.out(`${dir}: starting (${String(res.state)})`);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
failed++;
|
|
308
|
+
deps.err(`slipstream: ${dir}: NOT recording (${String(res.reason ?? res.state)}${res.message ? `: ${String(res.message)}` : ''})`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
deps.out(`${recording} recording, ${starting} starting, ${failed} not recording.`);
|
|
312
|
+
return failed > 0 ? 1 : 0;
|
|
313
|
+
}
|
package/dist/control-protocol.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Slipstream daemon control protocol: newline-delimited JSON over an
|
|
3
3
|
* owner-only `node:net` unix socket. This is a PRIVATE CONTROL channel — its
|
|
4
|
-
* verbs (attach / detach / status / begin_task /
|
|
4
|
+
* verbs (attach / detach / unlink / status / begin_task / ask / record_question_exchange /
|
|
5
|
+
* record_question_relay_update / claim_question / answer_question / delete_session / gc) WRITE
|
|
5
6
|
* public events or maintain the store and never read or serve the feed. Readers
|
|
6
7
|
* keep serving the public HTTP/disk view; there is no privileged back channel
|
|
7
8
|
* for reading.
|