@platless/axom-cli 0.1.0 → 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/README.md +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +132 -0
- 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-steering.json +37 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +63 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- 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 +36 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- 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/mcp.js +32 -14
- 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 +2514 -681
- package/dist/workstation/acp/acpClient.js +95 -318
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +28 -356
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- 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/delegationMcp.js +24 -0
- 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 +144 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +80 -0
- package/dist/workstation/acp/permission.js +335 -37
- 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 +45 -0
- package/dist/workstation/acp/registry/catalog.js +385 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +171 -0
- 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/fakeAgent.js +185 -9
- 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/acp/validate.js +33 -5
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1686 -348
- package/dist/workstation/channels/agent.js +14 -6
- package/dist/workstation/channels/computer.js +203 -7
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +86 -6
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +309 -6
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- 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/files/text.js +356 -0
- 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/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +201 -8
- package/dist/workstation/protocol/common.js +36 -0
- package/dist/workstation/protocol/computer.js +107 -7
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
- package/dist/workstation/qoderAdapter/history.js +85 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +95 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +85 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +277 -0
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +124 -0
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +86 -0
- package/dist/workstation/update/coordinator.js +32 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +45 -0
- package/dist/workstation/update/taskRegistry.js +141 -23
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +20 -7
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
- package/dist/workstation/acp/acpSession.js +0 -1047
- package/dist/workstation/acp/contract.js +0 -494
- package/dist/workstation/acp/draftInterceptor.js +0 -154
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// An adapter's stderr in keeper-acp (spec §5.3, §13; analysis A §5): read continuously (a full pipe would block the adapter),
|
|
2
|
+
// split into lines, redacted BEFORE anything is written anywhere, then (1) appended to a per-session rotating, size-capped file,
|
|
3
|
+
// (2) handed to the caller as an `err` record, and (3) kept as an 8 KiB tail for the `exit` record.
|
|
4
|
+
// Keeper-side module: node builtins and kit siblings only (the import guard enforces it).
|
|
5
|
+
import { closeSync, fstatSync, openSync, renameSync, writeSync } from 'node:fs';
|
|
6
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
7
|
+
import { ACP_STDERR_FILE_BYTES, ACP_STDERR_LINE_BYTES, ACP_STDERR_TAIL_BYTES } from './protocol.js';
|
|
8
|
+
export const REDACTED = '[REDACTED]';
|
|
9
|
+
/** What replaces a line that was too long to keep (it is never cut: a cut could split a secret and leak its halves). */
|
|
10
|
+
export const omittedLine = (chars) => `[keeper-acp] stderr line of ${chars}+ characters omitted`;
|
|
11
|
+
/** A string as it appears inside a JSON string literal (what keeper-acp substitutes into a stdin line). */
|
|
12
|
+
export const jsonEscaped = (value) => JSON.stringify(value).slice(1, -1);
|
|
13
|
+
/** The JSON-escaped form with every non-ASCII character as `\uXXXX` (Python's `ensure_ascii`, many loggers). */
|
|
14
|
+
const asciiEscaped = (value) => jsonEscaped(value).replace(/[^\x00-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
15
|
+
/**
|
|
16
|
+
* The strings to remove for one secret: the value itself, the token of a `Bearer <token>` value (today's rule,
|
|
17
|
+
* `acpClient.ts` `redactSecret`), each non-trivial line of a multi-line value (a line-based log can only ever hold one line of it),
|
|
18
|
+
* and of each of those the encodings an adapter plausibly logs: JSON-escaped (stdin receives that form, an adapter echoing its input
|
|
19
|
+
* prints it), JSON-escaped twice (that line logged inside another JSON string), and ASCII-only JSON escapes.
|
|
20
|
+
*/
|
|
21
|
+
export function secretForms(secret) {
|
|
22
|
+
if (!secret)
|
|
23
|
+
return [];
|
|
24
|
+
const base = new Set([secret]);
|
|
25
|
+
if (secret.startsWith('Bearer ') && secret.length > 'Bearer '.length)
|
|
26
|
+
base.add(secret.slice('Bearer '.length));
|
|
27
|
+
if (/[\r\n]/.test(secret))
|
|
28
|
+
for (const line of secret.split(/\r?\n|\r/))
|
|
29
|
+
if (line.trim().length >= 8)
|
|
30
|
+
base.add(line);
|
|
31
|
+
const forms = new Set();
|
|
32
|
+
for (const form of base)
|
|
33
|
+
for (const encoded of [form, jsonEscaped(form), jsonEscaped(jsonEscaped(form)), asciiEscaped(form)])
|
|
34
|
+
if (encoded)
|
|
35
|
+
forms.add(encoded);
|
|
36
|
+
return [...forms];
|
|
37
|
+
}
|
|
38
|
+
export class StderrCapture {
|
|
39
|
+
options;
|
|
40
|
+
/** Longest first, so a secret that contains another is removed whole. */
|
|
41
|
+
secrets = [];
|
|
42
|
+
decoder = new StringDecoder('utf8');
|
|
43
|
+
partial = '';
|
|
44
|
+
dropping = false;
|
|
45
|
+
tailText = '';
|
|
46
|
+
fd;
|
|
47
|
+
fileSize = 0;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.options = options;
|
|
50
|
+
}
|
|
51
|
+
/** Adds secrets to redact from now on (kept for the session's lifetime: a later spawn may print an earlier secret). */
|
|
52
|
+
addSecrets(values) {
|
|
53
|
+
const all = new Set(this.secrets);
|
|
54
|
+
for (const value of values)
|
|
55
|
+
for (const form of secretForms(value))
|
|
56
|
+
all.add(form);
|
|
57
|
+
this.secrets = [...all].sort((a, b) => b.length - a.length);
|
|
58
|
+
}
|
|
59
|
+
redact(text) {
|
|
60
|
+
let safe = text;
|
|
61
|
+
for (const secret of this.secrets)
|
|
62
|
+
if (safe.includes(secret))
|
|
63
|
+
safe = safe.replaceAll(secret, REDACTED);
|
|
64
|
+
return safe;
|
|
65
|
+
}
|
|
66
|
+
/** A new process: a fresh line buffer and tail (the tail belongs to one process's exit). */
|
|
67
|
+
restart() { this.decoder = new StringDecoder('utf8'); this.partial = ''; this.dropping = false; this.tailText = ''; }
|
|
68
|
+
get tail() { return this.tailText; }
|
|
69
|
+
push(chunk) {
|
|
70
|
+
let text = this.decoder.write(chunk);
|
|
71
|
+
for (;;) {
|
|
72
|
+
const newline = text.indexOf('\n');
|
|
73
|
+
if (newline < 0)
|
|
74
|
+
break;
|
|
75
|
+
const piece = text.slice(0, newline);
|
|
76
|
+
text = text.slice(newline + 1);
|
|
77
|
+
if (this.dropping) {
|
|
78
|
+
this.dropping = false;
|
|
79
|
+
this.partial = '';
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
this.line(this.partial + piece);
|
|
83
|
+
this.partial = '';
|
|
84
|
+
}
|
|
85
|
+
if (this.dropping)
|
|
86
|
+
return;
|
|
87
|
+
this.partial += text;
|
|
88
|
+
if (this.partial.length > ACP_STDERR_LINE_BYTES) {
|
|
89
|
+
this.emit(omittedLine(ACP_STDERR_LINE_BYTES));
|
|
90
|
+
this.partial = '';
|
|
91
|
+
this.dropping = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The stream ended: a final unterminated line is still a line. */
|
|
95
|
+
end() {
|
|
96
|
+
const rest = this.partial + this.decoder.end();
|
|
97
|
+
this.partial = '';
|
|
98
|
+
if (this.dropping) {
|
|
99
|
+
this.dropping = false;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (rest)
|
|
103
|
+
this.line(rest);
|
|
104
|
+
}
|
|
105
|
+
line(raw) {
|
|
106
|
+
this.emit(raw.length > ACP_STDERR_LINE_BYTES ? omittedLine(ACP_STDERR_LINE_BYTES) : this.redact(raw));
|
|
107
|
+
}
|
|
108
|
+
emit(safe) {
|
|
109
|
+
this.tailText = (this.tailText + safe + '\n').slice(-ACP_STDERR_TAIL_BYTES);
|
|
110
|
+
this.writeFile(`${safe}\n`);
|
|
111
|
+
try {
|
|
112
|
+
this.options.onLine(safe);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.options.onError?.(error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
writeFile(text) {
|
|
119
|
+
const file = this.options.file;
|
|
120
|
+
if (!file)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
const data = Buffer.from(text, 'utf8');
|
|
124
|
+
const cap = this.options.fileBytes ?? ACP_STDERR_FILE_BYTES;
|
|
125
|
+
if (this.fd === undefined) {
|
|
126
|
+
this.fd = openSync(file, 'a', 0o600);
|
|
127
|
+
this.fileSize = fstatSync(this.fd).size;
|
|
128
|
+
}
|
|
129
|
+
if (this.fileSize > 0 && this.fileSize + data.length > cap) {
|
|
130
|
+
closeSync(this.fd);
|
|
131
|
+
this.fd = undefined;
|
|
132
|
+
renameSync(file, `${file}.1`);
|
|
133
|
+
this.fd = openSync(file, 'a', 0o600);
|
|
134
|
+
this.fileSize = 0;
|
|
135
|
+
}
|
|
136
|
+
let written = 0;
|
|
137
|
+
while (written < data.length)
|
|
138
|
+
written += writeSync(this.fd, data, written, data.length - written);
|
|
139
|
+
this.fileSize += data.length;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
this.options.onError?.(error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
close() {
|
|
146
|
+
if (this.fd !== undefined) {
|
|
147
|
+
try {
|
|
148
|
+
closeSync(this.fd);
|
|
149
|
+
}
|
|
150
|
+
catch { /* ignore */ }
|
|
151
|
+
this.fd = undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Hard cap on `items` in one answer (spec §3.5). */
|
|
2
|
+
export const MAX_ACTIVITY_ITEMS = 32;
|
|
3
|
+
/** Longest `type`, `id`, `label` and `state` of an item; longer strings are cut. */
|
|
4
|
+
export const MAX_ACTIVITY_STRING = 256;
|
|
5
|
+
/** Metric names starting with this belong to the kit; a sampler's own are dropped. */
|
|
6
|
+
export const RESERVED_METRIC_PREFIX = 'kit.';
|
|
7
|
+
/** A sampler that has not answered by then is abandoned for this sample. */
|
|
8
|
+
export const DEFAULT_SAMPLE_TIMEOUT_MS = 2000;
|
|
9
|
+
const validItem = (value) => {
|
|
10
|
+
if (typeof value !== 'object' || value === null)
|
|
11
|
+
return false;
|
|
12
|
+
const item = value;
|
|
13
|
+
return typeof item.type === 'string' && typeof item.id === 'string' && typeof item.label === 'string'
|
|
14
|
+
&& typeof item.state === 'string' && typeof item.sinceEpochMs === 'number' && Number.isFinite(item.sinceEpochMs);
|
|
15
|
+
};
|
|
16
|
+
const clip = (value) => (value.length > MAX_ACTIVITY_STRING ? value.slice(0, MAX_ACTIVITY_STRING) : value);
|
|
17
|
+
/** A sampler whose previous sample has not settled. A new sample joins it instead of calling the sampler again, so a hung sampler never piles up calls. */
|
|
18
|
+
const pending = new WeakMap();
|
|
19
|
+
async function runSampler(sampler, timeoutMs) {
|
|
20
|
+
let timer;
|
|
21
|
+
let sample = pending.get(sampler);
|
|
22
|
+
if (!sample) {
|
|
23
|
+
const started = Promise.resolve().then(sampler);
|
|
24
|
+
sample = started;
|
|
25
|
+
pending.set(sampler, started);
|
|
26
|
+
const release = () => { if (pending.get(sampler) === started)
|
|
27
|
+
pending.delete(sampler); };
|
|
28
|
+
started.then(release, release);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([
|
|
32
|
+
sample,
|
|
33
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('activity sampler timed out')), timeoutMs); timer.unref(); }),
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Samples every sampler now and merges the answers: metrics are summed per name (finite numbers only), items are
|
|
42
|
+
* concatenated in sampler order and cut at {@link MAX_ACTIVITY_ITEMS}; item strings (and an optional string `jobId`, the only
|
|
43
|
+
* extra field kept) are cut at {@link MAX_ACTIVITY_STRING}.
|
|
44
|
+
* A failing sampler is skipped and counted in `kit.sampleErrors`; cut items are counted in `kit.itemsOmitted`; a sampler metric
|
|
45
|
+
* in the reserved `kit.` namespace is dropped and counted in `kit.reservedDropped`. These kit metrics appear only when non-zero.
|
|
46
|
+
*
|
|
47
|
+
* Same-named metrics from different samplers are SUMMED. That is right for counts and wrong for gauges, so samplers
|
|
48
|
+
* must use distinct names for anything that is not additive.
|
|
49
|
+
*/
|
|
50
|
+
export async function sampleActivity(samplers, options = {}) {
|
|
51
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SAMPLE_TIMEOUT_MS;
|
|
52
|
+
const settled = await Promise.allSettled(samplers.map((sampler) => runSampler(sampler, timeoutMs)));
|
|
53
|
+
const metrics = {};
|
|
54
|
+
const items = [];
|
|
55
|
+
let errors = 0;
|
|
56
|
+
let omitted = 0;
|
|
57
|
+
let reserved = 0;
|
|
58
|
+
for (const result of settled) {
|
|
59
|
+
if (result.status === 'rejected') {
|
|
60
|
+
errors += 1;
|
|
61
|
+
try {
|
|
62
|
+
options.onError?.(result.reason);
|
|
63
|
+
}
|
|
64
|
+
catch { /* diagnostics must not matter */ }
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const part = result.value;
|
|
68
|
+
if (typeof part !== 'object' || part === null)
|
|
69
|
+
continue;
|
|
70
|
+
if (typeof part.metrics === 'object' && part.metrics !== null) {
|
|
71
|
+
for (const [name, value] of Object.entries(part.metrics)) {
|
|
72
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
73
|
+
continue;
|
|
74
|
+
if (name.startsWith(RESERVED_METRIC_PREFIX)) {
|
|
75
|
+
reserved += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
metrics[name] = (metrics[name] ?? 0) + value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(part.items)) {
|
|
82
|
+
for (const item of part.items) {
|
|
83
|
+
if (!validItem(item))
|
|
84
|
+
continue;
|
|
85
|
+
if (items.length < MAX_ACTIVITY_ITEMS) {
|
|
86
|
+
const kept = { type: clip(item.type), id: clip(item.id), label: clip(item.label), state: clip(item.state), sinceEpochMs: item.sinceEpochMs };
|
|
87
|
+
if (typeof item.jobId === 'string' && item.jobId)
|
|
88
|
+
kept.jobId = clip(item.jobId);
|
|
89
|
+
items.push(kept);
|
|
90
|
+
}
|
|
91
|
+
else
|
|
92
|
+
omitted += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (errors > 0)
|
|
97
|
+
metrics['kit.sampleErrors'] = errors;
|
|
98
|
+
if (omitted > 0)
|
|
99
|
+
metrics['kit.itemsOmitted'] = omitted;
|
|
100
|
+
if (reserved > 0)
|
|
101
|
+
metrics['kit.reservedDropped'] = reserved;
|
|
102
|
+
return { metrics, items };
|
|
103
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The shared start-up of every keeper process (`keepers/<kind>/main.ts`): read the launch config, publish the pid manifest,
|
|
2
|
+
// listen with a fresh token, wire `keeper.stop` and SIGTERM/SIGINT to a clean exit, and report errors. A keeper main only
|
|
3
|
+
// says what it hosts (capabilities, methods, activity, what to do on stop). Keeper-side module: node builtins and kit siblings only.
|
|
4
|
+
import { readFileSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { decodeLaunchConfig } from './launchConfig.js';
|
|
6
|
+
import { manifestProcessLive, readPidManifest, writePidManifest } from './lock.js';
|
|
7
|
+
import { KeeperServer } from './server.js';
|
|
8
|
+
const STRING_FIELDS = ['identity', 'gen', 'unit', 'buildVersion', 'bundleDir', 'runDir', 'dataDir', 'socket', 'pidFile', 'tokenFile'];
|
|
9
|
+
/** The launch config from argv, refusing anything incomplete or meant for another kind. */
|
|
10
|
+
export function readLaunchConfig(argv, kind) {
|
|
11
|
+
const encoded = argv[2];
|
|
12
|
+
if (!encoded)
|
|
13
|
+
throw new Error('Missing keeper launch config argument');
|
|
14
|
+
let config;
|
|
15
|
+
try {
|
|
16
|
+
config = decodeLaunchConfig(encoded);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error('Unreadable keeper launch config');
|
|
20
|
+
}
|
|
21
|
+
if (typeof config !== 'object' || config === null)
|
|
22
|
+
throw new Error('Unreadable keeper launch config');
|
|
23
|
+
if (config.kind !== kind)
|
|
24
|
+
throw new Error(`Launch config is for keeper kind ${String(config.kind)}, not ${kind}`);
|
|
25
|
+
for (const field of STRING_FIELDS)
|
|
26
|
+
if (typeof config[field] !== 'string' || !config[field])
|
|
27
|
+
throw new Error(`Launch config lacks ${field}`);
|
|
28
|
+
if (!Number.isInteger(config.umask) || config.umask < 0 || config.umask > 0o777)
|
|
29
|
+
throw new Error('Launch config lacks umask');
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
const defaultLog = (kind) => (line) => {
|
|
33
|
+
try {
|
|
34
|
+
process.stderr.write(`${new Date().toISOString()} keeper-${kind}: ${line}\n`);
|
|
35
|
+
}
|
|
36
|
+
catch { /* no stderr */ }
|
|
37
|
+
};
|
|
38
|
+
const unlinkIf = (file, owned) => {
|
|
39
|
+
try {
|
|
40
|
+
if (owned())
|
|
41
|
+
unlinkSync(file);
|
|
42
|
+
}
|
|
43
|
+
catch { /* already gone or unreadable: not ours to remove */ }
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Starts a keeper process. Order matters:
|
|
47
|
+
* 0. (process hooks) the daemon's umask from the launch config becomes the process umask, so PTYs, execs and files follow it;
|
|
48
|
+
* 1. the pid manifest is written before anything else initialises, so a launcher probing a slow start sees a live keeper
|
|
49
|
+
* (and never unlinks its socket); a manifest naming another live keeper of this generation makes this start give up;
|
|
50
|
+
* 2. the keeper module is created and installed;
|
|
51
|
+
* 3. the server listens (token published only once the socket is ours) under a private 077 umask, restored to the daemon's
|
|
52
|
+
* umask afterwards;
|
|
53
|
+
* 4. `keeper.stop` and SIGTERM/SIGINT stop through the kit's stop path; the stop runs the module's shutdown, removes the
|
|
54
|
+
* pid manifest and token (only if still ours), closes the socket and exits 0.
|
|
55
|
+
* A start that fails exits 1 after removing what it published. Uncaught errors are logged, never fatal: a keeper
|
|
56
|
+
* hosts live user sessions, and dying on an unexpected exception would lose every one of them.
|
|
57
|
+
*/
|
|
58
|
+
export async function bootstrapKeeper(options) {
|
|
59
|
+
const log = options.log ?? defaultLog(options.kind);
|
|
60
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
61
|
+
const report = (error) => log(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
62
|
+
const hooks = options.processHooks !== false;
|
|
63
|
+
const config = readLaunchConfig(options.argv ?? process.argv, options.kind);
|
|
64
|
+
const ownsManifest = () => { try {
|
|
65
|
+
return JSON.parse(readFileSync(config.pidFile, 'utf8')).pid === process.pid;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
} };
|
|
70
|
+
if (hooks) {
|
|
71
|
+
// The daemon's umask, process-wide: under a systemd transient unit the inherited one is the user manager's, not the user's.
|
|
72
|
+
process.umask(config.umask);
|
|
73
|
+
// Never hold the daemon's working directory (a removable volume, a deleted worktree) for the keeper's whole life.
|
|
74
|
+
try {
|
|
75
|
+
process.chdir(config.runDir);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
report(error);
|
|
79
|
+
}
|
|
80
|
+
process.on('uncaughtException', (error) => report(error));
|
|
81
|
+
process.on('unhandledRejection', (error) => report(error));
|
|
82
|
+
process.on('SIGHUP', () => { }); // detached and without a controlling terminal; never let a stray hangup end the keeper
|
|
83
|
+
}
|
|
84
|
+
const previous = await readPidManifest(config.pidFile).catch(() => null);
|
|
85
|
+
if (previous && previous.pid !== process.pid && previous.identity === config.identity && previous.gen === config.gen && await manifestProcessLive(previous))
|
|
86
|
+
throw new Error(`Keeper ${options.kind} ${config.gen} already runs as pid ${previous.pid}`);
|
|
87
|
+
await writePidManifest(config.pidFile, {
|
|
88
|
+
kind: config.kind, gen: config.gen, buildVersion: config.buildVersion, bundleDir: config.bundleDir, startedAt: Date.now(), identity: config.identity, pid: process.pid,
|
|
89
|
+
});
|
|
90
|
+
let finishStop;
|
|
91
|
+
const stopped = new Promise((resolve) => { finishStop = resolve; });
|
|
92
|
+
let module;
|
|
93
|
+
let server;
|
|
94
|
+
let token = '';
|
|
95
|
+
let stopping = false;
|
|
96
|
+
const onStop = (mode) => {
|
|
97
|
+
if (stopping)
|
|
98
|
+
return;
|
|
99
|
+
stopping = true;
|
|
100
|
+
void (async () => {
|
|
101
|
+
try {
|
|
102
|
+
if (module?.shutdown) {
|
|
103
|
+
let timer;
|
|
104
|
+
await Promise.race([
|
|
105
|
+
Promise.resolve().then(() => module.shutdown(mode)),
|
|
106
|
+
new Promise((resolve) => { timer = setTimeout(resolve, options.shutdownTimeoutMs ?? 5000); timer.unref(); }),
|
|
107
|
+
]).catch(report).finally(() => clearTimeout(timer));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
unlinkIf(config.pidFile, ownsManifest);
|
|
112
|
+
unlinkIf(config.tokenFile, () => token !== '' && readFileSync(config.tokenFile, 'utf8') === token);
|
|
113
|
+
await server.close().catch(report);
|
|
114
|
+
finishStop();
|
|
115
|
+
exit(0);
|
|
116
|
+
}
|
|
117
|
+
})();
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
module = await options.create(config, { log });
|
|
121
|
+
server = new KeeperServer({
|
|
122
|
+
kind: options.kind, identity: config.identity, gen: config.gen, buildVersion: config.buildVersion, protocolVersion: options.protocolVersion,
|
|
123
|
+
socket: config.socket, tokenFile: config.tokenFile, capabilities: module.capabilities ?? [], onError: report, onStop, ...options.server,
|
|
124
|
+
});
|
|
125
|
+
module.install(server);
|
|
126
|
+
const umask = hooks ? process.umask(0o077) : undefined; // process-wide; in-process (tests, workers) the socket's 0700 directory suffices
|
|
127
|
+
try {
|
|
128
|
+
await server.listen();
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
if (umask !== undefined)
|
|
132
|
+
process.umask(umask);
|
|
133
|
+
}
|
|
134
|
+
token = server.token;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
unlinkIf(config.pidFile, ownsManifest);
|
|
138
|
+
try {
|
|
139
|
+
await module?.shutdown?.('force');
|
|
140
|
+
}
|
|
141
|
+
catch (shutdownError) {
|
|
142
|
+
report(shutdownError);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
const stop = (mode) => server.stopped(mode);
|
|
147
|
+
if (hooks) {
|
|
148
|
+
process.on('SIGTERM', () => stop('graceful'));
|
|
149
|
+
process.on('SIGINT', () => stop('graceful'));
|
|
150
|
+
}
|
|
151
|
+
log(`listening (pid ${process.pid}, build ${config.buildVersion.slice(0, 12)}, protocol ${options.protocolVersion})`);
|
|
152
|
+
return { config, server, stop, stopped };
|
|
153
|
+
}
|
|
154
|
+
/** `bootstrapKeeper` for a keeper process's main: a failed start is logged and exits 1. */
|
|
155
|
+
export async function runKeeperMain(options) {
|
|
156
|
+
try {
|
|
157
|
+
return await bootstrapKeeper(options);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
(options.log ?? defaultLog(options.kind))(`start failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
161
|
+
(options.exit ?? ((code) => process.exit(code)))(1);
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* The keeper buildVersion: sha256 over the bundle bytes plus the sorted list of
|
|
6
|
+
* (relative path, executable flag, sha256) of every file under `node_modules/`.
|
|
7
|
+
*
|
|
8
|
+
* This is the single implementation of the hash. `scripts/bundle-keepers.mjs` imports the compiled module to
|
|
9
|
+
* stamp `manifest.json`, and `ensureBundle` re-runs it over the copied snapshot, so the two cannot drift.
|
|
10
|
+
* The snapshot is a full copy of `dist/keepers/<kind>` (all darwin/linux prebuilds, ~600 KiB), because the
|
|
11
|
+
* hash covers every native file the build carried; copying only the current platform would not verify.
|
|
12
|
+
*
|
|
13
|
+
* It depends on nothing else (no package version, timestamps or absolute paths), so shipping a CLI with
|
|
14
|
+
* unchanged keeper code does not change it.
|
|
15
|
+
*/
|
|
16
|
+
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
17
|
+
function walkFiles(dir) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
20
|
+
const full = join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory())
|
|
22
|
+
out.push(...walkFiles(full));
|
|
23
|
+
else
|
|
24
|
+
out.push(full);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Relative (posix) paths of the native files carried next to a bundle, sorted. */
|
|
29
|
+
export function nativeFilesOf(kindDir) {
|
|
30
|
+
let files;
|
|
31
|
+
try {
|
|
32
|
+
files = walkFiles(join(kindDir, 'node_modules'));
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT')
|
|
36
|
+
return [];
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return files.map((file) => relative(kindDir, file).split(sep).join('/')).sort();
|
|
40
|
+
}
|
|
41
|
+
export function computeBuildVersion(kindDir) {
|
|
42
|
+
const hash = createHash('sha256');
|
|
43
|
+
const bundle = readFileSync(join(kindDir, 'keeper.mjs'));
|
|
44
|
+
hash.update(`bundle ${bundle.length}\n`).update(bundle);
|
|
45
|
+
for (const rel of nativeFilesOf(kindDir)) {
|
|
46
|
+
const file = join(kindDir, rel);
|
|
47
|
+
const executable = (statSync(file).mode & 0o111) !== 0 ? 'x' : '-';
|
|
48
|
+
hash.update(`\nnative ${rel} ${executable} ${sha256(readFileSync(file))}`);
|
|
49
|
+
}
|
|
50
|
+
return hash.digest('hex');
|
|
51
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, cpSync, promises as fs, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { computeBuildVersion } from './buildVersion.js';
|
|
5
|
+
import { KEEPER_KINDS } from './identity.js';
|
|
6
|
+
import { manifestProcessLive, readPidManifest } from './lock.js';
|
|
7
|
+
export class BundleIntegrityError extends Error {
|
|
8
|
+
constructor(message) { super(message); this.name = 'BundleIntegrityError'; }
|
|
9
|
+
}
|
|
10
|
+
const BUILD_VERSION = /^[0-9a-f]{64}$/;
|
|
11
|
+
export async function readKeeperManifest(kindDir) {
|
|
12
|
+
const value = JSON.parse(await fs.readFile(path.join(kindDir, 'manifest.json'), 'utf8'));
|
|
13
|
+
const range = value.daemonProtocol;
|
|
14
|
+
if (!KEEPER_KINDS.includes(value.kind) || !Number.isInteger(value.protocolVersion) || typeof value.buildVersion !== 'string'
|
|
15
|
+
|| !BUILD_VERSION.test(value.buildVersion) || !Array.isArray(range) || range.length !== 2 || !range.every(Number.isInteger))
|
|
16
|
+
throw new BundleIntegrityError(`Invalid keeper manifest in ${kindDir}`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
/** Throws unless the files under `dir` hash to `buildVersion` (the same hash `bundle-keepers.mjs` stamped). */
|
|
20
|
+
function verify(dir, buildVersion, what) {
|
|
21
|
+
let actual;
|
|
22
|
+
try {
|
|
23
|
+
actual = computeBuildVersion(dir);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new BundleIntegrityError(`${what} is unreadable: ${error.message}`);
|
|
27
|
+
}
|
|
28
|
+
if (actual !== buildVersion)
|
|
29
|
+
throw new BundleIntegrityError(`${what} does not match keeper build ${buildVersion.slice(0, 12)}`);
|
|
30
|
+
}
|
|
31
|
+
/** Copies preserving modes explicitly: spawn-helper must stay executable whatever the copy primitive does. */
|
|
32
|
+
function copyTree(source, target) {
|
|
33
|
+
cpSync(source, target, { recursive: true, dereference: true });
|
|
34
|
+
const walk = (from, to) => {
|
|
35
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
36
|
+
const src = path.join(from, entry.name);
|
|
37
|
+
const dst = path.join(to, entry.name);
|
|
38
|
+
if (entry.isDirectory())
|
|
39
|
+
walk(src, dst);
|
|
40
|
+
else
|
|
41
|
+
chmodSync(dst, statSync(src).mode & 0o777);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk(source, target);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Makes `bundlesDir/<buildVersion>` an immutable snapshot of `distKindDir` and returns it. The keeper runs from
|
|
48
|
+
* the snapshot, so replacing `dist/keepers` (npm upgrade, rebuild) never changes a running keeper's files.
|
|
49
|
+
*
|
|
50
|
+
* Copy to `.tmp-<uuid>`, recompute the hash over the copy (all of it: bundle plus every native file the build
|
|
51
|
+
* carried) and compare with the manifest, then rename. A snapshot that already exists is re-verified instead of
|
|
52
|
+
* trusted, and a mismatch throws: the caller must not launch from it.
|
|
53
|
+
*/
|
|
54
|
+
export async function ensureBundle(distKindDir, bundlesDir) {
|
|
55
|
+
const manifest = await readKeeperManifest(distKindDir);
|
|
56
|
+
const target = path.join(bundlesDir, manifest.buildVersion);
|
|
57
|
+
const existing = await fs.lstat(target).then((stat) => stat, (error) => {
|
|
58
|
+
if (error.code === 'ENOENT')
|
|
59
|
+
return null;
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
if (existing) {
|
|
63
|
+
if (!existing.isDirectory() || existing.isSymbolicLink())
|
|
64
|
+
throw new BundleIntegrityError(`${target} is not a keeper bundle directory`);
|
|
65
|
+
verify(target, manifest.buildVersion, `Existing keeper bundle ${target}`);
|
|
66
|
+
return { manifest, buildVersion: manifest.buildVersion, bundleDir: target, created: false };
|
|
67
|
+
}
|
|
68
|
+
await fs.mkdir(bundlesDir, { recursive: true, mode: 0o700 });
|
|
69
|
+
const temp = path.join(bundlesDir, `.tmp-${randomUUID()}`);
|
|
70
|
+
try {
|
|
71
|
+
copyTree(distKindDir, temp);
|
|
72
|
+
verify(temp, manifest.buildVersion, `Keeper bundle copy of ${distKindDir}`);
|
|
73
|
+
try {
|
|
74
|
+
await fs.rename(temp, target);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const code = error.code;
|
|
78
|
+
if (code !== 'ENOTEMPTY' && code !== 'EEXIST')
|
|
79
|
+
throw error;
|
|
80
|
+
// A concurrent ensure won the rename. Its snapshot must be as good as ours.
|
|
81
|
+
verify(target, manifest.buildVersion, `Existing keeper bundle ${target}`);
|
|
82
|
+
return { manifest, buildVersion: manifest.buildVersion, bundleDir: target, created: false };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await fs.rm(temp, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
return { manifest, buildVersion: manifest.buildVersion, bundleDir: target, created: true };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Deletes `bundles/*` that no live keeper's pid manifest references (a pid reused by an unrelated process does not count) (and stale `.tmp-*` leftovers).
|
|
92
|
+
* Returns the removed paths.
|
|
93
|
+
*/
|
|
94
|
+
export async function gcBundles(options) {
|
|
95
|
+
const minAge = options.minAgeMs ?? 10 * 60_000;
|
|
96
|
+
const now = options.now ?? Date.now();
|
|
97
|
+
const keep = new Set(options.keep ?? []);
|
|
98
|
+
const removed = [];
|
|
99
|
+
for (const kind of KEEPER_KINDS) {
|
|
100
|
+
const root = path.join(path.resolve(options.configDir), 'keepers', kind);
|
|
101
|
+
const referenced = new Set(keep);
|
|
102
|
+
for (const gen of await fs.readdir(path.join(root, 'run')).catch(() => [])) {
|
|
103
|
+
const manifest = await readPidManifest(path.join(root, 'run', gen, 'keeper.pid.json')).catch(() => null);
|
|
104
|
+
if (manifest && await manifestProcessLive(manifest))
|
|
105
|
+
referenced.add(manifest.buildVersion);
|
|
106
|
+
}
|
|
107
|
+
const bundles = path.join(root, 'bundles');
|
|
108
|
+
for (const name of await fs.readdir(bundles).catch(() => [])) {
|
|
109
|
+
if (referenced.has(name))
|
|
110
|
+
continue;
|
|
111
|
+
const dir = path.join(bundles, name);
|
|
112
|
+
const stat = await fs.lstat(dir).catch(() => null);
|
|
113
|
+
if (!stat || (minAge > 0 && now - Math.max(stat.mtimeMs, stat.ctimeMs) < minAge))
|
|
114
|
+
continue;
|
|
115
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
116
|
+
removed.push(dir);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return removed;
|
|
120
|
+
}
|