@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
1
3
|
import path from 'node:path';
|
|
2
4
|
import { fileURLToPath } from 'node:url';
|
|
3
5
|
import { AGENT_WIRE_MIN_VERSION } from './protocol/agent.js';
|
|
@@ -12,7 +14,21 @@ import { TaskRegistry } from './update/taskRegistry.js';
|
|
|
12
14
|
import { createUpdateCoordinator } from './update/runUpdate.js';
|
|
13
15
|
import { resolveCliPath } from './daemonctl.js';
|
|
14
16
|
import { SystemMetricsSampler } from './systemMetrics.js';
|
|
15
|
-
import { preflightOptionalPty } from './update/ptyPreflight.js';
|
|
17
|
+
import { cachedPtyStatus, preflightOptionalPty } from './update/ptyPreflight.js';
|
|
18
|
+
import { FilesHostClient } from './files/client.js';
|
|
19
|
+
import { ToolsClient } from './tools/client.js';
|
|
20
|
+
import { WorkstationTools } from './tools/router.js';
|
|
21
|
+
import { aggregateToolsInfo, computeFilesInfo } from './tools/info.js';
|
|
22
|
+
import { defaultDistDir, KeeperLauncher } from './keepers/kit/launcher.js';
|
|
23
|
+
import { readKeeperManifest } from './keepers/kit/bundle.js';
|
|
24
|
+
import { KEEPER_POLICY_TICK_MS, KeeperPolicy, oneShotDrain, oneShotProbe } from './keepers/policy.js';
|
|
25
|
+
import { TerminalGenerations } from './keepers/terminal/generations.js';
|
|
26
|
+
import { AcpKeeperClient } from './keepers/acp/client.js';
|
|
27
|
+
import { KeeperTransport } from './acp/processor/transport/keeper.js';
|
|
28
|
+
import { keeperGenerations } from './acp/processor/transport/generations.js';
|
|
29
|
+
import { ExecPolicy } from './exec/policy.js';
|
|
30
|
+
import { LegacyToolsKeeper } from './keepers/legacy.js';
|
|
31
|
+
import { TerminalRouter, terminalEnv, terminalShell } from './tools/terminalRouter.js';
|
|
16
32
|
/**
|
|
17
33
|
* The path `runDaemon` hands `createUpdateCoordinator` as `cliPath`, used to re-provision the OS
|
|
18
34
|
* service after an update (see runUpdate.ts's `provisionService`).
|
|
@@ -106,6 +122,45 @@ export const IDLE_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
|
|
106
122
|
* nobody awaits a timer callback, and an escaping rejection is an unhandled rejection, which Node
|
|
107
123
|
* answers by exiting.
|
|
108
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* The daemon's start-time adapter build upkeep: legacy migration, then the certified reconcile; calls
|
|
127
|
+
* `onRosterChanged` once if either changed any agent's current build. Never rejects.
|
|
128
|
+
*/
|
|
129
|
+
export async function maintainAdapterBuilds(acp, onRosterChanged) {
|
|
130
|
+
try {
|
|
131
|
+
const migrated = await acp.migrateLegacyAdapters();
|
|
132
|
+
const moves = await acp.reconcileCertifiedBuilds();
|
|
133
|
+
if (migrated.length || moves.length)
|
|
134
|
+
await onRosterChanged();
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.warn(`[acp-builds] adapter build upkeep failed: ${String(error)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** How often held sessions are looked at for a pending switch (spec §6.3 step 7) or a lost adapter to reopen (Task 24). */
|
|
141
|
+
export const SWITCH_POLL_INTERVAL_MS = 5_000;
|
|
142
|
+
/** How often unused adapter builds are collected (spec §6.3 "GC"; the first run is one grace period after start). */
|
|
143
|
+
export const BUILD_GC_INTERVAL_MS = 10 * 60_000;
|
|
144
|
+
/**
|
|
145
|
+
* Task 24: the switch scheduler (every {@link SWITCH_POLL_INTERVAL_MS}: `agent_reopen_request` for idle sessions with a pending
|
|
146
|
+
* switch, and for keeper-hosted sessions whose adapter keeper-acp lost) and build GC (every {@link BUILD_GC_INTERVAL_MS}). Both
|
|
147
|
+
* timers are `unref`'d, and neither callback can reject. Returns the stop.
|
|
148
|
+
*/
|
|
149
|
+
export function startSwitchUpkeep(agents, acp, pollMs = SWITCH_POLL_INTERVAL_MS, gcMs = BUILD_GC_INTERVAL_MS) {
|
|
150
|
+
let polling = false;
|
|
151
|
+
const poll = setInterval(() => {
|
|
152
|
+
if (polling)
|
|
153
|
+
return;
|
|
154
|
+
polling = true;
|
|
155
|
+
void Promise.resolve().then(() => agents.pollSwitches()).catch((err) => {
|
|
156
|
+
console.error(`[agent] switch poll failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
157
|
+
}).finally(() => { polling = false; });
|
|
158
|
+
}, pollMs);
|
|
159
|
+
poll.unref();
|
|
160
|
+
const gc = setInterval(() => { void Promise.resolve().then(() => acp.collectBuilds()).catch(() => undefined); }, gcMs);
|
|
161
|
+
gc.unref();
|
|
162
|
+
return () => { clearInterval(poll); clearInterval(gc); };
|
|
163
|
+
}
|
|
109
164
|
export function startIdleSweep(agents, ttlMs = IDLE_SESSION_TTL_MS, intervalMs = IDLE_SWEEP_INTERVAL_MS) {
|
|
110
165
|
const timer = setInterval(() => {
|
|
111
166
|
void (async () => {
|
|
@@ -157,6 +212,74 @@ export function resolveServiceLifecycle(options) {
|
|
|
157
212
|
export async function buildManagementSnapshot(supervisor, tasks, updates) {
|
|
158
213
|
return { channels: supervisor.channelSnapshot(), ...tasks.snapshot(), update: await updates.snapshotAsync() };
|
|
159
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* The files host's configured roots: every workspace cwd, absolute (a relative one is taken relative to the home
|
|
217
|
+
* directory; FileTools refuses relative roots because they would resolve against whatever directory a pinned section
|
|
218
|
+
* holds).
|
|
219
|
+
*/
|
|
220
|
+
export function workspaceRoots(cfg, home = os.homedir()) {
|
|
221
|
+
return [path.join(home, '.axom', 'playground'), ...(cfg.workspaces ?? []).filter(workspace => typeof workspace?.cwd === 'string' && workspace.cwd.length > 0).map(workspace => path.resolve(home, workspace.cwd))];
|
|
222
|
+
}
|
|
223
|
+
/** The roots that exist as directories right now. A missing workspace must not keep the host (all of Files) down. */
|
|
224
|
+
export async function existingDirectories(roots) {
|
|
225
|
+
const existing = [];
|
|
226
|
+
for (const root of roots) {
|
|
227
|
+
try {
|
|
228
|
+
if ((await fs.stat(root)).isDirectory())
|
|
229
|
+
existing.push(root);
|
|
230
|
+
}
|
|
231
|
+
catch { /* left out until it exists */ }
|
|
232
|
+
}
|
|
233
|
+
return existing;
|
|
234
|
+
}
|
|
235
|
+
/** How often the daemon re-reads the workspaces config (and root existence) to restart the files host on a change. */
|
|
236
|
+
export const FILES_ROOTS_POLL_MS = 10_000;
|
|
237
|
+
/** How often the legacy tools keeper is re-probed and checked for retirement (keepers/legacy.ts). */
|
|
238
|
+
export const LEGACY_TICK_MS = 30_000;
|
|
239
|
+
/**
|
|
240
|
+
* Builds the object `ManagementChannelOptions.keepers` reports (Task 26): the policy's statuses, never a rejection (a failure is
|
|
241
|
+
* `null`, which the channel also guarantees). `countedExecs` (final review minor 7): keeper-terminal execs `activeShellTasks` also
|
|
242
|
+
* counts right now — reported as the current terminal keeper's `execCounted` metric (sampled with this heartbeat), so the
|
|
243
|
+
* Workstations page counts `activeShellTasks + execRunning − execCounted` commands, each once.
|
|
244
|
+
*/
|
|
245
|
+
export async function buildKeeperStatuses(policy, countedExecs = () => 0) {
|
|
246
|
+
let statuses;
|
|
247
|
+
try {
|
|
248
|
+
statuses = await policy.statuses();
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const terminals = statuses.filter((status) => status.kind === 'terminal' && !status.legacy);
|
|
254
|
+
const target = terminals.find((status) => status.replacement === 'none') ?? terminals[0];
|
|
255
|
+
if (!target)
|
|
256
|
+
return statuses;
|
|
257
|
+
let counted = 0;
|
|
258
|
+
try {
|
|
259
|
+
const value = countedExecs();
|
|
260
|
+
counted = Number.isSafeInteger(value) && value > 0 ? value : 0;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
counted = 0;
|
|
264
|
+
}
|
|
265
|
+
// A copy: the policy reuses its sample for a while.
|
|
266
|
+
return statuses.map((status) => (status === target ? { ...status, metrics: { ...status.metrics, execCounted: counted } } : status));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Runs `poll` every `intervalMs`, skipping a tick while the previous run is still in flight (on a hung mount a stat
|
|
270
|
+
* may never return; overlapping runs would pile up behind it). The timer is unref'd. Returns the stop function.
|
|
271
|
+
*/
|
|
272
|
+
export function startExclusivePoll(poll, intervalMs) {
|
|
273
|
+
let running = false;
|
|
274
|
+
const timer = setInterval(() => {
|
|
275
|
+
if (running)
|
|
276
|
+
return;
|
|
277
|
+
running = true;
|
|
278
|
+
void poll().catch(() => { }).finally(() => { running = false; });
|
|
279
|
+
}, intervalMs);
|
|
280
|
+
timer.unref();
|
|
281
|
+
return () => clearInterval(timer);
|
|
282
|
+
}
|
|
160
283
|
export async function runDaemon(options = {}) {
|
|
161
284
|
// Last line of defence for the whole process. Node's default for an unhandled rejection is to
|
|
162
285
|
// *exit*, and this daemon is the only thing keeping every session on this workstation alive —
|
|
@@ -170,7 +293,7 @@ export async function runDaemon(options = {}) {
|
|
|
170
293
|
const cfg = await loadClientConfig(dir);
|
|
171
294
|
const { clientKey, serverPub } = await readKeys(dir);
|
|
172
295
|
// Repair the first fixed release even when it was installed by an older ignore-scripts updater,
|
|
173
|
-
// before a newly started terminal
|
|
296
|
+
// before a newly started keeper-terminal snapshots native dependency availability.
|
|
174
297
|
await preflightOptionalPty(updateServiceContext(dir));
|
|
175
298
|
const tasks = new TaskRegistry();
|
|
176
299
|
// Attaches both the server-driven update path (onUpdate below) and axom update's manual path to
|
|
@@ -196,15 +319,132 @@ export async function runDaemon(options = {}) {
|
|
|
196
319
|
});
|
|
197
320
|
// One ACP client for every external agent; the roster it reports is the ACP agent registry, keyed
|
|
198
321
|
// by registry agent id (ruling R2) — the same key `agent_open.bridge` now carries.
|
|
199
|
-
|
|
200
|
-
|
|
322
|
+
// Live sessions run in keeper-acp (spec §5.3) — the adapters outlive the daemon there — or, when it cannot be reached, in the
|
|
323
|
+
// daemon itself through the local fallback (§5.7). Short-lived connections (list, delete, verify, login) stay direct children.
|
|
324
|
+
const acpLauncher = new KeeperLauncher({ configDir: dir, workstationId: cfg.workstationId, server: cfg.server });
|
|
325
|
+
const acpTransport = new KeeperTransport(new AcpKeeperClient({ launcher: acpLauncher }));
|
|
326
|
+
// Sessions keeper-acp still runs from a previous daemon (a restart, an upgrade, a crash) are adopted where they are — across
|
|
327
|
+
// keeper-acp generations (spec §5.5, §6.3) — before the agent channel's reconnect offer is answered.
|
|
328
|
+
const acpGenerations = keeperGenerations({ launcher: acpLauncher, current: acpTransport });
|
|
329
|
+
const acp = new AcpAgents({ cacheDir: dir, transports: { keeper: acpTransport }, keeperGenerations: acpGenerations });
|
|
330
|
+
const agents = new AgentSessionManager(acp, null, path.join(dir, 'agents'), tasks);
|
|
331
|
+
void agents.adoptSessions();
|
|
201
332
|
let bridges = await acp.roster();
|
|
333
|
+
// A scan's roster (and a pushed one) also carries the adapter builds live sessions run and how many could move now (spec §6.4,
|
|
334
|
+
// Task 25): keeper-acp's process labels joined with this daemon's sessions. The hello's roster above does not — it is not a scan.
|
|
335
|
+
const scanRoster = () => acp.roster({ sessions: (session) => agents.rosterSessionState(session) });
|
|
202
336
|
let supervisor;
|
|
203
337
|
// Plaintext WebSockets are only for an explicitly opted-in local development fixture. Production
|
|
204
338
|
// ticket handshakes must be WSS because the ticket is the data-channel key derivation material.
|
|
205
339
|
const allowInsecureWs = process.env.AXOM_WORKSTATION_ALLOW_INSECURE_WS === '1';
|
|
206
|
-
|
|
207
|
-
|
|
340
|
+
// Files: one daemon-owned host process (files/host.ts) serves file_*_request frames and files.* tool requests.
|
|
341
|
+
const home = os.homedir();
|
|
342
|
+
const playgroundCwd = path.join(home, '.axom', 'playground');
|
|
343
|
+
await fs.mkdir(playgroundCwd, { recursive: true });
|
|
344
|
+
let roots = workspaceRoots(cfg, home);
|
|
345
|
+
let hostRoots = [];
|
|
346
|
+
const filesHost = new FilesHostClient({
|
|
347
|
+
configDir: dir,
|
|
348
|
+
options: async () => { hostRoots = await existingDirectories(roots); return { home, roots: hostRoots }; },
|
|
349
|
+
onActivityChanged: () => tasks.externalActivityChanged(),
|
|
350
|
+
log: (line) => console.error(`${new Date().toISOString()} ${line}`),
|
|
351
|
+
});
|
|
352
|
+
tasks.externalActivity = () => filesHost.activity();
|
|
353
|
+
filesHost.start();
|
|
354
|
+
const stopRootsPoll = startExclusivePoll(async () => {
|
|
355
|
+
try {
|
|
356
|
+
roots = workspaceRoots(await loadClientConfig(dir), home);
|
|
357
|
+
const current = await existingDirectories(roots);
|
|
358
|
+
if (JSON.stringify(current) !== JSON.stringify(hostRoots))
|
|
359
|
+
filesHost.reconfigure();
|
|
360
|
+
}
|
|
361
|
+
catch { /* an unreadable config keeps the current host */ }
|
|
362
|
+
}, FILES_ROOTS_POLL_MS);
|
|
363
|
+
// tools.info is aggregated here and starts no keeper: its terminal fields come from keeper-terminal's hello when that keeper
|
|
364
|
+
// already runs, else from the cached node-pty check. terminal.* go through the TerminalRouter (keeper-terminal, or a legacy
|
|
365
|
+
// tools keeper an older CLI left running with live PTYs: served until they end, then stopped). browser.* keep the
|
|
366
|
+
// browser-kind ToolsClient path. Nothing here constructs a terminal-kind ToolsClient: its request() would start the legacy keeper.
|
|
367
|
+
const toolsConfig = { configDir: dir, workstationId: cfg.workstationId, server: cfg.server, roots, browserBinding: cfg.browserBinding };
|
|
368
|
+
const browserTools = new ToolsClient(toolsConfig, 'browser');
|
|
369
|
+
const ptyStatus = cachedPtyStatus();
|
|
370
|
+
// keeper-terminal generations (Task 26): new work goes to the serving generation; PTYs and execs an older generation still runs
|
|
371
|
+
// after an upgrade are routed to it by owner until the replacement policy retires it.
|
|
372
|
+
const terminalLauncher = new KeeperLauncher({ configDir: dir, workstationId: cfg.workstationId, server: cfg.server });
|
|
373
|
+
const terminalKeepers = new TerminalGenerations({ launcher: terminalLauncher });
|
|
374
|
+
const legacyKeeper = new LegacyToolsKeeper({
|
|
375
|
+
tools: toolsConfig,
|
|
376
|
+
recoverUploads: (uploads) => filesHost.recoverUploads(uploads),
|
|
377
|
+
log: (line) => console.error(`${new Date().toISOString()} ${line}`),
|
|
378
|
+
});
|
|
379
|
+
void legacyKeeper.detect();
|
|
380
|
+
const stopLegacyPoll = startExclusivePoll(() => legacyKeeper.tick(), LEGACY_TICK_MS);
|
|
381
|
+
const terminals = new TerminalRouter({
|
|
382
|
+
kits: terminalKeepers,
|
|
383
|
+
legacy: legacyKeeper,
|
|
384
|
+
launch: async (cwd) => ({ cwd: await filesHost.terminalCwd(cwd), shell: terminalShell(), env: terminalEnv() }),
|
|
385
|
+
});
|
|
386
|
+
const workstationTools = new WorkstationTools({
|
|
387
|
+
files: filesHost,
|
|
388
|
+
terminal: (method, params) => terminals.request(method, params),
|
|
389
|
+
browser: (method, params) => browserTools.request(method, params),
|
|
390
|
+
info: () => aggregateToolsInfo({
|
|
391
|
+
files: async () => filesHost.info() ?? computeFilesInfo(home, roots),
|
|
392
|
+
terminal: () => terminalKeepers.terminalStatus(ptyStatus),
|
|
393
|
+
browser: (method, params) => browserTools.request(method, params),
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
// exec_request: keeper-terminal (durable) when available, else in this process; argv/env/deadline/shaping decided here.
|
|
397
|
+
// Execs a previous daemon left in keeper-terminal are recovered first: wire ≤ 4 ones are stopped (their channel is gone), wire v5
|
|
398
|
+
// ones are followed for the server to re-attach (exec_attach on the next computer channel).
|
|
399
|
+
// Keeper replacement (spec §8.3, Task 26): what the heartbeat reports of each keeper generation, and what happens to an older one.
|
|
400
|
+
const drainKeeper = oneShotDrain((kind, gen) => (kind === 'acp' ? acpLauncher : terminalLauncher).pathsFor(kind, gen));
|
|
401
|
+
const keeperPolicy = new KeeperPolicy({
|
|
402
|
+
dir: path.join(dir, 'keepers'),
|
|
403
|
+
probe: oneShotProbe((kind, gen) => (kind === 'acp' ? acpLauncher : terminalLauncher).pathsFor(kind, gen)),
|
|
404
|
+
terminal: {
|
|
405
|
+
currentGen: () => terminalKeepers.currentGen(),
|
|
406
|
+
live: async () => (await terminalKeepers.running()).map((generation) => generation.gen),
|
|
407
|
+
manifest: () => terminalKeepers.manifest(),
|
|
408
|
+
stop: async (gen, mode) => {
|
|
409
|
+
const result = await terminalLauncher.stop('terminal', gen, mode);
|
|
410
|
+
if (result.stopped)
|
|
411
|
+
terminalKeepers.forget(gen);
|
|
412
|
+
return result;
|
|
413
|
+
},
|
|
414
|
+
reachable: async (gen) => (await terminalKeepers.client(gen)).runningHello(),
|
|
415
|
+
drain: (gen) => drainKeeper('terminal', gen),
|
|
416
|
+
},
|
|
417
|
+
legacy: legacyKeeper,
|
|
418
|
+
acp: {
|
|
419
|
+
currentGen: () => acpGenerations.current(),
|
|
420
|
+
live: () => acpGenerations.live(),
|
|
421
|
+
manifest: () => readKeeperManifest(path.join(defaultDistDir(), 'acp')),
|
|
422
|
+
stop: (gen, mode) => acpLauncher.stop('acp', gen, mode),
|
|
423
|
+
moveOff: (gens) => agents.moveOffOlderKeeperGenerations(undefined, gens),
|
|
424
|
+
cancelMoves: async (refs) => { for (const sessionRef of refs)
|
|
425
|
+
await agents.cancelSwitch({ sessionRef }); },
|
|
426
|
+
retireIdle: () => acp.retireIdleGenerations(),
|
|
427
|
+
session: (ref) => agents.sessionView(ref),
|
|
428
|
+
},
|
|
429
|
+
agentName: (agentId) => bridges.find((bridge) => bridge.type === agentId)?.displayName || agentId,
|
|
430
|
+
legacyShell: () => terminalShell(),
|
|
431
|
+
log: (line) => console.error(`${new Date().toISOString()} ${line}`),
|
|
432
|
+
});
|
|
433
|
+
terminalKeepers.picker = (current, older) => keeperPolicy.terminalServing(current, older);
|
|
434
|
+
const stopKeeperPolicy = startExclusivePoll(() => keeperPolicy.tick(), KEEPER_POLICY_TICK_MS);
|
|
435
|
+
// Spec §3.1: keeper sockets that fell back to /tmp (a long configDir path) are touched so a /tmp cleaner never reaps them.
|
|
436
|
+
const stopSocketTouch = startExclusivePoll(async () => {
|
|
437
|
+
await acpLauncher.touchSockets('acp');
|
|
438
|
+
await terminalLauncher.touchSockets('terminal');
|
|
439
|
+
}, KEEPER_POLICY_TICK_MS);
|
|
440
|
+
void keeperPolicy.tick();
|
|
441
|
+
const execPolicy = new ExecPolicy({ generations: terminalKeepers, log: (line) => console.error(`${new Date().toISOString()} ${line}`) });
|
|
442
|
+
void execPolicy.recover();
|
|
443
|
+
const computer = new ComputerChannel({ server: cfg.server, allowInsecureWs, taskRegistry: tasks, browserBinding: cfg.browserBinding,
|
|
444
|
+
runExec: (request, onOutput, signal, hooks) => execPolicy.run(request, onOutput, signal, hooks),
|
|
445
|
+
execs: execPolicy, log: (line) => console.error(`${new Date().toISOString()} ${line}`),
|
|
446
|
+
tools: (method, params) => workstationTools.request(method, params),
|
|
447
|
+
runFrame: (request, signal) => workstationTools.runFrame(request, signal),
|
|
208
448
|
});
|
|
209
449
|
const metrics = new SystemMetricsSampler(cfg.workspaces ?? []);
|
|
210
450
|
await metrics.start();
|
|
@@ -212,17 +452,46 @@ export async function runDaemon(options = {}) {
|
|
|
212
452
|
server: cfg.server, workstationId: cfg.workstationId, workstation: cfg.workstationId, clientKey, serverPub,
|
|
213
453
|
bridges: () => bridges,
|
|
214
454
|
workspaces: cfg.workspaces,
|
|
455
|
+
playgroundCwd,
|
|
215
456
|
metrics: () => metrics.snapshot(),
|
|
216
457
|
allowInsecureWs,
|
|
217
458
|
onReady: (ready) => updates.managementReady(ready.release, ready.generation),
|
|
218
459
|
onUpdate: (command) => updates.handle(command),
|
|
219
460
|
onExternalAgentsScan: async () => {
|
|
220
|
-
bridges = await
|
|
461
|
+
bridges = await scanRoster();
|
|
221
462
|
return bridges;
|
|
222
463
|
},
|
|
223
464
|
snapshot: () => buildManagementSnapshot(supervisor, tasks, updates),
|
|
465
|
+
keepers: () => buildKeeperStatuses(keeperPolicy, () => tasks.countedKeeperExecs),
|
|
466
|
+
onKeeperControl: (frame) => keeperPolicy.control(frame),
|
|
224
467
|
});
|
|
225
468
|
const agent = new AgentChannel({ server: cfg.server, agents, bridges: () => bridges, allowInsecureWs });
|
|
469
|
+
// Spec §6.2: first take over what an older CLI left (`_npx` copies, pre-build binary dirs) so agents
|
|
470
|
+
// that worked keep working; then §6.1: a version this CLI certifies (runtime.json/catalog pin) or a
|
|
471
|
+
// changed patch set moves an installed agent's current build forward by itself ("Updated by Axom").
|
|
472
|
+
// In the background: either may copy or download, an open of an agent not done yet waits on that
|
|
473
|
+
// agent's lock, and running sessions keep the build they were spawned from either way. A changed
|
|
474
|
+
// roster reaches the server the way a scan's does; offline, the next hello carries it.
|
|
475
|
+
void maintainAdapterBuilds(acp, async () => {
|
|
476
|
+
bridges = await scanRoster();
|
|
477
|
+
management.pushRoster(bridges);
|
|
478
|
+
});
|
|
479
|
+
// A quota capability learned mid-flight (this process's first spawn of an agent) changes what the
|
|
480
|
+
// roster would say; push it the way a build change does. Coalesced: several agents flipping
|
|
481
|
+
// around one burst of opens share a single scan, and a flip during that scan queues one more.
|
|
482
|
+
let rosterPushQueued = false;
|
|
483
|
+
acp.onRosterChanged(() => {
|
|
484
|
+
if (rosterPushQueued)
|
|
485
|
+
return;
|
|
486
|
+
rosterPushQueued = true;
|
|
487
|
+
void Promise.resolve().then(async () => {
|
|
488
|
+
rosterPushQueued = false;
|
|
489
|
+
bridges = await scanRoster();
|
|
490
|
+
management.pushRoster(bridges);
|
|
491
|
+
}).catch((error) => {
|
|
492
|
+
console.warn(`[acp-builds] roster push after capability change failed: ${String(error)}`);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
226
495
|
supervisor = new ConnectionSupervisor({
|
|
227
496
|
management, computer, agent,
|
|
228
497
|
// Timestamped here rather than inside the supervisor: the daemon owns this log's shape. The
|
|
@@ -231,6 +500,7 @@ export async function runDaemon(options = {}) {
|
|
|
231
500
|
log: (message) => console.log(`${new Date().toISOString()} ${message}`),
|
|
232
501
|
});
|
|
233
502
|
const stopIdleSweep = startIdleSweep(agents);
|
|
503
|
+
const stopSwitchUpkeep = startSwitchUpkeep(agents, acp);
|
|
234
504
|
const shutdown = new AbortController();
|
|
235
505
|
const stop = () => shutdown.abort();
|
|
236
506
|
process.once('SIGINT', stop);
|
|
@@ -242,6 +512,17 @@ export async function runDaemon(options = {}) {
|
|
|
242
512
|
process.removeListener('SIGINT', stop);
|
|
243
513
|
process.removeListener('SIGTERM', stop);
|
|
244
514
|
stopIdleSweep();
|
|
515
|
+
stopSwitchUpkeep();
|
|
245
516
|
metrics.stop();
|
|
517
|
+
stopRootsPoll();
|
|
518
|
+
stopLegacyPoll();
|
|
519
|
+
stopKeeperPolicy();
|
|
520
|
+
stopSocketTouch();
|
|
521
|
+
terminalKeepers.disconnect(); // only the connections: keeper-terminal and its PTYs outlive the daemon
|
|
522
|
+
// keeper-hosted sessions are detached (head checkpoint, adapter untouched, no failed turn); the others checkpoint (spec §5.5.5)
|
|
523
|
+
await agents.detachAll();
|
|
524
|
+
acp.closeTransports(); // keeper-acp's adapters outlive the daemon; local-fallback adapters die with it (spec §5.7)
|
|
525
|
+
// Bounded: a graceful host shutdown, then SIGKILL (a hung IO cannot hold the daemon's exit).
|
|
526
|
+
await filesHost.stop();
|
|
246
527
|
}
|
|
247
528
|
}
|
|
@@ -5,6 +5,8 @@ import { clientDir, loadClientConfig } from './config.js';
|
|
|
5
5
|
import { createServiceManager } from './service/installer.js';
|
|
6
6
|
import { buildServicePath } from './service/types.js';
|
|
7
7
|
import { loginShellPath } from './service/loginShell.js';
|
|
8
|
+
import { KeeperLauncher } from './keepers/kit/launcher.js';
|
|
9
|
+
import { stopKeepers } from './keepers/stop.js';
|
|
8
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
11
|
/**
|
|
10
12
|
* The package's real npm bin entry (`package.json`'s `bin.axom`), given the directory a compiled
|
|
@@ -49,9 +51,20 @@ export function serviceContext() {
|
|
|
49
51
|
pathEnv: buildServicePath(process.env.PATH, nodeDirectory),
|
|
50
52
|
};
|
|
51
53
|
}
|
|
54
|
+
/** The workstation's keepers stopped (see {@link DaemonControllerDeps.stopKeepers}); an unregistered client has none. */
|
|
55
|
+
async function stopRegisteredKeepers() {
|
|
56
|
+
const configDir = clientDir();
|
|
57
|
+
const config = await loadClientConfig(configDir).catch(() => null);
|
|
58
|
+
if (!config?.workstationId || !config.server)
|
|
59
|
+
return;
|
|
60
|
+
await stopKeepers(new KeeperLauncher({ configDir, workstationId: config.workstationId, server: config.server }), {
|
|
61
|
+
log: (line) => console.error(line),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
52
64
|
const defaultDeps = {
|
|
53
65
|
service: (context) => createServiceManager(context ?? serviceContext()),
|
|
54
66
|
config: () => loadClientConfig(clientDir()),
|
|
67
|
+
stopKeepers: stopRegisteredKeepers,
|
|
55
68
|
};
|
|
56
69
|
/**
|
|
57
70
|
* User services own the daemon lifecycle. Deliberately no PID-file fallback remains: a stale
|
|
@@ -73,9 +86,22 @@ export function createDaemonController(overrides = {}) {
|
|
|
73
86
|
: base;
|
|
74
87
|
await deps.service(context).install({ interactive: true });
|
|
75
88
|
},
|
|
76
|
-
|
|
89
|
+
// The workstation is stopped by the user: what its keepers host ends too (the daemon first, so nothing relaunches them).
|
|
90
|
+
// In a `finally`: a service that is not installed (or fails to stop) must not leave the keepers running.
|
|
91
|
+
async uninstall() { try {
|
|
92
|
+
await deps.service().uninstall();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await deps.stopKeepers();
|
|
96
|
+
} },
|
|
77
97
|
async start() { await deps.service().start(); },
|
|
78
|
-
async stop() {
|
|
98
|
+
async stop() { try {
|
|
99
|
+
await deps.service().stop();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await deps.stopKeepers();
|
|
103
|
+
} },
|
|
104
|
+
// A restart keeps the keepers: terminals, commands and agent sessions go on under the next daemon.
|
|
79
105
|
async restart() { await deps.service().restart(); },
|
|
80
106
|
async status() {
|
|
81
107
|
const state = await deps.service().status();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ExecWireText } from './shaping.js';
|
|
2
|
+
/** Spec §4.3: `exec_output` waits while the socket holds more than the high mark, until it drains below the low mark. */
|
|
3
|
+
export const EXEC_WINDOW_HIGH_BYTES = 1024 * 1024;
|
|
4
|
+
export const EXEC_WINDOW_LOW_BYTES = 256 * 1024;
|
|
5
|
+
const WINDOW_POLL_MS = 20;
|
|
6
|
+
/** Records per `exec.read` page (the keeper also caps a page at 1.5 MiB). */
|
|
7
|
+
const PAGE_RECORDS = 1024;
|
|
8
|
+
/** Re-read cadence when nothing new arrived and no progress was signalled. */
|
|
9
|
+
const IDLE_POLL_MS = 500;
|
|
10
|
+
const RETRY_MS = 1000;
|
|
11
|
+
/** The v5 `exec_exit` of an ended durable exec (`exitFrame` is the today-shaped frame, exec/policy.ts `durableExitFrame`). */
|
|
12
|
+
export function wireExitFrame(exitFrame, outcome, gapBytes) {
|
|
13
|
+
const signal = outcome.state === 'exited' ? outcome.exit.signal : null;
|
|
14
|
+
return { ...exitFrame, cancelled: outcome.cancelled, ...(signal ? { signal } : {}), ...(gapBytes > 0 ? { gaps: gapBytes } : {}) };
|
|
15
|
+
}
|
|
16
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
17
|
+
if (signal.aborted) {
|
|
18
|
+
resolve();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
|
|
22
|
+
const timer = setTimeout(done, ms);
|
|
23
|
+
signal.addEventListener('abort', done, { once: true });
|
|
24
|
+
});
|
|
25
|
+
/** Resolves once the socket has room for more output: at once below the high mark, else when it drained below the low mark. */
|
|
26
|
+
export async function waitForWindow(bufferedAmount, signal, pollMs = WINDOW_POLL_MS) {
|
|
27
|
+
if (bufferedAmount() <= EXEC_WINDOW_HIGH_BYTES)
|
|
28
|
+
return;
|
|
29
|
+
while (!signal.aborted && bufferedAmount() > EXEC_WINDOW_LOW_BYTES)
|
|
30
|
+
await sleep(pollMs, signal);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Streams `exec` to `channel`: reads its records from keeper-terminal from the commit point on (decoding silently up to
|
|
34
|
+
* `fromOffset`), sends one `exec_output` per chunk at or after `fromOffset`, honouring the socket window, then the final
|
|
35
|
+
* `exec_status` and `exec_exit` once the exec ended. A non-streaming exec sends only the final two. `stop()` ends it silently
|
|
36
|
+
* (the channel closed, or a newer attach replaced it); the exec itself is untouched.
|
|
37
|
+
*/
|
|
38
|
+
export class ExecAttachment {
|
|
39
|
+
exec;
|
|
40
|
+
channel;
|
|
41
|
+
options;
|
|
42
|
+
abort = new AbortController();
|
|
43
|
+
done;
|
|
44
|
+
constructor(exec, channel, options) {
|
|
45
|
+
this.exec = exec;
|
|
46
|
+
this.channel = channel;
|
|
47
|
+
this.options = options;
|
|
48
|
+
this.done = this.run().catch((error) => { this.options.log?.(`exec ${options.id}: attachment failed: ${error instanceof Error ? error.message : String(error)}`); });
|
|
49
|
+
}
|
|
50
|
+
stop() { this.abort.abort(); }
|
|
51
|
+
get stopped() { return this.abort.signal.aborted; }
|
|
52
|
+
async run() {
|
|
53
|
+
const { exec } = this;
|
|
54
|
+
if (exec.wire?.streaming !== false)
|
|
55
|
+
await this.stream();
|
|
56
|
+
if (this.stopped)
|
|
57
|
+
return;
|
|
58
|
+
const outcome = await Promise.race([exec.outcome, new Promise((resolve) => this.abort.signal.addEventListener('abort', () => resolve(null), { once: true }))]);
|
|
59
|
+
if (outcome === null || this.stopped)
|
|
60
|
+
return;
|
|
61
|
+
const id = this.options.id;
|
|
62
|
+
this.channel.send({ t: 'exec_status', id, state: 'exited', headOffset: exec.finalHead, committedOffset: exec.committedOffset });
|
|
63
|
+
this.channel.send(this.options.exitFrame(outcome));
|
|
64
|
+
}
|
|
65
|
+
/** Waits for more records: a progress signal from the exec's own follower, a stop, or the idle poll. */
|
|
66
|
+
wait(ms) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const unsubscribe = this.exec.onProgress(() => { unsubscribe(); resolve(); });
|
|
69
|
+
void sleep(ms, this.abort.signal).then(() => { unsubscribe(); resolve(); });
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async stream() {
|
|
73
|
+
const { exec, channel } = this;
|
|
74
|
+
const { id, fromOffset } = this.options;
|
|
75
|
+
const text = new ExecWireText();
|
|
76
|
+
let cursor = exec.commitFloor + 1;
|
|
77
|
+
const hold = exec.hold(cursor);
|
|
78
|
+
try {
|
|
79
|
+
while (!this.stopped) {
|
|
80
|
+
const exitAt = exec.exitRecordOffset;
|
|
81
|
+
if (exitAt !== undefined && cursor > exitAt)
|
|
82
|
+
return;
|
|
83
|
+
let page;
|
|
84
|
+
try {
|
|
85
|
+
page = await exec.read(cursor, PAGE_RECORDS);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (exec.finished)
|
|
89
|
+
return; // deleted, reclaimed or unreachable: the outcome says so
|
|
90
|
+
this.options.log?.(`exec ${id}: read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
91
|
+
await this.wait(RETRY_MS);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (this.stopped)
|
|
95
|
+
return;
|
|
96
|
+
if (page.records.length === 0) {
|
|
97
|
+
if (exec.finished)
|
|
98
|
+
return;
|
|
99
|
+
await this.wait(this.options.idleMs ?? IDLE_POLL_MS);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const record of page.records) {
|
|
103
|
+
if (record.o < cursor)
|
|
104
|
+
continue;
|
|
105
|
+
cursor = record.o + 1;
|
|
106
|
+
hold.at = cursor;
|
|
107
|
+
if (record.k === 'exit')
|
|
108
|
+
return;
|
|
109
|
+
if (record.k !== 'out' && record.k !== 'err')
|
|
110
|
+
continue;
|
|
111
|
+
const stream = record.k === 'out' ? 'stdout' : 'stderr';
|
|
112
|
+
const chunk = text.push(stream, record.so, Buffer.from(record.b, 'base64'));
|
|
113
|
+
if (!chunk || record.o < fromOffset)
|
|
114
|
+
continue;
|
|
115
|
+
await waitForWindow(() => channel.bufferedAmount(), this.abort.signal);
|
|
116
|
+
if (this.stopped)
|
|
117
|
+
return;
|
|
118
|
+
channel.send({ t: 'exec_output', id, stream, chunk, offset: record.o, recvAtMs: record.t });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
exec.unhold(hold);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// What the daemon decides about one `exec_request` before anything runs (spec §4.2 "daemon (policy)" column; analysis E §6.3–6.4):
|
|
2
|
+
// the exact argv, the complete environment and the absolute deadline. Shared by the durable (keeper-terminal) path and the
|
|
3
|
+
// in-process fallback, so both run the same command line in the same environment.
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* `[shell, '-lc', command]`, element by element what the daemon has always spawned (`spawn(req.shell, ['-lc', req.command])`):
|
|
7
|
+
* a login, non-interactive shell of the user's choice reading the command string exactly. Never wrapped (`sh -c`, `; echo $?`, …):
|
|
8
|
+
* any wrapping would go through the user's shell dialect (zsh EQUALS, fish quoting).
|
|
9
|
+
*/
|
|
10
|
+
export function execArgv(req) {
|
|
11
|
+
return [req.shell, '-lc', req.command];
|
|
12
|
+
}
|
|
13
|
+
/** The daemon's credential for its own server: never visible to a command (commands/workstation.ts drops it already; again here). */
|
|
14
|
+
export const ACCESS_TOKEN_ENV = 'AXOM_ACCESS_TOKEN';
|
|
15
|
+
/**
|
|
16
|
+
* The complete environment of the command: the daemon's environment without the access token, then the request's `env`, then
|
|
17
|
+
* `pathPrepend` in front of PATH (exactly as the daemon always did). keeper-terminal gets this whole map and never falls back to its
|
|
18
|
+
* own environment (under a systemd transient unit that would be the user manager's, frozen at the keeper's start).
|
|
19
|
+
*/
|
|
20
|
+
export function execEnvironment(daemonEnv, req) {
|
|
21
|
+
const env = {};
|
|
22
|
+
for (const [name, value] of Object.entries(daemonEnv))
|
|
23
|
+
if (value !== undefined && name !== ACCESS_TOKEN_ENV)
|
|
24
|
+
env[name] = value;
|
|
25
|
+
Object.assign(env, req.env);
|
|
26
|
+
if (req.pathPrepend.length > 0)
|
|
27
|
+
env.PATH = [...req.pathPrepend, env.PATH ?? ''].filter(Boolean).join(path.delimiter);
|
|
28
|
+
return env;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The absolute deadline, from `timeoutSeconds` and the DAEMON's clock at the moment it first received the request (never the
|
|
32
|
+
* server's clock). No timeout (absent, null, 0 or negative: today's rule) means no deadline.
|
|
33
|
+
*/
|
|
34
|
+
export function execDeadline(req, receivedAtMs) {
|
|
35
|
+
return req.timeoutSeconds && req.timeoutSeconds > 0 ? receivedAtMs + req.timeoutSeconds * 1000 : undefined;
|
|
36
|
+
}
|
|
37
|
+
/** keeper-terminal wants an absolute cwd; a relative one resolves against the daemon's cwd, as `spawn` always resolved it. */
|
|
38
|
+
export function execCwd(req) {
|
|
39
|
+
return path.resolve(req.cwd || '.');
|
|
40
|
+
}
|