@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,517 @@
|
|
|
1
|
+
import { ExecErrorCode, } from '../keepers/terminal/protocol.js';
|
|
2
|
+
import { ExecShaper } from './shaping.js';
|
|
3
|
+
/** TERM → KILL grace of a cancel (spec §14). */
|
|
4
|
+
export const EXEC_KILL_GRACE_MS = 5000;
|
|
5
|
+
const CHECKPOINT_VERSION = 1;
|
|
6
|
+
function parseBlob(blob) {
|
|
7
|
+
if (!blob)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const value = JSON.parse(blob);
|
|
11
|
+
const stream = (s) => !!s && Number.isSafeInteger(s.total) && Number.isSafeInteger(s.headLen)
|
|
12
|
+
&& typeof s.tail === 'string' && typeof s.pending === 'string' && Number.isSafeInteger(s.lost);
|
|
13
|
+
if (value?.v !== CHECKPOINT_VERSION || !stream(value.stdout) || !stream(value.stderr))
|
|
14
|
+
return null;
|
|
15
|
+
const wire = value.wire && typeof value.wire === 'object' && typeof value.wire.streaming === 'boolean' ? { streaming: value.wire.streaming } : undefined;
|
|
16
|
+
return {
|
|
17
|
+
...value, cancel: value.cancel && Number.isFinite(value.cancel.termAtMs) ? value.cancel : null, outlivesChannel: value.outlivesChannel === true,
|
|
18
|
+
wire, gapBytes: Number.isSafeInteger(value.gapBytes) && value.gapBytes > 0 ? value.gapBytes : 0,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function errorCode(error) {
|
|
26
|
+
return typeof error === 'object' && error !== null && typeof error.code === 'string' ? error.code : undefined;
|
|
27
|
+
}
|
|
28
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
29
|
+
/**
|
|
30
|
+
* The head bytes a checkpoint did not carry, re-read from the keeper: records from offset 0 (below the commit only the kept head
|
|
31
|
+
* reserve and the spawn record remain) up to the checkpoint's offset, until each stream has `want` contiguous bytes from 0.
|
|
32
|
+
*/
|
|
33
|
+
async function readHeads(keeper, execId, want, through) {
|
|
34
|
+
const heads = { stdout: Buffer.alloc(want.stdout), stderr: Buffer.alloc(want.stderr) };
|
|
35
|
+
const filled = { stdout: 0, stderr: 0 };
|
|
36
|
+
let from = 0;
|
|
37
|
+
while ((filled.stdout < want.stdout || filled.stderr < want.stderr) && from <= through) {
|
|
38
|
+
const page = await keeper.execRead({ execId, from, limit: 1024 });
|
|
39
|
+
if (page.records.length === 0)
|
|
40
|
+
break;
|
|
41
|
+
for (const record of page.records) {
|
|
42
|
+
if (record.o > through) {
|
|
43
|
+
from = through + 1;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
if (record.k !== 'out' && record.k !== 'err')
|
|
47
|
+
continue;
|
|
48
|
+
const name = record.k === 'out' ? 'stdout' : 'stderr';
|
|
49
|
+
const so = record.so;
|
|
50
|
+
if (so !== filled[name] || so >= want[name])
|
|
51
|
+
continue; // contiguous from 0 only
|
|
52
|
+
const bytes = Buffer.from(record.b, 'base64');
|
|
53
|
+
filled[name] += bytes.copy(heads[name], so, 0, Math.min(bytes.length, want[name] - so));
|
|
54
|
+
}
|
|
55
|
+
if (from <= through)
|
|
56
|
+
from = page.next;
|
|
57
|
+
}
|
|
58
|
+
return { stdout: heads.stdout.subarray(0, filled.stdout), stderr: heads.stderr.subarray(0, filled.stderr) };
|
|
59
|
+
}
|
|
60
|
+
export class DurableExec {
|
|
61
|
+
keeper;
|
|
62
|
+
execId;
|
|
63
|
+
shaper;
|
|
64
|
+
outlivesChannel;
|
|
65
|
+
wire;
|
|
66
|
+
options;
|
|
67
|
+
now;
|
|
68
|
+
killGraceMs;
|
|
69
|
+
log;
|
|
70
|
+
next = 0;
|
|
71
|
+
processed = -1;
|
|
72
|
+
committed = -1;
|
|
73
|
+
checkpointed = -1;
|
|
74
|
+
bytesSinceCheckpoint = 0;
|
|
75
|
+
lastCheckpointMs;
|
|
76
|
+
lastRecordMs;
|
|
77
|
+
lastContactMs;
|
|
78
|
+
cancelState = null;
|
|
79
|
+
sink;
|
|
80
|
+
sub;
|
|
81
|
+
subscribing = false;
|
|
82
|
+
done = false;
|
|
83
|
+
exitOffset;
|
|
84
|
+
released = false;
|
|
85
|
+
processing = Promise.resolve();
|
|
86
|
+
persisting = Promise.resolve();
|
|
87
|
+
killTimer;
|
|
88
|
+
resubscribeTimer;
|
|
89
|
+
watchdog;
|
|
90
|
+
settle;
|
|
91
|
+
/** Highest offset the server acknowledged (wire v5); the keeper's commit watermark after a restart. */
|
|
92
|
+
acked = -1;
|
|
93
|
+
/** Offsets of written checkpoints whose decoders held no bytes, ascending (ack-gated commits land only on these). */
|
|
94
|
+
cleanCheckpoints = [];
|
|
95
|
+
holds = new Set();
|
|
96
|
+
progressListeners = new Set();
|
|
97
|
+
gapBytesSeen = 0;
|
|
98
|
+
/** Target of the commit in flight (or the last one sent), so a new reader never starts below what it may delete. */
|
|
99
|
+
committing = -1;
|
|
100
|
+
/** Resolves once with the exec's outcome. */
|
|
101
|
+
outcome;
|
|
102
|
+
/** Process id of the command (from the start answer or the spawn record), when known. */
|
|
103
|
+
pid = null;
|
|
104
|
+
/** When keeper-terminal created the exec (its clock). */
|
|
105
|
+
startedAtMs;
|
|
106
|
+
/**
|
|
107
|
+
* Settles once the exec has a checkpoint (its first write was attempted; at once for a restored exec). Until then a restarted
|
|
108
|
+
* daemon finds no checkpoint and has to guess what the exec is (exec/policy.ts `load`).
|
|
109
|
+
*/
|
|
110
|
+
firstCheckpoint = Promise.resolve();
|
|
111
|
+
constructor(keeper, execId, shaper,
|
|
112
|
+
/** See {@link CheckpointBlob.outlivesChannel}. */
|
|
113
|
+
outlivesChannel,
|
|
114
|
+
/** See {@link CheckpointBlob.wire}; null for an exec no server can re-attach to (wire ≤ 4). */
|
|
115
|
+
wire, options) {
|
|
116
|
+
this.keeper = keeper;
|
|
117
|
+
this.execId = execId;
|
|
118
|
+
this.shaper = shaper;
|
|
119
|
+
this.outlivesChannel = outlivesChannel;
|
|
120
|
+
this.wire = wire;
|
|
121
|
+
this.options = options;
|
|
122
|
+
this.now = options.now ?? Date.now;
|
|
123
|
+
this.killGraceMs = options.killGraceMs ?? EXEC_KILL_GRACE_MS;
|
|
124
|
+
this.log = options.log ?? (() => { });
|
|
125
|
+
this.lastCheckpointMs = this.lastRecordMs = this.lastContactMs = this.startedAtMs = this.now();
|
|
126
|
+
this.outcome = new Promise((resolve) => { this.settle = resolve; });
|
|
127
|
+
}
|
|
128
|
+
/** Follows an exec the keeper just started (`exec.start` answered `descriptor`). Writes the first checkpoint at once. */
|
|
129
|
+
static follow(keeper, descriptor, init, options = {}) {
|
|
130
|
+
const exec = new DurableExec(keeper, descriptor.execId, new ExecShaper(), init.outlivesChannel, init.wire ?? null, options);
|
|
131
|
+
exec.pid = descriptor.pid;
|
|
132
|
+
exec.startedAtMs = descriptor.createdAtMs;
|
|
133
|
+
exec.begin();
|
|
134
|
+
// Settles once the checkpoint WRITE was answered (or failed), not after the commit behind it: a keeper that is slow to commit
|
|
135
|
+
// must not hold the caller's shell slot (and a drain or a force update) for as long as the commit takes (Task 26).
|
|
136
|
+
exec.firstCheckpoint = new Promise((resolve) => {
|
|
137
|
+
exec.persist(resolve).catch((error) => exec.log(`exec ${exec.execId}: checkpoint failed: ${messageOf(error)}`)).finally(resolve);
|
|
138
|
+
});
|
|
139
|
+
return exec;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Restores an exec a previous daemon followed (after a crash, an upgrade, a kill -9): its checkpoint's shaping and cancel state,
|
|
143
|
+
* the head re-read from the keeper, then the records after the checkpoint's offset. A pending cancel continues its grace.
|
|
144
|
+
*/
|
|
145
|
+
static async restore(keeper, status, options = {}) {
|
|
146
|
+
const checkpoint = status.checkpoint;
|
|
147
|
+
const blob = parseBlob(checkpoint?.blob);
|
|
148
|
+
let shaper = new ExecShaper();
|
|
149
|
+
if (blob && checkpoint && !status.logReclaimed) {
|
|
150
|
+
const heads = await readHeads(keeper, status.execId, { stdout: blob.stdout.headLen, stderr: blob.stderr.headLen }, checkpoint.offset);
|
|
151
|
+
shaper = new ExecShaper(blob, heads);
|
|
152
|
+
}
|
|
153
|
+
// Without a checkpoint (it was never written) the caller says whether it outlives its channel; else it does not (wire ≤ 4).
|
|
154
|
+
const wire = blob ? blob.wire ?? null : options.wire ?? null;
|
|
155
|
+
const exec = new DurableExec(keeper, status.execId, shaper, blob?.outlivesChannel ?? options.outlivesChannel ?? false, wire, options);
|
|
156
|
+
exec.pid = status.pid;
|
|
157
|
+
exec.startedAtMs = status.createdAtMs;
|
|
158
|
+
if (blob && checkpoint) {
|
|
159
|
+
exec.next = checkpoint.offset + 1;
|
|
160
|
+
exec.processed = exec.checkpointed = checkpoint.offset;
|
|
161
|
+
exec.cancelState = blob.cancel;
|
|
162
|
+
exec.gapBytesSeen = blob.gapBytes ?? 0;
|
|
163
|
+
if (shaper.clean)
|
|
164
|
+
exec.cleanCheckpoints.push(checkpoint.offset);
|
|
165
|
+
}
|
|
166
|
+
// Under an ack-gated commit the keeper's commit watermark never passed an acknowledgement: it is the server's lower bound.
|
|
167
|
+
exec.committed = exec.acked = status.committed;
|
|
168
|
+
if (status.logReclaimed) {
|
|
169
|
+
exec.exitOffset = status.head;
|
|
170
|
+
exec.finishUnknown('keeper-terminal reclaimed the command output');
|
|
171
|
+
return exec;
|
|
172
|
+
}
|
|
173
|
+
exec.begin();
|
|
174
|
+
if (exec.cancelState && status.state !== 'exited')
|
|
175
|
+
exec.armKill();
|
|
176
|
+
return exec;
|
|
177
|
+
}
|
|
178
|
+
/** Stops following without an outcome (another follower owns the exec). Touches nothing on the keeper. */
|
|
179
|
+
abandon() { if (!this.done)
|
|
180
|
+
this.stop(); }
|
|
181
|
+
/** Streaming output goes to `sink` from now on (undefined: nobody is listening). */
|
|
182
|
+
attach(sink) { this.sink = sink; }
|
|
183
|
+
get finished() { return this.done; }
|
|
184
|
+
get cancelling() { return this.cancelState !== null; }
|
|
185
|
+
/** The keeper's commit watermark as this daemon last set or saw it (records up to here may be gone). */
|
|
186
|
+
get committedOffset() { return this.committed; }
|
|
187
|
+
/**
|
|
188
|
+
* Where a reader may start decoding from nothing (exec/attach.ts): the highest commit sent, whether or not it was answered yet.
|
|
189
|
+
* Every ack-gated commit lands on a clean checkpoint, so decoding from the record after it gives the shaper's text.
|
|
190
|
+
*/
|
|
191
|
+
get commitFloor() { return Math.max(this.committed, this.committing); }
|
|
192
|
+
/** Offset of the exit record, once processed (or the head of a reclaimed log). */
|
|
193
|
+
get exitRecordOffset() { return this.exitOffset; }
|
|
194
|
+
/**
|
|
195
|
+
* The offset an `exec_ack` must reach to release the ended exec: its exit record, or (an outcome that is unknown without an exit
|
|
196
|
+
* record) one past the last record this daemon processed.
|
|
197
|
+
*/
|
|
198
|
+
get finalHead() { return this.exitOffset ?? this.processed + 1; }
|
|
199
|
+
/** Output bytes the keeper's quota dropped (so far). */
|
|
200
|
+
get gapBytes() { return this.gapBytesSeen; }
|
|
201
|
+
get ackGated() { return this.wire?.streaming === true; }
|
|
202
|
+
/** Calls `listener` whenever more records were processed or the exec ended. Returns the unsubscribe. */
|
|
203
|
+
onProgress(listener) {
|
|
204
|
+
this.progressListeners.add(listener);
|
|
205
|
+
return () => { this.progressListeners.delete(listener); };
|
|
206
|
+
}
|
|
207
|
+
progressed() {
|
|
208
|
+
for (const listener of [...this.progressListeners]) {
|
|
209
|
+
try {
|
|
210
|
+
listener();
|
|
211
|
+
}
|
|
212
|
+
catch { /* a listener never breaks processing */ }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** A page of the exec's records from keeper-terminal (exec/attach.ts reads its own way through the log). */
|
|
216
|
+
read(from, limit) { return this.keeper.execRead({ execId: this.execId, from, limit }); }
|
|
217
|
+
/** Keeps every record from `at` on readable (an ack-gated commit stays below it) until {@link unhold}. */
|
|
218
|
+
hold(at) { const hold = { at }; this.holds.add(hold); return hold; }
|
|
219
|
+
unhold(hold) { if (this.holds.delete(hold))
|
|
220
|
+
this.commitSoon(); }
|
|
221
|
+
/**
|
|
222
|
+
* The server persisted everything through `through` (`exec_ack`): an ack-gated exec may commit up to there (on a clean
|
|
223
|
+
* checkpoint, below every hold). Never moves backwards.
|
|
224
|
+
*/
|
|
225
|
+
acknowledge(through) {
|
|
226
|
+
if (!this.wire || through <= this.acked)
|
|
227
|
+
return;
|
|
228
|
+
this.acked = through;
|
|
229
|
+
this.commitSoon();
|
|
230
|
+
}
|
|
231
|
+
/** Commits what the rules allow now, after any checkpoint or commit in flight. */
|
|
232
|
+
commitSoon() {
|
|
233
|
+
if (this.done || !this.ackGated)
|
|
234
|
+
return;
|
|
235
|
+
this.persisting = this.persisting.catch(() => { }).then(() => this.commitDue())
|
|
236
|
+
.catch((error) => this.log(`exec ${this.execId}: commit failed: ${messageOf(error)}`));
|
|
237
|
+
}
|
|
238
|
+
/** `exec_status` of this exec: from keeper-terminal when it answers, else from what this daemon knows. */
|
|
239
|
+
async wireStatus() {
|
|
240
|
+
if (this.done)
|
|
241
|
+
return { state: 'exited', headOffset: this.finalHead, committedOffset: this.committed };
|
|
242
|
+
try {
|
|
243
|
+
const status = await this.keeper.execStatus(this.execId);
|
|
244
|
+
return { state: status.state === 'exited' ? 'exited' : 'running', headOffset: status.head, committedOffset: status.committed };
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return { state: 'running', headOffset: this.processed, committedOffset: this.committed };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
begin() {
|
|
251
|
+
void this.subscribe();
|
|
252
|
+
this.watchdog = setInterval(() => { void this.check(); }, this.options.watchdogMs ?? 10_000);
|
|
253
|
+
this.watchdog.unref();
|
|
254
|
+
}
|
|
255
|
+
async subscribe() {
|
|
256
|
+
if (this.done || this.subscribing)
|
|
257
|
+
return;
|
|
258
|
+
this.subscribing = true;
|
|
259
|
+
try {
|
|
260
|
+
// Unsubscribe first: an unsubscribe sent after a new subscription would end the new one on the keeper.
|
|
261
|
+
const old = this.sub;
|
|
262
|
+
this.sub = undefined;
|
|
263
|
+
await old?.unsubscribe().catch(() => { });
|
|
264
|
+
if (this.done)
|
|
265
|
+
return;
|
|
266
|
+
this.sub = await this.keeper.execSubscribe(this.execId, (records) => this.enqueue(records), {
|
|
267
|
+
from: this.next,
|
|
268
|
+
onClosed: (error) => { this.log(`exec ${this.execId}: stream closed: ${messageOf(error)}`); this.sub = undefined; this.resubscribeSoon(); },
|
|
269
|
+
});
|
|
270
|
+
this.lastContactMs = this.now();
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
this.log(`exec ${this.execId}: subscribe failed: ${messageOf(error)}`);
|
|
274
|
+
this.resubscribeSoon(1000);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
this.subscribing = false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
resubscribeSoon(ms = 500) {
|
|
281
|
+
if (this.done || this.resubscribeTimer)
|
|
282
|
+
return;
|
|
283
|
+
this.resubscribeTimer = setTimeout(() => { this.resubscribeTimer = undefined; void this.check(); }, ms);
|
|
284
|
+
this.resubscribeTimer.unref();
|
|
285
|
+
}
|
|
286
|
+
/** Records are processed strictly in order, one batch after the other (also across a resubscription). */
|
|
287
|
+
enqueue(records) {
|
|
288
|
+
this.processing = this.processing.then(() => this.process(records)).catch((error) => { this.log(`exec ${this.execId}: ${messageOf(error)}`); });
|
|
289
|
+
return this.processing;
|
|
290
|
+
}
|
|
291
|
+
async process(records) {
|
|
292
|
+
if (this.done)
|
|
293
|
+
return;
|
|
294
|
+
this.lastRecordMs = this.lastContactMs = this.now();
|
|
295
|
+
for (const record of records) {
|
|
296
|
+
if (record.o < this.next)
|
|
297
|
+
continue;
|
|
298
|
+
this.next = record.o + 1;
|
|
299
|
+
if (record.k === 'out' || record.k === 'err') {
|
|
300
|
+
const name = record.k === 'out' ? 'stdout' : 'stderr';
|
|
301
|
+
const bytes = Buffer.from(record.b, 'base64');
|
|
302
|
+
const text = this.shaper.stream(name).push(record.so, bytes);
|
|
303
|
+
this.bytesSinceCheckpoint += bytes.length;
|
|
304
|
+
this.processed = record.o;
|
|
305
|
+
if (text)
|
|
306
|
+
this.emit(name, text);
|
|
307
|
+
}
|
|
308
|
+
else if (record.k === 'exit') {
|
|
309
|
+
this.processed = record.o;
|
|
310
|
+
await this.finish(record);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
// spawn, gap (bytes the quota dropped show up as a jump in the next chunk's stream offset)
|
|
315
|
+
if (record.k === 'spawn' && Number.isSafeInteger(record.pid))
|
|
316
|
+
this.pid = record.pid;
|
|
317
|
+
if (record.k === 'gap' && Number.isSafeInteger(record.droppedBytes) && record.droppedBytes > 0)
|
|
318
|
+
this.gapBytesSeen += record.droppedBytes;
|
|
319
|
+
this.processed = record.o;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
this.progressed();
|
|
323
|
+
const due = this.bytesSinceCheckpoint >= (this.options.checkpointBytes ?? 1024 * 1024)
|
|
324
|
+
|| (this.processed > this.checkpointed && this.now() - this.lastCheckpointMs >= (this.options.checkpointMs ?? 2000));
|
|
325
|
+
if (due)
|
|
326
|
+
await this.persist().catch((error) => this.log(`exec ${this.execId}: checkpoint failed: ${messageOf(error)}`));
|
|
327
|
+
}
|
|
328
|
+
emit(stream, text) {
|
|
329
|
+
try {
|
|
330
|
+
this.sink?.(stream, text);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
this.log(`exec ${this.execId}: output sink failed: ${messageOf(error)}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
blob() {
|
|
337
|
+
const blob = {
|
|
338
|
+
v: CHECKPOINT_VERSION, ...this.shaper.state(), cancel: this.cancelState, outlivesChannel: this.outlivesChannel,
|
|
339
|
+
...(this.wire ? { wire: this.wire } : {}), ...(this.gapBytesSeen > 0 ? { gapBytes: this.gapBytesSeen } : {}),
|
|
340
|
+
};
|
|
341
|
+
return JSON.stringify(blob);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Writes a checkpoint of the state as of the last processed record, then commits through that same offset (never further;
|
|
345
|
+
* an ack-gated exec possibly less, see the file comment). Checkpoints are serialised: each one covers at least what the previous
|
|
346
|
+
* one did.
|
|
347
|
+
*/
|
|
348
|
+
persist(onWritten) {
|
|
349
|
+
const run = async () => {
|
|
350
|
+
if (this.done)
|
|
351
|
+
return;
|
|
352
|
+
const offset = this.processed;
|
|
353
|
+
const blob = this.blob();
|
|
354
|
+
const clean = this.shaper.clean;
|
|
355
|
+
this.bytesSinceCheckpoint = 0;
|
|
356
|
+
this.lastCheckpointMs = this.now();
|
|
357
|
+
await this.keeper.execCheckpoint({ execId: this.execId, blob, offset });
|
|
358
|
+
onWritten?.();
|
|
359
|
+
this.checkpointed = Math.max(this.checkpointed, offset);
|
|
360
|
+
if (clean && offset > (this.cleanCheckpoints.at(-1) ?? -2))
|
|
361
|
+
this.cleanCheckpoints.push(offset);
|
|
362
|
+
await this.commitDue();
|
|
363
|
+
};
|
|
364
|
+
this.persisting = this.persisting.catch(() => { }).then(run);
|
|
365
|
+
return this.persisting;
|
|
366
|
+
}
|
|
367
|
+
/** Where a commit may go now: the checkpoint; ack-gated, the last clean checkpoint at or below the ack and every hold. */
|
|
368
|
+
commitTarget() {
|
|
369
|
+
if (!this.ackGated)
|
|
370
|
+
return this.checkpointed;
|
|
371
|
+
let limit = Math.min(this.acked, this.checkpointed);
|
|
372
|
+
for (const hold of this.holds)
|
|
373
|
+
limit = Math.min(limit, hold.at - 1);
|
|
374
|
+
let target = this.committed;
|
|
375
|
+
for (const offset of this.cleanCheckpoints)
|
|
376
|
+
if (offset <= limit)
|
|
377
|
+
target = Math.max(target, offset);
|
|
378
|
+
return target;
|
|
379
|
+
}
|
|
380
|
+
async commitDue() {
|
|
381
|
+
if (this.done)
|
|
382
|
+
return;
|
|
383
|
+
const target = this.commitTarget();
|
|
384
|
+
if (target <= this.committed)
|
|
385
|
+
return;
|
|
386
|
+
this.committing = Math.max(this.committing, target);
|
|
387
|
+
const result = await this.keeper.execCommit({ execId: this.execId, through: target });
|
|
388
|
+
this.committed = Math.max(this.committed, result.committed);
|
|
389
|
+
// Clean checkpoints below the commit are of no use any more (keep the one at it).
|
|
390
|
+
const keepFrom = this.cleanCheckpoints.findIndex((offset) => offset >= this.committed);
|
|
391
|
+
this.cleanCheckpoints = keepFrom < 0 ? [] : this.cleanCheckpoints.slice(keepFrom);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Cancels: the decision is checkpointed, then `signal` (default SIGTERM) goes to the process group and SIGKILL follows the grace
|
|
395
|
+
* (also from a restarted daemon, which finds the decision in the checkpoint). Idempotent: a second cancel changes nothing.
|
|
396
|
+
*/
|
|
397
|
+
async cancel(signal = 'SIGTERM') {
|
|
398
|
+
if (this.done || this.cancelState)
|
|
399
|
+
return;
|
|
400
|
+
this.cancelState = { termAtMs: this.now() };
|
|
401
|
+
await this.persist().catch((error) => this.log(`exec ${this.execId}: cancel checkpoint failed: ${messageOf(error)}`));
|
|
402
|
+
if (this.done)
|
|
403
|
+
return;
|
|
404
|
+
await this.keeper.execKill({ execId: this.execId, signal }).catch((error) => this.log(`exec ${this.execId}: ${signal} failed: ${messageOf(error)}`));
|
|
405
|
+
this.armKill();
|
|
406
|
+
}
|
|
407
|
+
armKill() {
|
|
408
|
+
if (this.done || !this.cancelState || this.killTimer)
|
|
409
|
+
return;
|
|
410
|
+
const delay = Math.max(0, this.cancelState.termAtMs + this.killGraceMs - this.now());
|
|
411
|
+
this.killTimer = setTimeout(() => { void this.sendKill(); }, delay);
|
|
412
|
+
this.killTimer.unref();
|
|
413
|
+
}
|
|
414
|
+
async sendKill() {
|
|
415
|
+
if (this.done)
|
|
416
|
+
return;
|
|
417
|
+
await this.keeper.execKill({ execId: this.execId, signal: 'SIGKILL' }).catch((error) => this.log(`exec ${this.execId}: SIGKILL failed: ${messageOf(error)}`));
|
|
418
|
+
}
|
|
419
|
+
/** Watchdog: a deleted exec, a stalled or lost subscription, an overdue SIGKILL, an unreachable keeper, a quiet exec's checkpoint. */
|
|
420
|
+
async check() {
|
|
421
|
+
if (this.done)
|
|
422
|
+
return;
|
|
423
|
+
let status;
|
|
424
|
+
try {
|
|
425
|
+
status = await this.keeper.execStatus(this.execId);
|
|
426
|
+
this.lastContactMs = this.now();
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (errorCode(error) === ExecErrorCode.notFound) {
|
|
430
|
+
this.finishUnknown('keeper-terminal no longer knows the command');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (this.now() - this.lastContactMs >= (this.options.giveUpMs ?? 120_000)) {
|
|
434
|
+
this.finishUnknown('keeper-terminal could not be reached');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
this.resubscribeSoon(2000);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (this.done)
|
|
441
|
+
return;
|
|
442
|
+
if (status.logReclaimed) {
|
|
443
|
+
this.exitOffset = status.head;
|
|
444
|
+
this.finishUnknown('keeper-terminal reclaimed the command output');
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// A command that went quiet still gets its last records checkpointed (and so committed, or committable on the server's ack).
|
|
448
|
+
if (this.processed > this.checkpointed && this.now() - this.lastCheckpointMs >= (this.options.checkpointMs ?? 2000)) {
|
|
449
|
+
void this.persist().catch((error) => this.log(`exec ${this.execId}: checkpoint failed: ${messageOf(error)}`));
|
|
450
|
+
}
|
|
451
|
+
if (this.cancelState && status.state !== 'exited' && this.now() >= this.cancelState.termAtMs + this.killGraceMs)
|
|
452
|
+
void this.sendKill();
|
|
453
|
+
const stalled = status.head >= this.next && this.now() - this.lastRecordMs >= (this.options.watchdogMs ?? 10_000);
|
|
454
|
+
if (!this.sub || stalled)
|
|
455
|
+
void this.subscribe();
|
|
456
|
+
}
|
|
457
|
+
stop() {
|
|
458
|
+
this.done = true;
|
|
459
|
+
clearTimeout(this.killTimer);
|
|
460
|
+
clearTimeout(this.resubscribeTimer);
|
|
461
|
+
clearInterval(this.watchdog);
|
|
462
|
+
const sub = this.sub;
|
|
463
|
+
this.sub = undefined;
|
|
464
|
+
void sub?.unsubscribe().catch(() => { });
|
|
465
|
+
}
|
|
466
|
+
flushStreaming() {
|
|
467
|
+
for (const name of ['stdout', 'stderr']) {
|
|
468
|
+
const text = this.shaper.stream(name).flushStreaming();
|
|
469
|
+
if (text)
|
|
470
|
+
this.emit(name, text);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async finish(record) {
|
|
474
|
+
this.stop();
|
|
475
|
+
this.exitOffset = record.o;
|
|
476
|
+
this.flushStreaming();
|
|
477
|
+
await this.persisting.catch(() => { }); // a checkpoint in flight lands before anything else touches the exec
|
|
478
|
+
const exit = {
|
|
479
|
+
code: record.code ?? null, signal: record.signal ?? null, timedOut: record.timedOut === true,
|
|
480
|
+
...(record.reason ? { reason: record.reason } : {}), ...(record.error ? { error: String(record.error) } : {}),
|
|
481
|
+
atMs: record.t,
|
|
482
|
+
};
|
|
483
|
+
this.settle({ state: 'exited', exit, cancelled: this.cancelState !== null, ...this.shaper.result() });
|
|
484
|
+
this.progressed();
|
|
485
|
+
}
|
|
486
|
+
finishUnknown(reason) {
|
|
487
|
+
this.stop();
|
|
488
|
+
this.flushStreaming();
|
|
489
|
+
this.settle({ state: 'unknown', reason, cancelled: this.cancelState !== null, ...this.shaper.result() });
|
|
490
|
+
this.progressed();
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* The outcome was delivered (or nobody will ever want it): the keeper may delete the exec (`exec.commit{final}`). Only for an
|
|
494
|
+
* ended exec; a no-op otherwise and when already released.
|
|
495
|
+
*/
|
|
496
|
+
async release() {
|
|
497
|
+
if (!this.done || this.released || this.exitOffset === undefined)
|
|
498
|
+
return;
|
|
499
|
+
this.released = true;
|
|
500
|
+
try {
|
|
501
|
+
await this.keeper.execCommit({ execId: this.execId, through: this.exitOffset, final: true });
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
if (errorCode(error) === ExecErrorCode.notFound)
|
|
505
|
+
return;
|
|
506
|
+
try { // the head moved (it cannot after an exit, but the keeper decides): cover its head
|
|
507
|
+
const status = await this.keeper.execStatus(this.execId);
|
|
508
|
+
if (status.state === 'exited')
|
|
509
|
+
await this.keeper.execCommit({ execId: this.execId, through: status.head, final: true });
|
|
510
|
+
}
|
|
511
|
+
catch (retry) {
|
|
512
|
+
if (errorCode(retry) !== ExecErrorCode.notFound)
|
|
513
|
+
this.log(`exec ${this.execId}: final commit failed: ${messageOf(retry)}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The in-process exec (spec §4.2 "fallback"): the daemon spawns the command itself, as it always did, when keeper-terminal is not
|
|
2
|
+
// available or not compatible. Such an exec is NOT durable: it dies with the daemon and with its channel (today's semantics).
|
|
3
|
+
// Result shaping and argv/env are the same as the durable path's (exec/shaping.ts, exec/command.ts); cancellation and the timeout
|
|
4
|
+
// keep today's immediate SIGKILL of the process group.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { execArgv, execDeadline, execEnvironment } from './command.js';
|
|
7
|
+
import { ExecShaper } from './shaping.js';
|
|
8
|
+
export function runExec(req, onOutput, signal, options = {}) {
|
|
9
|
+
const running = startExec(req, onOutput, options);
|
|
10
|
+
const onAbort = () => { void running.cancel(); };
|
|
11
|
+
if (signal?.aborted)
|
|
12
|
+
onAbort();
|
|
13
|
+
else
|
|
14
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
15
|
+
void running.result.finally(() => signal?.removeEventListener('abort', onAbort));
|
|
16
|
+
return running.result;
|
|
17
|
+
}
|
|
18
|
+
/** Starts an exec in its own process group and exposes the same cancellation used by force update. */
|
|
19
|
+
export function startExec(req, onOutput, options = {}) {
|
|
20
|
+
const now = options.now ?? Date.now;
|
|
21
|
+
const deadline = options.deadlineEpochMs ?? execDeadline(req, now());
|
|
22
|
+
let cancel = async () => { };
|
|
23
|
+
let pid;
|
|
24
|
+
if (deadline !== undefined && deadline <= now()) { // already past its deadline: never spawned
|
|
25
|
+
const result = Promise.resolve({ t: 'exec_exit', id: req.id, exitCode: null, stdout: '', stderr: '', timedOut: true, truncated: false });
|
|
26
|
+
return { result, cancel: async () => { }, pid: undefined };
|
|
27
|
+
}
|
|
28
|
+
const result = new Promise((resolve) => {
|
|
29
|
+
const env = options.env ?? execEnvironment(process.env, req);
|
|
30
|
+
const [file, ...args] = execArgv(req);
|
|
31
|
+
// macOS and Linux create a new session/process group for a detached child. Every command it
|
|
32
|
+
// launches (pipelines and background jobs included) stays in that group, so cancellation does
|
|
33
|
+
// not leave descendants running after the shell parent is gone.
|
|
34
|
+
const child = spawn(file, args, { cwd: req.cwd, env, detached: process.platform !== 'win32' });
|
|
35
|
+
pid = child.pid;
|
|
36
|
+
const shaper = new ExecShaper();
|
|
37
|
+
const offsets = { stdout: 0, stderr: 0 };
|
|
38
|
+
let timedOut = false;
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
let settled = false;
|
|
41
|
+
const capture = (buf, stream) => {
|
|
42
|
+
const text = shaper.stream(stream).push(offsets[stream], buf);
|
|
43
|
+
offsets[stream] += buf.length;
|
|
44
|
+
if (req.streaming && onOutput && text)
|
|
45
|
+
onOutput(stream, text);
|
|
46
|
+
};
|
|
47
|
+
child.stdout.on('data', (b) => capture(b, 'stdout'));
|
|
48
|
+
child.stderr.on('data', (b) => capture(b, 'stderr'));
|
|
49
|
+
const terminateCommandGroup = () => {
|
|
50
|
+
if (child.pid && process.platform !== 'win32') {
|
|
51
|
+
try {
|
|
52
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// The group may have already exited between pid observation and signal delivery.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
child.kill('SIGKILL');
|
|
60
|
+
};
|
|
61
|
+
let timer;
|
|
62
|
+
if (deadline !== undefined)
|
|
63
|
+
timer = setTimeout(() => { timedOut = true; terminateCommandGroup(); }, Math.max(0, deadline - now()));
|
|
64
|
+
const abort = () => { cancelled = true; terminateCommandGroup(); };
|
|
65
|
+
cancel = async () => { abort(); };
|
|
66
|
+
const flushStreaming = () => {
|
|
67
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
68
|
+
const text = shaper.stream(stream).flushStreaming();
|
|
69
|
+
if (req.streaming && onOutput && text)
|
|
70
|
+
onOutput(stream, text);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
child.on('close', (code) => {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
settled = true;
|
|
77
|
+
if (timer)
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
flushStreaming();
|
|
80
|
+
const shaped = shaper.result();
|
|
81
|
+
resolve({
|
|
82
|
+
t: 'exec_exit', id: req.id,
|
|
83
|
+
exitCode: timedOut || cancelled ? null : code,
|
|
84
|
+
stdout: shaped.stdout, stderr: shaped.stderr, timedOut, truncated: shaped.truncated,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
child.on('error', (err) => {
|
|
88
|
+
if (settled)
|
|
89
|
+
return;
|
|
90
|
+
settled = true;
|
|
91
|
+
if (timer)
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
flushStreaming();
|
|
94
|
+
const shaped = shaper.result();
|
|
95
|
+
resolve({
|
|
96
|
+
t: 'exec_exit', id: req.id,
|
|
97
|
+
exitCode: null,
|
|
98
|
+
stdout: shaped.stdout,
|
|
99
|
+
stderr: shaped.stderr + String(err?.message ?? err),
|
|
100
|
+
timedOut: false,
|
|
101
|
+
truncated: shaped.truncated,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
return { result, cancel: async () => cancel(), get pid() { return pid; } };
|
|
106
|
+
}
|