@ravefox/space 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +203 -0
- package/NOTICE +44 -0
- package/README.md +49 -2
- package/assets/webhook-sdk/python/bot.py +63 -0
- package/assets/webhook-sdk/python/commonly.py +232 -0
- package/node_modules/@commonlyai/seats/README.md +25 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +44 -0
- package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
- package/package.json +56 -4
- package/skills/commonly/SKILL.md +233 -0
- package/src/commands/agent.js +6334 -0
- package/src/commands/daemon.js +933 -0
- package/src/commands/dev.js +153 -0
- package/src/commands/doctor.js +141 -0
- package/src/commands/inbox.js +330 -0
- package/src/commands/login.js +194 -0
- package/src/commands/mcp.js +56 -0
- package/src/commands/pod.js +120 -0
- package/src/commands/task.js +279 -0
- package/src/commands/verify.js +48 -0
- package/src/index.js +99 -0
- package/src/lib/adapter-login.js +76 -0
- package/src/lib/adapters/broker-bridge-entry.js +76 -0
- package/src/lib/adapters/claude.js +2207 -0
- package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
- package/src/lib/adapters/codex.js +1898 -0
- package/src/lib/adapters/dsh.js +513 -0
- package/src/lib/adapters/goose.js +510 -0
- package/src/lib/adapters/hermes.js +18 -0
- package/src/lib/adapters/index.js +68 -0
- package/src/lib/adapters/opencode.js +1681 -0
- package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
- package/src/lib/adapters/pi-mcp-client.mjs +617 -0
- package/src/lib/adapters/pi-shell-env.mjs +41 -0
- package/src/lib/adapters/pi.js +1025 -0
- package/src/lib/adapters/qwen.js +523 -0
- package/src/lib/adapters/seat-model.js +8 -0
- package/src/lib/adapters/stub.js +1 -0
- package/src/lib/adapters/tested-version.js +106 -0
- package/src/lib/api.js +205 -0
- package/src/lib/claim-outcome.js +128 -0
- package/src/lib/claimed-work-gate.js +73 -0
- package/src/lib/cli-hint.js +33 -0
- package/src/lib/commit-attribution.js +391 -0
- package/src/lib/config.js +155 -0
- package/src/lib/content-refusal.js +94 -0
- package/src/lib/credential-file.js +208 -0
- package/src/lib/daemon-agent-states.js +95 -0
- package/src/lib/daemon-hint.js +18 -0
- package/src/lib/daemon-logs.js +43 -0
- package/src/lib/daemon-service-windows.js +263 -0
- package/src/lib/daemon-service.js +486 -0
- package/src/lib/daemon-state.js +84 -0
- package/src/lib/daemon-store.js +119 -0
- package/src/lib/daemon-supervisor.js +629 -0
- package/src/lib/daemon-watch.js +115 -0
- package/src/lib/declared-mcp-guard.js +284 -0
- package/src/lib/default-environment.js +207 -0
- package/src/lib/device-login.js +126 -0
- package/src/lib/digest-frame.js +151 -0
- package/src/lib/doctor.js +291 -0
- package/src/lib/egress-proxy.js +160 -0
- package/src/lib/enforcement.js +946 -0
- package/src/lib/environment.js +681 -0
- package/src/lib/first-output-watchdog.js +191 -0
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +463 -0
- package/src/lib/git-remote-commonly.mjs +100 -0
- package/src/lib/grant-broker-guard.js +345 -0
- package/src/lib/hooks-config.js +238 -0
- package/src/lib/host-gh-broker.js +152 -0
- package/src/lib/host-gh-shim.mjs +98 -0
- package/src/lib/inbox.js +483 -0
- package/src/lib/instance-proxy.js +76 -0
- package/src/lib/machine-host.js +29 -0
- package/src/lib/mcp-credential-delivery.js +121 -0
- package/src/lib/mcp-first-run.js +50 -0
- package/src/lib/mcp-home.js +644 -0
- package/src/lib/mcp-ready.js +68 -0
- package/src/lib/mcp-server-version.js +123 -0
- package/src/lib/mcp-token.js +36 -0
- package/src/lib/mcp-warm-child.mjs +412 -0
- package/src/lib/memory-bridge.js +191 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/missing-harness.js +31 -0
- package/src/lib/model-unavailable.js +154 -0
- package/src/lib/native-runtime-inventory.js +40 -0
- package/src/lib/outsider-grant-mcp.js +82 -0
- package/src/lib/own-key-launch.js +42 -0
- package/src/lib/pod-catch-up.js +55 -0
- package/src/lib/pod-focus.js +224 -0
- package/src/lib/pod-tail.js +68 -0
- package/src/lib/poll-liveness.js +103 -0
- package/src/lib/poll-retry.js +81 -0
- package/src/lib/poller.js +200 -0
- package/src/lib/provider-proxy.js +42 -0
- package/src/lib/public-seat-mcp-flag.js +36 -0
- package/src/lib/public-tool-error.js +60 -0
- package/src/lib/public-withheld-tools.js +48 -0
- package/src/lib/reply-receipt.js +55 -0
- package/src/lib/resolve-on-path.js +22 -0
- package/src/lib/runtime-inventory.js +56 -0
- package/src/lib/runtime-observation.js +37 -0
- package/src/lib/sandbox/bwrap.js +275 -0
- package/src/lib/sandbox/codex-perimeter.js +16 -0
- package/src/lib/sandbox/host-check.js +268 -0
- package/src/lib/sandbox/mode.js +49 -0
- package/src/lib/sandbox/opencode-loopback.mjs +68 -0
- package/src/lib/sandbox/seatbelt.js +684 -0
- package/src/lib/sandbox/turn-dir.js +56 -0
- package/src/lib/sandbox/windows-unsandboxed.js +183 -0
- package/src/lib/sandbox/workspace-git-config.js +243 -0
- package/src/lib/sandbox/workspace-pin.js +158 -0
- package/src/lib/seat-runtime-controls.js +61 -0
- package/src/lib/seat-stop.js +97 -0
- package/src/lib/server-skills.js +32 -0
- package/src/lib/session-recycle.js +219 -0
- package/src/lib/session-store.js +215 -0
- package/src/lib/skill-prompt.js +74 -0
- package/src/lib/skills-import.js +113 -0
- package/src/lib/spawn-credential.js +402 -0
- package/src/lib/spawn-retry.js +311 -0
- package/src/lib/stable-install.js +176 -0
- package/src/lib/supervised-turn.js +352 -0
- package/src/lib/task.js +235 -0
- package/src/lib/turn-failure-notice.js +177 -0
- package/src/lib/unconfined-seat-guard.js +94 -0
- package/src/lib/update-check.js +192 -0
- package/src/lib/upstream-refusal.js +1 -0
- package/src/lib/webhook-server.js +109 -0
- package/src/lib/windows-command.js +393 -0
- package/src/lib/workspace-git.js +87 -0
- package/src/lib/workspace-trust.js +162 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only trace store on the local filesystem.
|
|
3
|
+
*
|
|
4
|
+
* <root>/runs/<runId>.jsonl the run record (docs/record-v1.md): one
|
|
5
|
+
* canonical, hash-chained entry per line
|
|
6
|
+
* <root>/index.jsonl one line per started run, one per settled run;
|
|
7
|
+
* a store convenience, not part of the format
|
|
8
|
+
*
|
|
9
|
+
* Files are 0600 and directories 0700: a trace holds agent output.
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import {
|
|
15
|
+
appendEntry, hashLine, kindOfType, readRecord, traceTypeOf, validateActor, verifyRecord, BOARD_ACTOR,
|
|
16
|
+
} from '../record/format.js';
|
|
17
|
+
|
|
18
|
+
const RUN_ID = /^[A-Za-z0-9_-]{6,64}$/;
|
|
19
|
+
const readJsonl = (path) => (existsSync(path)
|
|
20
|
+
? readFileSync(path, 'utf8').split('\n').filter(Boolean).flatMap((line) => {
|
|
21
|
+
try { return [JSON.parse(line)]; } catch { return []; }
|
|
22
|
+
})
|
|
23
|
+
: []);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Known credential values never reach a line, even inside agent text. Every
|
|
27
|
+
* registered value is replaced, whatever its length: the eight-character
|
|
28
|
+
* floor in redactKnownCredentials belongs to the refusal-text reader, not to
|
|
29
|
+
* the record. `secrets` is kept longest first, so a value that contains a
|
|
30
|
+
* shorter one is replaced whole rather than in pieces.
|
|
31
|
+
*/
|
|
32
|
+
const scrubText = (text, secrets) => {
|
|
33
|
+
let out = text;
|
|
34
|
+
for (const value of secrets) out = out.split(value).join('[redacted]');
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const scrub = (value, secrets) => {
|
|
39
|
+
if (secrets.length === 0) return value;
|
|
40
|
+
if (typeof value === 'string') return scrubText(value, secrets);
|
|
41
|
+
if (Array.isArray(value)) return value.map((item) => scrub(item, secrets));
|
|
42
|
+
if (value && typeof value === 'object') {
|
|
43
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item, secrets)]));
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Streamed agent text arrives in deltas, so a value can be split across two or
|
|
50
|
+
* more of them and match none on its own. Each stream (kind + actor) is
|
|
51
|
+
* scrubbed over a rolling window instead: the held tail is joined to the next
|
|
52
|
+
* delta, the join is scrubbed, and the longest suffix of the result that
|
|
53
|
+
* could still begin a known value is held back again. What is written is the
|
|
54
|
+
* rest.
|
|
55
|
+
*
|
|
56
|
+
* Why that is enough: an occurrence that started inside written text and ran
|
|
57
|
+
* past its end would make the written part plus the held tail a proper prefix
|
|
58
|
+
* of that value, a longer such suffix than the one held. So no value spans a
|
|
59
|
+
* written boundary, and each written piece is itself scrubbed. The tail is
|
|
60
|
+
* shorter than the longest value, and only as long as an actual prefix match.
|
|
61
|
+
*/
|
|
62
|
+
const STREAMED_KINDS = new Set(['agent.message_chunk', 'agent.thought_chunk']);
|
|
63
|
+
// The held text is written out before these, so it stays in its own attempt
|
|
64
|
+
// and is never lost at settle, whichever path (ok, failure, cancel, kill) got there.
|
|
65
|
+
const FLUSH_BEFORE = new Set(['attempt.started', 'attempt.settled', 'run.settled']);
|
|
66
|
+
|
|
67
|
+
const heldLength = (text, secrets) => {
|
|
68
|
+
const longest = secrets.length ? secrets[0].length : 0;
|
|
69
|
+
for (let start = Math.max(0, text.length - longest + 1); start < text.length; start += 1) {
|
|
70
|
+
const tail = text.slice(start);
|
|
71
|
+
if (secrets.some((value) => value.length > tail.length && value.startsWith(tail))) return text.length - start;
|
|
72
|
+
}
|
|
73
|
+
return 0;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** What readRun has always returned, plus the envelope: `type` is the trace name. */
|
|
77
|
+
const asEvent = (entry) => (entry.kind ? { ...entry, type: traceTypeOf(entry.kind) } : entry);
|
|
78
|
+
|
|
79
|
+
export const createTraceStore = ({ root, now = () => new Date() } = {}) => {
|
|
80
|
+
if (!root) throw new Error('createTraceStore needs a root directory');
|
|
81
|
+
const runsDir = join(root, 'runs');
|
|
82
|
+
mkdirSync(runsDir, { recursive: true, mode: 0o700 });
|
|
83
|
+
const append = (path, value) => appendFileSync(path, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
84
|
+
const indexPath = join(root, 'index.jsonl');
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* startRun({ runId?, adapter?, label?, taskId?, podId?, actor? })
|
|
88
|
+
* → { runId, emit, redact, revokeGrant, expireGrants, openGrants, settled }
|
|
89
|
+
*
|
|
90
|
+
* `taskId` is the board's task id verbatim and needs `podId`, since task
|
|
91
|
+
* ids repeat across pods. `actor` is the run's own party, normally
|
|
92
|
+
* {type: 'agent', id: <user id>, owner: <user id>}.
|
|
93
|
+
*/
|
|
94
|
+
const startRun = (meta = {}) => {
|
|
95
|
+
const runId = meta.runId || randomUUID();
|
|
96
|
+
if (!RUN_ID.test(runId)) throw new Error('runId must be 6-64 characters of [A-Za-z0-9_-]');
|
|
97
|
+
const taskId = meta.taskId || undefined;
|
|
98
|
+
const podId = meta.podId || undefined;
|
|
99
|
+
if (taskId && !podId) throw new Error('a taskId needs a podId: task ids repeat across pods');
|
|
100
|
+
const runActor = meta.actor === undefined ? null : validateActor(meta.actor);
|
|
101
|
+
const path = join(runsDir, `${runId}.jsonl`);
|
|
102
|
+
try {
|
|
103
|
+
writeFileSync(path, '', { mode: 0o600, flag: 'wx' });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code === 'EEXIST') throw new Error(`run ${runId} already exists`);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
let last = null; // the previous line, which the next one hashes
|
|
109
|
+
let seq = 0;
|
|
110
|
+
let settled = false;
|
|
111
|
+
let broken = false;
|
|
112
|
+
const secrets = []; // longest first; see scrubText
|
|
113
|
+
const streams = new Map(); // stream key → { kind, actor, held }
|
|
114
|
+
const grants = new Map(); // open grants: grantId → { parentGrantId, binding, expiresAt }
|
|
115
|
+
const startedAt = now();
|
|
116
|
+
|
|
117
|
+
const writeLine = (kind, data, actor) => {
|
|
118
|
+
const line = appendEntry(last, {
|
|
119
|
+
runId, taskId, podId, ts: now().toISOString(), kind, data: scrub(data, secrets), actor,
|
|
120
|
+
}, { runActor });
|
|
121
|
+
try {
|
|
122
|
+
appendFileSync(path, `${line}\n`, { mode: 0o600 });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// Some bytes may have landed; a chain cannot continue past an unknown tail.
|
|
125
|
+
broken = true;
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
last = line;
|
|
129
|
+
seq += 1;
|
|
130
|
+
const entry = JSON.parse(line);
|
|
131
|
+
if (kind === 'grant.issued') {
|
|
132
|
+
grants.set(entry.data.grantId, {
|
|
133
|
+
parentGrantId: entry.data.parentGrantId ?? null, binding: entry.data.binding || null, expiresAt: entry.data.expiresAt || null,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (kind === 'grant.revoked') for (const id of entry.data.grantIds) grants.delete(id);
|
|
137
|
+
return asEvent(entry);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** A streamed delta's text, less a tail that could begin a known value. */
|
|
141
|
+
const windowed = (kind, data, actor) => {
|
|
142
|
+
if (!STREAMED_KINDS.has(kind) || typeof data?.text !== 'string') return data;
|
|
143
|
+
const key = JSON.stringify([kind, actor ?? null]);
|
|
144
|
+
const joined = scrubText(`${streams.get(key)?.held ?? ''}${data.text}`, secrets);
|
|
145
|
+
const cut = joined.length - heldLength(joined, secrets);
|
|
146
|
+
if (cut < joined.length) streams.set(key, { kind, actor, held: joined.slice(cut) });
|
|
147
|
+
else streams.delete(key);
|
|
148
|
+
return { ...data, text: joined.slice(0, cut) };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const write = (kind, data, actor) => writeLine(kind, windowed(kind, data, actor), actor);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The stream has ended, so each held tail can no longer complete a value.
|
|
155
|
+
* A tail is held only while it is a prefix of a known value, so it is part
|
|
156
|
+
* of a credential: write the marker in its place, never the prefix.
|
|
157
|
+
*/
|
|
158
|
+
const flushStreams = () => {
|
|
159
|
+
for (const [key, { kind, actor }] of streams) {
|
|
160
|
+
streams.delete(key);
|
|
161
|
+
writeLine(kind, { text: '[redacted]' }, actor);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const revoke = (grantIds, reason, actor) => (grantIds.length ? write('grant.revoked', { grantIds, reason }, actor) : null);
|
|
166
|
+
|
|
167
|
+
const emit = (type, data = {}, options = {}) => {
|
|
168
|
+
const kind = kindOfType(type);
|
|
169
|
+
if (!kind) throw new Error(`unknown trace event type: ${type}`);
|
|
170
|
+
if (settled) return null;
|
|
171
|
+
if (broken) throw new Error(`run ${runId}: the record is broken after a failed write`);
|
|
172
|
+
if (FLUSH_BEFORE.has(kind)) flushStreams();
|
|
173
|
+
const event = write(kind, data, options?.actor);
|
|
174
|
+
if (kind === 'task.done') {
|
|
175
|
+
// The board closed the task: every grant bound to it ends, in one write.
|
|
176
|
+
const bound = [...grants].filter(([, g]) => g.binding?.endsOn === 'task.done').map(([id]) => id);
|
|
177
|
+
revoke(bound, 'task.done', BOARD_ACTOR);
|
|
178
|
+
}
|
|
179
|
+
if (kind === 'run.settled') {
|
|
180
|
+
settled = true;
|
|
181
|
+
append(indexPath, {
|
|
182
|
+
runId,
|
|
183
|
+
settledAt: event.ts,
|
|
184
|
+
outcome: event.data.outcome,
|
|
185
|
+
failure: event.data.failure || null,
|
|
186
|
+
durationMs: event.data.durationMs ?? null,
|
|
187
|
+
entries: seq,
|
|
188
|
+
head: hashLine(last),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return event;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Revoke a grant and every descendant this run knows of, in one entry. */
|
|
195
|
+
const revokeGrant = (grantId, { actor } = {}) => {
|
|
196
|
+
if (settled || !grants.has(grantId)) return null;
|
|
197
|
+
const lineage = [grantId];
|
|
198
|
+
for (let i = 0; i < lineage.length; i += 1) {
|
|
199
|
+
for (const [id, g] of grants) if (g.parentGrantId === lineage[i] && !lineage.includes(id)) lineage.push(id);
|
|
200
|
+
}
|
|
201
|
+
return revoke(lineage, lineage.length > 1 ? 'cascade' : 'manual', actor);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Record the end of every open grant whose expiresAt has passed. */
|
|
205
|
+
const expireGrants = (at = now()) => {
|
|
206
|
+
if (settled) return null;
|
|
207
|
+
const cutoff = at.getTime();
|
|
208
|
+
return revoke([...grants].filter(([, g]) => g.expiresAt && Date.parse(g.expiresAt) <= cutoff).map(([id]) => id), 'expired');
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** Every non-empty string is scrubbed from here on, whatever its length. */
|
|
212
|
+
const redact = (values = []) => {
|
|
213
|
+
for (const value of values) if (typeof value === 'string' && value && !secrets.includes(value)) secrets.push(value);
|
|
214
|
+
secrets.sort((a, b) => b.length - a.length);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
append(indexPath, {
|
|
218
|
+
runId,
|
|
219
|
+
startedAt: startedAt.toISOString(),
|
|
220
|
+
adapter: meta.adapter || null,
|
|
221
|
+
label: meta.label || null,
|
|
222
|
+
...(taskId ? { taskId, podId } : podId ? { podId } : {}),
|
|
223
|
+
...(runActor ? { actor: runActor } : {}),
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
runId,
|
|
227
|
+
taskId: taskId ?? null,
|
|
228
|
+
podId: podId ?? null,
|
|
229
|
+
emit,
|
|
230
|
+
redact,
|
|
231
|
+
revokeGrant,
|
|
232
|
+
expireGrants,
|
|
233
|
+
openGrants: () => [...grants.keys()],
|
|
234
|
+
get settled() { return settled; },
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const listRuns = ({ limit = 50 } = {}) => {
|
|
239
|
+
const byId = new Map();
|
|
240
|
+
for (const line of readJsonl(indexPath)) {
|
|
241
|
+
const row = byId.get(line.runId) || { runId: line.runId, outcome: 'running' };
|
|
242
|
+
Object.assign(row, line);
|
|
243
|
+
byId.set(line.runId, row);
|
|
244
|
+
}
|
|
245
|
+
return [...byId.values()].sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt))).slice(0, limit);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const runPath = (runId) => (RUN_ID.test(String(runId)) ? join(runsDir, `${runId}.jsonl`) : null);
|
|
249
|
+
|
|
250
|
+
const readRun = (runId) => {
|
|
251
|
+
const path = runPath(runId);
|
|
252
|
+
return path && existsSync(path) ? readRecord(readFileSync(path, 'utf8')).map(asEvent) : null;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* verifyRun(runId) → { ok, errors: [{seq, reason, detail?}], entries, head, settled } | null
|
|
257
|
+
*
|
|
258
|
+
* The chain check from verifyRecord, plus the last line against the head the
|
|
259
|
+
* index recorded at settle (head_mismatch, count_mismatch). The index sits
|
|
260
|
+
* beside the record, so that check catches accidents, not an editor.
|
|
261
|
+
*/
|
|
262
|
+
const verifyRun = (runId) => {
|
|
263
|
+
const path = runPath(runId);
|
|
264
|
+
if (!path || !existsSync(path)) return null;
|
|
265
|
+
const result = verifyRecord(readFileSync(path, 'utf8'));
|
|
266
|
+
const sealed = readJsonl(indexPath).filter((row) => row.runId === runId && row.head).pop();
|
|
267
|
+
if (sealed) {
|
|
268
|
+
if (sealed.entries !== result.entries) result.errors.push({ seq: sealed.entries, reason: 'count_mismatch', detail: `index ${sealed.entries}, file ${result.entries}` });
|
|
269
|
+
else if (sealed.head !== result.head) result.errors.push({ seq: sealed.entries, reason: 'head_mismatch' });
|
|
270
|
+
result.ok = result.errors.length === 0;
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
return { root, startRun, listRuns, readRun, verifyRun };
|
|
276
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Processes a turn leaves running.
|
|
3
|
+
*
|
|
4
|
+
* A turn can start work that outlives it. A dev server backgrounded from a
|
|
5
|
+
* shell tool (`npm exec vite &`) is orphaned the moment that shell exits and
|
|
6
|
+
* is reparented to launchd, where no group kill or descendant walk from the
|
|
7
|
+
* turn's child can reach it. Six such trees, one to four days old, took the
|
|
8
|
+
* fleet host's swap to 12 GB (SRE, 2026-11-01).
|
|
9
|
+
*
|
|
10
|
+
* So a turn's child is started with a fresh marker in its environment, which
|
|
11
|
+
* every process it starts inherits however it detaches. When the child has
|
|
12
|
+
* closed, the processes still carrying the marker are stopped: SIGTERM, then
|
|
13
|
+
* SIGKILL after a grace for any still alive. That includes a lasting server
|
|
14
|
+
* the turn started on first use, such as tmux, and a seat it restarted. A
|
|
15
|
+
* process started with COMMONLY_KEEP_PROCESS=1 is left running, and so is
|
|
16
|
+
* anything it starts, even one that clears the setting; the turn's child itself
|
|
17
|
+
* never inherits it. A confined seat's turn does not honour the setting: what
|
|
18
|
+
* a confined turn leaves running is stopped whatever its environment says,
|
|
19
|
+
* because a process that outlives the turn would keep running with the
|
|
20
|
+
* turn's grants after the host has moved on to preparing the next one.
|
|
21
|
+
* COMMONLY_TURN_REAP=0 turns the whole check off.
|
|
22
|
+
*
|
|
23
|
+
* Only processes started since the turn began are read, and each one's
|
|
24
|
+
* environment is only compared against the marker and the keep setting. Each
|
|
25
|
+
* pid is re-read just before it is signalled, and is signalled only while its
|
|
26
|
+
* start time is still the one recorded, so a reused pid is left alone.
|
|
27
|
+
*
|
|
28
|
+
* What it does not catch: on macOS `ps -E` cannot read the environment of an
|
|
29
|
+
* Apple platform binary (sh, bash, sleep), so one of those is stopped only as
|
|
30
|
+
* a descendant of a marked process. A process that clears its environment
|
|
31
|
+
* loses the marker. Both are left running. The same unreadable environment
|
|
32
|
+
* hides a keep setting: a kept Apple binary is not seen as kept, so a process
|
|
33
|
+
* it starts is kept only if that process keeps the setting itself.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { randomUUID } from 'node:crypto';
|
|
37
|
+
import { readFileSync } from 'node:fs';
|
|
38
|
+
import { spawnSync } from 'node:child_process';
|
|
39
|
+
import {
|
|
40
|
+
PROCESS_GROUPS_SUPPORTED, readProcesses, readProcessTable, startedMs,
|
|
41
|
+
} from './process-group.js';
|
|
42
|
+
|
|
43
|
+
export const TURN_MARK_ENV = 'COMMONLY_TURN_ID';
|
|
44
|
+
export const KEEP_PROCESS_ENV = 'COMMONLY_KEEP_PROCESS';
|
|
45
|
+
export const TURN_REAP_ENV = 'COMMONLY_TURN_REAP';
|
|
46
|
+
export const TURN_REAP_GRACE_MS = 5_000;
|
|
47
|
+
// How far before the turn a process's recorded start may read and still be
|
|
48
|
+
// looked at. lstart has one-second resolution, and on Linux ps derives it from
|
|
49
|
+
// the boot time, which drifts from the wall clock by a second or more. The
|
|
50
|
+
// window only bounds what is read; the marker decides.
|
|
51
|
+
export const TURN_START_SLACK_MS = 60_000;
|
|
52
|
+
|
|
53
|
+
export const turnReapEnabled = (env = process.env) => PROCESS_GROUPS_SUPPORTED && env?.[TURN_REAP_ENV] !== '0';
|
|
54
|
+
|
|
55
|
+
const PS_ENV = { ...process.env, LC_ALL: 'C' };
|
|
56
|
+
|
|
57
|
+
// pid -> the set of tokens its environment reads as. A pid whose environment
|
|
58
|
+
// could not be read has no entry, or a set without NAME=value pairs.
|
|
59
|
+
const readEnvironmentsDarwin = (pids) => {
|
|
60
|
+
const out = new Map();
|
|
61
|
+
const res = spawnSync('ps', ['-E', '-ww', '-o', 'pid=,command=', '-p', pids.join(',')], {
|
|
62
|
+
encoding: 'utf8', env: PS_ENV, timeout: 5000, maxBuffer: 64 * 1024 * 1024,
|
|
63
|
+
});
|
|
64
|
+
if (res.error) return out;
|
|
65
|
+
for (const line of String(res.stdout || '').split('\n')) {
|
|
66
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
67
|
+
if (match) out.set(Number(match[1]), new Set(match[2].split(/\s+/)));
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const readEnvironmentsLinux = (pids) => {
|
|
73
|
+
const out = new Map();
|
|
74
|
+
for (const pid of pids) {
|
|
75
|
+
try {
|
|
76
|
+
out.set(pid, new Set(readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0')));
|
|
77
|
+
} catch { /* gone, or not readable */ }
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const readEnvironments = (pids) => {
|
|
83
|
+
if (!pids.length || !PROCESS_GROUPS_SUPPORTED) return new Map();
|
|
84
|
+
return process.platform === 'linux' ? readEnvironmentsLinux(pids) : readEnvironmentsDarwin(pids);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const selfAndAncestors = (table, pid) => {
|
|
88
|
+
const byPid = new Map(table.map((row) => [row.pid, row]));
|
|
89
|
+
const out = new Set([pid]);
|
|
90
|
+
for (let row = byPid.get(pid); row && row.ppid > 0 && !out.has(row.ppid); row = byPid.get(row.ppid)) {
|
|
91
|
+
out.add(row.ppid);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The processes still carrying `mark`, as { pid, ppid, started }: started at
|
|
98
|
+
* or after `since` (ms), neither this process nor an ancestor of it, and
|
|
99
|
+
* neither kept nor under a kept one. Each one brings its descendants, except a
|
|
100
|
+
* kept one and everything
|
|
101
|
+
* under it, whose environments may be unreadable. The start-time window only
|
|
102
|
+
* limits what is read; the marker is what decides. With `honorKeep: false`
|
|
103
|
+
* (a confined turn) nothing is kept, and a row whose environment carried the
|
|
104
|
+
* keep setting is returned with `keepIgnored: true`. `io` is a test seam
|
|
105
|
+
* ({ table, envs, self }).
|
|
106
|
+
*/
|
|
107
|
+
export const findTurnProcesses = (mark, since, io = {}, { honorKeep = true } = {}) => {
|
|
108
|
+
const { table = readProcessTable, envs = readEnvironments, self = process.pid } = io;
|
|
109
|
+
if (!mark) return [];
|
|
110
|
+
const rows = table();
|
|
111
|
+
if (!rows) return [];
|
|
112
|
+
const skip = selfAndAncestors(rows, self);
|
|
113
|
+
const floor = Number(since) - TURN_START_SLACK_MS;
|
|
114
|
+
const recent = rows.filter((row) => !skip.has(row.pid) && startedMs(row.started) >= floor);
|
|
115
|
+
const environments = envs(recent.map((row) => row.pid));
|
|
116
|
+
const marked = `${TURN_MARK_ENV}=${mark}`;
|
|
117
|
+
const keep = `${KEEP_PROCESS_ENV}=1`;
|
|
118
|
+
const askedToKeep = (pid) => environments.get(pid)?.has(keep) === true;
|
|
119
|
+
const kept = (pid) => honorKeep && askedToKeep(pid);
|
|
120
|
+
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
|
121
|
+
// A kept process keeps what it starts even when that drops the setting.
|
|
122
|
+
const underKept = (row) => {
|
|
123
|
+
for (let up = byPid.get(row.ppid), seen = new Set(); up && !seen.has(up.pid); up = byPid.get(up.ppid)) {
|
|
124
|
+
if (kept(up.pid)) return true;
|
|
125
|
+
seen.add(up.pid);
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
};
|
|
129
|
+
const found = new Map();
|
|
130
|
+
for (const row of recent) {
|
|
131
|
+
if (environments.get(row.pid)?.has(marked) && !kept(row.pid) && !underKept(row)) found.set(row.pid, row);
|
|
132
|
+
}
|
|
133
|
+
const byParent = new Map();
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
if (row.pid === row.ppid) continue;
|
|
136
|
+
if (!byParent.has(row.ppid)) byParent.set(row.ppid, []);
|
|
137
|
+
byParent.get(row.ppid).push(row);
|
|
138
|
+
}
|
|
139
|
+
for (const stack = [...found.keys()]; stack.length;) {
|
|
140
|
+
for (const child of byParent.get(stack.pop()) || []) {
|
|
141
|
+
if (found.has(child.pid) || skip.has(child.pid) || kept(child.pid)) continue;
|
|
142
|
+
found.set(child.pid, child);
|
|
143
|
+
stack.push(child.pid);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const out = [...found.values()];
|
|
147
|
+
return honorKeep ? out : out.map((row) => (askedToKeep(row.pid) ? { ...row, keepIgnored: true } : row));
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Stop what `findTurnProcesses` returns. SIGTERM is sent before this returns;
|
|
152
|
+
* `done` settles after `graceMs`, once anything still alive has had SIGKILL.
|
|
153
|
+
* A pid is signalled only while its start time is the one recorded, read for
|
|
154
|
+
* all of them in one ps call per signal. The grace timer holds the process
|
|
155
|
+
* open until SIGKILL has gone out, so a seat stopped mid-turn still escalates.
|
|
156
|
+
* `io` adds `read` (pids -> rows) to the find seam.
|
|
157
|
+
*/
|
|
158
|
+
export const reapTurnProcesses = (mark, since, { graceMs = TURN_REAP_GRACE_MS, io = {}, honorKeep = true } = {}) => {
|
|
159
|
+
const read = io.read || readProcesses;
|
|
160
|
+
const signal = (rows, name) => {
|
|
161
|
+
const now = new Map((read(rows.map((row) => row.pid)) || []).map((row) => [row.pid, row]));
|
|
162
|
+
return rows.filter((row) => {
|
|
163
|
+
if (now.get(row.pid)?.started !== row.started) return false;
|
|
164
|
+
try { process.kill(row.pid, name); return true; } catch { return false; }
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
const termed = signal(findTurnProcesses(mark, since, io, { honorKeep }), 'SIGTERM');
|
|
168
|
+
const done = termed.length === 0
|
|
169
|
+
? Promise.resolve({ killed: [] })
|
|
170
|
+
: new Promise((resolve) => {
|
|
171
|
+
setTimeout(() => resolve({ killed: signal(termed, 'SIGKILL').map((row) => row.pid) }), graceMs);
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
termed: termed.map((row) => row.pid),
|
|
175
|
+
keepIgnored: termed.filter((row) => row.keepIgnored).map((row) => row.pid),
|
|
176
|
+
honorKeep,
|
|
177
|
+
done,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One turn's marker. `env` is the child's environment with the marker added;
|
|
183
|
+
* `reap(child)` runs once the child has closed, and returns what
|
|
184
|
+
* `reapTurnProcesses` does, or null when the check is off, the child never had
|
|
185
|
+
* a pid, or the turn was already reaped. `confined` marks a confined seat's
|
|
186
|
+
* turn, whose reap does not honour the keep setting.
|
|
187
|
+
*/
|
|
188
|
+
export const markTurn = (env, {
|
|
189
|
+
enabled = turnReapEnabled(), now = Date.now, graceMs, confined = false,
|
|
190
|
+
} = {}) => {
|
|
191
|
+
if (!enabled) return { env, mark: null, reap: () => null };
|
|
192
|
+
const mark = randomUUID();
|
|
193
|
+
const since = now();
|
|
194
|
+
// A keep setting the seat itself was started with would keep every turn.
|
|
195
|
+
const childEnv = { ...env, [TURN_MARK_ENV]: mark };
|
|
196
|
+
delete childEnv[KEEP_PROCESS_ENV];
|
|
197
|
+
let reaped = false;
|
|
198
|
+
return {
|
|
199
|
+
env: childEnv,
|
|
200
|
+
mark,
|
|
201
|
+
reap: (child, { io } = {}) => {
|
|
202
|
+
if (reaped || !Number.isInteger(child?.pid) || child.pid <= 0) return null;
|
|
203
|
+
reaped = true;
|
|
204
|
+
try {
|
|
205
|
+
return reapTurnProcesses(mark, since, { graceMs, io, honorKeep: !confined });
|
|
206
|
+
} catch { return null; }
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The log line for a reap that stopped something, or null. A confined turn's
|
|
213
|
+
* line offers no keep setting, and says once when one was set and not
|
|
214
|
+
* honoured.
|
|
215
|
+
*/
|
|
216
|
+
export const describeTurnReap = (result) => {
|
|
217
|
+
if (!result?.termed?.length) return null;
|
|
218
|
+
const n = result.termed.length;
|
|
219
|
+
const stopped = `stopped ${n} process${n === 1 ? '' : 'es'} this turn left running (pid ${result.termed.join(', ')})`;
|
|
220
|
+
if (result.honorKeep !== false) {
|
|
221
|
+
return `${stopped}; start one with ${KEEP_PROCESS_ENV}=1 to keep it across turns`;
|
|
222
|
+
}
|
|
223
|
+
if (!result.keepIgnored?.length) return stopped;
|
|
224
|
+
return `${stopped}; ${KEEP_PROCESS_ENV}=1 was set on pid ${result.keepIgnored.join(', ')} `
|
|
225
|
+
+ 'and is not honoured on a confined seat';
|
|
226
|
+
};
|