@platless/axom-cli 0.3.13 → 0.5.9
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/dist/axom-patch/cli/README.md +30 -8
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
- package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
- package/dist/axom-patch/cli/qoder-steering.json +15 -15
- package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
- package/dist/axom-patch/cli/runtime.json +34 -8
- package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +33 -6
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2163 -611
- package/dist/workstation/acp/acpClient.js +60 -497
- package/dist/workstation/acp/adoption.js +10 -396
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +235 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +76 -160
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +20 -3
- package/dist/workstation/acp/permission.js +42 -10
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +1 -1
- package/dist/workstation/acp/registry/catalog.js +154 -32
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +16 -42
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/keeperWorld.js +267 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1324 -474
- package/dist/workstation/channels/agent.js +7 -5
- package/dist/workstation/channels/computer.js +69 -21
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +84 -4
- package/dist/workstation/daemon.js +288 -7
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/{fileOps.js → files/text.js} +117 -38
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +120 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +249 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +75 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +48 -4
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
- package/dist/workstation/qoderAdapter/history.js +1 -3
- package/dist/workstation/qoderAdapter/main.js +4 -2
- package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +1 -1
- package/dist/workstation/qoderAdapter/subwork.js +15 -7
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/client.js +9 -1
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +15 -5
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +37 -1
- package/dist/workstation/update/coordinator.js +3 -0
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/package.json +10 -7
- package/dist/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- package/dist/workstation/acp/sessionScope.js +0 -19
- package/dist/workstation/filePaths.js +0 -174
- package/dist/workstation/fileWorker.js +0 -81
- package/dist/workstation/fileWorkerClient.js +0 -121
- package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- package/dist/workstation/tools/terminal.js +0 -214
|
@@ -0,0 +1,3083 @@
|
|
|
1
|
+
import { createRequire as __axomKeeperCreateRequire } from 'node:module';
|
|
2
|
+
const require = __axomKeeperCreateRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/workstation/keepers/kit/bootstrap.ts
|
|
5
|
+
import { readFileSync, unlinkSync as unlinkSync2 } from "node:fs";
|
|
6
|
+
|
|
7
|
+
// src/workstation/keepers/kit/launchConfig.ts
|
|
8
|
+
var decodeLaunchConfig = (encoded) => JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
|
9
|
+
|
|
10
|
+
// src/workstation/keepers/kit/lock.ts
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { promises as fs } from "node:fs";
|
|
14
|
+
var MAX_MANIFEST_BYTES = 4096;
|
|
15
|
+
function pidAlive(pid) {
|
|
16
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return error.code === "EPERM";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
var readProcCmdline = (pid) => fs.readFile(`/proc/${pid}/cmdline`);
|
|
25
|
+
var PS_TIMEOUT_MS = 2e3;
|
|
26
|
+
function processCommandFromPs(pid) {
|
|
27
|
+
return new Promise((resolve) => execFile("ps", ["-ww", "-p", String(pid), "-o", "command="], { timeout: PS_TIMEOUT_MS }, (error, stdout) => {
|
|
28
|
+
if (!error) {
|
|
29
|
+
resolve(stdout.trim());
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
resolve(error.code === 1 ? "" : null);
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
async function processCommand(pid, deps = {}) {
|
|
36
|
+
if ((deps.platform ?? process.platform) !== "linux") return processCommandFromPs(pid);
|
|
37
|
+
const read = deps.readCmdline ?? readProcCmdline;
|
|
38
|
+
try {
|
|
39
|
+
return (await read(pid)).toString("utf8").split("\0").filter((part) => part.length > 0).join(" ");
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const code = error.code;
|
|
42
|
+
if (code !== "ENOENT" && code !== "ESRCH") return null;
|
|
43
|
+
try {
|
|
44
|
+
await read("self");
|
|
45
|
+
return "";
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function manifestProcessLive(manifest) {
|
|
52
|
+
if (!pidAlive(manifest.pid)) return false;
|
|
53
|
+
const command = await processCommand(manifest.pid);
|
|
54
|
+
return command === null ? true : command.includes(manifest.bundleDir);
|
|
55
|
+
}
|
|
56
|
+
async function writePidManifest(pidFile, manifest) {
|
|
57
|
+
const temp = `${pidFile}.${randomUUID()}.tmp`;
|
|
58
|
+
await fs.writeFile(temp, JSON.stringify(manifest), { mode: 384 });
|
|
59
|
+
await fs.rename(temp, pidFile);
|
|
60
|
+
}
|
|
61
|
+
function validManifest(value) {
|
|
62
|
+
if (typeof value !== "object" || value === null) return false;
|
|
63
|
+
const m = value;
|
|
64
|
+
return (m.kind === "terminal" || m.kind === "acp") && typeof m.gen === "string" && typeof m.buildVersion === "string" && typeof m.bundleDir === "string" && typeof m.identity === "string" && Number.isFinite(m.startedAt) && Number.isSafeInteger(m.pid);
|
|
65
|
+
}
|
|
66
|
+
async function readPidManifest(pidFile) {
|
|
67
|
+
try {
|
|
68
|
+
const stat = await fs.lstat(pidFile);
|
|
69
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_MANIFEST_BYTES) throw new Error("Invalid keeper PID manifest");
|
|
70
|
+
const value = JSON.parse(await fs.readFile(pidFile, "utf8"));
|
|
71
|
+
if (!validManifest(value)) throw new Error("Invalid keeper PID manifest");
|
|
72
|
+
return value;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.code === "ENOENT") return null;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/workstation/keepers/kit/server.ts
|
|
80
|
+
import { randomBytes, randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
|
|
81
|
+
import { promises as fs2, renameSync, unlinkSync } from "node:fs";
|
|
82
|
+
import net from "node:net";
|
|
83
|
+
|
|
84
|
+
// src/workstation/keepers/kit/activity.ts
|
|
85
|
+
var MAX_ACTIVITY_ITEMS = 32;
|
|
86
|
+
var MAX_ACTIVITY_STRING = 256;
|
|
87
|
+
var RESERVED_METRIC_PREFIX = "kit.";
|
|
88
|
+
var DEFAULT_SAMPLE_TIMEOUT_MS = 2e3;
|
|
89
|
+
var validItem = (value) => {
|
|
90
|
+
if (typeof value !== "object" || value === null) return false;
|
|
91
|
+
const item = value;
|
|
92
|
+
return typeof item.type === "string" && typeof item.id === "string" && typeof item.label === "string" && typeof item.state === "string" && typeof item.sinceEpochMs === "number" && Number.isFinite(item.sinceEpochMs);
|
|
93
|
+
};
|
|
94
|
+
var clip = (value) => value.length > MAX_ACTIVITY_STRING ? value.slice(0, MAX_ACTIVITY_STRING) : value;
|
|
95
|
+
var pending = /* @__PURE__ */ new WeakMap();
|
|
96
|
+
async function runSampler(sampler, timeoutMs) {
|
|
97
|
+
let timer;
|
|
98
|
+
let sample = pending.get(sampler);
|
|
99
|
+
if (!sample) {
|
|
100
|
+
const started = Promise.resolve().then(sampler);
|
|
101
|
+
sample = started;
|
|
102
|
+
pending.set(sampler, started);
|
|
103
|
+
const release = () => {
|
|
104
|
+
if (pending.get(sampler) === started) pending.delete(sampler);
|
|
105
|
+
};
|
|
106
|
+
started.then(release, release);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return await Promise.race([
|
|
110
|
+
sample,
|
|
111
|
+
new Promise((_, reject) => {
|
|
112
|
+
timer = setTimeout(() => reject(new Error("activity sampler timed out")), timeoutMs);
|
|
113
|
+
timer.unref();
|
|
114
|
+
})
|
|
115
|
+
]);
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function sampleActivity(samplers, options = {}) {
|
|
121
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SAMPLE_TIMEOUT_MS;
|
|
122
|
+
const settled = await Promise.allSettled(samplers.map((sampler) => runSampler(sampler, timeoutMs)));
|
|
123
|
+
const metrics = {};
|
|
124
|
+
const items = [];
|
|
125
|
+
let errors = 0;
|
|
126
|
+
let omitted = 0;
|
|
127
|
+
let reserved = 0;
|
|
128
|
+
for (const result of settled) {
|
|
129
|
+
if (result.status === "rejected") {
|
|
130
|
+
errors += 1;
|
|
131
|
+
try {
|
|
132
|
+
options.onError?.(result.reason);
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const part = result.value;
|
|
138
|
+
if (typeof part !== "object" || part === null) continue;
|
|
139
|
+
if (typeof part.metrics === "object" && part.metrics !== null) {
|
|
140
|
+
for (const [name, value] of Object.entries(part.metrics)) {
|
|
141
|
+
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
|
142
|
+
if (name.startsWith(RESERVED_METRIC_PREFIX)) {
|
|
143
|
+
reserved += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
metrics[name] = (metrics[name] ?? 0) + value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(part.items)) {
|
|
150
|
+
for (const item of part.items) {
|
|
151
|
+
if (!validItem(item)) continue;
|
|
152
|
+
if (items.length < MAX_ACTIVITY_ITEMS) {
|
|
153
|
+
const kept = { type: clip(item.type), id: clip(item.id), label: clip(item.label), state: clip(item.state), sinceEpochMs: item.sinceEpochMs };
|
|
154
|
+
if (typeof item.jobId === "string" && item.jobId) kept.jobId = clip(item.jobId);
|
|
155
|
+
items.push(kept);
|
|
156
|
+
} else omitted += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (errors > 0) metrics["kit.sampleErrors"] = errors;
|
|
161
|
+
if (omitted > 0) metrics["kit.itemsOmitted"] = omitted;
|
|
162
|
+
if (reserved > 0) metrics["kit.reservedDropped"] = reserved;
|
|
163
|
+
return { metrics, items };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/workstation/keepers/kit/ipc.ts
|
|
167
|
+
var FrameError = class extends Error {
|
|
168
|
+
constructor(message, code) {
|
|
169
|
+
super(message);
|
|
170
|
+
this.code = code;
|
|
171
|
+
this.name = "FrameError";
|
|
172
|
+
}
|
|
173
|
+
code;
|
|
174
|
+
};
|
|
175
|
+
var NEWLINE = 10;
|
|
176
|
+
var LineDecoder = class {
|
|
177
|
+
constructor(maxBytes) {
|
|
178
|
+
this.maxBytes = maxBytes;
|
|
179
|
+
}
|
|
180
|
+
maxBytes;
|
|
181
|
+
chunks = [];
|
|
182
|
+
pending = 0;
|
|
183
|
+
/** Changes the cap for lines not yet completed (used once a peer has authenticated). */
|
|
184
|
+
setMax(maxBytes) {
|
|
185
|
+
this.maxBytes = maxBytes;
|
|
186
|
+
}
|
|
187
|
+
/** Bytes of an incomplete trailing line currently buffered. */
|
|
188
|
+
get buffered() {
|
|
189
|
+
return this.pending;
|
|
190
|
+
}
|
|
191
|
+
push(chunk) {
|
|
192
|
+
const lines = [];
|
|
193
|
+
let start = 0;
|
|
194
|
+
for (; ; ) {
|
|
195
|
+
const newline = chunk.indexOf(NEWLINE, start);
|
|
196
|
+
if (newline < 0) break;
|
|
197
|
+
const tail = chunk.subarray(start, newline);
|
|
198
|
+
const length = this.pending + tail.length;
|
|
199
|
+
if (length > this.maxBytes) throw new FrameError(`Frame exceeds ${this.maxBytes} bytes`, "too_large");
|
|
200
|
+
lines.push(this.chunks.length === 0 ? Buffer.from(tail) : Buffer.concat([...this.chunks, tail], length));
|
|
201
|
+
this.chunks = [];
|
|
202
|
+
this.pending = 0;
|
|
203
|
+
start = newline + 1;
|
|
204
|
+
}
|
|
205
|
+
if (start < chunk.length) {
|
|
206
|
+
const rest = chunk.subarray(start);
|
|
207
|
+
if (this.pending + rest.length > this.maxBytes) throw new FrameError(`Frame exceeds ${this.maxBytes} bytes`, "too_large");
|
|
208
|
+
this.chunks.push(Buffer.from(rest));
|
|
209
|
+
this.pending += rest.length;
|
|
210
|
+
}
|
|
211
|
+
return lines;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
function encodeFrame(frame) {
|
|
215
|
+
const body = Buffer.from(JSON.stringify(frame), "utf8");
|
|
216
|
+
return { data: Buffer.concat([body, Buffer.from("\n")]), length: body.length };
|
|
217
|
+
}
|
|
218
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
219
|
+
var isId = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
220
|
+
var malformed = (what) => {
|
|
221
|
+
throw new FrameError(`Malformed frame: ${what}`, "malformed");
|
|
222
|
+
};
|
|
223
|
+
function parseJsonObject(line) {
|
|
224
|
+
let value;
|
|
225
|
+
try {
|
|
226
|
+
value = JSON.parse(line.toString("utf8"));
|
|
227
|
+
} catch {
|
|
228
|
+
return malformed("not JSON");
|
|
229
|
+
}
|
|
230
|
+
return isRecord(value) ? value : malformed("not an object");
|
|
231
|
+
}
|
|
232
|
+
function parseServerBound(line) {
|
|
233
|
+
const frame = parseJsonObject(line);
|
|
234
|
+
if (frame.t === "req") {
|
|
235
|
+
if (!isId(frame.id)) malformed("req id");
|
|
236
|
+
if (typeof frame.method !== "string" || frame.method.length === 0 || frame.method.length > 128) malformed("req method");
|
|
237
|
+
if (frame.params !== void 0 && !isRecord(frame.params)) malformed("req params");
|
|
238
|
+
return frame;
|
|
239
|
+
}
|
|
240
|
+
if (frame.t === "credit") {
|
|
241
|
+
if (typeof frame.stream !== "string" || frame.stream.length === 0) malformed("credit stream");
|
|
242
|
+
if (!Number.isSafeInteger(frame.n) || frame.n <= 0) malformed("credit n");
|
|
243
|
+
if (frame.bytes !== void 0 && (!Number.isSafeInteger(frame.bytes) || frame.bytes < 0)) malformed("credit bytes");
|
|
244
|
+
return frame;
|
|
245
|
+
}
|
|
246
|
+
return malformed("unexpected frame type");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/workstation/keepers/kit/protocol.ts
|
|
250
|
+
var FRAME_LIMITS = {
|
|
251
|
+
terminal: { in: 128 * 1024, out: 2 * 1024 * 1024 },
|
|
252
|
+
acp: { in: 32 * 1024 * 1024, out: 32 * 1024 * 1024 }
|
|
253
|
+
};
|
|
254
|
+
var UNKNOWN_KIND_LIMITS = { in: 1024 * 1024, out: 1024 * 1024 };
|
|
255
|
+
var frameLimitsOf = (kind) => kind ? FRAME_LIMITS[kind] : UNKNOWN_KIND_LIMITS;
|
|
256
|
+
var MAX_CONNECTIONS = 32;
|
|
257
|
+
var PRE_HELLO_MAX_BYTES = 64 * 1024;
|
|
258
|
+
var MAX_EVT_RECORDS = 256;
|
|
259
|
+
function isProtocolWindow(value) {
|
|
260
|
+
return Array.isArray(value) && value.length === 2 && Number.isSafeInteger(value[0]) && Number.isSafeInteger(value[1]) && value[0] >= 0 && value[0] <= value[1];
|
|
261
|
+
}
|
|
262
|
+
var ErrorCode = {
|
|
263
|
+
badRequest: "bad_request",
|
|
264
|
+
unauthenticated: "unauthenticated",
|
|
265
|
+
badToken: "bad_token",
|
|
266
|
+
unknownMethod: "unknown_method",
|
|
267
|
+
unknownStream: "unknown_stream",
|
|
268
|
+
frameTooLarge: "frame_too_large",
|
|
269
|
+
/** A method that starts new work was called on a keeper that is draining or stopping. */
|
|
270
|
+
draining: "draining",
|
|
271
|
+
internal: "internal"
|
|
272
|
+
};
|
|
273
|
+
var KeeperMethodError = class extends Error {
|
|
274
|
+
constructor(code, message) {
|
|
275
|
+
super(message);
|
|
276
|
+
this.code = code;
|
|
277
|
+
this.name = "KeeperMethodError";
|
|
278
|
+
}
|
|
279
|
+
code;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// src/workstation/keepers/kit/server.ts
|
|
283
|
+
var GENERIC = /* @__PURE__ */ new Set(["keeper.hello", "keeper.ping", "keeper.info", "keeper.activity", "keeper.drain", "keeper.stop", "keeper.subscribe", "keeper.unsubscribe"]);
|
|
284
|
+
var MAX_CREDIT = 1e9;
|
|
285
|
+
var MAX_CREDIT_BYTES = Number.MAX_SAFE_INTEGER;
|
|
286
|
+
var wireError = (code, message) => ({ code, message });
|
|
287
|
+
var Subscription = class {
|
|
288
|
+
constructor(connection, stream, source, from, credit, creditBytes) {
|
|
289
|
+
this.connection = connection;
|
|
290
|
+
this.stream = stream;
|
|
291
|
+
this.source = source;
|
|
292
|
+
this.next = from;
|
|
293
|
+
this.credit = credit;
|
|
294
|
+
this.byteWindowed = creditBytes !== void 0;
|
|
295
|
+
this.byteCredit = creditBytes ?? Number.POSITIVE_INFINITY;
|
|
296
|
+
this.unwatch = source.watch(() => this.kick());
|
|
297
|
+
}
|
|
298
|
+
connection;
|
|
299
|
+
stream;
|
|
300
|
+
source;
|
|
301
|
+
credit;
|
|
302
|
+
/** Bytes the subscriber may still be sent; `Infinity` on a subscription without a byte window. */
|
|
303
|
+
byteCredit;
|
|
304
|
+
byteWindowed;
|
|
305
|
+
next;
|
|
306
|
+
pumping = false;
|
|
307
|
+
dirty = false;
|
|
308
|
+
closed = false;
|
|
309
|
+
started = false;
|
|
310
|
+
unwatch;
|
|
311
|
+
/** Begins pushing. Called after the subscribe reply is written so no `evt` can overtake its `res`. */
|
|
312
|
+
start() {
|
|
313
|
+
this.started = true;
|
|
314
|
+
this.kick();
|
|
315
|
+
}
|
|
316
|
+
grant(n, bytes) {
|
|
317
|
+
this.credit = Math.min(MAX_CREDIT, this.credit + n);
|
|
318
|
+
if (this.byteWindowed && bytes !== void 0) this.byteCredit = Math.min(MAX_CREDIT_BYTES, this.byteCredit + bytes);
|
|
319
|
+
this.kick();
|
|
320
|
+
}
|
|
321
|
+
close() {
|
|
322
|
+
if (this.closed) return;
|
|
323
|
+
this.closed = true;
|
|
324
|
+
this.unwatch();
|
|
325
|
+
}
|
|
326
|
+
kick() {
|
|
327
|
+
this.dirty = true;
|
|
328
|
+
if (this.started && !this.pumping && !this.closed) void this.pump();
|
|
329
|
+
}
|
|
330
|
+
async pump() {
|
|
331
|
+
this.pumping = true;
|
|
332
|
+
try {
|
|
333
|
+
while (this.dirty && !this.closed) {
|
|
334
|
+
this.dirty = false;
|
|
335
|
+
while (!this.closed && this.credit > 0 && this.byteCredit > 0) {
|
|
336
|
+
if (!await this.connection.writable()) return;
|
|
337
|
+
const limit = Math.min(this.credit, MAX_EVT_RECORDS);
|
|
338
|
+
const records = await (this.byteWindowed ? this.source.read(this.next, limit, { maxBytes: this.byteCredit }) : this.source.read(this.next, limit));
|
|
339
|
+
if (this.closed || records.length === 0) break;
|
|
340
|
+
const { batch, bytes } = this.fit(records);
|
|
341
|
+
if (batch.length === 0) {
|
|
342
|
+
this.end("record_too_large");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.next = batch[batch.length - 1].o + 1;
|
|
346
|
+
this.credit -= batch.length;
|
|
347
|
+
if (this.byteWindowed) this.byteCredit -= bytes;
|
|
348
|
+
this.connection.send({ t: "evt", stream: this.stream, records: batch, ...this.byteWindowed ? { bytes } : {} });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
this.end("source_error");
|
|
353
|
+
} finally {
|
|
354
|
+
this.pumping = false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** Longest prefix of `records` whose evt frame stays under the outbound cap, and its size (each record's JSON line). */
|
|
358
|
+
fit(records) {
|
|
359
|
+
const budget = this.connection.limits.out - 256 - this.stream.length * 2;
|
|
360
|
+
let used = 0;
|
|
361
|
+
let count = 0;
|
|
362
|
+
for (const record of records) {
|
|
363
|
+
const size = Buffer.byteLength(JSON.stringify(record)) + 1;
|
|
364
|
+
if (used + size > budget) break;
|
|
365
|
+
used += size;
|
|
366
|
+
count += 1;
|
|
367
|
+
}
|
|
368
|
+
return { batch: records.slice(0, count), bytes: used };
|
|
369
|
+
}
|
|
370
|
+
end(reason) {
|
|
371
|
+
this.close();
|
|
372
|
+
this.connection.removeSubscription(this.stream, this);
|
|
373
|
+
this.connection.send({ t: "evt", stream: this.stream, records: [], closed: reason });
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
var Connection = class {
|
|
377
|
+
constructor(server, socket, unauthenticatedTimeoutMs) {
|
|
378
|
+
this.server = server;
|
|
379
|
+
this.socket = socket;
|
|
380
|
+
this.limits = frameLimitsOf(server.options.kind);
|
|
381
|
+
this.decoder = new LineDecoder(Math.min(PRE_HELLO_MAX_BYTES, this.limits.in));
|
|
382
|
+
socket.on("data", (chunk) => this.onData(chunk));
|
|
383
|
+
socket.on("error", () => this.destroy());
|
|
384
|
+
socket.on("close", () => this.destroy());
|
|
385
|
+
socket.on("drain", () => this.wake());
|
|
386
|
+
this.authTimer = setTimeout(() => {
|
|
387
|
+
if (!this.authenticated) this.destroy();
|
|
388
|
+
}, unauthenticatedTimeoutMs);
|
|
389
|
+
this.authTimer.unref();
|
|
390
|
+
}
|
|
391
|
+
server;
|
|
392
|
+
socket;
|
|
393
|
+
id = randomUUID2();
|
|
394
|
+
limits;
|
|
395
|
+
authenticated = false;
|
|
396
|
+
decoder;
|
|
397
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
398
|
+
dead = false;
|
|
399
|
+
authTimer;
|
|
400
|
+
drainWaiters = [];
|
|
401
|
+
get isDead() {
|
|
402
|
+
return this.dead;
|
|
403
|
+
}
|
|
404
|
+
pendingAnswers = 0;
|
|
405
|
+
/** No `res` frame of this connection is still waiting to leave the socket. Pushed `evt` data does not count: a stalled subscriber must not hold up a stop. */
|
|
406
|
+
get flushed() {
|
|
407
|
+
return this.dead || this.pendingAnswers === 0;
|
|
408
|
+
}
|
|
409
|
+
destroy() {
|
|
410
|
+
if (this.dead) return;
|
|
411
|
+
this.dead = true;
|
|
412
|
+
clearTimeout(this.authTimer);
|
|
413
|
+
for (const sub of this.subscriptions.values()) sub.close();
|
|
414
|
+
this.subscriptions.clear();
|
|
415
|
+
this.socket.destroy();
|
|
416
|
+
this.wake();
|
|
417
|
+
this.server.forget(this);
|
|
418
|
+
}
|
|
419
|
+
wake() {
|
|
420
|
+
const waiters = this.drainWaiters;
|
|
421
|
+
this.drainWaiters = [];
|
|
422
|
+
for (const w of waiters) w();
|
|
423
|
+
}
|
|
424
|
+
/** Resolves true when the socket can take more data, false when the connection died meanwhile. */
|
|
425
|
+
async writable() {
|
|
426
|
+
while (!this.dead && this.socket.writableNeedDrain) await new Promise((resolve) => this.drainWaiters.push(resolve));
|
|
427
|
+
return !this.dead;
|
|
428
|
+
}
|
|
429
|
+
/** Writes one frame. An oversized `res` is replaced by an error answer so the request still completes. */
|
|
430
|
+
send(frame, then) {
|
|
431
|
+
if (this.dead) return;
|
|
432
|
+
let encoded;
|
|
433
|
+
try {
|
|
434
|
+
encoded = encodeFrame(frame);
|
|
435
|
+
} catch {
|
|
436
|
+
encoded = frame.t === "res" ? encodeFrame({ t: "res", id: frame.id, ok: false, error: wireError(ErrorCode.internal, "Result is not serialisable") }) : encodeFrame({ t: "evt", stream: frame.stream, records: [], closed: "source_error" });
|
|
437
|
+
}
|
|
438
|
+
if (encoded.length > this.limits.out) {
|
|
439
|
+
if (frame.t !== "res") return;
|
|
440
|
+
encoded = encodeFrame({ t: "res", id: frame.id, ok: false, error: wireError(ErrorCode.frameTooLarge, `Response exceeds ${this.limits.out} bytes`) });
|
|
441
|
+
}
|
|
442
|
+
if (frame.t === "res") {
|
|
443
|
+
this.pendingAnswers += 1;
|
|
444
|
+
this.socket.write(encoded.data, () => {
|
|
445
|
+
this.pendingAnswers -= 1;
|
|
446
|
+
then?.();
|
|
447
|
+
});
|
|
448
|
+
} else this.socket.write(encoded.data);
|
|
449
|
+
}
|
|
450
|
+
removeSubscription(stream, sub) {
|
|
451
|
+
if (this.subscriptions.get(stream) === sub) this.subscriptions.delete(stream);
|
|
452
|
+
}
|
|
453
|
+
starts = [];
|
|
454
|
+
subscribe(stream, source, from, credit, creditBytes) {
|
|
455
|
+
this.subscriptions.get(stream)?.close();
|
|
456
|
+
const sub = new Subscription(this, stream, source, from, credit, creditBytes);
|
|
457
|
+
this.subscriptions.set(stream, sub);
|
|
458
|
+
this.starts.push(sub);
|
|
459
|
+
}
|
|
460
|
+
discardStarts() {
|
|
461
|
+
const starts = this.starts;
|
|
462
|
+
this.starts = [];
|
|
463
|
+
for (const sub of starts) this.unsubscribeIf(sub);
|
|
464
|
+
}
|
|
465
|
+
unsubscribeIf(sub) {
|
|
466
|
+
if (this.subscriptions.get(sub.stream) === sub) {
|
|
467
|
+
sub.close();
|
|
468
|
+
this.subscriptions.delete(sub.stream);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
startSubscriptions() {
|
|
472
|
+
const starts = this.starts;
|
|
473
|
+
this.starts = [];
|
|
474
|
+
for (const sub of starts) if (this.subscriptions.get(sub.stream) === sub) sub.start();
|
|
475
|
+
}
|
|
476
|
+
unsubscribe(stream) {
|
|
477
|
+
const sub = this.subscriptions.get(stream);
|
|
478
|
+
if (!sub) return false;
|
|
479
|
+
sub.close();
|
|
480
|
+
this.subscriptions.delete(stream);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
onData(chunk) {
|
|
484
|
+
let lines;
|
|
485
|
+
try {
|
|
486
|
+
lines = this.decoder.push(chunk);
|
|
487
|
+
} catch {
|
|
488
|
+
this.protocolViolation();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
for (const line of lines) {
|
|
492
|
+
if (this.dead) return;
|
|
493
|
+
let frame;
|
|
494
|
+
try {
|
|
495
|
+
frame = parseServerBound(line);
|
|
496
|
+
} catch {
|
|
497
|
+
this.protocolViolation();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this.dispatch(frame);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** A malformed or oversized frame ends this connection (and only this one). */
|
|
504
|
+
protocolViolation() {
|
|
505
|
+
this.destroy();
|
|
506
|
+
}
|
|
507
|
+
dispatch(frame) {
|
|
508
|
+
if (frame.t === "credit") {
|
|
509
|
+
if (!this.authenticated) {
|
|
510
|
+
this.destroy();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this.subscriptions.get(frame.stream)?.grant(frame.n, frame.bytes);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const reply = (ok, payload, then) => this.send(ok ? { t: "res", id: frame.id, ok: true, result: payload } : { t: "res", id: frame.id, ok: false, error: payload }, then);
|
|
517
|
+
const fail = (code, message, then) => reply(false, wireError(code, message), then);
|
|
518
|
+
const { method } = frame;
|
|
519
|
+
const params = frame.params ?? {};
|
|
520
|
+
if (method === "keeper.hello") {
|
|
521
|
+
const token = typeof params.token === "string" ? Buffer.from(params.token) : Buffer.alloc(0);
|
|
522
|
+
const expected = Buffer.from(this.server.token);
|
|
523
|
+
if (token.length !== expected.length || !timingSafeEqual(token, expected)) {
|
|
524
|
+
fail(ErrorCode.badToken, "Invalid keeper token", () => this.destroy());
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!isProtocolWindow(params.daemonProtocol)) {
|
|
528
|
+
fail(ErrorCode.badRequest, "daemonProtocol must be [min, max]");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.authenticated = true;
|
|
532
|
+
clearTimeout(this.authTimer);
|
|
533
|
+
this.decoder.setMax(this.limits.in);
|
|
534
|
+
reply(true, this.server.hello());
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!this.authenticated && method !== "keeper.ping") {
|
|
538
|
+
fail(ErrorCode.unauthenticated, "keeper.hello required");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const context = {
|
|
542
|
+
connection: { id: this.id },
|
|
543
|
+
subscribe: (stream, source, from, credit, creditBytes) => this.subscribe(stream, source, from, credit, creditBytes),
|
|
544
|
+
unsubscribe: (stream) => {
|
|
545
|
+
this.unsubscribe(stream);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
void (async () => {
|
|
549
|
+
try {
|
|
550
|
+
const result = GENERIC.has(method) ? await this.server.generic(method, params, context, () => reply(true, {}, () => this.server.stopped(params.mode))) : await this.server.callHandler(method, params, context);
|
|
551
|
+
if (result !== NOT_REPLIED) reply(true, result);
|
|
552
|
+
this.startSubscriptions();
|
|
553
|
+
} catch (error) {
|
|
554
|
+
this.discardStarts();
|
|
555
|
+
if (error instanceof KeeperMethodError) fail(error.code, error.message);
|
|
556
|
+
else fail(ErrorCode.internal, error instanceof Error ? error.message : String(error));
|
|
557
|
+
}
|
|
558
|
+
})();
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
var NOT_REPLIED = /* @__PURE__ */ Symbol("replied");
|
|
562
|
+
var KeeperServer = class {
|
|
563
|
+
options;
|
|
564
|
+
startedAt = Date.now();
|
|
565
|
+
handlers = /* @__PURE__ */ new Map();
|
|
566
|
+
streamResolver;
|
|
567
|
+
connections = /* @__PURE__ */ new Set();
|
|
568
|
+
net = net.createServer((socket) => this.accept(socket));
|
|
569
|
+
tokenValue = "";
|
|
570
|
+
drainingFlag = false;
|
|
571
|
+
startsWork = /* @__PURE__ */ new Set();
|
|
572
|
+
samplers = [];
|
|
573
|
+
inFlightCount = 0;
|
|
574
|
+
stopState;
|
|
575
|
+
constructor(options) {
|
|
576
|
+
this.options = options;
|
|
577
|
+
if (options.activity) this.samplers.push(options.activity);
|
|
578
|
+
this.net.on("error", () => {
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
get token() {
|
|
582
|
+
return this.tokenValue;
|
|
583
|
+
}
|
|
584
|
+
get connectionCount() {
|
|
585
|
+
return this.connections.size;
|
|
586
|
+
}
|
|
587
|
+
/** Connections that passed `keeper.hello` (a daemon, not a stray probe). */
|
|
588
|
+
get authenticatedConnectionCount() {
|
|
589
|
+
let count = 0;
|
|
590
|
+
for (const connection of this.connections) if (connection.authenticated) count += 1;
|
|
591
|
+
return count;
|
|
592
|
+
}
|
|
593
|
+
get draining() {
|
|
594
|
+
return this.drainingFlag;
|
|
595
|
+
}
|
|
596
|
+
/** Domain requests currently running (the `keeper.*` methods are not counted). */
|
|
597
|
+
get inFlight() {
|
|
598
|
+
return this.inFlightCount;
|
|
599
|
+
}
|
|
600
|
+
/** Registers a domain method. The `keeper.*` namespace belongs to the kit. */
|
|
601
|
+
register(method, handler, options = {}) {
|
|
602
|
+
if (method.startsWith("keeper.")) throw new Error(`The keeper.* namespace is reserved: ${method}`);
|
|
603
|
+
if (this.handlers.has(method)) throw new Error(`Method already registered: ${method}`);
|
|
604
|
+
this.handlers.set(method, handler);
|
|
605
|
+
if (options.startsWork) this.startsWork.add(method);
|
|
606
|
+
}
|
|
607
|
+
/** Adds a source for `keeper.activity`. Each sample asks every sampler afresh; nothing is accumulated. */
|
|
608
|
+
addActivitySampler(sampler) {
|
|
609
|
+
this.samplers.push(sampler);
|
|
610
|
+
}
|
|
611
|
+
report(error) {
|
|
612
|
+
try {
|
|
613
|
+
this.options.onError?.(error);
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Stops accepting new work (methods registered with `startsWork`). Everything else keeps working. Idempotent. */
|
|
618
|
+
drain() {
|
|
619
|
+
if (this.drainingFlag) return;
|
|
620
|
+
this.drainingFlag = true;
|
|
621
|
+
try {
|
|
622
|
+
this.options.onDrain?.();
|
|
623
|
+
} catch (error) {
|
|
624
|
+
this.report(error);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** How `keeper.subscribe{stream}` finds its source. */
|
|
628
|
+
setStreamResolver(resolver) {
|
|
629
|
+
this.streamResolver = resolver;
|
|
630
|
+
}
|
|
631
|
+
hello() {
|
|
632
|
+
const o = this.options;
|
|
633
|
+
return { kind: o.kind, protocolVersion: o.protocolVersion, buildVersion: o.buildVersion, gen: o.gen, pid: process.pid, startedAt: this.startedAt, capabilities: [...o.capabilities ?? []] };
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Generates a fresh 32-byte token and listens on the 0600 socket. The token reaches `run/<gen>/token` (0600, atomic rename)
|
|
637
|
+
* only once this process actually owns the socket, before any connection is served: a second start for the same generation
|
|
638
|
+
* that fails with EADDRINUSE must leave the live keeper's token file alone.
|
|
639
|
+
*/
|
|
640
|
+
async listen() {
|
|
641
|
+
const token = randomBytes(32).toString("hex");
|
|
642
|
+
const temp = `${this.options.tokenFile}.${randomUUID2()}.tmp`;
|
|
643
|
+
await fs2.writeFile(temp, token, { mode: 384 });
|
|
644
|
+
try {
|
|
645
|
+
await new Promise((resolve, reject) => {
|
|
646
|
+
const failed = (error) => reject(error);
|
|
647
|
+
this.net.once("error", failed);
|
|
648
|
+
this.net.listen(this.options.socket, () => {
|
|
649
|
+
this.net.off("error", failed);
|
|
650
|
+
try {
|
|
651
|
+
renameSync(temp, this.options.tokenFile);
|
|
652
|
+
this.tokenValue = token;
|
|
653
|
+
resolve();
|
|
654
|
+
} catch (error) {
|
|
655
|
+
this.net.close();
|
|
656
|
+
reject(error);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
} catch (error) {
|
|
661
|
+
try {
|
|
662
|
+
unlinkSync(temp);
|
|
663
|
+
} catch {
|
|
664
|
+
}
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
await fs2.chmod(this.options.socket, 384);
|
|
668
|
+
}
|
|
669
|
+
async close() {
|
|
670
|
+
for (const connection of [...this.connections]) connection.destroy();
|
|
671
|
+
if (!this.net.listening) return;
|
|
672
|
+
await new Promise((resolve) => this.net.close(() => resolve()));
|
|
673
|
+
}
|
|
674
|
+
forget(connection) {
|
|
675
|
+
this.connections.delete(connection);
|
|
676
|
+
}
|
|
677
|
+
accept(socket) {
|
|
678
|
+
if (this.connections.size >= (this.options.maxConnections ?? MAX_CONNECTIONS)) {
|
|
679
|
+
socket.destroy();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
this.connections.add(new Connection(this, socket, this.options.unauthenticatedTimeoutMs ?? 1e4));
|
|
683
|
+
}
|
|
684
|
+
async callHandler(method, params, context) {
|
|
685
|
+
const handler = this.handlers.get(method);
|
|
686
|
+
if (!handler) throw new KeeperMethodError(ErrorCode.unknownMethod, `Unknown method: ${method}`);
|
|
687
|
+
if (this.drainingFlag && this.startsWork.has(method)) throw new KeeperMethodError(ErrorCode.draining, `Keeper is draining; ${method} starts new work`);
|
|
688
|
+
this.inFlightCount += 1;
|
|
689
|
+
try {
|
|
690
|
+
return await handler(params, context);
|
|
691
|
+
} finally {
|
|
692
|
+
this.inFlightCount -= 1;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* `keeper.stop` was accepted (before its reply is written): from now on `info.stopping` is set and new work is refused (both modes:
|
|
697
|
+
* a force stop's onStop may still be tearing things down, and no `startsWork` call may slip in meanwhile).
|
|
698
|
+
* A force request upgrades a pending graceful one.
|
|
699
|
+
*/
|
|
700
|
+
beginStop(mode) {
|
|
701
|
+
if (!this.stopState) this.stopState = { mode };
|
|
702
|
+
else if (mode === "force") this.stopState.mode = "force";
|
|
703
|
+
this.drain();
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* The stop reply has been written. Graceful: wait until no request is in flight and every answer has left its socket, bounded by
|
|
707
|
+
* `stopGraceMs`, then stop (also when the grace ran out). Force: stop now, even over a pending graceful stop. The stop happens once.
|
|
708
|
+
*/
|
|
709
|
+
stopped(mode) {
|
|
710
|
+
this.beginStop(mode);
|
|
711
|
+
const state = this.stopState;
|
|
712
|
+
if (state.finish) {
|
|
713
|
+
if (state.mode === "force") state.finish();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
let done = false;
|
|
717
|
+
let timer;
|
|
718
|
+
let poll;
|
|
719
|
+
const finish = () => {
|
|
720
|
+
if (done) return;
|
|
721
|
+
done = true;
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
clearTimeout(poll);
|
|
724
|
+
try {
|
|
725
|
+
if (this.options.onStop) this.options.onStop(state.mode);
|
|
726
|
+
else {
|
|
727
|
+
for (const connection of [...this.connections]) connection.destroy();
|
|
728
|
+
if (this.net.listening) this.net.close();
|
|
729
|
+
this.options.exit?.(0);
|
|
730
|
+
}
|
|
731
|
+
} catch (error) {
|
|
732
|
+
this.report(error);
|
|
733
|
+
this.stopState = void 0;
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
state.finish = finish;
|
|
737
|
+
if (state.mode === "force") {
|
|
738
|
+
finish();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
timer = setTimeout(finish, this.options.stopGraceMs ?? 3e3);
|
|
742
|
+
timer.unref();
|
|
743
|
+
const check = () => {
|
|
744
|
+
if (done) return;
|
|
745
|
+
if (this.inFlightCount === 0 && [...this.connections].every((c) => c.flushed)) {
|
|
746
|
+
finish();
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
poll = setTimeout(check, 10);
|
|
750
|
+
};
|
|
751
|
+
check();
|
|
752
|
+
}
|
|
753
|
+
async generic(method, params, context, replyStop) {
|
|
754
|
+
const o = this.options;
|
|
755
|
+
switch (method) {
|
|
756
|
+
case "keeper.ping":
|
|
757
|
+
return { pid: process.pid, identity: o.identity, gen: o.gen, kind: o.kind, buildVersion: o.buildVersion, protocolVersion: o.protocolVersion };
|
|
758
|
+
case "keeper.info":
|
|
759
|
+
return { ...this.hello(), identity: o.identity, draining: this.drainingFlag, stopping: this.stopState?.mode ?? null, inFlight: this.inFlightCount, connections: this.connections.size, uptimeMs: Date.now() - this.startedAt };
|
|
760
|
+
case "keeper.activity":
|
|
761
|
+
return sampleActivity(this.samplers, { timeoutMs: o.activityTimeoutMs, onError: (error) => this.report(error) });
|
|
762
|
+
case "keeper.drain":
|
|
763
|
+
this.drain();
|
|
764
|
+
return { draining: true };
|
|
765
|
+
case "keeper.stop": {
|
|
766
|
+
if (params.mode !== "graceful" && params.mode !== "force") throw new KeeperMethodError(ErrorCode.badRequest, "mode must be graceful or force");
|
|
767
|
+
this.beginStop(params.mode);
|
|
768
|
+
replyStop();
|
|
769
|
+
return NOT_REPLIED;
|
|
770
|
+
}
|
|
771
|
+
case "keeper.subscribe": {
|
|
772
|
+
const { stream, creditBytes } = params;
|
|
773
|
+
const from = params.from ?? 0;
|
|
774
|
+
const credit = params.credit ?? 0;
|
|
775
|
+
if (typeof stream !== "string" || !stream || !Number.isSafeInteger(from) || from < 0 || !Number.isSafeInteger(credit) || credit < 0)
|
|
776
|
+
throw new KeeperMethodError(ErrorCode.badRequest, "subscribe needs stream, from >= 0 and credit >= 0");
|
|
777
|
+
if (creditBytes !== void 0 && (!Number.isSafeInteger(creditBytes) || creditBytes <= 0))
|
|
778
|
+
throw new KeeperMethodError(ErrorCode.badRequest, "creditBytes must be a positive integer");
|
|
779
|
+
const source = await this.streamResolver?.(stream);
|
|
780
|
+
if (!source) throw new KeeperMethodError(ErrorCode.unknownStream, `Unknown stream: ${stream}`);
|
|
781
|
+
context.subscribe(stream, source, from, Math.min(credit, MAX_CREDIT), creditBytes);
|
|
782
|
+
return { stream, from };
|
|
783
|
+
}
|
|
784
|
+
case "keeper.unsubscribe": {
|
|
785
|
+
if (typeof params.stream !== "string") throw new KeeperMethodError(ErrorCode.badRequest, "unsubscribe needs stream");
|
|
786
|
+
context.unsubscribe(params.stream);
|
|
787
|
+
return {};
|
|
788
|
+
}
|
|
789
|
+
default:
|
|
790
|
+
throw new KeeperMethodError(ErrorCode.unknownMethod, `Unknown method: ${method}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// src/workstation/keepers/kit/bootstrap.ts
|
|
796
|
+
var STRING_FIELDS = ["identity", "gen", "unit", "buildVersion", "bundleDir", "runDir", "dataDir", "socket", "pidFile", "tokenFile"];
|
|
797
|
+
function readLaunchConfig(argv, kind) {
|
|
798
|
+
const encoded = argv[2];
|
|
799
|
+
if (!encoded) throw new Error("Missing keeper launch config argument");
|
|
800
|
+
let config;
|
|
801
|
+
try {
|
|
802
|
+
config = decodeLaunchConfig(encoded);
|
|
803
|
+
} catch {
|
|
804
|
+
throw new Error("Unreadable keeper launch config");
|
|
805
|
+
}
|
|
806
|
+
if (typeof config !== "object" || config === null) throw new Error("Unreadable keeper launch config");
|
|
807
|
+
if (config.kind !== kind) throw new Error(`Launch config is for keeper kind ${String(config.kind)}, not ${kind}`);
|
|
808
|
+
for (const field of STRING_FIELDS) if (typeof config[field] !== "string" || !config[field]) throw new Error(`Launch config lacks ${field}`);
|
|
809
|
+
if (!Number.isInteger(config.umask) || config.umask < 0 || config.umask > 511) throw new Error("Launch config lacks umask");
|
|
810
|
+
return config;
|
|
811
|
+
}
|
|
812
|
+
var defaultLog = (kind) => (line) => {
|
|
813
|
+
try {
|
|
814
|
+
process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} keeper-${kind}: ${line}
|
|
815
|
+
`);
|
|
816
|
+
} catch {
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
var unlinkIf = (file, owned) => {
|
|
820
|
+
try {
|
|
821
|
+
if (owned()) unlinkSync2(file);
|
|
822
|
+
} catch {
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
async function bootstrapKeeper(options) {
|
|
826
|
+
const log = options.log ?? defaultLog(options.kind);
|
|
827
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
828
|
+
const report = (error) => log(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
829
|
+
const hooks = options.processHooks !== false;
|
|
830
|
+
const config = readLaunchConfig(options.argv ?? process.argv, options.kind);
|
|
831
|
+
const ownsManifest = () => {
|
|
832
|
+
try {
|
|
833
|
+
return JSON.parse(readFileSync(config.pidFile, "utf8")).pid === process.pid;
|
|
834
|
+
} catch {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
if (hooks) {
|
|
839
|
+
process.umask(config.umask);
|
|
840
|
+
try {
|
|
841
|
+
process.chdir(config.runDir);
|
|
842
|
+
} catch (error) {
|
|
843
|
+
report(error);
|
|
844
|
+
}
|
|
845
|
+
process.on("uncaughtException", (error) => report(error));
|
|
846
|
+
process.on("unhandledRejection", (error) => report(error));
|
|
847
|
+
process.on("SIGHUP", () => {
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
const previous = await readPidManifest(config.pidFile).catch(() => null);
|
|
851
|
+
if (previous && previous.pid !== process.pid && previous.identity === config.identity && previous.gen === config.gen && await manifestProcessLive(previous))
|
|
852
|
+
throw new Error(`Keeper ${options.kind} ${config.gen} already runs as pid ${previous.pid}`);
|
|
853
|
+
await writePidManifest(config.pidFile, {
|
|
854
|
+
kind: config.kind,
|
|
855
|
+
gen: config.gen,
|
|
856
|
+
buildVersion: config.buildVersion,
|
|
857
|
+
bundleDir: config.bundleDir,
|
|
858
|
+
startedAt: Date.now(),
|
|
859
|
+
identity: config.identity,
|
|
860
|
+
pid: process.pid
|
|
861
|
+
});
|
|
862
|
+
let finishStop;
|
|
863
|
+
const stopped = new Promise((resolve) => {
|
|
864
|
+
finishStop = resolve;
|
|
865
|
+
});
|
|
866
|
+
let module;
|
|
867
|
+
let server;
|
|
868
|
+
let token = "";
|
|
869
|
+
let stopping = false;
|
|
870
|
+
const onStop = (mode) => {
|
|
871
|
+
if (stopping) return;
|
|
872
|
+
stopping = true;
|
|
873
|
+
void (async () => {
|
|
874
|
+
try {
|
|
875
|
+
if (module?.shutdown) {
|
|
876
|
+
let timer;
|
|
877
|
+
await Promise.race([
|
|
878
|
+
Promise.resolve().then(() => module.shutdown(mode)),
|
|
879
|
+
new Promise((resolve) => {
|
|
880
|
+
timer = setTimeout(resolve, options.shutdownTimeoutMs ?? 5e3);
|
|
881
|
+
timer.unref();
|
|
882
|
+
})
|
|
883
|
+
]).catch(report).finally(() => clearTimeout(timer));
|
|
884
|
+
}
|
|
885
|
+
} finally {
|
|
886
|
+
unlinkIf(config.pidFile, ownsManifest);
|
|
887
|
+
unlinkIf(config.tokenFile, () => token !== "" && readFileSync(config.tokenFile, "utf8") === token);
|
|
888
|
+
await server.close().catch(report);
|
|
889
|
+
finishStop();
|
|
890
|
+
exit(0);
|
|
891
|
+
}
|
|
892
|
+
})();
|
|
893
|
+
};
|
|
894
|
+
try {
|
|
895
|
+
module = await options.create(config, { log });
|
|
896
|
+
server = new KeeperServer({
|
|
897
|
+
kind: options.kind,
|
|
898
|
+
identity: config.identity,
|
|
899
|
+
gen: config.gen,
|
|
900
|
+
buildVersion: config.buildVersion,
|
|
901
|
+
protocolVersion: options.protocolVersion,
|
|
902
|
+
socket: config.socket,
|
|
903
|
+
tokenFile: config.tokenFile,
|
|
904
|
+
capabilities: module.capabilities ?? [],
|
|
905
|
+
onError: report,
|
|
906
|
+
onStop,
|
|
907
|
+
...options.server
|
|
908
|
+
});
|
|
909
|
+
module.install(server);
|
|
910
|
+
const umask = hooks ? process.umask(63) : void 0;
|
|
911
|
+
try {
|
|
912
|
+
await server.listen();
|
|
913
|
+
} finally {
|
|
914
|
+
if (umask !== void 0) process.umask(umask);
|
|
915
|
+
}
|
|
916
|
+
token = server.token;
|
|
917
|
+
} catch (error) {
|
|
918
|
+
unlinkIf(config.pidFile, ownsManifest);
|
|
919
|
+
try {
|
|
920
|
+
await module?.shutdown?.("force");
|
|
921
|
+
} catch (shutdownError) {
|
|
922
|
+
report(shutdownError);
|
|
923
|
+
}
|
|
924
|
+
throw error;
|
|
925
|
+
}
|
|
926
|
+
const stop = (mode) => server.stopped(mode);
|
|
927
|
+
if (hooks) {
|
|
928
|
+
process.on("SIGTERM", () => stop("graceful"));
|
|
929
|
+
process.on("SIGINT", () => stop("graceful"));
|
|
930
|
+
}
|
|
931
|
+
log(`listening (pid ${process.pid}, build ${config.buildVersion.slice(0, 12)}, protocol ${options.protocolVersion})`);
|
|
932
|
+
return { config, server, stop, stopped };
|
|
933
|
+
}
|
|
934
|
+
async function runKeeperMain(options) {
|
|
935
|
+
try {
|
|
936
|
+
return await bootstrapKeeper(options);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
(options.log ?? defaultLog(options.kind))(`start failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
939
|
+
(options.exit ?? ((code) => process.exit(code)))(1);
|
|
940
|
+
return void 0;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/workstation/keepers/acp/host.ts
|
|
945
|
+
import { createHash } from "node:crypto";
|
|
946
|
+
import { closeSync as closeSync4, existsSync, fsyncSync as fsyncSync2, mkdirSync as mkdirSync2, openSync as openSync4, renameSync as renameSync4, rmSync as rmSync2, statfs, writeSync as writeSync3 } from "node:fs";
|
|
947
|
+
import path4 from "node:path";
|
|
948
|
+
|
|
949
|
+
// src/workstation/keepers/kit/streamLog.ts
|
|
950
|
+
import {
|
|
951
|
+
chmodSync,
|
|
952
|
+
closeSync,
|
|
953
|
+
fstatSync,
|
|
954
|
+
fsync,
|
|
955
|
+
fsyncSync,
|
|
956
|
+
ftruncateSync,
|
|
957
|
+
mkdirSync,
|
|
958
|
+
openSync,
|
|
959
|
+
readFileSync as readFileSync2,
|
|
960
|
+
readSync,
|
|
961
|
+
readdirSync,
|
|
962
|
+
renameSync as renameSync2,
|
|
963
|
+
unlinkSync as unlinkSync3,
|
|
964
|
+
writeSync
|
|
965
|
+
} from "node:fs";
|
|
966
|
+
import path from "node:path";
|
|
967
|
+
var SEGMENT_BYTES = 8 * 1024 * 1024;
|
|
968
|
+
var SOFT_QUOTA_BYTES = 256 * 1024 * 1024;
|
|
969
|
+
var FSYNC_INTERVAL_MS = 100;
|
|
970
|
+
var FSYNC_BYTES = 64 * 1024;
|
|
971
|
+
var IMMEDIATE_KINDS = ["spawn", "exit", "checkpoint", "commit"];
|
|
972
|
+
var INDEX_STRIDE = 64;
|
|
973
|
+
var CHUNK_BYTES = 1024 * 1024;
|
|
974
|
+
var SUBSCRIBE_BATCH = 256;
|
|
975
|
+
var SEGMENT_NAME = /^seg-(\d+)\.log$/;
|
|
976
|
+
var CHECKPOINT_NAME = /^checkpoint-(\d+)\.json$/;
|
|
977
|
+
var META_FILE = "meta.json";
|
|
978
|
+
var KEPT_CHECKPOINTS = 2;
|
|
979
|
+
var StreamCorruptError = class extends Error {
|
|
980
|
+
constructor(message) {
|
|
981
|
+
super(message);
|
|
982
|
+
this.name = "StreamCorruptError";
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
var openStreams = /* @__PURE__ */ new Set();
|
|
986
|
+
function fsyncDirectory(dir) {
|
|
987
|
+
let fd;
|
|
988
|
+
try {
|
|
989
|
+
fd = openSync(dir, "r");
|
|
990
|
+
fsyncSync(fd);
|
|
991
|
+
} catch {
|
|
992
|
+
} finally {
|
|
993
|
+
if (fd !== void 0) try {
|
|
994
|
+
closeSync(fd);
|
|
995
|
+
} catch {
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
function writeAll(fd, data) {
|
|
1000
|
+
let written = 0;
|
|
1001
|
+
while (written < data.length) written += writeSync(fd, data, written, data.length - written);
|
|
1002
|
+
}
|
|
1003
|
+
function atomicWrite(file, content) {
|
|
1004
|
+
const temp = `${file}.tmp`;
|
|
1005
|
+
const fd = openSync(temp, "w", 384);
|
|
1006
|
+
try {
|
|
1007
|
+
writeAll(fd, Buffer.from(content));
|
|
1008
|
+
fsyncSync(fd);
|
|
1009
|
+
} finally {
|
|
1010
|
+
closeSync(fd);
|
|
1011
|
+
}
|
|
1012
|
+
renameSync2(temp, file);
|
|
1013
|
+
fsyncDirectory(path.dirname(file));
|
|
1014
|
+
}
|
|
1015
|
+
function scanLines(fd, start, end, onLine) {
|
|
1016
|
+
const chunk = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, Math.max(1, end - start)));
|
|
1017
|
+
let pos = start;
|
|
1018
|
+
let lineStart = start;
|
|
1019
|
+
let carry = [];
|
|
1020
|
+
while (pos < end) {
|
|
1021
|
+
const n = readSync(fd, chunk, 0, Math.min(chunk.length, end - pos), pos);
|
|
1022
|
+
if (n <= 0) break;
|
|
1023
|
+
const view = chunk.subarray(0, n);
|
|
1024
|
+
let from = 0;
|
|
1025
|
+
for (; ; ) {
|
|
1026
|
+
const nl = view.indexOf(10, from);
|
|
1027
|
+
if (nl < 0) break;
|
|
1028
|
+
let line;
|
|
1029
|
+
if (carry.length > 0) {
|
|
1030
|
+
carry.push(chunk.subarray(from, nl));
|
|
1031
|
+
line = Buffer.concat(carry);
|
|
1032
|
+
carry = [];
|
|
1033
|
+
} else line = chunk.subarray(from, nl);
|
|
1034
|
+
if (onLine(line, lineStart) === false) return lineStart;
|
|
1035
|
+
lineStart = pos + nl + 1;
|
|
1036
|
+
from = nl + 1;
|
|
1037
|
+
}
|
|
1038
|
+
if (from < n) carry.push(Buffer.from(chunk.subarray(from, n)));
|
|
1039
|
+
pos += n;
|
|
1040
|
+
}
|
|
1041
|
+
return lineStart;
|
|
1042
|
+
}
|
|
1043
|
+
function parseRecord(line) {
|
|
1044
|
+
try {
|
|
1045
|
+
const value = JSON.parse(line.toString("utf8"));
|
|
1046
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1047
|
+
const r = value;
|
|
1048
|
+
if (Number.isSafeInteger(r.o) && typeof r.k === "string" && typeof r.t === "number") return r;
|
|
1049
|
+
}
|
|
1050
|
+
} catch {
|
|
1051
|
+
}
|
|
1052
|
+
return void 0;
|
|
1053
|
+
}
|
|
1054
|
+
var StreamLog = class _StreamLog {
|
|
1055
|
+
/** Bytes dropped from the newest segment's end when the log was opened (a torn tail); 0 for a clean open. */
|
|
1056
|
+
repairedBytes;
|
|
1057
|
+
dir;
|
|
1058
|
+
segmentBytes;
|
|
1059
|
+
softQuota;
|
|
1060
|
+
fsyncIntervalMs;
|
|
1061
|
+
fsyncBytes;
|
|
1062
|
+
immediate;
|
|
1063
|
+
now;
|
|
1064
|
+
syncFile;
|
|
1065
|
+
onError;
|
|
1066
|
+
segments = [];
|
|
1067
|
+
active;
|
|
1068
|
+
nextOffset = 0;
|
|
1069
|
+
committedOffset = -1;
|
|
1070
|
+
tail;
|
|
1071
|
+
checkpointSeq = 0;
|
|
1072
|
+
checkpointOffset = -1;
|
|
1073
|
+
totalBytes = 0;
|
|
1074
|
+
/** Bytes ever appended / bytes known durable (by a completed fsync). The difference is what a crash could still lose. */
|
|
1075
|
+
appendedBytes = 0;
|
|
1076
|
+
syncedBytes = 0;
|
|
1077
|
+
syncTimer;
|
|
1078
|
+
syncCount = 0;
|
|
1079
|
+
syncErrors = 0;
|
|
1080
|
+
lastSyncError;
|
|
1081
|
+
/** The one background fsync (batched appends never fsync on the event loop); `again` = more bytes arrived meanwhile. */
|
|
1082
|
+
inflight;
|
|
1083
|
+
closed = false;
|
|
1084
|
+
notifyPending = false;
|
|
1085
|
+
watchers = /* @__PURE__ */ new Set();
|
|
1086
|
+
static open(options) {
|
|
1087
|
+
return new _StreamLog(options);
|
|
1088
|
+
}
|
|
1089
|
+
constructor(options) {
|
|
1090
|
+
this.dir = path.resolve(options.dir);
|
|
1091
|
+
this.segmentBytes = options.segmentBytes ?? SEGMENT_BYTES;
|
|
1092
|
+
this.softQuota = options.softQuotaBytes ?? SOFT_QUOTA_BYTES;
|
|
1093
|
+
this.fsyncIntervalMs = options.fsyncIntervalMs ?? FSYNC_INTERVAL_MS;
|
|
1094
|
+
this.fsyncBytes = options.fsyncBytes ?? FSYNC_BYTES;
|
|
1095
|
+
this.immediate = new Set(options.immediateKinds ?? IMMEDIATE_KINDS);
|
|
1096
|
+
this.now = options.now ?? Date.now;
|
|
1097
|
+
this.onError = options.onError;
|
|
1098
|
+
this.syncFile = options.syncFile ?? fsyncSync;
|
|
1099
|
+
if (options.baseOffset !== void 0 && (!Number.isSafeInteger(options.baseOffset) || options.baseOffset < 0))
|
|
1100
|
+
throw new RangeError(`Invalid baseOffset ${options.baseOffset}`);
|
|
1101
|
+
if (openStreams.has(this.dir)) throw new Error(`Stream log already open in this process: ${this.dir}`);
|
|
1102
|
+
mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
1103
|
+
chmodSync(this.dir, 448);
|
|
1104
|
+
openStreams.add(this.dir);
|
|
1105
|
+
let repaired = 0;
|
|
1106
|
+
try {
|
|
1107
|
+
repaired = this.recover(options.baseOffset);
|
|
1108
|
+
if (repaired > 0) this.append({ k: "discontinuity", reason: "torn-tail", droppedBytes: repaired }, { sync: true });
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
this.closed = true;
|
|
1111
|
+
this.closeActive();
|
|
1112
|
+
if (this.syncTimer) clearTimeout(this.syncTimer);
|
|
1113
|
+
openStreams.delete(this.dir);
|
|
1114
|
+
throw error;
|
|
1115
|
+
}
|
|
1116
|
+
this.repairedBytes = repaired;
|
|
1117
|
+
}
|
|
1118
|
+
// ---- recovery ----------------------------------------------------------------------------------------------
|
|
1119
|
+
recover(baseOffset) {
|
|
1120
|
+
const names = readdirSync(this.dir);
|
|
1121
|
+
if (baseOffset !== void 0 && baseOffset > 0 && !names.some((n) => SEGMENT_NAME.test(n) || n === META_FILE)) {
|
|
1122
|
+
atomicWrite(path.join(this.dir, META_FILE), JSON.stringify({ v: 1, committed: baseOffset - 1, t: this.now() }));
|
|
1123
|
+
names.push(META_FILE);
|
|
1124
|
+
}
|
|
1125
|
+
for (const name of names) if (name.endsWith(".tmp")) try {
|
|
1126
|
+
unlinkSync3(path.join(this.dir, name));
|
|
1127
|
+
} catch {
|
|
1128
|
+
}
|
|
1129
|
+
const firsts = names.map((n) => SEGMENT_NAME.exec(n)).filter((m) => !!m).map((m) => Number(m[1])).sort((a, b) => a - b);
|
|
1130
|
+
for (let i = 0; i < firsts.length; i++) {
|
|
1131
|
+
const file = path.join(this.dir, `seg-${firsts[i]}.log`);
|
|
1132
|
+
const bytes = fstatSizeOf(file);
|
|
1133
|
+
const count = i + 1 < firsts.length ? firsts[i + 1] - firsts[i] : 0;
|
|
1134
|
+
this.segments.push({ first: firsts[i], file, bytes, count, idx: [], indexed: false });
|
|
1135
|
+
}
|
|
1136
|
+
let meta = {};
|
|
1137
|
+
try {
|
|
1138
|
+
meta = JSON.parse(readFileSync2(path.join(this.dir, META_FILE), "utf8"));
|
|
1139
|
+
} catch {
|
|
1140
|
+
}
|
|
1141
|
+
this.committedOffset = Number.isSafeInteger(meta.committed) ? meta.committed : -1;
|
|
1142
|
+
this.tail = meta.tail;
|
|
1143
|
+
let repaired = 0;
|
|
1144
|
+
const newest = this.segments[this.segments.length - 1];
|
|
1145
|
+
if (newest) repaired = this.repairNewest(newest);
|
|
1146
|
+
const firstRetained = this.segments[0]?.first;
|
|
1147
|
+
if (firstRetained !== void 0) this.committedOffset = Math.max(this.committedOffset, firstRetained - 1);
|
|
1148
|
+
this.nextOffset = newest ? newest.first + newest.count : 0;
|
|
1149
|
+
this.nextOffset = Math.max(this.nextOffset, this.committedOffset + 1);
|
|
1150
|
+
this.totalBytes = this.segments.reduce((sum, s) => sum + s.bytes, 0);
|
|
1151
|
+
this.deleteCommittedSegments();
|
|
1152
|
+
const last = this.segments[this.segments.length - 1];
|
|
1153
|
+
if (last && last.first + last.count === this.nextOffset) {
|
|
1154
|
+
const fd = openSync(last.file, "a", 384);
|
|
1155
|
+
this.active = { seg: last, fd };
|
|
1156
|
+
}
|
|
1157
|
+
const checkpoints = this.checkpointFiles();
|
|
1158
|
+
if (checkpoints.length > 0) this.checkpointSeq = checkpoints[checkpoints.length - 1].seq;
|
|
1159
|
+
this.checkpointOffset = this.latestCheckpoint()?.offset ?? -1;
|
|
1160
|
+
return repaired;
|
|
1161
|
+
}
|
|
1162
|
+
/** Scans the newest segment: counts and indexes its records and truncates anything after the last intact one. */
|
|
1163
|
+
repairNewest(seg) {
|
|
1164
|
+
const fd = openSync(seg.file, "r+", 384);
|
|
1165
|
+
try {
|
|
1166
|
+
let expected = seg.first;
|
|
1167
|
+
let good = 0;
|
|
1168
|
+
const idx = [];
|
|
1169
|
+
const size = fstatSync(fd).size;
|
|
1170
|
+
scanLines(fd, 0, size, (line, pos) => {
|
|
1171
|
+
const record = parseRecord(line);
|
|
1172
|
+
if (!record || record.o !== expected) return false;
|
|
1173
|
+
if ((expected - seg.first) % INDEX_STRIDE === 0) idx.push(pos);
|
|
1174
|
+
expected++;
|
|
1175
|
+
good = pos + line.length + 1;
|
|
1176
|
+
return true;
|
|
1177
|
+
});
|
|
1178
|
+
let dropped = 0;
|
|
1179
|
+
if (good < size) {
|
|
1180
|
+
dropped = size - good;
|
|
1181
|
+
ftruncateSync(fd, good);
|
|
1182
|
+
fsyncSync(fd);
|
|
1183
|
+
}
|
|
1184
|
+
seg.bytes = good;
|
|
1185
|
+
seg.count = expected - seg.first;
|
|
1186
|
+
seg.idx = idx;
|
|
1187
|
+
seg.indexed = true;
|
|
1188
|
+
return dropped;
|
|
1189
|
+
} finally {
|
|
1190
|
+
closeSync(fd);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
deleteCommittedSegments() {
|
|
1194
|
+
let removed = false;
|
|
1195
|
+
while (this.segments.length > 0) {
|
|
1196
|
+
const seg = this.segments[0];
|
|
1197
|
+
if (seg.first + seg.count - 1 > this.committedOffset) break;
|
|
1198
|
+
if (this.active?.seg === seg) {
|
|
1199
|
+
this.retireFd(this.active.fd);
|
|
1200
|
+
this.active = void 0;
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
unlinkSync3(seg.file);
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
if (error.code !== "ENOENT") throw error;
|
|
1206
|
+
}
|
|
1207
|
+
this.totalBytes -= seg.bytes;
|
|
1208
|
+
this.segments.shift();
|
|
1209
|
+
removed = true;
|
|
1210
|
+
}
|
|
1211
|
+
if (removed) fsyncDirectory(this.dir);
|
|
1212
|
+
}
|
|
1213
|
+
// ---- append ------------------------------------------------------------------------------------------------
|
|
1214
|
+
/**
|
|
1215
|
+
* Appends one record (stamping `o` and `t`) and returns it.
|
|
1216
|
+
*
|
|
1217
|
+
* Durability: ordinary records are fsynced in the background (at most one fsync in flight; started when 64 KiB are
|
|
1218
|
+
* unsynced or 100 ms after the first unsynced append) and never block the caller. Records of an immediate kind
|
|
1219
|
+
* (spawn/exit/checkpoint/commit by default) or appended with `sync: true` are fsynced synchronously before returning.
|
|
1220
|
+
* Once the line is written the append has succeeded: if that synchronous fsync then fails, the record IS in the log
|
|
1221
|
+
* (a retry would duplicate it), so the error is reported through `onError` and `stats().syncErrors`/`lastSyncError`
|
|
1222
|
+
* instead of being thrown, and the record stays counted as unsynced. Only a failure to write the line itself throws.
|
|
1223
|
+
*/
|
|
1224
|
+
append(input, options = {}) {
|
|
1225
|
+
this.assertOpen();
|
|
1226
|
+
if (typeof input.k !== "string" || input.k === "") throw new TypeError("A stream record needs a non-empty kind `k`");
|
|
1227
|
+
if ("o" in input || "t" in input) throw new TypeError("`o` and `t` are stamped by the stream log");
|
|
1228
|
+
const { k, ...rest } = input;
|
|
1229
|
+
const record = { o: this.nextOffset, t: this.now(), k, ...rest };
|
|
1230
|
+
const line = Buffer.from(`${JSON.stringify(record)}
|
|
1231
|
+
`);
|
|
1232
|
+
let target = this.active;
|
|
1233
|
+
if (target && target.seg.bytes > 0 && target.seg.bytes + line.length > this.segmentBytes) {
|
|
1234
|
+
this.sealActive();
|
|
1235
|
+
target = void 0;
|
|
1236
|
+
}
|
|
1237
|
+
if (!target) target = this.startSegment(record.o);
|
|
1238
|
+
try {
|
|
1239
|
+
writeAll(target.fd, line);
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
try {
|
|
1242
|
+
ftruncateSync(target.fd, target.seg.bytes);
|
|
1243
|
+
} catch {
|
|
1244
|
+
}
|
|
1245
|
+
throw error;
|
|
1246
|
+
}
|
|
1247
|
+
const seg = target.seg;
|
|
1248
|
+
if (seg.count % INDEX_STRIDE === 0) seg.idx.push(seg.bytes);
|
|
1249
|
+
seg.bytes += line.length;
|
|
1250
|
+
seg.count++;
|
|
1251
|
+
this.totalBytes += line.length;
|
|
1252
|
+
this.nextOffset++;
|
|
1253
|
+
this.appendedBytes += line.length;
|
|
1254
|
+
this.scheduleNotify();
|
|
1255
|
+
if (options.sync || this.immediate.has(k)) {
|
|
1256
|
+
try {
|
|
1257
|
+
this.sync();
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
this.reportSyncError(error);
|
|
1260
|
+
}
|
|
1261
|
+
} else if (this.appendedBytes - this.syncedBytes >= this.fsyncBytes) this.startBackgroundSync();
|
|
1262
|
+
else if (!this.syncTimer && !this.inflight) {
|
|
1263
|
+
this.syncTimer = setTimeout(() => {
|
|
1264
|
+
this.syncTimer = void 0;
|
|
1265
|
+
this.startBackgroundSync();
|
|
1266
|
+
}, this.fsyncIntervalMs);
|
|
1267
|
+
this.syncTimer.unref();
|
|
1268
|
+
}
|
|
1269
|
+
return record;
|
|
1270
|
+
}
|
|
1271
|
+
reportSyncError(error) {
|
|
1272
|
+
this.syncErrors++;
|
|
1273
|
+
this.lastSyncError = error instanceof Error ? error : new Error(String(error));
|
|
1274
|
+
this.onError?.(this.lastSyncError);
|
|
1275
|
+
}
|
|
1276
|
+
/** Starts the single background fsync of the active segment, or asks the running one to go again when it finishes. */
|
|
1277
|
+
startBackgroundSync() {
|
|
1278
|
+
if (this.closed || !this.active) return;
|
|
1279
|
+
if (this.inflight) {
|
|
1280
|
+
this.inflight.again = true;
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (this.appendedBytes === this.syncedBytes) return;
|
|
1284
|
+
const flight = { fd: this.active.fd, covers: this.appendedBytes, again: false, closeAfter: false };
|
|
1285
|
+
this.inflight = flight;
|
|
1286
|
+
fsync(flight.fd, (error) => {
|
|
1287
|
+
this.inflight = void 0;
|
|
1288
|
+
if (flight.closeAfter) {
|
|
1289
|
+
try {
|
|
1290
|
+
closeSync(flight.fd);
|
|
1291
|
+
} catch {
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (error) {
|
|
1295
|
+
if (!flight.closeAfter) this.reportSyncError(error);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
this.syncCount++;
|
|
1299
|
+
this.syncedBytes = Math.max(this.syncedBytes, flight.covers);
|
|
1300
|
+
if (this.closed) return;
|
|
1301
|
+
if (flight.again || this.appendedBytes - this.syncedBytes >= this.fsyncBytes) this.startBackgroundSync();
|
|
1302
|
+
else if (this.appendedBytes > this.syncedBytes && !this.syncTimer) {
|
|
1303
|
+
this.syncTimer = setTimeout(() => {
|
|
1304
|
+
this.syncTimer = void 0;
|
|
1305
|
+
this.startBackgroundSync();
|
|
1306
|
+
}, this.fsyncIntervalMs);
|
|
1307
|
+
this.syncTimer.unref();
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
/** Closes `fd`, or hands it to the in-flight fsync to close when that completes (never close under a running fsync). */
|
|
1312
|
+
retireFd(fd) {
|
|
1313
|
+
if (this.inflight?.fd === fd) {
|
|
1314
|
+
this.inflight.closeAfter = true;
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
try {
|
|
1318
|
+
closeSync(fd);
|
|
1319
|
+
} catch {
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/** Appends an explicit `discontinuity` record: the caller (or the keeper) knows data before this point may be missing. */
|
|
1323
|
+
markDiscontinuity(reason, fields = {}) {
|
|
1324
|
+
return this.append({ ...fields, k: "discontinuity", reason }, { sync: true });
|
|
1325
|
+
}
|
|
1326
|
+
/** Synchronously fsyncs everything appended so far (throws if the fsync fails). */
|
|
1327
|
+
sync() {
|
|
1328
|
+
if (this.syncTimer) {
|
|
1329
|
+
clearTimeout(this.syncTimer);
|
|
1330
|
+
this.syncTimer = void 0;
|
|
1331
|
+
}
|
|
1332
|
+
if (this.active && this.appendedBytes > this.syncedBytes) {
|
|
1333
|
+
const covers = this.appendedBytes;
|
|
1334
|
+
this.syncFile(this.active.fd);
|
|
1335
|
+
this.syncCount++;
|
|
1336
|
+
this.syncedBytes = Math.max(this.syncedBytes, covers);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
startSegment(first) {
|
|
1340
|
+
const file = path.join(this.dir, `seg-${first}.log`);
|
|
1341
|
+
const fd = openSync(file, "a", 384);
|
|
1342
|
+
const seg = { first, file, bytes: 0, count: 0, idx: [], indexed: true };
|
|
1343
|
+
this.segments.push(seg);
|
|
1344
|
+
this.active = { seg, fd };
|
|
1345
|
+
fsyncDirectory(this.dir);
|
|
1346
|
+
return this.active;
|
|
1347
|
+
}
|
|
1348
|
+
sealActive() {
|
|
1349
|
+
if (!this.active) return;
|
|
1350
|
+
this.sync();
|
|
1351
|
+
this.retireFd(this.active.fd);
|
|
1352
|
+
this.active = void 0;
|
|
1353
|
+
}
|
|
1354
|
+
// ---- read / subscribe --------------------------------------------------------------------------------------
|
|
1355
|
+
/**
|
|
1356
|
+
* Up to `limit` records with `o >= from`, in order. If `from` was already truncated away the result starts at the first
|
|
1357
|
+
* retained record (the caller can tell from `records[0].o`; use `readFrom` or `subscribe`'s `onTruncated` to be told).
|
|
1358
|
+
*/
|
|
1359
|
+
read(from, limit, options = {}) {
|
|
1360
|
+
return this.readFrom(from, limit, options).records;
|
|
1361
|
+
}
|
|
1362
|
+
/** Like `read`, plus `first` (first retained offset) and `truncated` (true when `from < first`: requested records are gone). */
|
|
1363
|
+
readFrom(from, limit, options = {}) {
|
|
1364
|
+
this.assertOpen();
|
|
1365
|
+
const maxBytes = options.maxBytes ?? Number.POSITIVE_INFINITY;
|
|
1366
|
+
let bytes = 0;
|
|
1367
|
+
const first = this.segments[0]?.first ?? this.nextOffset;
|
|
1368
|
+
const truncated = from < first;
|
|
1369
|
+
const out = [];
|
|
1370
|
+
if (!(limit > 0) || this.segments.length === 0) return { records: out, first, truncated };
|
|
1371
|
+
from = Math.max(from, first);
|
|
1372
|
+
if (from >= this.nextOffset) return { records: out, first, truncated };
|
|
1373
|
+
let si = this.segments.length - 1;
|
|
1374
|
+
while (si > 0 && this.segments[si].first > from) si--;
|
|
1375
|
+
let full = false;
|
|
1376
|
+
for (; si < this.segments.length && out.length < limit && !full; si++) {
|
|
1377
|
+
const seg = this.segments[si];
|
|
1378
|
+
if (seg.count === 0) continue;
|
|
1379
|
+
this.ensureIndexed(seg);
|
|
1380
|
+
const rel = Math.max(0, from - seg.first);
|
|
1381
|
+
const slot = Math.floor(rel / INDEX_STRIDE);
|
|
1382
|
+
let skip = rel - slot * INDEX_STRIDE;
|
|
1383
|
+
let expected = seg.first + slot * INDEX_STRIDE;
|
|
1384
|
+
const fd = openSync(seg.file, "r");
|
|
1385
|
+
try {
|
|
1386
|
+
scanLines(fd, seg.idx[slot], seg.bytes, (line) => {
|
|
1387
|
+
if (skip > 0) {
|
|
1388
|
+
skip--;
|
|
1389
|
+
expected++;
|
|
1390
|
+
return true;
|
|
1391
|
+
}
|
|
1392
|
+
if (out.length > 0 && bytes + line.length + 1 > maxBytes) {
|
|
1393
|
+
full = true;
|
|
1394
|
+
return false;
|
|
1395
|
+
}
|
|
1396
|
+
const record = parseRecord(line);
|
|
1397
|
+
if (!record || record.o !== expected) throw new StreamCorruptError(`Corrupt record at offset ${expected} in ${seg.file}`);
|
|
1398
|
+
expected++;
|
|
1399
|
+
out.push(record);
|
|
1400
|
+
bytes += line.length + 1;
|
|
1401
|
+
return out.length < limit;
|
|
1402
|
+
});
|
|
1403
|
+
} finally {
|
|
1404
|
+
closeSync(fd);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return { records: out, first, truncated };
|
|
1408
|
+
}
|
|
1409
|
+
ensureIndexed(seg) {
|
|
1410
|
+
if (seg.indexed) return;
|
|
1411
|
+
const fd = openSync(seg.file, "r");
|
|
1412
|
+
try {
|
|
1413
|
+
let n = 0;
|
|
1414
|
+
const idx = [];
|
|
1415
|
+
scanLines(fd, 0, seg.bytes, (_line, pos) => {
|
|
1416
|
+
if (n % INDEX_STRIDE === 0) idx.push(pos);
|
|
1417
|
+
n++;
|
|
1418
|
+
});
|
|
1419
|
+
if (n !== seg.count) throw new StreamCorruptError(`Segment ${seg.file} holds ${n} records, expected ${seg.count}`);
|
|
1420
|
+
seg.idx = idx;
|
|
1421
|
+
seg.indexed = true;
|
|
1422
|
+
} finally {
|
|
1423
|
+
closeSync(fd);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Pushes records with `o >= from` to `on`, in batches, now and whenever more are appended. Returns the unsubscribe.
|
|
1428
|
+
* Delivery is asynchronous (never from inside `append`). A throwing `on` ends that subscription and goes to `onError`.
|
|
1429
|
+
* If `from` is below the first retained record, delivery starts at that record and `onTruncated({from, first})` is
|
|
1430
|
+
* called once before the first batch, so the loss of the committed range is observable.
|
|
1431
|
+
*/
|
|
1432
|
+
subscribe(from, on, options = {}) {
|
|
1433
|
+
let next = from;
|
|
1434
|
+
let live = true;
|
|
1435
|
+
let reported = false;
|
|
1436
|
+
const pump = () => {
|
|
1437
|
+
if (!live || this.closed) return;
|
|
1438
|
+
try {
|
|
1439
|
+
for (; ; ) {
|
|
1440
|
+
const { records: batch, first, truncated } = this.readFrom(next, SUBSCRIBE_BATCH);
|
|
1441
|
+
if (truncated && !reported) {
|
|
1442
|
+
reported = true;
|
|
1443
|
+
options.onTruncated?.({ from: next, first });
|
|
1444
|
+
}
|
|
1445
|
+
if (batch.length === 0) return;
|
|
1446
|
+
next = batch[batch.length - 1].o + 1;
|
|
1447
|
+
on(batch);
|
|
1448
|
+
if (!live) return;
|
|
1449
|
+
}
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
live = false;
|
|
1452
|
+
unwatch();
|
|
1453
|
+
this.onError?.(error);
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
const unwatch = this.watch(pump);
|
|
1457
|
+
this.scheduleNotify(pump);
|
|
1458
|
+
return () => {
|
|
1459
|
+
live = false;
|
|
1460
|
+
unwatch();
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
/** Calls `notify` (asynchronously, coalesced) after appends. Returns the unwatch. */
|
|
1464
|
+
watch(notify) {
|
|
1465
|
+
this.watchers.add(notify);
|
|
1466
|
+
return () => {
|
|
1467
|
+
this.watchers.delete(notify);
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
/** The adapter the kit server streams from (`keeper.subscribe`, credit counted in records). */
|
|
1471
|
+
asEventSource(options = {}) {
|
|
1472
|
+
return {
|
|
1473
|
+
read: (from, limit, perRead) => {
|
|
1474
|
+
const caps = [options.maxBytes, perRead?.maxBytes].filter((cap) => cap !== void 0);
|
|
1475
|
+
const result = this.readFrom(from, limit, { maxBytes: caps.length > 0 ? Math.min(...caps) : void 0 });
|
|
1476
|
+
if (result.truncated) options.onTruncated?.({ from, first: result.first });
|
|
1477
|
+
return result.records;
|
|
1478
|
+
},
|
|
1479
|
+
watch: (notify) => this.watch(notify)
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
scheduleNotify(only) {
|
|
1483
|
+
if (only) {
|
|
1484
|
+
queueMicrotask(() => {
|
|
1485
|
+
if (!this.closed) only();
|
|
1486
|
+
});
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (this.notifyPending || this.watchers.size === 0) return;
|
|
1490
|
+
this.notifyPending = true;
|
|
1491
|
+
queueMicrotask(() => {
|
|
1492
|
+
this.notifyPending = false;
|
|
1493
|
+
if (this.closed) return;
|
|
1494
|
+
for (const watcher of [...this.watchers]) {
|
|
1495
|
+
try {
|
|
1496
|
+
watcher();
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
this.onError?.(error);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
// ---- commit / checkpoint -----------------------------------------------------------------------------------
|
|
1504
|
+
/**
|
|
1505
|
+
* Marks records through `through` (inclusive) as committed and deletes the segments that hold nothing else — whole
|
|
1506
|
+
* segments only, files are never rewritten. `tail` is the caller's opaque watermark; it is persisted atomically with
|
|
1507
|
+
* the truncation and stays readable (`state().committedTail`) after the records are gone. Omitting `tail` keeps the
|
|
1508
|
+
* previous one. A `through` at or below the current watermark changes nothing (but a given `tail` is still stored).
|
|
1509
|
+
*/
|
|
1510
|
+
commit(through, tail) {
|
|
1511
|
+
this.assertOpen();
|
|
1512
|
+
if (!Number.isSafeInteger(through) || through < -1) throw new RangeError(`Invalid commit offset ${through}`);
|
|
1513
|
+
if (through > this.nextOffset - 1) throw new RangeError(`Commit offset ${through} is beyond the head ${this.nextOffset - 1}`);
|
|
1514
|
+
const advance = through > this.committedOffset;
|
|
1515
|
+
if (!advance && tail === void 0) return;
|
|
1516
|
+
this.sync();
|
|
1517
|
+
const committed = advance ? through : this.committedOffset;
|
|
1518
|
+
const newTail = tail === void 0 ? this.tail : tail;
|
|
1519
|
+
atomicWrite(path.join(this.dir, META_FILE), JSON.stringify({ v: 1, committed, tail: newTail, t: this.now() }));
|
|
1520
|
+
this.committedOffset = committed;
|
|
1521
|
+
this.tail = newTail;
|
|
1522
|
+
if (advance) this.deleteCommittedSegments();
|
|
1523
|
+
}
|
|
1524
|
+
/** Atomically stores a checkpoint slot (tmp + rename, fsynced), keeping the latest two. `blob` is opaque. */
|
|
1525
|
+
writeCheckpoint(offset, blob) {
|
|
1526
|
+
this.assertOpen();
|
|
1527
|
+
if (!Number.isSafeInteger(offset) || offset < -1) throw new RangeError(`Invalid checkpoint offset ${offset}`);
|
|
1528
|
+
const data = typeof blob === "string" ? Buffer.from(blob, "utf8") : Buffer.from(blob);
|
|
1529
|
+
this.sync();
|
|
1530
|
+
const seq = this.checkpointSeq + 1;
|
|
1531
|
+
const t = this.now();
|
|
1532
|
+
atomicWrite(path.join(this.dir, `checkpoint-${String(seq).padStart(12, "0")}.json`), JSON.stringify({ v: 1, seq, offset, t, blob: data.toString("base64") }));
|
|
1533
|
+
this.checkpointSeq = seq;
|
|
1534
|
+
this.checkpointOffset = offset;
|
|
1535
|
+
const files = this.checkpointFiles();
|
|
1536
|
+
for (const old of files.slice(0, Math.max(0, files.length - KEPT_CHECKPOINTS))) try {
|
|
1537
|
+
unlinkSync3(old.file);
|
|
1538
|
+
} catch {
|
|
1539
|
+
}
|
|
1540
|
+
return { seq, offset, t, blob: data };
|
|
1541
|
+
}
|
|
1542
|
+
/** The newest readable checkpoint (falls back to the previous slot when the newest cannot be parsed). */
|
|
1543
|
+
latestCheckpoint() {
|
|
1544
|
+
const files = this.checkpointFiles();
|
|
1545
|
+
for (let i = files.length - 1; i >= 0; i--) {
|
|
1546
|
+
try {
|
|
1547
|
+
const value = JSON.parse(readFileSync2(files[i].file, "utf8"));
|
|
1548
|
+
if (Number.isSafeInteger(value.seq) && Number.isSafeInteger(value.offset) && typeof value.t === "number" && typeof value.blob === "string") {
|
|
1549
|
+
return { seq: value.seq, offset: value.offset, t: value.t, blob: Buffer.from(value.blob, "base64") };
|
|
1550
|
+
}
|
|
1551
|
+
} catch {
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
return void 0;
|
|
1555
|
+
}
|
|
1556
|
+
checkpointFiles() {
|
|
1557
|
+
return readdirSync(this.dir).map((n) => CHECKPOINT_NAME.exec(n)).filter((m) => !!m).map((m) => ({ seq: Number(m[1]), file: path.join(this.dir, m[0]) })).sort((a, b) => a.seq - b.seq);
|
|
1558
|
+
}
|
|
1559
|
+
// ---- state / lifecycle -------------------------------------------------------------------------------------
|
|
1560
|
+
state() {
|
|
1561
|
+
return {
|
|
1562
|
+
head: this.nextOffset - 1,
|
|
1563
|
+
committed: this.committedOffset,
|
|
1564
|
+
committedTail: this.tail,
|
|
1565
|
+
overQuota: this.totalBytes > this.softQuota,
|
|
1566
|
+
checkpointOffset: this.checkpointOffset,
|
|
1567
|
+
first: this.segments[0]?.first ?? this.nextOffset,
|
|
1568
|
+
next: this.nextOffset,
|
|
1569
|
+
bytes: this.totalBytes,
|
|
1570
|
+
segments: this.segments.length
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
/** Counters for tests and diagnostics. */
|
|
1574
|
+
stats() {
|
|
1575
|
+
return { fsyncs: this.syncCount, unsyncedBytes: this.appendedBytes - this.syncedBytes, syncErrors: this.syncErrors, lastSyncError: this.lastSyncError };
|
|
1576
|
+
}
|
|
1577
|
+
/** Flushes, closes the files and releases the directory. Idempotent. */
|
|
1578
|
+
close() {
|
|
1579
|
+
if (this.closed) return;
|
|
1580
|
+
try {
|
|
1581
|
+
this.sync();
|
|
1582
|
+
} finally {
|
|
1583
|
+
this.closed = true;
|
|
1584
|
+
this.closeActive();
|
|
1585
|
+
this.watchers.clear();
|
|
1586
|
+
openStreams.delete(this.dir);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
closeActive() {
|
|
1590
|
+
if (this.active) {
|
|
1591
|
+
this.retireFd(this.active.fd);
|
|
1592
|
+
this.active = void 0;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
assertOpen() {
|
|
1596
|
+
if (this.closed) throw new Error("Stream log is closed");
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
function fstatSizeOf(file) {
|
|
1600
|
+
const fd = openSync(file, "r");
|
|
1601
|
+
try {
|
|
1602
|
+
return fstatSync(fd).size;
|
|
1603
|
+
} finally {
|
|
1604
|
+
closeSync(fd);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// src/workstation/keepers/acp/params.ts
|
|
1609
|
+
import { statSync } from "node:fs";
|
|
1610
|
+
import path2 from "node:path";
|
|
1611
|
+
|
|
1612
|
+
// src/workstation/keepers/acp/protocol.ts
|
|
1613
|
+
var ACP_CAPABILITY = "acp";
|
|
1614
|
+
function parseAcpStream(name) {
|
|
1615
|
+
for (const stream of ["raw", "outbox"]) if (name.startsWith(`${stream}/`) && name.length > stream.length + 1) return { stream, sessionKey: name.slice(stream.length + 1) };
|
|
1616
|
+
return void 0;
|
|
1617
|
+
}
|
|
1618
|
+
var ACP_TERMINATE_GRACE_MS = 2e3;
|
|
1619
|
+
var ACP_KILL_GRACE_MS = 2e3;
|
|
1620
|
+
var ACP_EXIT_DRAIN_MS = 2e3;
|
|
1621
|
+
var ACP_STDERR_TAIL_BYTES = 8 * 1024;
|
|
1622
|
+
var ACP_STDERR_LINE_BYTES = 16 * 1024;
|
|
1623
|
+
var ACP_STDERR_FILE_BYTES = 1024 * 1024;
|
|
1624
|
+
var ACP_MAX_RECORD_BYTES = 30 * 1024 * 1024;
|
|
1625
|
+
var ACP_READ_MAX_BYTES = 16 * 1024 * 1024;
|
|
1626
|
+
var ACP_READ_MAX_RECORDS = 4096;
|
|
1627
|
+
var ACP_MIN_FREE_DISK_BYTES = 1024 * 1024 * 1024;
|
|
1628
|
+
var ACP_DISK_CHECK_MS = 5e3;
|
|
1629
|
+
var ACP_SESSION_LIMIT = 256;
|
|
1630
|
+
var ACP_SESSION_KEY_CHARS = 256;
|
|
1631
|
+
var ACP_LABEL_BYTES = 4096;
|
|
1632
|
+
var ACP_ANNOTATION_BYTES = 64 * 1024;
|
|
1633
|
+
var ACP_ENV_BYTES = 1024 * 1024;
|
|
1634
|
+
var ACP_ENVELOPE_PENDING = 256;
|
|
1635
|
+
var ACP_ENVELOPE_SNIFF_BYTES = 256 * 1024;
|
|
1636
|
+
var AcpErrorCode = {
|
|
1637
|
+
badRequest: "bad_request",
|
|
1638
|
+
notFound: "acp_session_not_found",
|
|
1639
|
+
/** A process is starting or running in this session (spawn, release). */
|
|
1640
|
+
busy: "acp_session_busy",
|
|
1641
|
+
limit: "acp_limit",
|
|
1642
|
+
/** The command could not be started (ENOENT, EACCES, …): nothing was appended to the session. */
|
|
1643
|
+
spawnFailed: "acp_spawn_failed",
|
|
1644
|
+
/** The session belongs to another live keeper-acp generation. */
|
|
1645
|
+
foreign: "acp_session_foreign",
|
|
1646
|
+
/** The session's directory has a session.json this keeper cannot read (corrupt, another schema): left alone, never reused. */
|
|
1647
|
+
unreadable: "acp_session_unreadable"
|
|
1648
|
+
};
|
|
1649
|
+
|
|
1650
|
+
// src/workstation/keepers/acp/params.ts
|
|
1651
|
+
var MAX_PATH = 4096;
|
|
1652
|
+
var MAX_ARGS = 4096;
|
|
1653
|
+
var MAX_ENV_ENTRIES = 4096;
|
|
1654
|
+
var MAX_LIST = 256;
|
|
1655
|
+
var bad = (message) => {
|
|
1656
|
+
throw new KeeperMethodError(AcpErrorCode.badRequest, message);
|
|
1657
|
+
};
|
|
1658
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1659
|
+
var jsonBytes = (value) => {
|
|
1660
|
+
try {
|
|
1661
|
+
return Buffer.byteLength(JSON.stringify(value) ?? "");
|
|
1662
|
+
} catch {
|
|
1663
|
+
return Number.POSITIVE_INFINITY;
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
function onlyKeys(params, allowed) {
|
|
1667
|
+
for (const key of Object.keys(params)) if (!allowed.includes(key)) bad(`Unknown parameter ${key}`);
|
|
1668
|
+
}
|
|
1669
|
+
function sessionKeyOf(value) {
|
|
1670
|
+
if (typeof value !== "string" || !value || value.length > ACP_SESSION_KEY_CHARS || value.includes("\0")) bad("Invalid sessionKey");
|
|
1671
|
+
return value;
|
|
1672
|
+
}
|
|
1673
|
+
function offsetOf(value, name, min = 0) {
|
|
1674
|
+
if (!Number.isSafeInteger(value) || value < min) bad(`Invalid ${name}`);
|
|
1675
|
+
return value;
|
|
1676
|
+
}
|
|
1677
|
+
function causeOf(value, name = "cause") {
|
|
1678
|
+
if (!isRecord2(value) || !Number.isSafeInteger(value.o) || value.o < 0 || !Number.isSafeInteger(value.i) || value.i < 0) bad(`Invalid ${name}`);
|
|
1679
|
+
return { o: value.o, i: value.i };
|
|
1680
|
+
}
|
|
1681
|
+
function absolutePath(value, name) {
|
|
1682
|
+
if (typeof value !== "string" || !value || value.length > MAX_PATH || value.includes("\0") || !path2.isAbsolute(value)) bad(`Invalid ${name}`);
|
|
1683
|
+
return path2.normalize(value);
|
|
1684
|
+
}
|
|
1685
|
+
function stringList(value, name, max) {
|
|
1686
|
+
if (value === void 0) return [];
|
|
1687
|
+
if (!Array.isArray(value) || value.length > max || value.some((v) => typeof v !== "string" || v.includes("\0"))) bad(`Invalid ${name}`);
|
|
1688
|
+
return [...value];
|
|
1689
|
+
}
|
|
1690
|
+
function environment(value) {
|
|
1691
|
+
if (!isRecord2(value)) return bad("Invalid env");
|
|
1692
|
+
const entries = Object.entries(value);
|
|
1693
|
+
if (entries.length > MAX_ENV_ENTRIES || jsonBytes(value) > ACP_ENV_BYTES) bad("Invalid env");
|
|
1694
|
+
const env = {};
|
|
1695
|
+
for (const [name, entry] of entries) {
|
|
1696
|
+
if (!name || name.includes("=") || name.includes("\0") || typeof entry !== "string" || entry.includes("\0")) bad("Invalid env");
|
|
1697
|
+
env[name] = entry;
|
|
1698
|
+
}
|
|
1699
|
+
return env;
|
|
1700
|
+
}
|
|
1701
|
+
function removablePath(value, protectedDirs) {
|
|
1702
|
+
const target = absolutePath(value, "removeOnExit");
|
|
1703
|
+
if (target.split(path2.sep).filter(Boolean).length < 2) bad("removeOnExit path is too shallow");
|
|
1704
|
+
for (const dir of protectedDirs) {
|
|
1705
|
+
const rel = path2.relative(target, dir);
|
|
1706
|
+
if (rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel)) bad("removeOnExit may not contain keeper data");
|
|
1707
|
+
}
|
|
1708
|
+
return target;
|
|
1709
|
+
}
|
|
1710
|
+
function spawnSpecOf(value, protectedDirs) {
|
|
1711
|
+
if (!isRecord2(value)) return bad("Invalid spec");
|
|
1712
|
+
onlyKeys(value, ["command", "args", "env", "cwd", "stderrSecrets", "removeOnExit"]);
|
|
1713
|
+
const { command } = value;
|
|
1714
|
+
if (typeof command !== "string" || !command || command.length > MAX_PATH || command.includes("\0")) bad("Invalid command");
|
|
1715
|
+
const args = stringList(value.args, "args", MAX_ARGS);
|
|
1716
|
+
const cwd = absolutePath(value.cwd, "cwd");
|
|
1717
|
+
let isDirectory = false;
|
|
1718
|
+
try {
|
|
1719
|
+
isDirectory = statSync(cwd).isDirectory();
|
|
1720
|
+
} catch {
|
|
1721
|
+
}
|
|
1722
|
+
if (!isDirectory) bad("Adapter working directory is not a directory");
|
|
1723
|
+
const stderrSecrets = stringList(value.stderrSecrets, "stderrSecrets", MAX_LIST);
|
|
1724
|
+
const removeOnExit = stringList(value.removeOnExit, "removeOnExit", MAX_LIST).map((p) => removablePath(p, protectedDirs));
|
|
1725
|
+
return { command, args, env: environment(value.env), cwd, stderrSecrets, removeOnExit };
|
|
1726
|
+
}
|
|
1727
|
+
function labelOf(value) {
|
|
1728
|
+
if (!isRecord2(value) || jsonBytes(value) > ACP_LABEL_BYTES) return bad("Invalid label");
|
|
1729
|
+
return JSON.parse(JSON.stringify(value));
|
|
1730
|
+
}
|
|
1731
|
+
function annotationOf(value) {
|
|
1732
|
+
if (!isRecord2(value) || jsonBytes(value) > ACP_ANNOTATION_BYTES) return bad("Invalid annotation");
|
|
1733
|
+
causeOf(value.cause, "annotation.cause");
|
|
1734
|
+
return value;
|
|
1735
|
+
}
|
|
1736
|
+
function redactionsOf(value) {
|
|
1737
|
+
if (value === void 0) return [];
|
|
1738
|
+
if (!Array.isArray(value) || value.length > MAX_LIST) return bad("Invalid redact");
|
|
1739
|
+
return value.map((entry) => {
|
|
1740
|
+
if (!isRecord2(entry) || typeof entry.ref !== "string" || !entry.ref || typeof entry.value !== "string") return bad("Invalid redact");
|
|
1741
|
+
return { ref: entry.ref, value: entry.value };
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
function noteOf(value) {
|
|
1745
|
+
if (!isRecord2(value) || typeof value.type !== "string" || !value.type) return bad("Invalid note");
|
|
1746
|
+
return value;
|
|
1747
|
+
}
|
|
1748
|
+
function outboxEntriesOf(value) {
|
|
1749
|
+
if (!Array.isArray(value) || value.length === 0) return bad("Invalid entries");
|
|
1750
|
+
return value.map((entry) => {
|
|
1751
|
+
if (!isRecord2(entry) || !Number.isSafeInteger(entry.seq) || !isRecord2(entry.frame)) return bad("Invalid outbox entry");
|
|
1752
|
+
return { seq: entry.seq, src: causeOf(entry.src, "outbox src"), frame: entry.frame };
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// src/workstation/keepers/acp/recover.ts
|
|
1757
|
+
import { closeSync as closeSync2, openSync as openSync2, readFileSync as readFileSync4, readSync as readSync2, readdirSync as readdirSync2, fstatSync as fstatSync2 } from "node:fs";
|
|
1758
|
+
import path3 from "node:path";
|
|
1759
|
+
|
|
1760
|
+
// src/workstation/keepers/kit/orphanSweep.ts
|
|
1761
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
1762
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1763
|
+
var procStat = (pid) => readFileSync3(`/proc/${pid}/stat`, "utf8");
|
|
1764
|
+
var procBootId = () => readFileSync3("/proc/sys/kernel/random/boot_id", "utf8");
|
|
1765
|
+
function darwinLstart(pid) {
|
|
1766
|
+
return new Promise((resolve) => {
|
|
1767
|
+
execFile2("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { timeout: 5e3, env: { LC_ALL: "C", TZ: "UTC", PATH: "/bin:/usr/bin" } }, (error, stdout) => {
|
|
1768
|
+
if (!error) {
|
|
1769
|
+
resolve(stdout.trim());
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
resolve(error.code === 1 ? "" : null);
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
function linuxStartTicks(stat) {
|
|
1777
|
+
const close = stat.lastIndexOf(")");
|
|
1778
|
+
if (close < 0) return null;
|
|
1779
|
+
const fields = stat.slice(close + 2).trim().split(/\s+/);
|
|
1780
|
+
const ticks = fields[19];
|
|
1781
|
+
return ticks && /^\d+$/.test(ticks) ? ticks : null;
|
|
1782
|
+
}
|
|
1783
|
+
async function processStartTime(pid, deps = {}) {
|
|
1784
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
1785
|
+
const platform = deps.platform ?? process.platform;
|
|
1786
|
+
if (platform === "linux") {
|
|
1787
|
+
try {
|
|
1788
|
+
const ticks = linuxStartTicks((deps.readStat ?? procStat)(pid));
|
|
1789
|
+
const boot = (deps.readBootId ?? procBootId)().trim();
|
|
1790
|
+
return ticks && boot ? `linux:${boot}:${ticks}` : null;
|
|
1791
|
+
} catch {
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
const lstart = await (deps.psLstart ?? darwinLstart)(pid);
|
|
1796
|
+
return lstart ? `${platform}:${lstart.replace(/\s+/g, " ")}` : null;
|
|
1797
|
+
}
|
|
1798
|
+
async function sameProcess(pid, startTime, deps = {}) {
|
|
1799
|
+
if (!startTime) return false;
|
|
1800
|
+
return await processStartTime(pid, deps) === startTime;
|
|
1801
|
+
}
|
|
1802
|
+
function signalGroup(pgid, signal) {
|
|
1803
|
+
if (!Number.isSafeInteger(pgid) || pgid <= 1) return false;
|
|
1804
|
+
try {
|
|
1805
|
+
process.kill(-pgid, signal);
|
|
1806
|
+
return true;
|
|
1807
|
+
} catch {
|
|
1808
|
+
return false;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
async function settleOrphan(spawn2, onKeeperLoss, killGraceMs, deps = {}) {
|
|
1812
|
+
if (!await sameProcess(spawn2.pid, spawn2.startTime, deps)) return "gone";
|
|
1813
|
+
if (onKeeperLoss === "leave") return "left";
|
|
1814
|
+
const send = deps.signal ?? signalGroup;
|
|
1815
|
+
send(spawn2.pgid, "SIGTERM");
|
|
1816
|
+
const later = deps.later ?? ((ms, run) => {
|
|
1817
|
+
setTimeout(run, ms).unref();
|
|
1818
|
+
});
|
|
1819
|
+
later(killGraceMs, () => {
|
|
1820
|
+
send(spawn2.pgid, "SIGKILL");
|
|
1821
|
+
});
|
|
1822
|
+
return "killed";
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// src/workstation/keepers/acp/recover.ts
|
|
1826
|
+
var SESSION_FILE = "session.json";
|
|
1827
|
+
var STDERR_FILE = "stderr.log";
|
|
1828
|
+
function validMeta(value) {
|
|
1829
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1830
|
+
const m = value;
|
|
1831
|
+
const spawn2 = m.spawn;
|
|
1832
|
+
return m.v === 1 && typeof m.sessionKey === "string" && typeof m.label === "object" && m.label !== null && Number.isFinite(m.createdAtMs) && typeof m.exited === "boolean" && Array.isArray(m.removeOnExit) && m.removeOnExit.every((p) => typeof p === "string") && (spawn2 === null || typeof spawn2 === "object" && typeof spawn2.spawnId === "string" && Number.isSafeInteger(spawn2.pid) && Number.isSafeInteger(spawn2.pgid) && (spawn2.startTime === null || typeof spawn2.startTime === "string"));
|
|
1833
|
+
}
|
|
1834
|
+
function readSessionMeta(dir) {
|
|
1835
|
+
let text;
|
|
1836
|
+
try {
|
|
1837
|
+
text = readFileSync4(path3.join(dir, SESSION_FILE), "utf8");
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
return error.code === "ENOENT" ? "absent" : "unreadable";
|
|
1840
|
+
}
|
|
1841
|
+
try {
|
|
1842
|
+
const value = JSON.parse(text);
|
|
1843
|
+
return validMeta(value) ? value : "unreadable";
|
|
1844
|
+
} catch {
|
|
1845
|
+
return "unreadable";
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
function listSessionDirs(sessionsDir, report) {
|
|
1849
|
+
let names;
|
|
1850
|
+
try {
|
|
1851
|
+
names = readdirSync2(sessionsDir);
|
|
1852
|
+
} catch {
|
|
1853
|
+
return [];
|
|
1854
|
+
}
|
|
1855
|
+
const found = [];
|
|
1856
|
+
for (const name of names.sort()) {
|
|
1857
|
+
const dir = path3.join(sessionsDir, name);
|
|
1858
|
+
let value;
|
|
1859
|
+
try {
|
|
1860
|
+
value = JSON.parse(readFileSync4(path3.join(dir, SESSION_FILE), "utf8"));
|
|
1861
|
+
} catch {
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
if (!validMeta(value)) {
|
|
1865
|
+
report(`${name}: unreadable session metadata, left alone`);
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
found.push({ dir, meta: value });
|
|
1869
|
+
}
|
|
1870
|
+
return found;
|
|
1871
|
+
}
|
|
1872
|
+
function stderrTailOf(dir) {
|
|
1873
|
+
let fd;
|
|
1874
|
+
try {
|
|
1875
|
+
fd = openSync2(path3.join(dir, STDERR_FILE), "r");
|
|
1876
|
+
const size = fstatSync2(fd).size;
|
|
1877
|
+
const length = Math.min(size, ACP_STDERR_TAIL_BYTES);
|
|
1878
|
+
const buffer = Buffer.alloc(length);
|
|
1879
|
+
readSync2(fd, buffer, 0, length, size - length);
|
|
1880
|
+
let text = buffer.toString("utf8");
|
|
1881
|
+
if (length < size) text = text.replace(/^[^\n]*\n?/, "");
|
|
1882
|
+
return text;
|
|
1883
|
+
} catch {
|
|
1884
|
+
return "";
|
|
1885
|
+
} finally {
|
|
1886
|
+
if (fd !== void 0) try {
|
|
1887
|
+
closeSync2(fd);
|
|
1888
|
+
} catch {
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
async function settleLostAdapter(meta, afterGrace, deps = {}) {
|
|
1893
|
+
const spawn2 = meta.spawn;
|
|
1894
|
+
if (!spawn2) {
|
|
1895
|
+
afterGrace();
|
|
1896
|
+
return "gone";
|
|
1897
|
+
}
|
|
1898
|
+
const later = deps.later ?? ((ms, run) => {
|
|
1899
|
+
setTimeout(run, ms).unref();
|
|
1900
|
+
});
|
|
1901
|
+
let killDue;
|
|
1902
|
+
const outcome = await settleOrphan({ pid: spawn2.pid, pgid: spawn2.pgid, startTime: spawn2.startTime }, "kill", ACP_KILL_GRACE_MS, {
|
|
1903
|
+
...deps,
|
|
1904
|
+
later: (ms, run) => {
|
|
1905
|
+
killDue = run;
|
|
1906
|
+
later(ms, () => {
|
|
1907
|
+
try {
|
|
1908
|
+
run();
|
|
1909
|
+
} finally {
|
|
1910
|
+
afterGrace();
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
if (!killDue) afterGrace();
|
|
1916
|
+
return outcome;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// src/workstation/keepers/acp/session.ts
|
|
1920
|
+
import { spawn } from "node:child_process";
|
|
1921
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1922
|
+
import { rmSync } from "node:fs";
|
|
1923
|
+
|
|
1924
|
+
// src/workstation/keepers/acp/envelope.ts
|
|
1925
|
+
function sniffEnvelope(line) {
|
|
1926
|
+
if (line.length > ACP_ENVELOPE_SNIFF_BYTES) return void 0;
|
|
1927
|
+
let value;
|
|
1928
|
+
try {
|
|
1929
|
+
value = JSON.parse(line);
|
|
1930
|
+
} catch {
|
|
1931
|
+
return void 0;
|
|
1932
|
+
}
|
|
1933
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
1934
|
+
const message = value;
|
|
1935
|
+
const id = typeof message.id === "string" || typeof message.id === "number" ? JSON.stringify(message.id) : void 0;
|
|
1936
|
+
const method = typeof message.method === "string" ? message.method.slice(0, 128) : void 0;
|
|
1937
|
+
return { ...id !== void 0 ? { id } : {}, ...method !== void 0 ? { method } : {} };
|
|
1938
|
+
}
|
|
1939
|
+
var EnvelopeTracker = class {
|
|
1940
|
+
/** Daemon → adapter requests (down lines) awaiting an up response. */
|
|
1941
|
+
outbound = /* @__PURE__ */ new Map();
|
|
1942
|
+
/** Adapter → daemon requests (up lines) awaiting a down response. */
|
|
1943
|
+
inbound = /* @__PURE__ */ new Map();
|
|
1944
|
+
lastUp = null;
|
|
1945
|
+
lastDown = null;
|
|
1946
|
+
unsniffed = 0;
|
|
1947
|
+
/** A new process: nothing from the previous one can be answered any more. */
|
|
1948
|
+
reset() {
|
|
1949
|
+
this.outbound.clear();
|
|
1950
|
+
this.inbound.clear();
|
|
1951
|
+
}
|
|
1952
|
+
up(line, at) {
|
|
1953
|
+
this.lastUp = at;
|
|
1954
|
+
this.observe(line, at, this.inbound, this.outbound);
|
|
1955
|
+
}
|
|
1956
|
+
down(line, at) {
|
|
1957
|
+
this.lastDown = at;
|
|
1958
|
+
this.observe(line, at, this.outbound, this.inbound);
|
|
1959
|
+
}
|
|
1960
|
+
observe(line, at, mine, theirs) {
|
|
1961
|
+
const envelope = sniffEnvelope(line);
|
|
1962
|
+
if (!envelope) {
|
|
1963
|
+
this.unsniffed += 1;
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
if (envelope.id === void 0) return;
|
|
1967
|
+
if (envelope.method !== void 0) {
|
|
1968
|
+
mine.delete(envelope.id);
|
|
1969
|
+
mine.set(envelope.id, { id: envelope.id, method: envelope.method, sinceEpochMs: at });
|
|
1970
|
+
while (mine.size > ACP_ENVELOPE_PENDING) mine.delete(mine.keys().next().value);
|
|
1971
|
+
} else theirs.delete(envelope.id);
|
|
1972
|
+
}
|
|
1973
|
+
get pendingInboundCount() {
|
|
1974
|
+
return this.inbound.size;
|
|
1975
|
+
}
|
|
1976
|
+
get pendingOutboundCount() {
|
|
1977
|
+
return this.outbound.size;
|
|
1978
|
+
}
|
|
1979
|
+
snapshot() {
|
|
1980
|
+
return {
|
|
1981
|
+
pendingOutbound: [...this.outbound.values()].map((p) => ({ ...p })),
|
|
1982
|
+
pendingInbound: [...this.inbound.values()].map((p) => ({ ...p })),
|
|
1983
|
+
lastUpEpochMs: this.lastUp,
|
|
1984
|
+
lastDownEpochMs: this.lastDown,
|
|
1985
|
+
unsniffed: this.unsniffed
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
// src/workstation/keepers/acp/stderr.ts
|
|
1991
|
+
import { closeSync as closeSync3, fstatSync as fstatSync3, openSync as openSync3, renameSync as renameSync3, writeSync as writeSync2 } from "node:fs";
|
|
1992
|
+
import { StringDecoder } from "node:string_decoder";
|
|
1993
|
+
var REDACTED = "[REDACTED]";
|
|
1994
|
+
var omittedLine = (chars) => `[keeper-acp] stderr line of ${chars}+ characters omitted`;
|
|
1995
|
+
var jsonEscaped = (value) => JSON.stringify(value).slice(1, -1);
|
|
1996
|
+
var asciiEscaped = (value) => jsonEscaped(value).replace(/[^\x00-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1997
|
+
function secretForms(secret) {
|
|
1998
|
+
if (!secret) return [];
|
|
1999
|
+
const base = /* @__PURE__ */ new Set([secret]);
|
|
2000
|
+
if (secret.startsWith("Bearer ") && secret.length > "Bearer ".length) base.add(secret.slice("Bearer ".length));
|
|
2001
|
+
if (/[\r\n]/.test(secret)) {
|
|
2002
|
+
for (const line of secret.split(/\r?\n|\r/)) if (line.trim().length >= 8) base.add(line);
|
|
2003
|
+
}
|
|
2004
|
+
const forms = /* @__PURE__ */ new Set();
|
|
2005
|
+
for (const form of base) for (const encoded of [form, jsonEscaped(form), jsonEscaped(jsonEscaped(form)), asciiEscaped(form)]) if (encoded) forms.add(encoded);
|
|
2006
|
+
return [...forms];
|
|
2007
|
+
}
|
|
2008
|
+
var StderrCapture = class {
|
|
2009
|
+
constructor(options) {
|
|
2010
|
+
this.options = options;
|
|
2011
|
+
}
|
|
2012
|
+
options;
|
|
2013
|
+
/** Longest first, so a secret that contains another is removed whole. */
|
|
2014
|
+
secrets = [];
|
|
2015
|
+
decoder = new StringDecoder("utf8");
|
|
2016
|
+
partial = "";
|
|
2017
|
+
dropping = false;
|
|
2018
|
+
tailText = "";
|
|
2019
|
+
fd;
|
|
2020
|
+
fileSize = 0;
|
|
2021
|
+
/** Adds secrets to redact from now on (kept for the session's lifetime: a later spawn may print an earlier secret). */
|
|
2022
|
+
addSecrets(values) {
|
|
2023
|
+
const all = new Set(this.secrets);
|
|
2024
|
+
for (const value of values) for (const form of secretForms(value)) all.add(form);
|
|
2025
|
+
this.secrets = [...all].sort((a, b) => b.length - a.length);
|
|
2026
|
+
}
|
|
2027
|
+
redact(text) {
|
|
2028
|
+
let safe = text;
|
|
2029
|
+
for (const secret of this.secrets) if (safe.includes(secret)) safe = safe.replaceAll(secret, REDACTED);
|
|
2030
|
+
return safe;
|
|
2031
|
+
}
|
|
2032
|
+
/** A new process: a fresh line buffer and tail (the tail belongs to one process's exit). */
|
|
2033
|
+
restart() {
|
|
2034
|
+
this.decoder = new StringDecoder("utf8");
|
|
2035
|
+
this.partial = "";
|
|
2036
|
+
this.dropping = false;
|
|
2037
|
+
this.tailText = "";
|
|
2038
|
+
}
|
|
2039
|
+
get tail() {
|
|
2040
|
+
return this.tailText;
|
|
2041
|
+
}
|
|
2042
|
+
push(chunk) {
|
|
2043
|
+
let text = this.decoder.write(chunk);
|
|
2044
|
+
for (; ; ) {
|
|
2045
|
+
const newline = text.indexOf("\n");
|
|
2046
|
+
if (newline < 0) break;
|
|
2047
|
+
const piece = text.slice(0, newline);
|
|
2048
|
+
text = text.slice(newline + 1);
|
|
2049
|
+
if (this.dropping) {
|
|
2050
|
+
this.dropping = false;
|
|
2051
|
+
this.partial = "";
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
this.line(this.partial + piece);
|
|
2055
|
+
this.partial = "";
|
|
2056
|
+
}
|
|
2057
|
+
if (this.dropping) return;
|
|
2058
|
+
this.partial += text;
|
|
2059
|
+
if (this.partial.length > ACP_STDERR_LINE_BYTES) {
|
|
2060
|
+
this.emit(omittedLine(ACP_STDERR_LINE_BYTES));
|
|
2061
|
+
this.partial = "";
|
|
2062
|
+
this.dropping = true;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
/** The stream ended: a final unterminated line is still a line. */
|
|
2066
|
+
end() {
|
|
2067
|
+
const rest = this.partial + this.decoder.end();
|
|
2068
|
+
this.partial = "";
|
|
2069
|
+
if (this.dropping) {
|
|
2070
|
+
this.dropping = false;
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (rest) this.line(rest);
|
|
2074
|
+
}
|
|
2075
|
+
line(raw) {
|
|
2076
|
+
this.emit(raw.length > ACP_STDERR_LINE_BYTES ? omittedLine(ACP_STDERR_LINE_BYTES) : this.redact(raw));
|
|
2077
|
+
}
|
|
2078
|
+
emit(safe) {
|
|
2079
|
+
this.tailText = (this.tailText + safe + "\n").slice(-ACP_STDERR_TAIL_BYTES);
|
|
2080
|
+
this.writeFile(`${safe}
|
|
2081
|
+
`);
|
|
2082
|
+
try {
|
|
2083
|
+
this.options.onLine(safe);
|
|
2084
|
+
} catch (error) {
|
|
2085
|
+
this.options.onError?.(error);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
writeFile(text) {
|
|
2089
|
+
const file = this.options.file;
|
|
2090
|
+
if (!file) return;
|
|
2091
|
+
try {
|
|
2092
|
+
const data = Buffer.from(text, "utf8");
|
|
2093
|
+
const cap = this.options.fileBytes ?? ACP_STDERR_FILE_BYTES;
|
|
2094
|
+
if (this.fd === void 0) {
|
|
2095
|
+
this.fd = openSync3(file, "a", 384);
|
|
2096
|
+
this.fileSize = fstatSync3(this.fd).size;
|
|
2097
|
+
}
|
|
2098
|
+
if (this.fileSize > 0 && this.fileSize + data.length > cap) {
|
|
2099
|
+
closeSync3(this.fd);
|
|
2100
|
+
this.fd = void 0;
|
|
2101
|
+
renameSync3(file, `${file}.1`);
|
|
2102
|
+
this.fd = openSync3(file, "a", 384);
|
|
2103
|
+
this.fileSize = 0;
|
|
2104
|
+
}
|
|
2105
|
+
let written = 0;
|
|
2106
|
+
while (written < data.length) written += writeSync2(this.fd, data, written, data.length - written);
|
|
2107
|
+
this.fileSize += data.length;
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
this.options.onError?.(error);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
close() {
|
|
2113
|
+
if (this.fd !== void 0) {
|
|
2114
|
+
try {
|
|
2115
|
+
closeSync3(this.fd);
|
|
2116
|
+
} catch {
|
|
2117
|
+
}
|
|
2118
|
+
this.fd = void 0;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
};
|
|
2122
|
+
|
|
2123
|
+
// src/workstation/keepers/acp/session.ts
|
|
2124
|
+
var MAX_EVENT_RECORDS = 256;
|
|
2125
|
+
var GROUP_POLL_MS = 50;
|
|
2126
|
+
var SpawnFailedError = class extends KeeperMethodError {
|
|
2127
|
+
constructor(message) {
|
|
2128
|
+
super(AcpErrorCode.spawnFailed, message);
|
|
2129
|
+
this.name = "SpawnFailedError";
|
|
2130
|
+
}
|
|
2131
|
+
};
|
|
2132
|
+
var messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
2133
|
+
var causeKeyOf = (cause) => `${cause.o}:${cause.i}`;
|
|
2134
|
+
var sleep = (ms) => new Promise((resolve) => {
|
|
2135
|
+
setTimeout(resolve, ms).unref();
|
|
2136
|
+
});
|
|
2137
|
+
var AcpSession = class {
|
|
2138
|
+
constructor(options) {
|
|
2139
|
+
this.options = options;
|
|
2140
|
+
this.meta = options.meta;
|
|
2141
|
+
this.raw = options.raw;
|
|
2142
|
+
this.outbox = options.outbox;
|
|
2143
|
+
this.downsComplete = options.freshRaw;
|
|
2144
|
+
this.lastExit = options.meta.lastExit ?? null;
|
|
2145
|
+
this.stderr = new StderrCapture({ file: options.stderrFile, onLine: (line) => this.appendRaw({ k: "err", l: line }), onError: (error) => this.report(error) });
|
|
2146
|
+
const head = this.outbox.state().head;
|
|
2147
|
+
if (head >= this.outbox.state().first) {
|
|
2148
|
+
const last = this.outbox.readFrom(head, 1).records[0];
|
|
2149
|
+
if (last && Number.isSafeInteger(last.seq)) this.lastSeq = last.seq;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
options;
|
|
2153
|
+
meta;
|
|
2154
|
+
raw;
|
|
2155
|
+
outbox;
|
|
2156
|
+
proc;
|
|
2157
|
+
lastExit = null;
|
|
2158
|
+
downs = /* @__PURE__ */ new Map();
|
|
2159
|
+
downsComplete;
|
|
2160
|
+
envelope = new EnvelopeTracker();
|
|
2161
|
+
stderr;
|
|
2162
|
+
lastSeq = null;
|
|
2163
|
+
stdoutHeld = false;
|
|
2164
|
+
released = false;
|
|
2165
|
+
spawning = false;
|
|
2166
|
+
get sessionKey() {
|
|
2167
|
+
return this.meta.sessionKey;
|
|
2168
|
+
}
|
|
2169
|
+
get running() {
|
|
2170
|
+
return !!this.proc && this.proc.state !== "exited";
|
|
2171
|
+
}
|
|
2172
|
+
get pendingInbound() {
|
|
2173
|
+
return this.envelope.pendingInboundCount;
|
|
2174
|
+
}
|
|
2175
|
+
report(error) {
|
|
2176
|
+
this.options.log(`session ${this.meta.sessionKey}: ${messageOf(error)}`);
|
|
2177
|
+
}
|
|
2178
|
+
appendRaw(input) {
|
|
2179
|
+
return this.raw.append(input);
|
|
2180
|
+
}
|
|
2181
|
+
// ---- spawn ---------------------------------------------------------------------------------------------------------------
|
|
2182
|
+
/**
|
|
2183
|
+
* Starts the adapter: `spawn(command, args, {cwd, env, detached})` with exactly the daemon's env (never this process's own).
|
|
2184
|
+
* Either a `proc spawn` record is appended (and later exactly one `proc exit`), or the spawn fails with nothing appended.
|
|
2185
|
+
* stdout/stderr are held until the spawn record is in raw, so it is always the process's first record.
|
|
2186
|
+
*/
|
|
2187
|
+
async spawn(spec) {
|
|
2188
|
+
if (this.released) throw new KeeperMethodError(AcpErrorCode.notFound, "Session released");
|
|
2189
|
+
if (this.spawning || this.proc && this.proc.state !== "exited") throw new KeeperMethodError(AcpErrorCode.busy, "A process is already running in this session");
|
|
2190
|
+
this.spawning = true;
|
|
2191
|
+
try {
|
|
2192
|
+
return await this.spawnNow(spec);
|
|
2193
|
+
} finally {
|
|
2194
|
+
this.spawning = false;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
get busy() {
|
|
2198
|
+
return this.spawning || this.running;
|
|
2199
|
+
}
|
|
2200
|
+
async spawnNow(spec) {
|
|
2201
|
+
const spawnId = randomUUID3();
|
|
2202
|
+
let child;
|
|
2203
|
+
try {
|
|
2204
|
+
child = (this.options.spawnProcess ?? spawn)(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, detached: this.options.detached, stdio: ["pipe", "pipe", "pipe"] });
|
|
2205
|
+
} catch (error) {
|
|
2206
|
+
throw new SpawnFailedError(messageOf(error));
|
|
2207
|
+
}
|
|
2208
|
+
child.stdin.on("error", () => {
|
|
2209
|
+
});
|
|
2210
|
+
if (child.pid === void 0) {
|
|
2211
|
+
const error = await new Promise((resolve) => child.once("error", resolve));
|
|
2212
|
+
throw new SpawnFailedError(messageOf(error));
|
|
2213
|
+
}
|
|
2214
|
+
child.on("error", (error) => this.report(error));
|
|
2215
|
+
const pid = child.pid;
|
|
2216
|
+
const pgid = this.options.detached ? pid : 0;
|
|
2217
|
+
let markRecorded;
|
|
2218
|
+
const proc = {
|
|
2219
|
+
spawnId,
|
|
2220
|
+
child,
|
|
2221
|
+
pid,
|
|
2222
|
+
pgid,
|
|
2223
|
+
startTime: null,
|
|
2224
|
+
sinceMs: this.options.now(),
|
|
2225
|
+
state: "starting",
|
|
2226
|
+
recorded: new Promise((resolve) => {
|
|
2227
|
+
markRecorded = resolve;
|
|
2228
|
+
}),
|
|
2229
|
+
stdout: { chunks: [], bytes: 0, dropping: 0 }
|
|
2230
|
+
};
|
|
2231
|
+
this.proc = proc;
|
|
2232
|
+
this.envelope.reset();
|
|
2233
|
+
this.stderr.restart();
|
|
2234
|
+
this.stderr.addSecrets(spec.stderrSecrets ?? []);
|
|
2235
|
+
this.meta.spawn = { spawnId, pid, pgid, startTime: null, atMs: proc.sinceMs };
|
|
2236
|
+
this.meta.exited = false;
|
|
2237
|
+
this.meta.removeOnExit = [...spec.removeOnExit ?? []];
|
|
2238
|
+
this.saveMeta();
|
|
2239
|
+
let held = [];
|
|
2240
|
+
child.stdout.on("data", (chunk) => {
|
|
2241
|
+
if (held) {
|
|
2242
|
+
held.push(["out", chunk]);
|
|
2243
|
+
child.stdout.pause();
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
this.onStdout(proc, chunk);
|
|
2247
|
+
});
|
|
2248
|
+
child.stderr.on("data", (chunk) => {
|
|
2249
|
+
if (held) {
|
|
2250
|
+
held.push(["err", chunk]);
|
|
2251
|
+
child.stderr.pause();
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
this.stderr.push(chunk);
|
|
2255
|
+
});
|
|
2256
|
+
child.stdout.on("error", () => {
|
|
2257
|
+
});
|
|
2258
|
+
child.stderr.on("error", () => {
|
|
2259
|
+
});
|
|
2260
|
+
child.stdout.pause();
|
|
2261
|
+
child.stderr.pause();
|
|
2262
|
+
child.once("exit", (code, signal) => this.onLeaderExit(proc, code, signal));
|
|
2263
|
+
child.once("close", () => {
|
|
2264
|
+
void this.finish(proc);
|
|
2265
|
+
});
|
|
2266
|
+
let record;
|
|
2267
|
+
try {
|
|
2268
|
+
proc.startTime = await (this.options.startTime ?? ((p) => processStartTime(p)))(pid).catch(() => null);
|
|
2269
|
+
this.meta.spawn.startTime = proc.startTime;
|
|
2270
|
+
this.saveMeta();
|
|
2271
|
+
record = this.appendRaw({
|
|
2272
|
+
k: "proc",
|
|
2273
|
+
kind: "spawn",
|
|
2274
|
+
spawnId,
|
|
2275
|
+
pid,
|
|
2276
|
+
pgid,
|
|
2277
|
+
startTime: proc.startTime ?? "",
|
|
2278
|
+
command: spec.command,
|
|
2279
|
+
args: spec.args,
|
|
2280
|
+
cwd: spec.cwd,
|
|
2281
|
+
envKeys: Object.keys(spec.env).sort()
|
|
2282
|
+
});
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
this.signal(proc, "SIGKILL");
|
|
2285
|
+
held = void 0;
|
|
2286
|
+
proc.state = "exited";
|
|
2287
|
+
this.proc = void 0;
|
|
2288
|
+
this.meta.spawn = null;
|
|
2289
|
+
this.meta.exited = true;
|
|
2290
|
+
this.saveMeta();
|
|
2291
|
+
markRecorded();
|
|
2292
|
+
throw new SpawnFailedError(`Could not record the spawn: ${messageOf(error)}`);
|
|
2293
|
+
}
|
|
2294
|
+
if (proc.state === "starting") proc.state = "running";
|
|
2295
|
+
markRecorded();
|
|
2296
|
+
const pending2 = held;
|
|
2297
|
+
held = void 0;
|
|
2298
|
+
for (const [stream, chunk] of pending2) {
|
|
2299
|
+
if (stream === "out") this.onStdout(proc, chunk);
|
|
2300
|
+
else this.stderr.push(chunk);
|
|
2301
|
+
}
|
|
2302
|
+
if (!this.stdoutHeld) child.stdout.resume();
|
|
2303
|
+
child.stderr.resume();
|
|
2304
|
+
return { spawnId, pid, pgid, o: record.o };
|
|
2305
|
+
}
|
|
2306
|
+
saveMeta() {
|
|
2307
|
+
try {
|
|
2308
|
+
this.options.saveMeta(this.meta);
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
this.report(error);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
/** Persists the metadata now (an adopting keeper records itself as the owner). */
|
|
2314
|
+
persist() {
|
|
2315
|
+
this.saveMeta();
|
|
2316
|
+
}
|
|
2317
|
+
/** stdout → one `up` record per line, verbatim (no newline). A line over the record cap is dropped and a `gap` says so. */
|
|
2318
|
+
onStdout(proc, chunk) {
|
|
2319
|
+
const out = proc.stdout;
|
|
2320
|
+
let start = 0;
|
|
2321
|
+
for (; ; ) {
|
|
2322
|
+
const newline = chunk.indexOf(10, start);
|
|
2323
|
+
if (newline < 0) break;
|
|
2324
|
+
const piece = chunk.subarray(start, newline);
|
|
2325
|
+
start = newline + 1;
|
|
2326
|
+
if (out.dropping > 0) {
|
|
2327
|
+
this.appendGap(out.dropping + piece.length);
|
|
2328
|
+
out.dropping = 0;
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
const line = out.chunks.length === 0 ? piece : Buffer.concat([...out.chunks, piece], out.bytes + piece.length);
|
|
2332
|
+
out.chunks = [];
|
|
2333
|
+
out.bytes = 0;
|
|
2334
|
+
this.upLine(line);
|
|
2335
|
+
}
|
|
2336
|
+
if (start >= chunk.length) return;
|
|
2337
|
+
const rest = chunk.subarray(start);
|
|
2338
|
+
if (out.dropping > 0) {
|
|
2339
|
+
out.dropping += rest.length;
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
out.chunks.push(Buffer.from(rest));
|
|
2343
|
+
out.bytes += rest.length;
|
|
2344
|
+
if (out.bytes > ACP_MAX_RECORD_BYTES) {
|
|
2345
|
+
out.dropping = out.bytes;
|
|
2346
|
+
out.chunks = [];
|
|
2347
|
+
out.bytes = 0;
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
upLine(bytes) {
|
|
2351
|
+
const line = bytes.toString("utf8");
|
|
2352
|
+
if (bytes.length > ACP_MAX_RECORD_BYTES / 2 && Buffer.byteLength(JSON.stringify(line)) > ACP_MAX_RECORD_BYTES - 1024) {
|
|
2353
|
+
this.appendGap(bytes.length);
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
try {
|
|
2357
|
+
const record = this.appendRaw({ k: "up", l: line });
|
|
2358
|
+
this.envelope.up(line, record.t);
|
|
2359
|
+
} catch (error) {
|
|
2360
|
+
this.report(error);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
appendGap(droppedBytes) {
|
|
2364
|
+
try {
|
|
2365
|
+
this.appendRaw({ k: "gap", stream: "stdout", droppedBytes });
|
|
2366
|
+
} catch (error) {
|
|
2367
|
+
this.report(error);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
/** The keeper's disk guard (spec §3.4: below 1 GiB free, stop reading adapter stdout). stderr keeps flowing (bounded file). */
|
|
2371
|
+
holdStdout(hold) {
|
|
2372
|
+
if (this.stdoutHeld === hold) return;
|
|
2373
|
+
this.stdoutHeld = hold;
|
|
2374
|
+
const proc = this.proc;
|
|
2375
|
+
if (!proc || proc.state === "starting" || proc.state === "exited") return;
|
|
2376
|
+
if (hold) proc.child.stdout.pause();
|
|
2377
|
+
else proc.child.stdout.resume();
|
|
2378
|
+
}
|
|
2379
|
+
// ---- exit ----------------------------------------------------------------------------------------------------------------
|
|
2380
|
+
onLeaderExit(proc, code, signal) {
|
|
2381
|
+
proc.leaderExit = { code, signal };
|
|
2382
|
+
if (proc.state !== "exited") proc.state = "exiting";
|
|
2383
|
+
if (this.stdoutHeld) proc.child.stdout.resume();
|
|
2384
|
+
if (proc.terminateReason) return;
|
|
2385
|
+
if (this.options.detached && this.groupAlive(proc)) {
|
|
2386
|
+
signalGroup(proc.pgid, "SIGTERM");
|
|
2387
|
+
proc.groupTimer = setTimeout(() => {
|
|
2388
|
+
if (this.groupAlive(proc)) signalGroup(proc.pgid, "SIGKILL");
|
|
2389
|
+
}, ACP_KILL_GRACE_MS);
|
|
2390
|
+
proc.groupTimer.unref();
|
|
2391
|
+
}
|
|
2392
|
+
proc.drainTimer = setTimeout(() => {
|
|
2393
|
+
this.closePipes(proc);
|
|
2394
|
+
void this.finish(proc);
|
|
2395
|
+
}, ACP_EXIT_DRAIN_MS);
|
|
2396
|
+
proc.drainTimer.unref();
|
|
2397
|
+
}
|
|
2398
|
+
/** Writes the exit record once: flushes partial lines, appends `proc exit`, runs `removeOnExit`. */
|
|
2399
|
+
finish(proc) {
|
|
2400
|
+
proc.finished ??= (async () => {
|
|
2401
|
+
await proc.recorded;
|
|
2402
|
+
clearTimeout(proc.drainTimer);
|
|
2403
|
+
if (this.proc !== proc) return this.lastExit;
|
|
2404
|
+
if (proc.stdout.dropping > 0) {
|
|
2405
|
+
this.appendGap(proc.stdout.dropping);
|
|
2406
|
+
proc.stdout.dropping = 0;
|
|
2407
|
+
}
|
|
2408
|
+
if (proc.stdout.bytes > 0) {
|
|
2409
|
+
const line = Buffer.concat(proc.stdout.chunks, proc.stdout.bytes);
|
|
2410
|
+
proc.stdout.chunks = [];
|
|
2411
|
+
proc.stdout.bytes = 0;
|
|
2412
|
+
this.upLine(line);
|
|
2413
|
+
}
|
|
2414
|
+
this.stderr.end();
|
|
2415
|
+
const code = proc.leaderExit?.code ?? proc.child.exitCode ?? null;
|
|
2416
|
+
const signal = proc.leaderExit?.signal ?? proc.child.signalCode ?? null;
|
|
2417
|
+
const stderrTail = this.stderr.tail;
|
|
2418
|
+
let exit = null;
|
|
2419
|
+
try {
|
|
2420
|
+
const record = this.appendRaw({ k: "proc", kind: "exit", code, signal, stderrTail, ...proc.terminateReason ? { reason: proc.terminateReason } : {} });
|
|
2421
|
+
exit = { code, signal, stderrTail, ...proc.terminateReason ? { reason: proc.terminateReason } : {}, o: record.o, atMs: record.t };
|
|
2422
|
+
} catch (error) {
|
|
2423
|
+
this.report(error);
|
|
2424
|
+
}
|
|
2425
|
+
proc.state = "exited";
|
|
2426
|
+
this.lastExit = exit;
|
|
2427
|
+
this.meta.exited = true;
|
|
2428
|
+
this.meta.lastExit = exit;
|
|
2429
|
+
this.saveMeta();
|
|
2430
|
+
this.removeOnExit();
|
|
2431
|
+
return exit;
|
|
2432
|
+
})();
|
|
2433
|
+
return proc.finished;
|
|
2434
|
+
}
|
|
2435
|
+
/**
|
|
2436
|
+
* Removes `targets` (default: what the current spawn named) and drops them from the metadata. A caller cleaning up after an
|
|
2437
|
+
* EARLIER spawn passes the list it captured then: a later spawn's own targets are never touched (final review minor 8).
|
|
2438
|
+
*/
|
|
2439
|
+
removeOnExit(targets = this.meta.removeOnExit) {
|
|
2440
|
+
const removing = [...targets];
|
|
2441
|
+
for (const target of removing) {
|
|
2442
|
+
try {
|
|
2443
|
+
rmSync(target, { recursive: true, force: true });
|
|
2444
|
+
} catch (error) {
|
|
2445
|
+
this.report(error);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
const remaining = this.meta.removeOnExit.filter((target) => !removing.includes(target));
|
|
2449
|
+
if (remaining.length !== this.meta.removeOnExit.length) {
|
|
2450
|
+
this.meta.removeOnExit = remaining;
|
|
2451
|
+
this.saveMeta();
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
groupAlive(proc) {
|
|
2455
|
+
if (!this.options.detached) return proc.child.exitCode === null && proc.child.signalCode === null;
|
|
2456
|
+
try {
|
|
2457
|
+
process.kill(-proc.pgid, 0);
|
|
2458
|
+
return true;
|
|
2459
|
+
} catch {
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
signal(proc, signal) {
|
|
2464
|
+
if (this.options.detached) {
|
|
2465
|
+
signalGroup(proc.pgid, signal);
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
try {
|
|
2469
|
+
proc.child.kill(signal);
|
|
2470
|
+
} catch {
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
closePipes(proc) {
|
|
2474
|
+
proc.child.stdin.destroy();
|
|
2475
|
+
proc.child.stdout.destroy();
|
|
2476
|
+
proc.child.stderr.destroy();
|
|
2477
|
+
}
|
|
2478
|
+
/** Until the group (fallback: the child) is gone or `ms` passed. True when it is gone. */
|
|
2479
|
+
async goneWithin(proc, ms) {
|
|
2480
|
+
const end = Date.now() + ms;
|
|
2481
|
+
for (; ; ) {
|
|
2482
|
+
const leaderDone = proc.leaderExit !== void 0;
|
|
2483
|
+
if (leaderDone && !this.groupAlive(proc)) return true;
|
|
2484
|
+
if (!this.options.detached && leaderDone) return true;
|
|
2485
|
+
if (Date.now() >= end) return false;
|
|
2486
|
+
await sleep(Math.min(GROUP_POLL_MS, Math.max(1, end - Date.now())));
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* SIGTERM to the process group → up to `graceMs` → SIGKILL → up to 2 s → close the pipes → `exit` record (spec §5.3). The group,
|
|
2491
|
+
* not just the leader: an adapter's CLI and MCP servers must not survive it. Idempotent; an exited session answers its last exit.
|
|
2492
|
+
*/
|
|
2493
|
+
async terminate(graceMs = ACP_TERMINATE_GRACE_MS, reason = "terminated") {
|
|
2494
|
+
const proc = this.proc;
|
|
2495
|
+
if (!proc) return this.lastExit;
|
|
2496
|
+
await proc.recorded;
|
|
2497
|
+
if (this.proc !== proc || proc.state === "exited") return this.lastExit;
|
|
2498
|
+
if (proc.terminating) return proc.terminating;
|
|
2499
|
+
proc.terminateReason ??= reason;
|
|
2500
|
+
proc.terminating = (async () => {
|
|
2501
|
+
clearTimeout(proc.drainTimer);
|
|
2502
|
+
clearTimeout(proc.groupTimer);
|
|
2503
|
+
if (graceMs > 0) {
|
|
2504
|
+
this.signal(proc, "SIGTERM");
|
|
2505
|
+
if (await this.goneWithin(proc, graceMs)) {
|
|
2506
|
+
await this.drainBriefly(proc);
|
|
2507
|
+
this.closePipes(proc);
|
|
2508
|
+
return this.finish(proc);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
this.signal(proc, "SIGKILL");
|
|
2512
|
+
await this.goneWithin(proc, ACP_KILL_GRACE_MS);
|
|
2513
|
+
this.closePipes(proc);
|
|
2514
|
+
return this.finish(proc);
|
|
2515
|
+
})();
|
|
2516
|
+
return proc.terminating;
|
|
2517
|
+
}
|
|
2518
|
+
/** After a clean exit, give stdio a moment to deliver what the adapter wrote last (it normally closes at once). */
|
|
2519
|
+
async drainBriefly(proc) {
|
|
2520
|
+
if (proc.finished) return;
|
|
2521
|
+
await Promise.race([new Promise((resolve) => proc.child.once("close", () => resolve())), sleep(250)]);
|
|
2522
|
+
}
|
|
2523
|
+
// ---- write / note / outbox / commit --------------------------------------------------------------------------------------
|
|
2524
|
+
/**
|
|
2525
|
+
* Atomic (one synchronous step, so no other request interleaves): refused unless `spawnId` is the process running now; a cause
|
|
2526
|
+
* already in raw is a duplicate; otherwise secrets join the stderr redaction, the LOGGED line is appended as `down`, and the line
|
|
2527
|
+
* with secrets substituted goes to stdin. Append before stdin: a line that reached the adapter always has its record.
|
|
2528
|
+
*/
|
|
2529
|
+
write(spawnId, line, annotation, redact) {
|
|
2530
|
+
const proc = this.proc;
|
|
2531
|
+
if (!proc || proc.state !== "running" || proc.terminateReason) return { status: "refused", reason: "not_running" };
|
|
2532
|
+
if (proc.spawnId !== spawnId) return { status: "refused", reason: "spawn_mismatch" };
|
|
2533
|
+
const key = causeKeyOf(annotation.cause);
|
|
2534
|
+
const existing = this.downs.get(key);
|
|
2535
|
+
if (existing !== void 0) return { status: "duplicate", o: existing };
|
|
2536
|
+
if (line.includes("\n")) throw new KeeperMethodError(AcpErrorCode.badRequest, "A down line may not contain a newline");
|
|
2537
|
+
if (line.length > ACP_MAX_RECORD_BYTES / 4 && Buffer.byteLength(JSON.stringify(line)) > ACP_MAX_RECORD_BYTES - 64 * 1024)
|
|
2538
|
+
throw new KeeperMethodError(AcpErrorCode.badRequest, "Down line exceeds the record limit");
|
|
2539
|
+
let stdinLine = line;
|
|
2540
|
+
for (const { ref, value } of redact) stdinLine = stdinLine.split(ref).join(jsonEscaped(value));
|
|
2541
|
+
this.stderr.addSecrets(redact.map((r) => r.value));
|
|
2542
|
+
const record = this.appendRaw({ k: "down", l: line, a: annotation, ...redact.length > 0 ? { redact: redact.map((r) => r.ref) } : {} });
|
|
2543
|
+
this.downs.set(key, record.o);
|
|
2544
|
+
this.envelope.down(line, record.t);
|
|
2545
|
+
proc.child.stdin.write(`${stdinLine}
|
|
2546
|
+
`);
|
|
2547
|
+
return { status: "written", o: record.o };
|
|
2548
|
+
}
|
|
2549
|
+
note(note) {
|
|
2550
|
+
return { o: this.appendRaw({ k: "note", n: note }).o };
|
|
2551
|
+
}
|
|
2552
|
+
outboxAppend(entries) {
|
|
2553
|
+
let last = this.lastSeq;
|
|
2554
|
+
for (const entry of entries) {
|
|
2555
|
+
if (last !== null && entry.seq <= last) throw new KeeperMethodError(AcpErrorCode.badRequest, `Outbox seq ${entry.seq} does not follow ${last}`);
|
|
2556
|
+
last = entry.seq;
|
|
2557
|
+
}
|
|
2558
|
+
let first = -1;
|
|
2559
|
+
let lastO = -1;
|
|
2560
|
+
for (const entry of entries) {
|
|
2561
|
+
const record = this.outbox.append({ k: "frame", seq: entry.seq, src: entry.src, frame: entry.frame });
|
|
2562
|
+
if (first < 0) first = record.o;
|
|
2563
|
+
lastO = record.o;
|
|
2564
|
+
this.lastSeq = entry.seq;
|
|
2565
|
+
}
|
|
2566
|
+
return { first, last: lastO };
|
|
2567
|
+
}
|
|
2568
|
+
/** Checkpoint, then outbox truncation (with its watermark), then raw truncation — never past the latest checkpoint. */
|
|
2569
|
+
commit(params) {
|
|
2570
|
+
const rawState = this.raw.state();
|
|
2571
|
+
if (params.checkpoint) {
|
|
2572
|
+
if (params.checkpoint.offset > rawState.head) throw new KeeperMethodError(AcpErrorCode.badRequest, "Checkpoint offset is beyond the raw head");
|
|
2573
|
+
this.raw.writeCheckpoint(params.checkpoint.offset, Buffer.from(params.checkpoint.blob, "base64"));
|
|
2574
|
+
}
|
|
2575
|
+
if (params.outbox) {
|
|
2576
|
+
if (params.outbox.through > this.outbox.state().head) throw new KeeperMethodError(AcpErrorCode.badRequest, "Outbox commit is beyond the outbox head");
|
|
2577
|
+
this.outbox.commit(params.outbox.through, params.outbox.tail);
|
|
2578
|
+
}
|
|
2579
|
+
if (params.rawThrough !== void 0) {
|
|
2580
|
+
const checkpointOffset = this.raw.latestCheckpoint()?.offset ?? -1;
|
|
2581
|
+
if (params.rawThrough > checkpointOffset) throw new KeeperMethodError(AcpErrorCode.badRequest, "Raw may only be truncated through the latest checkpoint");
|
|
2582
|
+
this.raw.commit(params.rawThrough);
|
|
2583
|
+
for (const [key, o] of this.downs) if (o <= params.rawThrough) this.downs.delete(key);
|
|
2584
|
+
}
|
|
2585
|
+
return { raw: this.streamState("raw"), outbox: this.streamState("outbox"), checkpointOffset: this.raw.latestCheckpoint()?.offset ?? -1 };
|
|
2586
|
+
}
|
|
2587
|
+
// ---- read ------------------------------------------------------------------------------------------------------------------
|
|
2588
|
+
read(stream, from, limit = ACP_READ_MAX_RECORDS) {
|
|
2589
|
+
const log = stream === "raw" ? this.raw : this.outbox;
|
|
2590
|
+
const result = log.readFrom(from, Math.min(limit, ACP_READ_MAX_RECORDS), { maxBytes: ACP_READ_MAX_BYTES });
|
|
2591
|
+
const state = log.state();
|
|
2592
|
+
const next = result.records.length > 0 ? result.records[result.records.length - 1].o + 1 : Math.max(from, result.first);
|
|
2593
|
+
return { records: result.records, first: result.first, head: state.head, next, truncated: result.truncated };
|
|
2594
|
+
}
|
|
2595
|
+
source(stream) {
|
|
2596
|
+
const log = stream === "raw" ? this.raw : this.outbox;
|
|
2597
|
+
const inner = log.asEventSource({ maxBytes: ACP_READ_MAX_BYTES });
|
|
2598
|
+
return { read: (from, limit, options) => inner.read(from, Math.min(limit, MAX_EVENT_RECORDS), options), watch: (notify) => inner.watch(notify) };
|
|
2599
|
+
}
|
|
2600
|
+
/** Is a `down` with this cause in raw (the in-process lookup of the daemon's fallback)? */
|
|
2601
|
+
hasDown(cause) {
|
|
2602
|
+
if (!this.downsComplete) this.downsFrom(0);
|
|
2603
|
+
return this.downs.has(causeKeyOf(cause));
|
|
2604
|
+
}
|
|
2605
|
+
/** Every retained `down` with a cause at or after `from` (scans raw once for a session this owner did not create). */
|
|
2606
|
+
downsFrom(from) {
|
|
2607
|
+
if (!this.downsComplete) {
|
|
2608
|
+
let next = this.raw.state().first;
|
|
2609
|
+
for (; ; ) {
|
|
2610
|
+
const { records } = this.raw.readFrom(next, ACP_READ_MAX_RECORDS, { maxBytes: ACP_READ_MAX_BYTES });
|
|
2611
|
+
if (records.length === 0) break;
|
|
2612
|
+
for (const record of records) {
|
|
2613
|
+
const cause = record.k === "down" ? record.a?.cause : void 0;
|
|
2614
|
+
if (cause && Number.isSafeInteger(cause.o) && Number.isSafeInteger(cause.i)) this.downs.set(causeKeyOf(cause), record.o);
|
|
2615
|
+
}
|
|
2616
|
+
next = records[records.length - 1].o + 1;
|
|
2617
|
+
}
|
|
2618
|
+
this.downsComplete = true;
|
|
2619
|
+
}
|
|
2620
|
+
const downs = [...this.downs.entries()].filter(([, o]) => o >= from).sort((a, b) => a[1] - b[1]).map(([key, o]) => {
|
|
2621
|
+
const [co, ci] = key.split(":").map(Number);
|
|
2622
|
+
return { o, cause: { o: co, i: ci } };
|
|
2623
|
+
});
|
|
2624
|
+
const state = this.raw.state();
|
|
2625
|
+
return { downs, first: state.first, head: state.head };
|
|
2626
|
+
}
|
|
2627
|
+
// ---- state -----------------------------------------------------------------------------------------------------------------
|
|
2628
|
+
streamState(stream) {
|
|
2629
|
+
const s = (stream === "raw" ? this.raw : this.outbox).state();
|
|
2630
|
+
return { head: s.head, first: s.first, next: s.next, committed: s.committed, bytes: s.bytes, overQuota: s.overQuota };
|
|
2631
|
+
}
|
|
2632
|
+
get overQuota() {
|
|
2633
|
+
return this.raw.state().overQuota || this.outbox.state().overQuota;
|
|
2634
|
+
}
|
|
2635
|
+
processInfo() {
|
|
2636
|
+
const proc = this.proc;
|
|
2637
|
+
if (proc && proc.state !== "exited") return { state: proc.state, spawnId: proc.spawnId, pid: proc.pid, pgid: proc.pgid, startTime: proc.startTime, sinceEpochMs: proc.sinceMs };
|
|
2638
|
+
if (proc || this.meta.spawn) return { state: "exited", spawnId: proc?.spawnId ?? this.meta.spawn?.spawnId ?? null, exit: this.lastExit };
|
|
2639
|
+
return { state: "none" };
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* A keeper loss (spec §5.3): the process this session's metadata names was not started by this keeper, so whatever it was is
|
|
2643
|
+
* over for this session. `proc exit{reason:'keeper_lost'}` is appended with the stderr tail the file kept, and the session is exited.
|
|
2644
|
+
*/
|
|
2645
|
+
recordKeeperLost(stderrTail) {
|
|
2646
|
+
let exit = null;
|
|
2647
|
+
try {
|
|
2648
|
+
const record = this.appendRaw({ k: "proc", kind: "exit", code: null, signal: null, stderrTail, reason: "keeper_lost" });
|
|
2649
|
+
exit = { code: null, signal: null, stderrTail, reason: "keeper_lost", o: record.o, atMs: record.t };
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
this.report(error);
|
|
2652
|
+
}
|
|
2653
|
+
this.lastExit = exit;
|
|
2654
|
+
this.meta.exited = true;
|
|
2655
|
+
this.meta.lastExit = exit;
|
|
2656
|
+
this.saveMeta();
|
|
2657
|
+
return exit;
|
|
2658
|
+
}
|
|
2659
|
+
summary() {
|
|
2660
|
+
const outbox = this.outbox.state();
|
|
2661
|
+
return {
|
|
2662
|
+
sessionKey: this.meta.sessionKey,
|
|
2663
|
+
label: this.meta.label,
|
|
2664
|
+
createdAtMs: this.meta.createdAtMs,
|
|
2665
|
+
process: this.processInfo(),
|
|
2666
|
+
raw: this.streamState("raw"),
|
|
2667
|
+
outbox: { ...this.streamState("outbox"), committedTail: outbox.committedTail, lastSeq: this.lastSeq }
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
attach() {
|
|
2671
|
+
const summary = this.summary();
|
|
2672
|
+
const raw = this.raw.state();
|
|
2673
|
+
const checkpoint = this.raw.latestCheckpoint();
|
|
2674
|
+
const cp = checkpoint ? { seq: checkpoint.seq, offset: checkpoint.offset, t: checkpoint.t, blob: checkpoint.blob.toString("base64") } : null;
|
|
2675
|
+
return { ...summary, head: raw.head, committed: raw.committed, committedTail: this.outbox.state().committedTail, checkpoint: cp, envelope: this.envelope.snapshot() };
|
|
2676
|
+
}
|
|
2677
|
+
/** Closes the streams (the process must have exited). `remove` also deletes what the caller keeps on disk for it. */
|
|
2678
|
+
close() {
|
|
2679
|
+
this.released = true;
|
|
2680
|
+
this.stderr.close();
|
|
2681
|
+
try {
|
|
2682
|
+
this.raw.close();
|
|
2683
|
+
} catch (error) {
|
|
2684
|
+
this.report(error);
|
|
2685
|
+
}
|
|
2686
|
+
try {
|
|
2687
|
+
this.outbox.close();
|
|
2688
|
+
} catch (error) {
|
|
2689
|
+
this.report(error);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
|
|
2694
|
+
// src/workstation/keepers/acp/host.ts
|
|
2695
|
+
var META_TMP = `${SESSION_FILE}.tmp`;
|
|
2696
|
+
function privateDir(dir) {
|
|
2697
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
2698
|
+
}
|
|
2699
|
+
function atomicJson(file, value) {
|
|
2700
|
+
const temp = path4.join(path4.dirname(file), META_TMP);
|
|
2701
|
+
const fd = openSync4(temp, "w", 384);
|
|
2702
|
+
try {
|
|
2703
|
+
const data = Buffer.from(JSON.stringify(value));
|
|
2704
|
+
let written = 0;
|
|
2705
|
+
while (written < data.length) written += writeSync3(fd, data, written, data.length - written);
|
|
2706
|
+
fsyncSync2(fd);
|
|
2707
|
+
} finally {
|
|
2708
|
+
closeSync4(fd);
|
|
2709
|
+
}
|
|
2710
|
+
renameSync4(temp, file);
|
|
2711
|
+
}
|
|
2712
|
+
var sessionDirName = (sessionKey) => createHash("sha256").update(sessionKey).digest("hex").slice(0, 32);
|
|
2713
|
+
var labelText = (label) => {
|
|
2714
|
+
for (const key of ["agentKey", "agentId"]) if (typeof label[key] === "string" && label[key]) return label[key].slice(0, 128);
|
|
2715
|
+
return "adapter";
|
|
2716
|
+
};
|
|
2717
|
+
var AcpHost = class {
|
|
2718
|
+
constructor(options) {
|
|
2719
|
+
this.options = options;
|
|
2720
|
+
this.sessionsDir = path4.join(options.dataDir, "sessions");
|
|
2721
|
+
this.now = options.now ?? Date.now;
|
|
2722
|
+
this.logLine = options.log ?? (() => {
|
|
2723
|
+
});
|
|
2724
|
+
}
|
|
2725
|
+
options;
|
|
2726
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2727
|
+
dirs = /* @__PURE__ */ new Map();
|
|
2728
|
+
sessionsDir;
|
|
2729
|
+
now;
|
|
2730
|
+
logLine;
|
|
2731
|
+
diskTimer;
|
|
2732
|
+
maintenanceTimer;
|
|
2733
|
+
stdoutHeld = false;
|
|
2734
|
+
stopping = false;
|
|
2735
|
+
adopting;
|
|
2736
|
+
/** Lost adapters of adopted sessions still being settled (SIGTERM, grace, SIGKILL), by key: a spawn into one waits (minor 8). */
|
|
2737
|
+
settling = /* @__PURE__ */ new Map();
|
|
2738
|
+
report(error) {
|
|
2739
|
+
this.logLine(`acp: ${error instanceof Error ? error.message : String(error)}`);
|
|
2740
|
+
}
|
|
2741
|
+
/** Start-up: adopt every session whose keeper is gone (keeper_lost), then start the disk guard and the periodic adoption. */
|
|
2742
|
+
async open() {
|
|
2743
|
+
privateDir(this.sessionsDir);
|
|
2744
|
+
await this.adopt();
|
|
2745
|
+
this.diskTimer = setInterval(() => {
|
|
2746
|
+
void this.checkDisk();
|
|
2747
|
+
}, ACP_DISK_CHECK_MS);
|
|
2748
|
+
this.diskTimer.unref();
|
|
2749
|
+
void this.checkDisk();
|
|
2750
|
+
this.maintenanceTimer = setInterval(() => {
|
|
2751
|
+
void this.adopt();
|
|
2752
|
+
}, this.options.maintenanceMs ?? 6e4);
|
|
2753
|
+
this.maintenanceTimer.unref();
|
|
2754
|
+
}
|
|
2755
|
+
async ownerAlive(owner) {
|
|
2756
|
+
if (!owner || owner.pid === this.options.owner.pid) return false;
|
|
2757
|
+
return (this.options.ownerAlive ?? manifestProcessLive)(owner);
|
|
2758
|
+
}
|
|
2759
|
+
/** Adopt now every session directory whose keeper is gone (what the periodic adoption does). */
|
|
2760
|
+
adoptNow() {
|
|
2761
|
+
return this.adopt();
|
|
2762
|
+
}
|
|
2763
|
+
adopt() {
|
|
2764
|
+
this.adopting ??= (async () => {
|
|
2765
|
+
const alive = /* @__PURE__ */ new Map();
|
|
2766
|
+
const ownerAlive = (owner) => {
|
|
2767
|
+
const key = JSON.stringify(owner ?? null);
|
|
2768
|
+
let probe = alive.get(key);
|
|
2769
|
+
if (!probe) alive.set(key, probe = this.ownerAlive(owner));
|
|
2770
|
+
return probe;
|
|
2771
|
+
};
|
|
2772
|
+
for (const { dir, meta } of listSessionDirs(this.sessionsDir, (message) => this.logLine(`acp: ${message}`))) {
|
|
2773
|
+
if (this.sessions.has(meta.sessionKey) || this.stopping) continue;
|
|
2774
|
+
if (path4.basename(dir) !== sessionDirName(meta.sessionKey)) {
|
|
2775
|
+
this.logLine(`acp: ${path4.basename(dir)}: misnamed session directory, left alone`);
|
|
2776
|
+
continue;
|
|
2777
|
+
}
|
|
2778
|
+
try {
|
|
2779
|
+
if (await ownerAlive(meta.owner)) continue;
|
|
2780
|
+
if (this.sessions.has(meta.sessionKey)) continue;
|
|
2781
|
+
await this.adoptOne(dir, meta);
|
|
2782
|
+
} catch (error) {
|
|
2783
|
+
this.report(error);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
})().finally(() => {
|
|
2787
|
+
this.adopting = void 0;
|
|
2788
|
+
});
|
|
2789
|
+
return this.adopting;
|
|
2790
|
+
}
|
|
2791
|
+
async adoptOne(dir, meta) {
|
|
2792
|
+
meta.owner = { ...this.options.owner };
|
|
2793
|
+
const lostRemoveOnExit = [...meta.removeOnExit];
|
|
2794
|
+
const key = meta.sessionKey;
|
|
2795
|
+
const session = this.openSession(dir, meta, { fresh: false });
|
|
2796
|
+
this.sessions.set(key, session);
|
|
2797
|
+
this.dirs.set(key, dir);
|
|
2798
|
+
session.persist();
|
|
2799
|
+
if (meta.spawn && !meta.exited) {
|
|
2800
|
+
session.recordKeeperLost(stderrTailOf(dir));
|
|
2801
|
+
let settled;
|
|
2802
|
+
const settling = new Promise((resolve) => {
|
|
2803
|
+
settled = resolve;
|
|
2804
|
+
});
|
|
2805
|
+
this.settling.set(key, settling);
|
|
2806
|
+
void settling.then(() => {
|
|
2807
|
+
if (this.settling.get(key) === settling) this.settling.delete(key);
|
|
2808
|
+
});
|
|
2809
|
+
let outcome;
|
|
2810
|
+
try {
|
|
2811
|
+
outcome = await settleLostAdapter(meta, () => {
|
|
2812
|
+
try {
|
|
2813
|
+
session.removeOnExit(lostRemoveOnExit);
|
|
2814
|
+
} finally {
|
|
2815
|
+
settled();
|
|
2816
|
+
}
|
|
2817
|
+
}, this.options.sweep);
|
|
2818
|
+
} catch (error) {
|
|
2819
|
+
settled();
|
|
2820
|
+
throw error;
|
|
2821
|
+
}
|
|
2822
|
+
this.logLine(`acp: session ${key}: keeper lost; adapter group ${outcome}`);
|
|
2823
|
+
} else if (lostRemoveOnExit.length > 0) session.removeOnExit(lostRemoveOnExit);
|
|
2824
|
+
}
|
|
2825
|
+
openSession(dir, meta, options) {
|
|
2826
|
+
const onError = (error) => this.report(error);
|
|
2827
|
+
const raw = StreamLog.open({ dir: path4.join(dir, "raw"), now: this.now, immediateKinds: ["proc"], onError, ...options.baseOffset !== void 0 ? { baseOffset: options.baseOffset } : {} });
|
|
2828
|
+
let outbox;
|
|
2829
|
+
try {
|
|
2830
|
+
outbox = StreamLog.open({ dir: path4.join(dir, "outbox"), now: this.now, immediateKinds: [], onError });
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
raw.close();
|
|
2833
|
+
throw error;
|
|
2834
|
+
}
|
|
2835
|
+
const session = new AcpSession({
|
|
2836
|
+
meta,
|
|
2837
|
+
raw,
|
|
2838
|
+
outbox,
|
|
2839
|
+
freshRaw: options.fresh,
|
|
2840
|
+
detached: true,
|
|
2841
|
+
stderrFile: path4.join(dir, STDERR_FILE),
|
|
2842
|
+
saveMeta: (m) => atomicJson(path4.join(dir, SESSION_FILE), m),
|
|
2843
|
+
now: this.now,
|
|
2844
|
+
log: this.logLine
|
|
2845
|
+
});
|
|
2846
|
+
if (this.stdoutHeld) session.holdStdout(true);
|
|
2847
|
+
return session;
|
|
2848
|
+
}
|
|
2849
|
+
find(sessionKey) {
|
|
2850
|
+
const session = this.sessions.get(sessionKey);
|
|
2851
|
+
if (!session) throw new KeeperMethodError(AcpErrorCode.notFound, "ACP session not found");
|
|
2852
|
+
return session;
|
|
2853
|
+
}
|
|
2854
|
+
// ---- methods ---------------------------------------------------------------------------------------------------------------
|
|
2855
|
+
async spawn(params) {
|
|
2856
|
+
onlyKeys(params, ["sessionKey", "spec", "label", "baseOffset"]);
|
|
2857
|
+
const sessionKey = sessionKeyOf(params.sessionKey);
|
|
2858
|
+
const spec = spawnSpecOf(params.spec, [this.options.dataDir]);
|
|
2859
|
+
const label = labelOf(params.label);
|
|
2860
|
+
const baseOffset = params.baseOffset === void 0 ? void 0 : offsetOf(params.baseOffset, "baseOffset");
|
|
2861
|
+
const existing = this.sessions.get(sessionKey);
|
|
2862
|
+
if (existing) {
|
|
2863
|
+
if (baseOffset !== void 0) bad("baseOffset only applies to a new session");
|
|
2864
|
+
await this.settling.get(sessionKey);
|
|
2865
|
+
return existing.spawn(spec);
|
|
2866
|
+
}
|
|
2867
|
+
if (this.sessions.size >= ACP_SESSION_LIMIT) throw new KeeperMethodError(AcpErrorCode.limit, "Too many ACP sessions in keeper-acp");
|
|
2868
|
+
const dir = path4.join(this.sessionsDir, sessionDirName(sessionKey));
|
|
2869
|
+
if (existsSync(dir)) {
|
|
2870
|
+
const found = readSessionMeta(dir);
|
|
2871
|
+
if (found === "unreadable") throw new KeeperMethodError(AcpErrorCode.unreadable, "This ACP session directory has unreadable metadata; it is left alone");
|
|
2872
|
+
if (found !== "absent") {
|
|
2873
|
+
if (found.sessionKey !== sessionKey || await this.ownerAlive(found.owner)) throw new KeeperMethodError(AcpErrorCode.foreign, "This ACP session belongs to another keeper-acp");
|
|
2874
|
+
if (!this.sessions.has(sessionKey)) await this.adoptOne(dir, found);
|
|
2875
|
+
const adopted = this.sessions.get(sessionKey);
|
|
2876
|
+
if (baseOffset !== void 0) bad("baseOffset only applies to a new session");
|
|
2877
|
+
await this.settling.get(sessionKey);
|
|
2878
|
+
return adopted.spawn(spec);
|
|
2879
|
+
}
|
|
2880
|
+
rmSync2(dir, { recursive: true, force: true });
|
|
2881
|
+
}
|
|
2882
|
+
const meta = { v: 1, sessionKey, label, createdAtMs: this.now(), owner: { ...this.options.owner }, spawn: null, exited: false, removeOnExit: [], lastExit: null };
|
|
2883
|
+
let session;
|
|
2884
|
+
try {
|
|
2885
|
+
privateDir(dir);
|
|
2886
|
+
atomicJson(path4.join(dir, SESSION_FILE), meta);
|
|
2887
|
+
session = this.openSession(dir, meta, { fresh: true, baseOffset });
|
|
2888
|
+
} catch (error) {
|
|
2889
|
+
rmSync2(dir, { recursive: true, force: true });
|
|
2890
|
+
throw error;
|
|
2891
|
+
}
|
|
2892
|
+
this.sessions.set(sessionKey, session);
|
|
2893
|
+
this.dirs.set(sessionKey, dir);
|
|
2894
|
+
try {
|
|
2895
|
+
return await session.spawn(spec);
|
|
2896
|
+
} catch (error) {
|
|
2897
|
+
if (this.sessions.get(sessionKey) === session) {
|
|
2898
|
+
this.sessions.delete(sessionKey);
|
|
2899
|
+
this.dirs.delete(sessionKey);
|
|
2900
|
+
}
|
|
2901
|
+
session.close();
|
|
2902
|
+
rmSync2(dir, { recursive: true, force: true });
|
|
2903
|
+
throw error;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
list() {
|
|
2907
|
+
return { sessions: [...this.sessions.values()].map((s) => s.summary()) };
|
|
2908
|
+
}
|
|
2909
|
+
attach(params) {
|
|
2910
|
+
onlyKeys(params, ["sessionKey"]);
|
|
2911
|
+
return this.find(sessionKeyOf(params.sessionKey)).attach();
|
|
2912
|
+
}
|
|
2913
|
+
write(params) {
|
|
2914
|
+
onlyKeys(params, ["sessionKey", "spawnId", "line", "annotation", "redact"]);
|
|
2915
|
+
const session = this.find(sessionKeyOf(params.sessionKey));
|
|
2916
|
+
if (typeof params.spawnId !== "string" || !params.spawnId) bad("Invalid spawnId");
|
|
2917
|
+
if (typeof params.line !== "string") bad("Invalid line");
|
|
2918
|
+
return session.write(params.spawnId, params.line, annotationOf(params.annotation), redactionsOf(params.redact));
|
|
2919
|
+
}
|
|
2920
|
+
note(params) {
|
|
2921
|
+
onlyKeys(params, ["sessionKey", "note"]);
|
|
2922
|
+
return this.find(sessionKeyOf(params.sessionKey)).note(noteOf(params.note));
|
|
2923
|
+
}
|
|
2924
|
+
read(params) {
|
|
2925
|
+
onlyKeys(params, ["sessionKey", "stream", "from", "limit"]);
|
|
2926
|
+
const session = this.find(sessionKeyOf(params.sessionKey));
|
|
2927
|
+
if (params.stream !== "raw" && params.stream !== "outbox") bad("Invalid stream");
|
|
2928
|
+
const limit = params.limit === void 0 ? void 0 : offsetOf(params.limit, "limit", 1);
|
|
2929
|
+
return session.read(params.stream, offsetOf(params.from, "from"), limit);
|
|
2930
|
+
}
|
|
2931
|
+
downs(params) {
|
|
2932
|
+
onlyKeys(params, ["sessionKey", "from"]);
|
|
2933
|
+
return this.find(sessionKeyOf(params.sessionKey)).downsFrom(params.from === void 0 ? 0 : offsetOf(params.from, "from"));
|
|
2934
|
+
}
|
|
2935
|
+
outboxAppend(params) {
|
|
2936
|
+
onlyKeys(params, ["sessionKey", "entries"]);
|
|
2937
|
+
return this.find(sessionKeyOf(params.sessionKey)).outboxAppend(outboxEntriesOf(params.entries));
|
|
2938
|
+
}
|
|
2939
|
+
commit(params) {
|
|
2940
|
+
onlyKeys(params, ["sessionKey", "checkpoint", "outbox", "rawThrough"]);
|
|
2941
|
+
const session = this.find(sessionKeyOf(params.sessionKey));
|
|
2942
|
+
const checkpoint = params.checkpoint;
|
|
2943
|
+
const outbox = params.outbox;
|
|
2944
|
+
if (checkpoint !== void 0 && (typeof checkpoint !== "object" || checkpoint === null || typeof checkpoint.blob !== "string")) bad("Invalid checkpoint");
|
|
2945
|
+
if (outbox !== void 0 && (typeof outbox !== "object" || outbox === null || typeof outbox.tail !== "object" || outbox.tail === null)) bad("Invalid outbox commit");
|
|
2946
|
+
return session.commit({
|
|
2947
|
+
...checkpoint ? { checkpoint: { offset: offsetOf(checkpoint.offset, "checkpoint.offset", -1), blob: checkpoint.blob } } : {},
|
|
2948
|
+
...outbox ? { outbox: { through: offsetOf(outbox.through, "outbox.through", -1), tail: outbox.tail } } : {},
|
|
2949
|
+
...params.rawThrough !== void 0 ? { rawThrough: offsetOf(params.rawThrough, "rawThrough", -1) } : {}
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
async terminate(params) {
|
|
2953
|
+
onlyKeys(params, ["sessionKey", "graceMs"]);
|
|
2954
|
+
const session = this.find(sessionKeyOf(params.sessionKey));
|
|
2955
|
+
const graceMs = params.graceMs === void 0 ? void 0 : Math.min(offsetOf(params.graceMs, "graceMs"), 6e4);
|
|
2956
|
+
return { exit: await session.terminate(graceMs) };
|
|
2957
|
+
}
|
|
2958
|
+
release(params) {
|
|
2959
|
+
onlyKeys(params, ["sessionKey"]);
|
|
2960
|
+
const sessionKey = sessionKeyOf(params.sessionKey);
|
|
2961
|
+
const session = this.find(sessionKey);
|
|
2962
|
+
if (session.busy) throw new KeeperMethodError(AcpErrorCode.busy, "Terminate the process before releasing the session");
|
|
2963
|
+
this.sessions.delete(sessionKey);
|
|
2964
|
+
const dir = this.dirs.get(sessionKey);
|
|
2965
|
+
this.dirs.delete(sessionKey);
|
|
2966
|
+
session.close();
|
|
2967
|
+
if (dir) rmSync2(dir, { recursive: true, force: true });
|
|
2968
|
+
return { released: true };
|
|
2969
|
+
}
|
|
2970
|
+
source(stream) {
|
|
2971
|
+
const parsed = parseAcpStream(stream);
|
|
2972
|
+
return parsed ? this.sessions.get(parsed.sessionKey)?.source(parsed.stream) : void 0;
|
|
2973
|
+
}
|
|
2974
|
+
// ---- disk guard, activity, shutdown --------------------------------------------------------------------------------------------
|
|
2975
|
+
async freeBytes() {
|
|
2976
|
+
if (this.options.diskFree) return this.options.diskFree();
|
|
2977
|
+
return new Promise((resolve, reject) => statfs(this.sessionsDir, (error, stats) => error ? reject(error) : resolve(Number(stats.bavail) * Number(stats.bsize))));
|
|
2978
|
+
}
|
|
2979
|
+
/** Spec §3.4's hard limit: with less than 1 GiB free, adapter stdout is no longer read (the adapter blocks) until space returns. */
|
|
2980
|
+
async checkDisk() {
|
|
2981
|
+
let free;
|
|
2982
|
+
try {
|
|
2983
|
+
free = await this.freeBytes();
|
|
2984
|
+
} catch (error) {
|
|
2985
|
+
this.report(error);
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
const hold = free < ACP_MIN_FREE_DISK_BYTES;
|
|
2989
|
+
if (hold === this.stdoutHeld) return;
|
|
2990
|
+
this.stdoutHeld = hold;
|
|
2991
|
+
this.logLine(`acp: ${hold ? "less than 1 GiB free: adapter stdout paused" : "disk space back: adapter stdout resumed"}`);
|
|
2992
|
+
for (const session of this.sessions.values()) session.holdStdout(hold);
|
|
2993
|
+
}
|
|
2994
|
+
/** Adapters this keeper runs now (any state but exited). */
|
|
2995
|
+
get runningAdapters() {
|
|
2996
|
+
let count = 0;
|
|
2997
|
+
for (const session of this.sessions.values()) if (session.running) count += 1;
|
|
2998
|
+
return count;
|
|
2999
|
+
}
|
|
3000
|
+
/** Mechanical facts only (spec §3.5): sessions, running adapters, unanswered adapter requests; one item per session. */
|
|
3001
|
+
activity() {
|
|
3002
|
+
const items = [];
|
|
3003
|
+
let running = 0;
|
|
3004
|
+
let overQuota = 0;
|
|
3005
|
+
let pendingInbound = 0;
|
|
3006
|
+
for (const session of this.sessions.values()) {
|
|
3007
|
+
const info = session.processInfo();
|
|
3008
|
+
if (session.running) running += 1;
|
|
3009
|
+
if (session.overQuota) overQuota += 1;
|
|
3010
|
+
pendingInbound += session.pendingInbound;
|
|
3011
|
+
const since = info.state === "exited" ? info.exit?.atMs ?? session.meta.createdAtMs : info.state === "none" ? session.meta.createdAtMs : info.sinceEpochMs;
|
|
3012
|
+
items.push({ type: "acp", id: session.sessionKey, label: labelText(session.meta.label), state: info.state, sinceEpochMs: since });
|
|
3013
|
+
}
|
|
3014
|
+
return { metrics: { sessions: this.sessions.size, sessionsRunning: running, sessionsOverQuota: overQuota, pendingInbound, stdoutPaused: this.stdoutHeld ? 1 : 0 }, items };
|
|
3015
|
+
}
|
|
3016
|
+
/** The keeper stops: every adapter is terminated (`keeper_stopped`; force: SIGKILL at once) and every stream closed. */
|
|
3017
|
+
async dispose(mode = "graceful") {
|
|
3018
|
+
this.stopping = true;
|
|
3019
|
+
clearInterval(this.diskTimer);
|
|
3020
|
+
clearInterval(this.maintenanceTimer);
|
|
3021
|
+
await Promise.all([...this.sessions.values()].map((s) => s.terminate(mode === "force" ? 0 : 1500, "keeper_stopped").catch((error) => this.report(error))));
|
|
3022
|
+
for (const session of this.sessions.values()) session.close();
|
|
3023
|
+
}
|
|
3024
|
+
};
|
|
3025
|
+
var ACP_IDLE_EXIT_MS = 24 * 60 * 60 * 1e3;
|
|
3026
|
+
var ACP_IDLE_CHECK_MS = 6e4;
|
|
3027
|
+
function createAcpKeeper(host, options = {}) {
|
|
3028
|
+
const now = options.now ?? Date.now;
|
|
3029
|
+
let idleTimer;
|
|
3030
|
+
return {
|
|
3031
|
+
capabilities: [ACP_CAPABILITY],
|
|
3032
|
+
install(server) {
|
|
3033
|
+
const idleExitMs = options.idleExitMs ?? ACP_IDLE_EXIT_MS;
|
|
3034
|
+
let busyAt = now();
|
|
3035
|
+
idleTimer = setInterval(() => {
|
|
3036
|
+
if (server.authenticatedConnectionCount > 0 || host.runningAdapters > 0) {
|
|
3037
|
+
busyAt = now();
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
if (now() - busyAt < idleExitMs) return;
|
|
3041
|
+
clearInterval(idleTimer);
|
|
3042
|
+
options.log?.(`acp: no daemon connection and no running adapter for ${Math.round(idleExitMs / 6e4)} min; stopping`);
|
|
3043
|
+
server.stopped("graceful");
|
|
3044
|
+
}, options.idleCheckMs ?? ACP_IDLE_CHECK_MS);
|
|
3045
|
+
idleTimer.unref();
|
|
3046
|
+
server.register("acp.spawn", (params) => host.spawn(params), { startsWork: true });
|
|
3047
|
+
server.register("acp.list", async () => {
|
|
3048
|
+
await host.adoptNow();
|
|
3049
|
+
return host.list();
|
|
3050
|
+
});
|
|
3051
|
+
server.register("acp.attach", (params) => host.attach(params));
|
|
3052
|
+
server.register("acp.write", (params) => host.write(params));
|
|
3053
|
+
server.register("acp.note", (params) => host.note(params));
|
|
3054
|
+
server.register("acp.read", (params) => host.read(params));
|
|
3055
|
+
server.register("acp.downs", (params) => host.downs(params));
|
|
3056
|
+
server.register("acp.outbox.append", (params) => host.outboxAppend(params));
|
|
3057
|
+
server.register("acp.commit", (params) => host.commit(params));
|
|
3058
|
+
server.register("acp.terminate", (params) => host.terminate(params));
|
|
3059
|
+
server.register("acp.release", (params) => host.release(params));
|
|
3060
|
+
server.setStreamResolver((stream) => host.source(stream));
|
|
3061
|
+
server.addActivitySampler(() => host.activity());
|
|
3062
|
+
},
|
|
3063
|
+
shutdown: (mode) => {
|
|
3064
|
+
clearInterval(idleTimer);
|
|
3065
|
+
return host.dispose(mode);
|
|
3066
|
+
}
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
// src/workstation/keepers/acp/main.ts
|
|
3071
|
+
var protocolVersion = 1;
|
|
3072
|
+
await runKeeperMain({
|
|
3073
|
+
kind: "acp",
|
|
3074
|
+
protocolVersion,
|
|
3075
|
+
create: async (config, { log }) => {
|
|
3076
|
+
const host = new AcpHost({ dataDir: config.dataDir, owner: { pid: process.pid, gen: config.gen, bundleDir: config.bundleDir }, log });
|
|
3077
|
+
await host.open();
|
|
3078
|
+
return createAcpKeeper(host, { log });
|
|
3079
|
+
}
|
|
3080
|
+
});
|
|
3081
|
+
export {
|
|
3082
|
+
protocolVersion
|
|
3083
|
+
};
|