@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.
Files changed (281) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +132 -0
  4. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  6. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  7. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  8. package/dist/axom-patch/cli/account-limits/generate.py +120 -0
  9. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
  10. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  11. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  12. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  13. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  14. package/dist/axom-patch/cli/nightly-task.md +152 -0
  15. package/dist/axom-patch/cli/pi-steering.json +37 -0
  16. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  17. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
  18. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  19. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  20. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  21. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
  22. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  23. package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
  24. package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
  25. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  26. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  27. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  28. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  29. package/dist/axom-patch/cli/runtime.json +63 -0
  30. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  31. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  32. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  33. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  34. package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
  35. package/dist/axom-patch/cli/zcode-models.json +38 -0
  36. package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
  37. package/dist/cli.js +36 -1
  38. package/dist/commands/auth.js +4 -13
  39. package/dist/commands/execute.js +4 -4
  40. package/dist/commands/record.js +6 -6
  41. package/dist/commands/struct.js +4 -4
  42. package/dist/commands/workstation.js +2 -2
  43. package/dist/credentials.js +2 -2
  44. package/dist/install/claude/commands.js +13 -13
  45. package/dist/keepers/acp/keeper.mjs +3083 -0
  46. package/dist/keepers/acp/manifest.json +9 -0
  47. package/dist/keepers/terminal/keeper.mjs +8469 -0
  48. package/dist/keepers/terminal/manifest.json +9 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  52. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  53. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  54. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  55. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  56. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  57. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  58. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  59. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  60. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  61. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  62. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  63. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  64. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  65. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  66. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  67. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  68. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  69. package/dist/mcp.js +32 -14
  70. package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
  71. package/dist/runtime-deps/qoder-sdk/package.json +8 -0
  72. package/dist/test/keeperKit.js +67 -0
  73. package/dist/workstation/acp/acpAgents.js +2514 -681
  74. package/dist/workstation/acp/acpClient.js +95 -318
  75. package/dist/workstation/acp/activityBusy.js +43 -0
  76. package/dist/workstation/acp/activityReporting.js +54 -0
  77. package/dist/workstation/acp/adoption.js +28 -356
  78. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  79. package/dist/workstation/acp/binaryInstall.js +100 -27
  80. package/dist/workstation/acp/builds/gc.js +245 -0
  81. package/dist/workstation/acp/builds/install/binary.js +28 -0
  82. package/dist/workstation/acp/builds/install/npm.js +235 -0
  83. package/dist/workstation/acp/builds/install/uv.js +254 -0
  84. package/dist/workstation/acp/builds/migrate.js +195 -0
  85. package/dist/workstation/acp/builds/model.js +128 -0
  86. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  87. package/dist/workstation/acp/builds/store.js +352 -0
  88. package/dist/workstation/acp/commandTableProbe.js +68 -0
  89. package/dist/workstation/acp/configOptions.js +103 -1
  90. package/dist/workstation/acp/delegationMcp.js +24 -0
  91. package/dist/workstation/acp/envSecrets.js +27 -0
  92. package/dist/workstation/acp/initializeResponse.js +285 -0
  93. package/dist/workstation/acp/installErrors.js +12 -2
  94. package/dist/workstation/acp/installProbe.js +1 -1
  95. package/dist/workstation/acp/managedPatches.js +144 -0
  96. package/dist/workstation/acp/outbox.js +330 -0
  97. package/dist/workstation/acp/patchAssets.js +80 -0
  98. package/dist/workstation/acp/permission.js +335 -37
  99. package/dist/workstation/acp/processor/checkpoint.js +87 -0
  100. package/dist/workstation/acp/processor/commands.js +35 -0
  101. package/dist/workstation/acp/processor/control.js +355 -0
  102. package/dist/workstation/acp/processor/emit.js +26 -0
  103. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  104. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  105. package/dist/workstation/acp/processor/origins.js +48 -0
  106. package/dist/workstation/acp/processor/output.js +67 -0
  107. package/dist/workstation/acp/processor/projection.js +42 -0
  108. package/dist/workstation/acp/processor/record.js +128 -0
  109. package/dist/workstation/acp/processor/runtime.js +181 -0
  110. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  111. package/dist/workstation/acp/processor/session.js +525 -0
  112. package/dist/workstation/acp/processor/sideChannel.js +80 -0
  113. package/dist/workstation/acp/processor/state.js +74 -0
  114. package/dist/workstation/acp/processor/step.js +459 -0
  115. package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
  116. package/dist/workstation/acp/processor/test/harness.js +120 -0
  117. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  118. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  119. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  120. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  121. package/dist/workstation/acp/processor/transport/local.js +269 -0
  122. package/dist/workstation/acp/processor/transport/types.js +78 -0
  123. package/dist/workstation/acp/processor/updates.js +364 -0
  124. package/dist/workstation/acp/processorSession.js +1512 -0
  125. package/dist/workstation/acp/qoderStartup.js +45 -0
  126. package/dist/workstation/acp/registry/catalog.js +385 -4
  127. package/dist/workstation/acp/registry/detect.js +55 -0
  128. package/dist/workstation/acp/registry/launch.js +61 -119
  129. package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
  130. package/dist/workstation/acp/steering.js +58 -0
  131. package/dist/workstation/acp/switching.js +114 -0
  132. package/dist/workstation/acp/terminalLogin.js +171 -0
  133. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  134. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  135. package/dist/workstation/acp/test/daemonLife.js +106 -0
  136. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  137. package/dist/workstation/acp/test/keeperWorld.js +267 -0
  138. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  139. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  140. package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
  141. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  142. package/dist/workstation/acp/test/raw/sources.js +249 -0
  143. package/dist/workstation/acp/test/realAdapter.js +377 -0
  144. package/dist/workstation/acp/test/scriptAgent.js +206 -0
  145. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  146. package/dist/workstation/acp/timedCommand.js +4 -2
  147. package/dist/workstation/acp/uvxInstall.js +72 -122
  148. package/dist/workstation/acp/validate.js +33 -5
  149. package/dist/workstation/agentFlow.js +103 -0
  150. package/dist/workstation/agentSessions.js +1686 -348
  151. package/dist/workstation/channels/agent.js +14 -6
  152. package/dist/workstation/channels/computer.js +203 -7
  153. package/dist/workstation/channels/computerExecs.js +200 -0
  154. package/dist/workstation/channels/management.js +86 -6
  155. package/dist/workstation/config.js +3 -1
  156. package/dist/workstation/connection/encryptedChannel.js +88 -21
  157. package/dist/workstation/daemon.js +309 -6
  158. package/dist/workstation/daemonctl.js +28 -2
  159. package/dist/workstation/desktop/binding.js +36 -0
  160. package/dist/workstation/desktop/bridge.js +212 -0
  161. package/dist/workstation/desktop/flow-control.js +52 -0
  162. package/dist/workstation/desktop/focused-text.js +130 -0
  163. package/dist/workstation/desktop/protocol.js +35 -0
  164. package/dist/workstation/exec/attach.js +126 -0
  165. package/dist/workstation/exec/command.js +40 -0
  166. package/dist/workstation/exec/durable.js +517 -0
  167. package/dist/workstation/exec/fallback.js +106 -0
  168. package/dist/workstation/exec/policy.js +837 -0
  169. package/dist/workstation/exec/shaping.js +211 -0
  170. package/dist/workstation/exec.js +3 -112
  171. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  172. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  173. package/dist/workstation/files/client.js +455 -0
  174. package/dist/workstation/files/errors.js +28 -0
  175. package/dist/workstation/files/fence.js +350 -0
  176. package/dist/workstation/files/find/fileIndex.js +153 -0
  177. package/dist/workstation/files/find/fuzzy.js +155 -0
  178. package/dist/workstation/files/host.js +122 -0
  179. package/dist/workstation/files/hostProtocol.js +2 -0
  180. package/dist/workstation/files/media/cache.js +620 -0
  181. package/dist/workstation/files/media/encoding.js +308 -0
  182. package/dist/workstation/files/media/estimator.js +100 -0
  183. package/dist/workstation/files/media/media.js +325 -0
  184. package/dist/workstation/files/media/packedAudio.js +23 -0
  185. package/dist/workstation/files/media/scheduler.js +551 -0
  186. package/dist/workstation/files/media/types.js +5 -0
  187. package/dist/workstation/files/scope.js +45 -0
  188. package/dist/workstation/files/structured.js +284 -0
  189. package/dist/workstation/files/text.js +356 -0
  190. package/dist/workstation/files/uploads.js +351 -0
  191. package/dist/workstation/journal.js +65 -236
  192. package/dist/workstation/keepers/acp/client.js +101 -0
  193. package/dist/workstation/keepers/acp/envelope.js +62 -0
  194. package/dist/workstation/keepers/acp/host.js +419 -0
  195. package/dist/workstation/keepers/acp/main.js +18 -0
  196. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  197. package/dist/workstation/keepers/acp/params.js +137 -0
  198. package/dist/workstation/keepers/acp/protocol.js +66 -0
  199. package/dist/workstation/keepers/acp/recover.js +120 -0
  200. package/dist/workstation/keepers/acp/session.js +584 -0
  201. package/dist/workstation/keepers/acp/stderr.js +154 -0
  202. package/dist/workstation/keepers/kit/activity.js +103 -0
  203. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  204. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  205. package/dist/workstation/keepers/kit/bundle.js +120 -0
  206. package/dist/workstation/keepers/kit/client.js +432 -0
  207. package/dist/workstation/keepers/kit/identity.js +21 -0
  208. package/dist/workstation/keepers/kit/ipc.js +112 -0
  209. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  210. package/dist/workstation/keepers/kit/launcher.js +249 -0
  211. package/dist/workstation/keepers/kit/lock.js +123 -0
  212. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  213. package/dist/workstation/keepers/kit/paths.js +75 -0
  214. package/dist/workstation/keepers/kit/protocol.js +43 -0
  215. package/dist/workstation/keepers/kit/server.js +518 -0
  216. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  217. package/dist/workstation/keepers/legacy.js +296 -0
  218. package/dist/workstation/keepers/policy.js +576 -0
  219. package/dist/workstation/keepers/stop.js +34 -0
  220. package/dist/workstation/keepers/terminal/client.js +213 -0
  221. package/dist/workstation/keepers/terminal/exec.js +981 -0
  222. package/dist/workstation/keepers/terminal/generations.js +129 -0
  223. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  224. package/dist/workstation/keepers/terminal/main.js +43 -0
  225. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  226. package/dist/workstation/keepers/terminal/params.js +27 -0
  227. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  228. package/dist/workstation/keepers/terminal/pty.js +330 -0
  229. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  230. package/dist/workstation/protocol/accountLimits.js +47 -0
  231. package/dist/workstation/protocol/agent.js +201 -8
  232. package/dist/workstation/protocol/common.js +36 -0
  233. package/dist/workstation/protocol/computer.js +107 -7
  234. package/dist/workstation/protocol/management.js +50 -2
  235. package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
  236. package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
  237. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
  238. package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
  239. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  240. package/dist/workstation/qoderAdapter/contract.js +78 -0
  241. package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
  242. package/dist/workstation/qoderAdapter/history.js +85 -0
  243. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  244. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  245. package/dist/workstation/qoderAdapter/main.js +95 -0
  246. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  247. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  248. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  249. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  250. package/dist/workstation/qoderAdapter/profile.js +93 -0
  251. package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
  252. package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
  253. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
  254. package/dist/workstation/qoderAdapter/session.js +319 -0
  255. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  256. package/dist/workstation/qoderAdapter/subwork.js +85 -0
  257. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  258. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  259. package/dist/workstation/qoderAdapter/types.js +1 -0
  260. package/dist/workstation/qoderAdapter/usage.js +37 -0
  261. package/dist/workstation/register.js +4 -0
  262. package/dist/workstation/systemMetrics.js +110 -1
  263. package/dist/workstation/tools/browser.js +394 -0
  264. package/dist/workstation/tools/client.js +277 -0
  265. package/dist/workstation/tools/info.js +50 -0
  266. package/dist/workstation/tools/keeperProcess.js +124 -0
  267. package/dist/workstation/tools/router.js +55 -0
  268. package/dist/workstation/tools/terminalRouter.js +222 -0
  269. package/dist/workstation/tools/validation.js +86 -0
  270. package/dist/workstation/update/coordinator.js +32 -0
  271. package/dist/workstation/update/npmInstaller.js +2 -0
  272. package/dist/workstation/update/ptyPreflight.js +45 -0
  273. package/dist/workstation/update/taskRegistry.js +141 -23
  274. package/dist/workstation/update/watchdog.js +66 -0
  275. package/package.json +20 -7
  276. package/skills/axom/SKILL.md +55 -17
  277. package/skills/axom-definition/SKILL.md +32 -32
  278. package/skills/axom-platform/SKILL.md +39 -39
  279. package/dist/workstation/acp/acpSession.js +0 -1047
  280. package/dist/workstation/acp/contract.js +0 -494
  281. package/dist/workstation/acp/draftInterceptor.js +0 -154
@@ -0,0 +1,981 @@
1
+ // keeper-terminal's exec MECHANISM (spec §4.2, §3.4; analysis E §3.1-3.2, §6): spawn the daemon's argv exactly, write the raw
2
+ // stdout/stderr bytes into one durable stream log per exec, kill the process group at the daemon's absolute deadline (with or
3
+ // without a daemon connected), enforce the output quotas, and settle execs whose keeper died. Every policy decision (argv, env,
4
+ // deadline, cancel, result shaping, what to do on keeper loss) arrives as a parameter from the daemon.
5
+ // Keeper-side module: node builtins and kit siblings only (the import guard enforces it).
6
+ //
7
+ // On disk, per exec, under `<configDir>/keepers/terminal/data/exec/<execId>/` (0700 directories, 0600 files, never /tmp):
8
+ // exec.json descriptor (argv, cwd, env KEY NAMES, parameters, owner keeper, spawn identity, exit). Atomically replaced.
9
+ // log/ the StreamLog (`spawn`, `out`, `err`, `gap`, `exit` records), relaxed fsync (1 s / 4 MiB; spawn/exit at once).
10
+ // keep.jsonl the records the quota never drops — spawn, gap, exit, and the first `retainHeadBytes` of each stream — with their
11
+ // original offsets, so they stay readable after the log's older segments are dropped. The head reserve and the
12
+ // spawn record also survive the daemon's non-final commits (the daemon re-reads the head after a restart).
13
+ // Env values never reach the disk.
14
+ //
15
+ // Quota (spec §3.4): the pipe is always read. Output records beyond each stream's head reserve are "droppable"; when the
16
+ // droppable uncommitted output exceeds `maxUncommittedBytes`, the oldest of it is dropped (down to 3/4 of the limit, so a
17
+ // flood does not pay one commit per chunk): `gap{stream, droppedBytes, from, through}` records are written and the log's
18
+ // watermark moves past `through` (whole 8 MiB segments are deleted). Reads below that watermark are served from keep.jsonl.
19
+ // The keeper's total over all exec logs is bounded by 512 MiB by reclaiming the logs of the oldest ended execs first.
20
+ // Ended execs are removed entirely 24 h after they ended (logs only: a running exec is never killed by a TTL).
21
+ import { spawn } from 'node:child_process';
22
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeSync, chmodSync, ftruncateSync, } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { manifestProcessLive } from '../kit/lock.js';
25
+ import { KeeperMethodError } from '../kit/protocol.js';
26
+ import { StreamLog } from '../kit/streamLog.js';
27
+ import { settleOrphan, processStartTime, signalGroup } from '../kit/orphanSweep.js';
28
+ import { absolutePath, badRequest as bad, environment } from './params.js';
29
+ import { EXEC_CHUNK_BYTES, EXEC_FSYNC_BYTES, EXEC_FSYNC_INTERVAL_MS, EXEC_LABEL_CHARS, EXEC_RUNNING_LIMIT, EXEC_SEGMENT_BYTES, EXEC_SIGNALS, EXEC_TERMINAL_TTL_MS, EXEC_TOTAL_BYTES, ExecErrorCode, } from './protocol.js';
30
+ const META_FILE = 'exec.json';
31
+ const KEEP_FILE = 'keep.jsonl';
32
+ const LOG_DIR = 'log';
33
+ /** Accounted cost of one output record beyond its payload (its JSON envelope), so floods of tiny records are bounded too. */
34
+ const RECORD_OVERHEAD = 64;
35
+ /** Droppable runs are cut at about this many accounted bytes: few runs to track, and a commit inside one re-reads few records. */
36
+ const RUN_COST = 16 * 1024;
37
+ /** After a SIGKILL, a leader that has exited but whose pipes stay open (held by an escaped process) is closed after this long. */
38
+ const PIPE_CLOSE_AFTER_KILL_MS = 1000;
39
+ /** While a leader-exited exec runs (or a grace SIGKILL is due), its group is probed this often, so `groupGone` lags at most this long. */
40
+ const GROUP_PROBE_MS = 1000;
41
+ const MAX_ARGV = 4096;
42
+ const MAX_READ_RECORDS = 1024;
43
+ /** Keeps an `exec.read` answer far below the 2 MiB outbound frame. */
44
+ const MAX_READ_BYTES = 1536 * 1024;
45
+ const MAX_GRACE_MS = 10 * 60 * 1000;
46
+ const EXEC_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
47
+ const START_KEYS = ['execId', 'argv', 'cwd', 'env', 'deadlineEpochMs', 'killGraceMs', 'retainHeadBytes', 'maxUncommittedBytes', 'onKeeperLoss', 'label', 'jobId'];
48
+ /**
49
+ * Whether a kept record stays in keep.jsonl once the daemon committed through `committed`. The head reserve (kept `out`/`err`
50
+ * records: the first `retainHeadBytes` of each stream) and the spawn record survive every non-final commit: the daemon shapes the
51
+ * head of its result from them, also after a restart that lost everything but its checkpoint (spec §4.2; the checkpoint carries
52
+ * only the tails). Gap records below the commit are dropped (the daemon has seen them). Bounded: at most `retainHeadBytes` per
53
+ * stream. A final commit or the TTL removes the whole exec.
54
+ */
55
+ function survivesCommit(record, committed) {
56
+ return record.o > committed || record.k === 'spawn' || record.k === 'out' || record.k === 'err';
57
+ }
58
+ function keys(params, allowed) {
59
+ for (const key of Object.keys(params))
60
+ if (!allowed.includes(key))
61
+ bad(`Unexpected parameter ${key}`);
62
+ }
63
+ function execIdOf(params) {
64
+ const id = params.execId;
65
+ if (typeof id !== 'string' || !EXEC_ID.test(id) || id === '.' || id === '..')
66
+ bad('Invalid execId');
67
+ return id;
68
+ }
69
+ function integer(value, name, min, max) {
70
+ if (!Number.isSafeInteger(value) || value < min || value > max)
71
+ bad(`Invalid ${name}`);
72
+ return value;
73
+ }
74
+ /** Atomically replaces `file` with `content` (tmp + fsync + rename + directory fsync), mode 0600. */
75
+ function atomicWrite(file, content) {
76
+ const temp = `${file}.tmp`;
77
+ const fd = openSync(temp, 'w', 0o600);
78
+ try {
79
+ writeAllSync(fd, Buffer.from(content));
80
+ fsyncSync(fd);
81
+ }
82
+ finally {
83
+ closeSync(fd);
84
+ }
85
+ renameSync(temp, file);
86
+ fsyncDir(path.dirname(file));
87
+ }
88
+ function writeAllSync(fd, data) {
89
+ let written = 0;
90
+ while (written < data.length)
91
+ written += writeSync(fd, data, written, data.length - written);
92
+ }
93
+ function fsyncDir(dir) {
94
+ let fd;
95
+ try {
96
+ fd = openSync(dir, 'r');
97
+ fsyncSync(fd);
98
+ }
99
+ catch { /* best effort */ }
100
+ finally {
101
+ if (fd !== undefined)
102
+ try {
103
+ closeSync(fd);
104
+ }
105
+ catch { /* ignore */ }
106
+ }
107
+ }
108
+ function privateDir(dir) {
109
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
110
+ chmodSync(dir, 0o700);
111
+ }
112
+ function validMeta(value) {
113
+ if (typeof value !== 'object' || value === null)
114
+ return false;
115
+ const m = value;
116
+ const owner = m.owner;
117
+ return m.v === 1 && typeof m.execId === 'string' && Array.isArray(m.argv) && typeof m.cwd === 'string' && typeof m.label === 'string'
118
+ && Number.isSafeInteger(m.killGraceMs) && (m.onKeeperLoss === 'kill' || m.onKeeperLoss === 'leave')
119
+ && typeof owner === 'object' && owner !== null && Number.isSafeInteger(owner.pid) && typeof owner.bundleDir === 'string';
120
+ }
121
+ /** Loads keep.jsonl, truncating a torn tail (anything after the last intact line). */
122
+ function loadKeep(file) {
123
+ let data;
124
+ try {
125
+ data = readFileSync(file);
126
+ }
127
+ catch {
128
+ return [];
129
+ }
130
+ const records = [];
131
+ let good = 0;
132
+ for (let start = 0; start < data.length;) {
133
+ const nl = data.indexOf(10, start);
134
+ if (nl < 0)
135
+ break;
136
+ try {
137
+ const record = JSON.parse(data.subarray(start, nl).toString('utf8'));
138
+ if (!Number.isSafeInteger(record?.o) || typeof record.k !== 'string')
139
+ break;
140
+ records.push(record);
141
+ }
142
+ catch {
143
+ break;
144
+ }
145
+ start = nl + 1;
146
+ good = start;
147
+ }
148
+ if (good < data.length) {
149
+ const fd = openSync(file, 'r+');
150
+ try {
151
+ ftruncateSync(fd, good);
152
+ fsyncSync(fd);
153
+ }
154
+ finally {
155
+ closeSync(fd);
156
+ }
157
+ }
158
+ return records;
159
+ }
160
+ export class ExecHost {
161
+ options;
162
+ entries = new Map();
163
+ now;
164
+ logLine;
165
+ maintenanceTimer;
166
+ sinceTotalCheck = 0;
167
+ stopping = false;
168
+ constructor(options) {
169
+ this.options = options;
170
+ this.now = options.now ?? Date.now;
171
+ this.logLine = options.log ?? (() => { });
172
+ }
173
+ // ---- startup: adopt what is on disk, settle execs whose keeper died -----------------------------------------------
174
+ /**
175
+ * Startup sweep (spec §4.2): every exec directory not owned by another live keeper is adopted. One that spawned and has no exit
176
+ * lost its keeper: `onKeeperLoss` is applied (`kill`: only after verifying pid + start time, see `settleOrphan`) and
177
+ * `exit{reason:'keeper_lost'}` is written either way. Then TTL / total quota run once and the maintenance timer starts.
178
+ */
179
+ async open() {
180
+ privateDir(this.options.root);
181
+ await this.adopt();
182
+ this.maintain();
183
+ const every = this.options.maintenanceMs ?? 60_000;
184
+ this.maintenanceTimer = setInterval(() => { void this.adopt().then(() => this.maintain(), (error) => this.report(error)); }, every);
185
+ this.maintenanceTimer.unref();
186
+ }
187
+ report(error) { this.logLine(`exec: ${error instanceof Error ? error.message : String(error)}`); }
188
+ async ownerAlive(owner) {
189
+ if (owner.pid === this.options.owner.pid)
190
+ return false; // ours (a previous host in this very process): adopt
191
+ return (this.options.ownerAlive ?? manifestProcessLive)(owner);
192
+ }
193
+ async adopt() {
194
+ let names;
195
+ try {
196
+ names = readdirSync(this.options.root);
197
+ }
198
+ catch {
199
+ return;
200
+ }
201
+ for (const name of names) {
202
+ if (this.entries.has(name) || !EXEC_ID.test(name))
203
+ continue;
204
+ const dir = path.join(this.options.root, name);
205
+ let meta;
206
+ try {
207
+ const value = JSON.parse(readFileSync(path.join(dir, META_FILE), 'utf8'));
208
+ if (!validMeta(value) || value.execId !== name) {
209
+ this.report(new Error(`${name}: unreadable descriptor, left alone`));
210
+ continue;
211
+ }
212
+ meta = value;
213
+ }
214
+ catch {
215
+ continue;
216
+ } // being created by its owner, or not an exec directory
217
+ try {
218
+ if (await this.ownerAlive(meta.owner))
219
+ continue;
220
+ if (this.entries.has(name))
221
+ continue; // adopted meanwhile
222
+ await this.adoptOne(meta, dir);
223
+ }
224
+ catch (error) {
225
+ this.report(error);
226
+ }
227
+ }
228
+ }
229
+ async adoptOne(meta, dir) {
230
+ const entry = this.openEntry(meta, dir);
231
+ if (!meta.exit && entry.log) {
232
+ const head = entry.log.state().head;
233
+ const last = head >= 0 ? entry.log.read(head, 1)[0] : undefined;
234
+ if (last?.k === 'exit')
235
+ meta.exit = { code: last.code, signal: last.signal, timedOut: last.timedOut === true, ...(last.reason ? { reason: last.reason } : {}), atMs: last.t };
236
+ }
237
+ if (!meta.exit) {
238
+ let outcome = 'gone';
239
+ if (meta.spawn)
240
+ outcome = await settleOrphan(meta.spawn, meta.onKeeperLoss, meta.killGraceMs, this.options.sweep);
241
+ this.writeExit(entry, { code: null, signal: null, timedOut: false, reason: 'keeper_lost' });
242
+ this.logLine(`exec ${meta.execId}: keeper lost; process group ${outcome}`);
243
+ }
244
+ meta.owner = this.options.owner;
245
+ this.saveMeta(entry);
246
+ this.entries.set(meta.execId, entry);
247
+ }
248
+ openEntry(meta, dir) {
249
+ const entry = {
250
+ meta, dir, keep: [], keepOffsets: new Set(), committed: -1, dropped: -1, droppedSinceCommit: 0,
251
+ runs: [], droppableCost: 0, headBytes: { out: 0, err: 0 }, streamBytes: { out: 0, err: 0 }, timedOut: false, timers: [], leaderExited: false,
252
+ groupGone: false, graceKillPending: false, deadlineFired: false, stoppedByKeeper: false,
253
+ };
254
+ if (meta.logReclaimed)
255
+ return entry;
256
+ entry.log = this.openLog(dir);
257
+ const state = entry.log.state();
258
+ const tail = state.committedTail;
259
+ entry.committed = Number.isSafeInteger(tail?.committed) ? tail.committed : state.committed;
260
+ entry.dropped = Number.isSafeInteger(tail?.dropped) ? tail.dropped : -1;
261
+ entry.keep = loadKeep(path.join(dir, KEEP_FILE)).filter((r) => survivesCommit(r, entry.committed));
262
+ for (const r of entry.keep) {
263
+ entry.keepOffsets.add(r.o);
264
+ if (r.k === 'gap' && Number.isSafeInteger(r.through))
265
+ entry.dropped = Math.max(entry.dropped, r.through);
266
+ }
267
+ return entry;
268
+ }
269
+ openLog(dir) {
270
+ return StreamLog.open({
271
+ dir: path.join(dir, LOG_DIR), segmentBytes: EXEC_SEGMENT_BYTES, fsyncIntervalMs: EXEC_FSYNC_INTERVAL_MS, fsyncBytes: EXEC_FSYNC_BYTES,
272
+ now: this.now, onError: (error) => this.report(error),
273
+ });
274
+ }
275
+ // ---- exec.start ----------------------------------------------------------------------------------------------------
276
+ async start(params) {
277
+ keys(params, START_KEYS);
278
+ const execId = execIdOf(params);
279
+ const existing = this.entries.get(execId);
280
+ if (existing) {
281
+ await existing.ready;
282
+ return this.describe(existing);
283
+ }
284
+ const dir = path.join(this.options.root, execId);
285
+ if (this.dirExists(dir))
286
+ throw new KeeperMethodError(ExecErrorCode.foreign, `Exec ${execId} belongs to another keeper`);
287
+ const argv = params.argv;
288
+ if (!Array.isArray(argv) || argv.length === 0 || argv.length > MAX_ARGV || argv.some((a) => typeof a !== 'string' || a.includes('\0')) || !argv[0])
289
+ bad('Invalid argv');
290
+ const cwd = absolutePath(params.cwd, 'cwd');
291
+ const env = environment(params.env);
292
+ const deadline = params.deadlineEpochMs;
293
+ if (deadline !== undefined && deadline !== null && (typeof deadline !== 'number' || !Number.isFinite(deadline)))
294
+ bad('Invalid deadlineEpochMs');
295
+ const killGraceMs = integer(params.killGraceMs, 'killGraceMs', 0, MAX_GRACE_MS);
296
+ const retainHeadBytes = integer(params.retainHeadBytes, 'retainHeadBytes', 0, 16 * 1024 * 1024);
297
+ const maxUncommittedBytes = integer(params.maxUncommittedBytes, 'maxUncommittedBytes', 1024, 1024 * 1024 * 1024);
298
+ const onKeeperLoss = params.onKeeperLoss ?? 'kill';
299
+ if (onKeeperLoss !== 'kill' && onKeeperLoss !== 'leave')
300
+ bad('Invalid onKeeperLoss');
301
+ if (typeof params.label !== 'string')
302
+ bad('Invalid label');
303
+ const jobId = params.jobId;
304
+ if (jobId !== undefined && (typeof jobId !== 'string' || !jobId || jobId.length > 256))
305
+ bad('Invalid jobId');
306
+ if (this.runningCount() >= (this.options.runningLimit ?? EXEC_RUNNING_LIMIT))
307
+ throw new KeeperMethodError(ExecErrorCode.limit, 'Too many running commands in keeper-terminal');
308
+ const meta = {
309
+ v: 1, execId, label: params.label.slice(0, EXEC_LABEL_CHARS), ...(jobId ? { jobId: jobId } : {}),
310
+ argv: argv, cwd, envKeys: Object.keys(env).sort(),
311
+ deadlineEpochMs: typeof deadline === 'number' ? deadline : null, killGraceMs, retainHeadBytes, maxUncommittedBytes, onKeeperLoss: onKeeperLoss,
312
+ createdAtMs: this.now(), owner: this.options.owner, spawn: null, exit: null, logReclaimed: null,
313
+ };
314
+ // Synchronously up to the spawn: a concurrent start of the same id finds the entry and waits for it.
315
+ let entry;
316
+ try {
317
+ privateDir(dir);
318
+ this.options.faults?.afterExecDir?.();
319
+ atomicWrite(path.join(dir, META_FILE), JSON.stringify(meta));
320
+ entry = this.openEntry(meta, dir);
321
+ }
322
+ catch (error) {
323
+ rmSync(dir, { recursive: true, force: true }); // never leave a directory that would read as another keeper's exec
324
+ throw error;
325
+ }
326
+ this.entries.set(execId, entry);
327
+ entry.ready = this.launch(entry, env).catch((error) => { this.report(error); });
328
+ await entry.ready;
329
+ return this.describe(entry);
330
+ }
331
+ dirExists(dir) {
332
+ try {
333
+ return statSync(dir).isDirectory();
334
+ }
335
+ catch {
336
+ return false;
337
+ }
338
+ }
339
+ /**
340
+ * `spawn(argv[0], argv.slice(1), {cwd, env, detached})` exactly: no shell, no wrapping, no fallback to this process's env.
341
+ * Detached = setsid, so the exec leads its own process group (pgid = pid) and every pipeline / background job it starts stays in
342
+ * it. stdin is a pipe that is never written (as the daemon's in-process exec always did).
343
+ */
344
+ async launch(entry, env) {
345
+ const { meta } = entry;
346
+ let child;
347
+ try {
348
+ child = spawn(meta.argv[0], meta.argv.slice(1), { cwd: meta.cwd, env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] });
349
+ }
350
+ catch (error) {
351
+ this.writeExit(entry, { code: null, signal: null, timedOut: false, reason: 'spawn_failed', error: messageOf(error) });
352
+ return;
353
+ }
354
+ child.stdin.on('error', () => { });
355
+ if (child.pid === undefined) {
356
+ const error = await new Promise((resolve) => child.once('error', resolve));
357
+ this.writeExit(entry, { code: null, signal: null, timedOut: false, reason: 'spawn_failed', error: messageOf(error) });
358
+ return;
359
+ }
360
+ entry.child = child;
361
+ const pid = child.pid;
362
+ meta.spawn = { pid, pgid: pid, startTime: null, atMs: this.now() };
363
+ this.saveMeta(entry); // the pid is durable before anything else happens: a keeper crash from here on is settled at restart
364
+ child.on('error', (error) => this.report(error));
365
+ child.once('exit', () => { entry.leaderExited = true; this.watchGroup(entry); });
366
+ const closed = new Promise((resolve) => child.once('close', (code, signal) => resolve({ code, signal })));
367
+ // The spawn record comes first. Until it is in the log the streams are held paused (the pipe fills and the command waits
368
+ // briefly). Listeners are attached right away: on the leader's exit Node resumes every stdio stream, and data reaching a
369
+ // stream without a listener would be lost. A chunk that arrives meanwhile is held and the stream paused again.
370
+ let held = [];
371
+ for (const [name, stream] of [['out', child.stdout], ['err', child.stderr]]) {
372
+ stream.on('data', (chunk) => {
373
+ if (held) {
374
+ held.push([name, chunk]);
375
+ stream.pause();
376
+ return;
377
+ }
378
+ this.onOutput(entry, name, chunk);
379
+ });
380
+ stream.pause();
381
+ }
382
+ // Deadline and exit handling never depend on the spawn record: if that write fails the exec still ends and still has a deadline.
383
+ this.armDeadline(entry);
384
+ const recorded = (async () => {
385
+ try {
386
+ meta.spawn.startTime = await (this.options.startTime ?? ((p) => processStartTime(p, this.options.sweep)))(pid).catch(() => null);
387
+ this.saveMeta(entry);
388
+ this.options.faults?.beforeSpawnRecord?.();
389
+ this.appendKept(entry, { k: 'spawn', pid, pgid: pid, startTime: meta.spawn.startTime, argv: meta.argv, cwd: meta.cwd, envKeys: meta.envKeys });
390
+ }
391
+ finally {
392
+ const pending = held;
393
+ held = undefined;
394
+ for (const [name, chunk] of pending)
395
+ this.onOutput(entry, name, chunk);
396
+ child.stdout.resume();
397
+ child.stderr.resume();
398
+ }
399
+ })();
400
+ void Promise.all([closed, recorded.catch((error) => { this.report(error); })]).then(([{ code, signal }]) => {
401
+ for (const timer of entry.timers)
402
+ clearTimeout(timer);
403
+ entry.timers = [];
404
+ if (this.stopping && meta.onKeeperLoss === 'leave')
405
+ return; // left to the next keeper's sweep
406
+ if (!meta.exit)
407
+ this.writeExit(entry, { code, signal, timedOut: entry.timedOut, ...(entry.stoppedByKeeper ? { reason: 'keeper_stopped' } : {}) });
408
+ entry.child = undefined;
409
+ this.stopWatchingIfDone(entry);
410
+ entry.closing?.();
411
+ this.maintainSoon();
412
+ });
413
+ await recorded;
414
+ }
415
+ armDeadline(entry) {
416
+ const { deadlineEpochMs, killGraceMs } = entry.meta;
417
+ if (deadlineEpochMs === null)
418
+ return;
419
+ entry.fireDeadline = () => {
420
+ if (entry.deadlineFired || entry.meta.exit || !entry.meta.spawn)
421
+ return;
422
+ entry.deadlineFired = true;
423
+ entry.timedOut = true;
424
+ if (!this.signalExec(entry, 'SIGTERM') && entry.groupGone)
425
+ this.closePipesLater(entry, 0); // only an escaped process holds the pipes
426
+ // Not one of entry.timers (those end with the exec): members that ignore SIGTERM and hold no pipe outlive the exec's end.
427
+ entry.graceKillPending = true;
428
+ this.watchGroup(entry);
429
+ setTimeout(() => { entry.graceKillPending = false; this.killGroup(entry); this.stopWatchingIfDone(entry); }, killGraceMs).unref();
430
+ };
431
+ const delay = deadlineEpochMs - this.now();
432
+ if (delay <= 0)
433
+ entry.fireDeadline();
434
+ else
435
+ this.later(entry, delay, entry.fireDeadline);
436
+ }
437
+ /** setTimeout bounded to the 32-bit range (a far deadline re-arms), unref'd: a timer never keeps the keeper alive by itself. */
438
+ later(entry, ms, run) {
439
+ const max = 2 ** 31 - 1;
440
+ const timer = setTimeout(() => { entry.timers = entry.timers.filter((t) => t !== timer); if (ms > max)
441
+ this.later(entry, ms - max, run);
442
+ else
443
+ run(); }, Math.min(ms, max));
444
+ timer.unref();
445
+ entry.timers.push(timer);
446
+ }
447
+ /**
448
+ * Probes whether the group still has a member (signal 0). Only meaningful once the leader has exited: while the leader (our
449
+ * child) lives, its pid — and so the group id — cannot be reused. ESRCH (or EPERM: the id now names a group we do not own) makes
450
+ * `groupGone` stick.
451
+ */
452
+ probeGroup(entry) {
453
+ if (entry.groupGone)
454
+ return true;
455
+ if (!entry.leaderExited || !entry.meta.spawn)
456
+ return false;
457
+ try {
458
+ process.kill(-entry.meta.spawn.pgid, 0);
459
+ return false;
460
+ }
461
+ catch (error) {
462
+ if (error.code === 'ESRCH' || error.code === 'EPERM') {
463
+ entry.groupGone = true;
464
+ this.stopWatchingIfDone(entry);
465
+ return true;
466
+ }
467
+ return false;
468
+ }
469
+ }
470
+ /** After the leader exited: probe the group now and every second while the exec runs or a grace SIGKILL is still due. */
471
+ watchGroup(entry) {
472
+ if (entry.groupProbe || !entry.leaderExited || this.probeGroup(entry))
473
+ return;
474
+ entry.groupProbe = setInterval(() => { this.probeGroup(entry); }, GROUP_PROBE_MS);
475
+ entry.groupProbe.unref();
476
+ }
477
+ stopWatchingIfDone(entry) {
478
+ if (entry.groupProbe && (entry.groupGone || (!entry.child && !entry.graceKillPending))) {
479
+ clearInterval(entry.groupProbe);
480
+ entry.groupProbe = undefined;
481
+ }
482
+ }
483
+ /** Signals the exec's group unless it is known gone. */
484
+ signalExec(entry, signal) {
485
+ if (!entry.meta.spawn || this.probeGroup(entry))
486
+ return false;
487
+ return (this.options.signalGroup ?? signalGroup)(entry.meta.spawn.pgid, signal);
488
+ }
489
+ /**
490
+ * SIGKILL to the group (unless it is known gone). The group may outlive its leader (members that ignored SIGTERM). If the leader
491
+ * is gone but the pipes stay open (a process that left the group still holds them), they are closed shortly after so the exec can
492
+ * end — at once when the group is already gone.
493
+ */
494
+ killGroup(entry) {
495
+ if (!entry.meta.spawn)
496
+ return;
497
+ this.signalExec(entry, 'SIGKILL');
498
+ this.closePipesLater(entry, entry.groupGone ? 0 : PIPE_CLOSE_AFTER_KILL_MS);
499
+ }
500
+ closePipesLater(entry, ms) {
501
+ const timer = setTimeout(() => {
502
+ const child = entry.child;
503
+ if (child && entry.leaderExited) {
504
+ child.stdout.destroy();
505
+ child.stderr.destroy();
506
+ child.stdin.destroy();
507
+ }
508
+ }, ms);
509
+ timer.unref();
510
+ }
511
+ // ---- output and quota ----------------------------------------------------------------------------------------------
512
+ onOutput(entry, stream, chunk) {
513
+ if (!entry.log)
514
+ return;
515
+ try {
516
+ for (let at = 0; at < chunk.length; at += EXEC_CHUNK_BYTES) {
517
+ let piece = chunk.subarray(at, Math.min(chunk.length, at + EXEC_CHUNK_BYTES));
518
+ const headLeft = entry.meta.retainHeadBytes - entry.headBytes[stream];
519
+ if (headLeft > 0) {
520
+ const head = piece.subarray(0, headLeft);
521
+ this.appendOutput(entry, stream, head, true);
522
+ piece = piece.subarray(head.length);
523
+ }
524
+ if (piece.length > 0)
525
+ this.appendOutput(entry, stream, piece, false);
526
+ }
527
+ }
528
+ catch (error) {
529
+ this.report(error);
530
+ } // a full disk must not crash the keeper: the pipe keeps being drained
531
+ }
532
+ appendOutput(entry, stream, bytes, head) {
533
+ const input = { k: stream, so: entry.streamBytes[stream], b: bytes.toString('base64') };
534
+ entry.streamBytes[stream] += bytes.length;
535
+ if (head) {
536
+ entry.headBytes[stream] += bytes.length;
537
+ this.appendKept(entry, input);
538
+ return;
539
+ }
540
+ const record = entry.log.append(input);
541
+ const cost = bytes.length + RECORD_OVERHEAD;
542
+ const last = entry.runs[entry.runs.length - 1];
543
+ if (last && last.cost < RUN_COST) {
544
+ last.through = record.o;
545
+ last[stream] += bytes.length;
546
+ last.cost += cost;
547
+ }
548
+ else
549
+ entry.runs.push({ from: record.o, through: record.o, out: stream === 'out' ? bytes.length : 0, err: stream === 'err' ? bytes.length : 0, cost });
550
+ entry.droppableCost += cost;
551
+ if (entry.droppableCost > entry.meta.maxUncommittedBytes)
552
+ this.dropOldest(entry);
553
+ this.sinceTotalCheck += cost;
554
+ if (this.sinceTotalCheck >= 4 * 1024 * 1024) {
555
+ this.sinceTotalCheck = 0;
556
+ this.maintainSoon();
557
+ }
558
+ }
559
+ /**
560
+ * Drops the oldest droppable uncommitted output down to 3/4 of `maxUncommittedBytes`: gap records (one per stream that lost
561
+ * bytes, also kept), then the drop watermark moves past the dropped range; the log follows (whole segments deleted) per segment.
562
+ */
563
+ dropOldest(entry) {
564
+ const log = entry.log;
565
+ const target = Math.floor(entry.meta.maxUncommittedBytes * 3 / 4);
566
+ let from = -1;
567
+ let through = -1;
568
+ let out = 0;
569
+ let err = 0;
570
+ while (entry.droppableCost > target && entry.runs.length > 0) {
571
+ const run = entry.runs.shift();
572
+ if (from < 0)
573
+ from = run.from;
574
+ through = run.through;
575
+ out += run.out;
576
+ err += run.err;
577
+ entry.droppableCost -= run.cost;
578
+ }
579
+ if (through < 0)
580
+ return;
581
+ if (out > 0)
582
+ this.appendKept(entry, { k: 'gap', stream: 'out', droppedBytes: out, from, through });
583
+ if (err > 0)
584
+ this.appendKept(entry, { k: 'gap', stream: 'err', droppedBytes: err, from, through });
585
+ entry.dropped = Math.max(entry.dropped, through);
586
+ // The watermark lives in the kept gap records (written to the page cache: a keeper crash keeps them; a power cut is the
587
+ // keeper_lost case). The log commit (fsync + meta + segment deletion) runs once a segment's worth was dropped, after keep.jsonl
588
+ // is fsynced, so a flood pays a few commits instead of one per drop.
589
+ entry.droppedSinceCommit += Math.max(1, entry.meta.maxUncommittedBytes - target);
590
+ if (entry.droppedSinceCommit >= EXEC_SEGMENT_BYTES) {
591
+ entry.droppedSinceCommit = 0;
592
+ if (entry.keepFd !== undefined)
593
+ fsyncSync(entry.keepFd);
594
+ this.commitLog(entry);
595
+ }
596
+ }
597
+ /** Appends to the log AND to keep.jsonl (spawn, gap, exit, head output): records the quota never drops. */
598
+ appendKept(entry, input) {
599
+ const record = entry.log.append(input);
600
+ if (entry.keepFd === undefined)
601
+ entry.keepFd = openSync(path.join(entry.dir, KEEP_FILE), 'a', 0o600);
602
+ writeAllSync(entry.keepFd, Buffer.from(`${JSON.stringify(record)}\n`));
603
+ entry.keep.push(record);
604
+ entry.keepOffsets.add(record.o);
605
+ return record;
606
+ }
607
+ commitLog(entry) {
608
+ entry.log.commit(Math.max(entry.committed, entry.dropped), { committed: entry.committed, dropped: entry.dropped });
609
+ }
610
+ writeExit(entry, exit) {
611
+ const atMs = this.now();
612
+ if (entry.log) {
613
+ try {
614
+ this.appendKept(entry, { k: 'exit', ...exit });
615
+ }
616
+ catch (error) {
617
+ this.report(error);
618
+ }
619
+ if (entry.keepFd !== undefined)
620
+ try {
621
+ fsyncSync(entry.keepFd);
622
+ }
623
+ catch { /* reported by the next open's repair */ }
624
+ }
625
+ entry.meta.exit = { ...exit, atMs };
626
+ entry.runs = [];
627
+ entry.droppableCost = 0;
628
+ this.saveMeta(entry);
629
+ }
630
+ saveMeta(entry) {
631
+ try {
632
+ atomicWrite(path.join(entry.dir, META_FILE), JSON.stringify(entry.meta));
633
+ }
634
+ catch (error) {
635
+ this.report(error);
636
+ }
637
+ }
638
+ // ---- read / subscribe ----------------------------------------------------------------------------------------------
639
+ entryOf(params) {
640
+ const entry = this.entries.get(execIdOf(params));
641
+ if (!entry)
642
+ throw new KeeperMethodError(ExecErrorCode.notFound, 'Exec not found');
643
+ return entry;
644
+ }
645
+ /**
646
+ * Records with `o >= from`, in order: below the drop watermark only the kept records (head, spawn, gap, exit) remain; above it
647
+ * the log. Below the daemon's own commit: the head reserve and the spawn record (they survive non-final commits, see
648
+ * {@link survivesCommit}) plus whatever else is still on disk.
649
+ */
650
+ readMerged(entry, from, limit) {
651
+ if (!entry.log || limit <= 0)
652
+ return [];
653
+ const dropped = entry.dropped;
654
+ const floor = Math.max(dropped, entry.committed);
655
+ const logged = entry.log.read(Math.max(from, dropped + 1), limit);
656
+ if (from > floor)
657
+ return logged;
658
+ const kept = entry.keep.filter((record) => record.o >= from && record.o <= floor);
659
+ if (kept.length === 0)
660
+ return logged;
661
+ // Both are in offset order; a kept record may also still be in the log (same offset): merge, once each.
662
+ const out = [];
663
+ let i = 0;
664
+ let j = 0;
665
+ while (out.length < limit && (i < kept.length || j < logged.length)) {
666
+ const a = kept[i];
667
+ const b = logged[j];
668
+ if (b === undefined || (a !== undefined && a.o < b.o)) {
669
+ out.push(a);
670
+ i += 1;
671
+ }
672
+ else {
673
+ if (a !== undefined && a.o === b.o)
674
+ i += 1;
675
+ out.push(b);
676
+ j += 1;
677
+ }
678
+ }
679
+ return out;
680
+ }
681
+ read(params) {
682
+ keys(params, ['execId', 'from', 'limit']);
683
+ const entry = this.entryOf(params);
684
+ const from = integer(params.from, 'from', 0, Number.MAX_SAFE_INTEGER);
685
+ const limit = params.limit === undefined ? 256 : integer(params.limit, 'limit', 1, MAX_READ_RECORDS);
686
+ const records = [];
687
+ let bytes = 0;
688
+ for (const record of this.readMerged(entry, from, limit)) {
689
+ bytes += Buffer.byteLength(JSON.stringify(record));
690
+ if (records.length > 0 && bytes > MAX_READ_BYTES)
691
+ break;
692
+ records.push(record);
693
+ }
694
+ return { records, head: this.headOf(entry), next: records.length > 0 ? records[records.length - 1].o + 1 : from };
695
+ }
696
+ /** The kit stream source for `exec/<execId>` (`keeper.subscribe`, credit in records). */
697
+ source(execId) {
698
+ const entry = this.entries.get(execId);
699
+ if (!entry?.log)
700
+ return undefined;
701
+ const log = entry.log;
702
+ return {
703
+ read: (from, limit) => {
704
+ if (this.entries.get(execId) !== entry || entry.log !== log)
705
+ throw new Error('Exec log is gone');
706
+ return this.readMerged(entry, from, limit);
707
+ },
708
+ watch: (notify) => log.watch(notify),
709
+ };
710
+ }
711
+ // ---- kill / commit / checkpoint ------------------------------------------------------------------------------------
712
+ kill(params) {
713
+ keys(params, ['execId', 'signal']);
714
+ const entry = this.entryOf(params);
715
+ const signal = params.signal;
716
+ if (typeof signal !== 'string' || !EXEC_SIGNALS.includes(signal))
717
+ bad('Invalid signal');
718
+ const state = this.stateOf(entry);
719
+ if (state !== 'running' || !entry.meta.spawn)
720
+ return { delivered: false, state };
721
+ const delivered = this.signalExec(entry, signal);
722
+ // The group is gone and only an escaped process holds the pipes: end the exec by closing them.
723
+ if (signal === 'SIGKILL' || entry.groupGone)
724
+ this.killGroup(entry);
725
+ return { delivered, state };
726
+ }
727
+ commit(params) {
728
+ keys(params, ['execId', 'through', 'final']);
729
+ const entry = this.entryOf(params);
730
+ const head = this.headOf(entry);
731
+ const through = integer(params.through, 'through', -1, Number.MAX_SAFE_INTEGER);
732
+ if (through > head)
733
+ bad('through is beyond the head');
734
+ if (params.final !== undefined && typeof params.final !== 'boolean')
735
+ bad('Invalid final');
736
+ if (params.final === true) {
737
+ if (!entry.meta.exit)
738
+ throw new KeeperMethodError(ExecErrorCode.notExited, 'Exec is still running');
739
+ if (through < head)
740
+ bad('A final commit must cover the head');
741
+ this.remove(entry);
742
+ return { committed: through, head, deleted: true };
743
+ }
744
+ if (through > entry.committed && entry.log) {
745
+ entry.committed = through;
746
+ this.commitLog(entry);
747
+ this.forgetCommitted(entry);
748
+ }
749
+ return { committed: entry.committed, head, deleted: false };
750
+ }
751
+ /** Drops committed records from the droppable runs (exactly: a run cut by the commit is recounted from the log) and from keep. */
752
+ forgetCommitted(entry) {
753
+ const committed = entry.committed;
754
+ while (entry.runs.length > 0 && entry.runs[0].through <= committed)
755
+ entry.droppableCost -= entry.runs.shift().cost;
756
+ const first = entry.runs[0];
757
+ if (first && first.from <= committed) {
758
+ entry.droppableCost -= first.cost;
759
+ const recount = { from: committed + 1, through: first.through, out: 0, err: 0, cost: 0 };
760
+ for (const record of entry.log.read(committed + 1, first.through - committed)) {
761
+ if (record.o > first.through)
762
+ break;
763
+ if ((record.k !== 'out' && record.k !== 'err') || entry.keepOffsets.has(record.o))
764
+ continue;
765
+ const size = Buffer.byteLength(record.b, 'base64');
766
+ recount[record.k] += size;
767
+ recount.cost += size + RECORD_OVERHEAD;
768
+ }
769
+ entry.runs[0] = recount;
770
+ entry.droppableCost += recount.cost;
771
+ }
772
+ if (entry.keep.some((r) => !survivesCommit(r, committed))) {
773
+ entry.keep = entry.keep.filter((r) => survivesCommit(r, committed));
774
+ entry.keepOffsets = new Set(entry.keep.map((r) => r.o));
775
+ this.rewriteKeep(entry);
776
+ }
777
+ }
778
+ rewriteKeep(entry) {
779
+ if (entry.keepFd !== undefined) {
780
+ try {
781
+ closeSync(entry.keepFd);
782
+ }
783
+ catch { /* ignore */ }
784
+ entry.keepFd = undefined;
785
+ }
786
+ atomicWrite(path.join(entry.dir, KEEP_FILE), entry.keep.map((r) => `${JSON.stringify(r)}\n`).join(''));
787
+ }
788
+ checkpoint(params) {
789
+ keys(params, ['execId', 'blob', 'offset']);
790
+ const entry = this.entryOf(params);
791
+ if (typeof params.blob !== 'string')
792
+ bad('Invalid blob');
793
+ if (!entry.log)
794
+ bad('Exec log was reclaimed');
795
+ const head = this.headOf(entry);
796
+ const offset = integer(params.offset, 'offset', -1, head); // required: a checkpoint stamped at the head would skip unprocessed records
797
+ const written = entry.log.writeCheckpoint(offset, params.blob);
798
+ return { seq: written.seq, offset: written.offset, atMs: written.t, blob: params.blob };
799
+ }
800
+ // ---- list / status / activity --------------------------------------------------------------------------------------
801
+ stateOf(entry) {
802
+ if (entry.meta.exit)
803
+ return 'exited';
804
+ return entry.meta.spawn ? 'running' : 'starting';
805
+ }
806
+ headOf(entry) {
807
+ if (entry.log)
808
+ return entry.log.state().head;
809
+ return entry.meta.logReclaimed?.head ?? -1;
810
+ }
811
+ describe(entry) {
812
+ const { meta } = entry;
813
+ return {
814
+ execId: meta.execId, label: meta.label, ...(meta.jobId ? { jobId: meta.jobId } : {}), state: this.stateOf(entry),
815
+ pid: meta.spawn?.pid ?? null, pgid: meta.spawn?.pgid ?? null, startTime: meta.spawn?.startTime ?? null,
816
+ createdAtMs: meta.createdAtMs, deadlineEpochMs: meta.deadlineEpochMs, onKeeperLoss: meta.onKeeperLoss, exit: meta.exit,
817
+ head: this.headOf(entry), committed: entry.committed, dropped: entry.dropped,
818
+ checkpointOffset: entry.log?.state().checkpointOffset ?? -1, logReclaimed: meta.logReclaimed !== null,
819
+ };
820
+ }
821
+ list() {
822
+ return { execs: [...this.entries.values()].sort((a, b) => a.meta.createdAtMs - b.meta.createdAtMs).map((entry) => this.describe(entry)) };
823
+ }
824
+ status(params) {
825
+ keys(params, ['execId']);
826
+ const entry = this.entryOf(params);
827
+ const latest = entry.log?.latestCheckpoint();
828
+ return { ...this.describe(entry), checkpoint: latest ? { seq: latest.seq, offset: latest.offset, atMs: latest.t, blob: latest.blob.toString('utf8') } : null };
829
+ }
830
+ runningCount() {
831
+ let n = 0;
832
+ for (const entry of this.entries.values())
833
+ if (!entry.meta.exit)
834
+ n += 1;
835
+ return n;
836
+ }
837
+ /** `execRunning` and one item per running exec (label and jobId as the daemon gave them; never the command line). */
838
+ activity = () => {
839
+ const items = [];
840
+ let running = 0;
841
+ for (const entry of this.entries.values()) {
842
+ if (entry.meta.exit)
843
+ continue;
844
+ running += 1;
845
+ items.push({ type: 'exec', id: entry.meta.execId, label: entry.meta.label, state: 'running', sinceEpochMs: entry.meta.spawn?.atMs ?? entry.meta.createdAtMs, ...(entry.meta.jobId ? { jobId: entry.meta.jobId } : {}) });
846
+ }
847
+ return { metrics: { execRunning: running }, items };
848
+ };
849
+ // ---- maintenance: TTL, total quota ---------------------------------------------------------------------------------
850
+ maintainPending = false;
851
+ maintainSoon() {
852
+ if (this.maintainPending || this.stopping)
853
+ return;
854
+ this.maintainPending = true;
855
+ setImmediate(() => { this.maintainPending = false; this.maintain(); });
856
+ }
857
+ /** Removes ended execs past the TTL, then reclaims the logs of the oldest ended execs while the total exceeds 512 MiB. */
858
+ maintain() {
859
+ if (this.stopping)
860
+ return;
861
+ try {
862
+ const now = this.now();
863
+ for (const entry of this.entries.values()) {
864
+ if (entry.meta.exit)
865
+ continue;
866
+ // Timers run on a monotonic clock that may stand still while the machine sleeps: the deadline is wall-clock.
867
+ if (entry.meta.deadlineEpochMs !== null && now >= entry.meta.deadlineEpochMs)
868
+ entry.fireDeadline?.();
869
+ if (entry.leaderExited)
870
+ this.probeGroup(entry);
871
+ }
872
+ const ttl = this.options.ttlMs ?? EXEC_TERMINAL_TTL_MS;
873
+ for (const entry of [...this.entries.values()])
874
+ if (entry.meta.exit && now - entry.meta.exit.atMs >= ttl)
875
+ this.remove(entry);
876
+ const limit = this.options.totalBytes ?? EXEC_TOTAL_BYTES;
877
+ let total = 0;
878
+ for (const entry of this.entries.values())
879
+ total += this.bytesOf(entry);
880
+ if (total <= limit)
881
+ return;
882
+ const ended = [...this.entries.values()].filter((e) => e.meta.exit && e.log).sort((a, b) => a.meta.exit.atMs - b.meta.exit.atMs);
883
+ for (const entry of ended) {
884
+ if (total <= limit)
885
+ break;
886
+ total -= this.bytesOf(entry);
887
+ this.reclaimLog(entry);
888
+ }
889
+ }
890
+ catch (error) {
891
+ this.report(error);
892
+ }
893
+ }
894
+ bytesOf(entry) {
895
+ if (!entry.log)
896
+ return 0;
897
+ let keep = 0;
898
+ try {
899
+ keep = statSync(path.join(entry.dir, KEEP_FILE)).size;
900
+ }
901
+ catch { /* none */ }
902
+ return entry.log.state().bytes + keep;
903
+ }
904
+ closeFiles(entry) {
905
+ try {
906
+ entry.log?.close();
907
+ }
908
+ catch (error) {
909
+ this.report(error);
910
+ }
911
+ if (entry.keepFd !== undefined) {
912
+ try {
913
+ closeSync(entry.keepFd);
914
+ }
915
+ catch { /* ignore */ }
916
+ entry.keepFd = undefined;
917
+ }
918
+ }
919
+ reclaimLog(entry) {
920
+ const head = this.headOf(entry);
921
+ this.closeFiles(entry);
922
+ entry.log = undefined;
923
+ entry.keep = [];
924
+ entry.keepOffsets = new Set();
925
+ entry.meta.logReclaimed = { atMs: this.now(), head };
926
+ this.saveMeta(entry);
927
+ rmSync(path.join(entry.dir, LOG_DIR), { recursive: true, force: true });
928
+ rmSync(path.join(entry.dir, KEEP_FILE), { force: true });
929
+ this.logLine(`exec ${entry.meta.execId}: log reclaimed (total quota)`);
930
+ }
931
+ remove(entry) {
932
+ this.closeFiles(entry);
933
+ entry.log = undefined;
934
+ this.entries.delete(entry.meta.execId);
935
+ rmSync(entry.dir, { recursive: true, force: true });
936
+ }
937
+ // ---- shutdown ------------------------------------------------------------------------------------------------------
938
+ /**
939
+ * The keeper is stopping (graceful or force). Running execs started with `onKeeperLoss:'kill'` get SIGTERM, SIGKILL after at most
940
+ * `graceMs`, and their exit carries `reason:'keeper_stopped'` (also when the real exit arrived in time). `onKeeperLoss:'leave'` is
941
+ * honoured here too: those groups are not signalled and get no exit record; the next keeper's startup sweep settles them as it would
942
+ * after a crash (left running, `keeper_lost`). Logs are closed.
943
+ */
944
+ async dispose(graceMs = 2000) {
945
+ this.stopping = true;
946
+ clearInterval(this.maintenanceTimer);
947
+ const live = [...this.entries.values()].filter((e) => !e.meta.exit && e.child && e.meta.onKeeperLoss === 'kill');
948
+ const ended = (entry, ms) => new Promise((resolve) => {
949
+ if (entry.meta.exit) {
950
+ resolve();
951
+ return;
952
+ }
953
+ const timer = setTimeout(resolve, ms);
954
+ timer.unref();
955
+ entry.closing = () => { clearTimeout(timer); resolve(); };
956
+ });
957
+ for (const entry of live) {
958
+ entry.stoppedByKeeper = true;
959
+ this.signalExec(entry, 'SIGTERM');
960
+ }
961
+ await Promise.all(live.map((entry) => ended(entry, Math.min(graceMs, entry.meta.killGraceMs))));
962
+ for (const entry of live)
963
+ if (!entry.meta.exit)
964
+ this.killGroup(entry);
965
+ await Promise.all(live.map((entry) => ended(entry, 500)));
966
+ for (const entry of live)
967
+ if (!entry.meta.exit)
968
+ this.writeExit(entry, { code: null, signal: null, timedOut: entry.timedOut, reason: 'keeper_stopped' });
969
+ for (const entry of this.entries.values()) {
970
+ for (const timer of entry.timers)
971
+ clearTimeout(timer);
972
+ entry.timers = [];
973
+ if (entry.groupProbe) {
974
+ clearInterval(entry.groupProbe);
975
+ entry.groupProbe = undefined;
976
+ }
977
+ this.closeFiles(entry);
978
+ }
979
+ }
980
+ }
981
+ function messageOf(error) { return error instanceof Error ? error.message : String(error); }