@platless/axom-cli 0.1.0 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +132 -0
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +63 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +36 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/mcp.js +32 -14
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2514 -681
- package/dist/workstation/acp/acpClient.js +95 -318
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +28 -356
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +235 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +144 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +80 -0
- package/dist/workstation/acp/permission.js +335 -37
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +385 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +171 -0
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/test/keeperWorld.js +267 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1686 -348
- package/dist/workstation/channels/agent.js +14 -6
- package/dist/workstation/channels/computer.js +203 -7
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +86 -6
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +309 -6
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/files/text.js +356 -0
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +120 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +249 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +75 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +201 -8
- package/dist/workstation/protocol/common.js +36 -0
- package/dist/workstation/protocol/computer.js +107 -7
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
- package/dist/workstation/qoderAdapter/history.js +85 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +95 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +85 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +277 -0
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +124 -0
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +86 -0
- package/dist/workstation/update/coordinator.js +32 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +45 -0
- package/dist/workstation/update/taskRegistry.js +141 -23
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +20 -7
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
- package/dist/workstation/acp/acpSession.js +0 -1047
- package/dist/workstation/acp/contract.js +0 -494
- package/dist/workstation/acp/draftInterceptor.js +0 -154
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { constants, lstatSync, linkSync, promises as fs, renameSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { pinned } from './fence.js';
|
|
5
|
+
/** Staging entries and reserved names start with this; `files.list` / `files.find` hide them. */
|
|
6
|
+
export const TEMP_PREFIX = '.axom-upload-';
|
|
7
|
+
export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
8
|
+
export const MAX_UPLOADS = 8;
|
|
9
|
+
export const MAX_UPLOAD_CHUNKS = 16384;
|
|
10
|
+
export const UPLOAD_TTL_MS = 15 * 60_000;
|
|
11
|
+
const same = (stat, identity) => stat.dev === identity.dev && stat.ino === identity.ino;
|
|
12
|
+
/**
|
|
13
|
+
* Upload sessions: one opened temporary file per upload inside a private 0700 staging directory beneath a trusted root
|
|
14
|
+
* on the destination's filesystem, per-block SHA-256 receipts, a private manifest per upload so `recover()` can remove
|
|
15
|
+
* orphans after a crash, and a synchronous identity-checked commit (exclusive hard link, or rename for overwrite).
|
|
16
|
+
* Every method that names a destination runs inside the caller's `pinned()` section on the destination parent.
|
|
17
|
+
*/
|
|
18
|
+
export class UploadStore {
|
|
19
|
+
roots;
|
|
20
|
+
ttl;
|
|
21
|
+
stateDir;
|
|
22
|
+
uploads = new Map();
|
|
23
|
+
sweeping;
|
|
24
|
+
constructor(roots, ttl = UPLOAD_TTL_MS, stateDir) {
|
|
25
|
+
this.roots = roots;
|
|
26
|
+
this.ttl = ttl;
|
|
27
|
+
this.stateDir = stateDir;
|
|
28
|
+
}
|
|
29
|
+
/** Creates the private manifest directory (0700, owned by this uid, not a symlink). */
|
|
30
|
+
static async prepareStateDir(stateDir) {
|
|
31
|
+
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
32
|
+
const stat = await fs.lstat(stateDir);
|
|
33
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
|
|
34
|
+
throw new Error('Unsafe upload state directory');
|
|
35
|
+
await fs.chmod(stateDir, 0o700);
|
|
36
|
+
}
|
|
37
|
+
/** Uploads in flight (the daemon samples this as activity). */
|
|
38
|
+
get size() { return this.uploads.size; }
|
|
39
|
+
get(id) { return this.uploads.get(id); }
|
|
40
|
+
/** Looks an upload up for a client call and refreshes its idle timer. */
|
|
41
|
+
use(id) {
|
|
42
|
+
const upload = this.uploads.get(id);
|
|
43
|
+
if (!upload)
|
|
44
|
+
throw new Error('Upload not found or expired');
|
|
45
|
+
upload.touched = Date.now();
|
|
46
|
+
return upload;
|
|
47
|
+
}
|
|
48
|
+
/** Removes temporary files that a crashed process left behind, as recorded in the private manifests. */
|
|
49
|
+
async recover(resolve) {
|
|
50
|
+
if (!this.stateDir)
|
|
51
|
+
return;
|
|
52
|
+
for (const name of await fs.readdir(this.stateDir)) {
|
|
53
|
+
if (!/^[0-9a-f-]{36}\.json$/.test(name))
|
|
54
|
+
continue;
|
|
55
|
+
const manifest = path.join(this.stateDir, name);
|
|
56
|
+
try {
|
|
57
|
+
const stat = await fs.lstat(manifest);
|
|
58
|
+
if (!stat.isFile() || stat.size > 8192)
|
|
59
|
+
continue;
|
|
60
|
+
const value = JSON.parse(await fs.readFile(manifest, 'utf8'));
|
|
61
|
+
if (typeof value.dir !== 'string' || path.basename(value.dir) !== TEMP_PREFIX + name.slice(0, -5) || value.temp !== path.join(value.dir, 'payload'))
|
|
62
|
+
throw new Error('Invalid orphan upload');
|
|
63
|
+
await resolve(value.dir, false, true);
|
|
64
|
+
const directory = await fs.lstat(value.dir);
|
|
65
|
+
if (!directory.isDirectory() || !same(directory, value.dirIdentity))
|
|
66
|
+
throw new Error('Orphan upload directory changed');
|
|
67
|
+
await pinned(value.dir, async () => {
|
|
68
|
+
for (const entry of await fs.readdir('.')) {
|
|
69
|
+
if (entry !== 'payload' && !/^commit-[0-9a-f-]{36}$/.test(entry))
|
|
70
|
+
continue;
|
|
71
|
+
const item = await fs.lstat(entry);
|
|
72
|
+
if (item.isFile() && same(item, value.identity))
|
|
73
|
+
await fs.unlink(entry);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
await fs.rmdir(value.dir);
|
|
77
|
+
}
|
|
78
|
+
catch { /* Replaced entries are never followed or deleted during cleanup. */ }
|
|
79
|
+
await fs.unlink(manifest).catch(() => { });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Staging names must live beneath a root protected from other-UID rename/write. The destination
|
|
83
|
+
* parent remains kernel-pinned; staging can be on any allowed root on that same filesystem. */
|
|
84
|
+
async stagingRoot(dev, extraRoot) {
|
|
85
|
+
const uid = process.getuid?.();
|
|
86
|
+
for (const root of [...this.roots, ...(extraRoot ? [extraRoot] : [])]) {
|
|
87
|
+
const rootStat = await fs.lstat(root);
|
|
88
|
+
if (!rootStat.isDirectory() || rootStat.dev !== dev || rootStat.uid !== uid || (rootStat.mode & 0o022))
|
|
89
|
+
continue;
|
|
90
|
+
let child = root;
|
|
91
|
+
let childStat = rootStat;
|
|
92
|
+
let trusted = true;
|
|
93
|
+
while (path.dirname(child) !== child) {
|
|
94
|
+
const parent = path.dirname(child);
|
|
95
|
+
const stat = await fs.lstat(parent);
|
|
96
|
+
if (!stat.isDirectory() || ((stat.mode & 0o022) && !((stat.mode & 0o1000) && childStat.uid === uid))) {
|
|
97
|
+
trusted = false;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
child = parent;
|
|
101
|
+
childStat = stat;
|
|
102
|
+
}
|
|
103
|
+
if (trusted)
|
|
104
|
+
return root;
|
|
105
|
+
}
|
|
106
|
+
throw new Error('Upload requires a trusted private staging root on the destination filesystem');
|
|
107
|
+
}
|
|
108
|
+
async verify(upload) {
|
|
109
|
+
const directory = await fs.lstat(upload.dir);
|
|
110
|
+
if (!directory.isDirectory() || !same(directory, upload.dirIdentity) || (directory.mode & 0o077))
|
|
111
|
+
throw new Error('Upload staging directory changed');
|
|
112
|
+
const opened = await upload.handle.stat();
|
|
113
|
+
const named = await fs.lstat(upload.temp);
|
|
114
|
+
if (!opened.isFile() || opened.size !== upload.size || !named.isFile() || !same(named, opened))
|
|
115
|
+
throw new Error('Upload temporary file changed');
|
|
116
|
+
for (const [offset, receipt] of upload.chunks) {
|
|
117
|
+
const data = Buffer.alloc(receipt.length);
|
|
118
|
+
let read = 0;
|
|
119
|
+
while (read < data.length) {
|
|
120
|
+
const result = await upload.handle.read(data, read, data.length - read, offset + read);
|
|
121
|
+
if (!result.bytesRead)
|
|
122
|
+
throw new Error('Upload integrity changed');
|
|
123
|
+
read += result.bytesRead;
|
|
124
|
+
}
|
|
125
|
+
if (createHash('sha256').update(data).digest('hex') !== receipt.digest)
|
|
126
|
+
throw new Error('Upload integrity changed');
|
|
127
|
+
}
|
|
128
|
+
const after = await upload.handle.stat();
|
|
129
|
+
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs)
|
|
130
|
+
throw new Error('Upload integrity changed during verification');
|
|
131
|
+
return after;
|
|
132
|
+
}
|
|
133
|
+
/** Runs inside `pinned(dirname(target))`; `resolveTarget` is the caller's configured-roots resolution of the path. */
|
|
134
|
+
async begin(pin, resolveTarget, size, overwrite, filesystem = false, reportedPath) {
|
|
135
|
+
if (this.uploads.size >= MAX_UPLOADS)
|
|
136
|
+
throw new Error('Upload limit exceeded');
|
|
137
|
+
const target = await resolveTarget();
|
|
138
|
+
if (path.basename(target).startsWith(TEMP_PREFIX))
|
|
139
|
+
throw new Error('Reserved upload filename');
|
|
140
|
+
let existing;
|
|
141
|
+
try {
|
|
142
|
+
existing = await fs.lstat(pin.leaf(target));
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code !== 'ENOENT')
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
if (existing && (overwrite !== true || !existing.isFile()))
|
|
149
|
+
throw new Error('Target already exists');
|
|
150
|
+
const inPlace = filesystem && !!existing && overwrite;
|
|
151
|
+
if (inPlace)
|
|
152
|
+
await fs.access(pin.leaf(target), constants.W_OK);
|
|
153
|
+
const id = randomUUID();
|
|
154
|
+
const parent = await fs.stat('.');
|
|
155
|
+
const root = await this.stagingRoot(inPlace ? (await fs.stat(this.roots[0])).dev : parent.dev, filesystem ? path.dirname(target) : undefined);
|
|
156
|
+
const dir = path.join(root, `${TEMP_PREFIX}${id}`);
|
|
157
|
+
const temp = path.join(dir, 'payload');
|
|
158
|
+
const manifest = this.stateDir ? path.join(this.stateDir, `${id}.json`) : undefined;
|
|
159
|
+
await fs.mkdir(dir, { mode: 0o700 });
|
|
160
|
+
let handle;
|
|
161
|
+
try {
|
|
162
|
+
handle = await fs.open(temp, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
await fs.rmdir(dir).catch(() => { });
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
const directory = await fs.lstat(dir);
|
|
169
|
+
const inode = await handle.stat();
|
|
170
|
+
const dirIdentity = { dev: directory.dev, ino: directory.ino };
|
|
171
|
+
const identity = { dev: inode.dev, ino: inode.ino };
|
|
172
|
+
try {
|
|
173
|
+
if (manifest)
|
|
174
|
+
await fs.writeFile(manifest, JSON.stringify({ temp, dir, identity, dirIdentity }), { flag: 'wx', mode: 0o600 });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
await handle.close();
|
|
178
|
+
await fs.unlink(temp).catch(() => { });
|
|
179
|
+
await fs.rmdir(dir).catch(() => { });
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
this.uploads.set(id, { id, manifest, target, temp, dir, dirIdentity, identity, handle, size, offset: 0, overwrite, inPlace, reportedPath: filesystem ? reportedPath : undefined, touched: Date.now(), parent: { dev: parent.dev, ino: parent.ino }, chunks: new Map(), commits: new Set() });
|
|
183
|
+
return { uploadId: id };
|
|
184
|
+
}
|
|
185
|
+
async chunk(upload, offset, bytes) {
|
|
186
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
187
|
+
const receipt = upload.chunks.get(offset);
|
|
188
|
+
if (receipt && receipt.length === bytes.length && receipt.digest === digest)
|
|
189
|
+
return { offset: upload.offset };
|
|
190
|
+
if (upload.chunks.size >= MAX_UPLOAD_CHUNKS)
|
|
191
|
+
throw new Error('Upload chunk count limit exceeded');
|
|
192
|
+
if (offset !== upload.offset || !bytes.length || offset + bytes.length > upload.size)
|
|
193
|
+
throw new Error('Upload offset or size mismatch');
|
|
194
|
+
let written = 0;
|
|
195
|
+
while (written < bytes.length) {
|
|
196
|
+
const result = await upload.handle.write(bytes, written, bytes.length - written, offset + written);
|
|
197
|
+
if (!result.bytesWritten)
|
|
198
|
+
throw new Error('Upload write failed');
|
|
199
|
+
written += result.bytesWritten;
|
|
200
|
+
}
|
|
201
|
+
upload.chunks.set(offset, { length: bytes.length, digest });
|
|
202
|
+
upload.offset += bytes.length;
|
|
203
|
+
return { offset: upload.offset };
|
|
204
|
+
}
|
|
205
|
+
/** Runs inside `pinned(dirname(upload.target))`. */
|
|
206
|
+
async finish(pin, upload, resolve) {
|
|
207
|
+
if (upload.offset !== upload.size)
|
|
208
|
+
throw new Error('Upload size mismatch');
|
|
209
|
+
await resolve(upload.target, true, true);
|
|
210
|
+
const parent = await fs.stat('.');
|
|
211
|
+
if (parent.dev !== upload.parent.dev || parent.ino !== upload.parent.ino)
|
|
212
|
+
throw new Error('Upload parent changed');
|
|
213
|
+
await upload.handle.sync();
|
|
214
|
+
await this.verify(upload);
|
|
215
|
+
if (upload.inPlace) {
|
|
216
|
+
// Write through the existing inode: the kernel enforces file ACLs and retains ownership,
|
|
217
|
+
// permissions and hard links, including when the parent does not permit replacement.
|
|
218
|
+
const destination = await pin.regularHandle(upload.target, constants.O_WRONLY);
|
|
219
|
+
try {
|
|
220
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
221
|
+
let offset = 0;
|
|
222
|
+
while (offset < upload.size) {
|
|
223
|
+
const { bytesRead } = await upload.handle.read(buffer, 0, Math.min(buffer.length, upload.size - offset), offset);
|
|
224
|
+
if (!bytesRead)
|
|
225
|
+
throw new Error('Upload temporary file changed');
|
|
226
|
+
let written = 0;
|
|
227
|
+
while (written < bytesRead) {
|
|
228
|
+
const result = await destination.write(buffer, written, bytesRead - written, offset + written);
|
|
229
|
+
if (!result.bytesWritten)
|
|
230
|
+
throw new Error('Upload write failed');
|
|
231
|
+
written += result.bytesWritten;
|
|
232
|
+
}
|
|
233
|
+
offset += bytesRead;
|
|
234
|
+
}
|
|
235
|
+
await destination.truncate(upload.size);
|
|
236
|
+
await destination.sync();
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
await destination.close();
|
|
240
|
+
}
|
|
241
|
+
await this.clean(upload);
|
|
242
|
+
return { ok: true, path: upload.reportedPath ?? upload.target };
|
|
243
|
+
}
|
|
244
|
+
const commit = path.join(upload.dir, `commit-${randomUUID()}`);
|
|
245
|
+
upload.commits.add(commit);
|
|
246
|
+
await fs.link(upload.temp, commit);
|
|
247
|
+
// The link may have raced source replacement. Check the actual linked inode before ever
|
|
248
|
+
// touching the destination; the final source lives in a trusted 0700 directory.
|
|
249
|
+
const linked = await fs.lstat(commit);
|
|
250
|
+
const opened = await upload.handle.stat();
|
|
251
|
+
if (!linked.isFile() || !same(linked, opened))
|
|
252
|
+
throw new Error('Upload staging integrity changed');
|
|
253
|
+
const verified = await this.verify(upload);
|
|
254
|
+
// No asynchronous yield between the final identity guard and atomic commit. Other-UID
|
|
255
|
+
// processes cannot replace the private staging entry; unrestricted same-UID tampering is
|
|
256
|
+
// outside this boundary (that UID can also control the files host or ptrace its process).
|
|
257
|
+
const directory = lstatSync(upload.dir);
|
|
258
|
+
const source = lstatSync(commit);
|
|
259
|
+
const original = lstatSync(upload.temp);
|
|
260
|
+
if (!directory.isDirectory() || !same(directory, upload.dirIdentity) || (directory.mode & 0o077)
|
|
261
|
+
|| !source.isFile() || !original.isFile() || !same(source, verified) || !same(original, verified)
|
|
262
|
+
|| source.size !== upload.size || source.mtimeMs !== verified.mtimeMs || source.ctimeMs !== verified.ctimeMs)
|
|
263
|
+
throw new Error('Upload commit source changed');
|
|
264
|
+
if (upload.overwrite) {
|
|
265
|
+
try {
|
|
266
|
+
if (!lstatSync(pin.leaf(upload.target)).isFile())
|
|
267
|
+
throw new Error('Target is not a regular file');
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (error.code !== 'ENOENT')
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
renameSync(commit, pin.leaf(upload.target));
|
|
274
|
+
}
|
|
275
|
+
else
|
|
276
|
+
linkSync(commit, pin.leaf(upload.target));
|
|
277
|
+
await this.clean(upload);
|
|
278
|
+
return { ok: true, path: upload.reportedPath ?? upload.target };
|
|
279
|
+
}
|
|
280
|
+
async cancel(id) {
|
|
281
|
+
const upload = this.uploads.get(id);
|
|
282
|
+
if (upload)
|
|
283
|
+
await this.clean(upload);
|
|
284
|
+
return { ok: true };
|
|
285
|
+
}
|
|
286
|
+
async clean(upload) {
|
|
287
|
+
this.uploads.delete(upload.id);
|
|
288
|
+
await upload.handle.close().catch(() => { });
|
|
289
|
+
try {
|
|
290
|
+
const directory = await fs.lstat(upload.dir);
|
|
291
|
+
if (directory.isDirectory() && same(directory, upload.dirIdentity)) {
|
|
292
|
+
for (const item of [upload.temp, ...upload.commits]) {
|
|
293
|
+
try {
|
|
294
|
+
const stat = await fs.lstat(item);
|
|
295
|
+
if (stat.isFile() && same(stat, upload.identity))
|
|
296
|
+
await fs.unlink(item);
|
|
297
|
+
}
|
|
298
|
+
catch { /* Preserve substituted entries. */ }
|
|
299
|
+
}
|
|
300
|
+
await fs.rmdir(upload.dir).catch(() => { });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch { /* Inaccessible private staging paths are not followed. */ }
|
|
304
|
+
if (upload.manifest)
|
|
305
|
+
await fs.unlink(upload.manifest).catch(() => { });
|
|
306
|
+
}
|
|
307
|
+
async abandon(upload) {
|
|
308
|
+
this.uploads.delete(upload.id);
|
|
309
|
+
await upload.handle.close().catch(() => { });
|
|
310
|
+
// Retain the private journal when its original parent cannot be safely resolved for cleanup.
|
|
311
|
+
// A later startup can retry; never remove a same-name file in a replacement directory.
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Cleans (or, when its parent was replaced, abandons) every upload idle for the TTL. Sweeps never overlap: a caller
|
|
315
|
+
* that arrives while one runs (the periodic timer, or another request) waits for it and then sweeps again itself, so
|
|
316
|
+
* once `expire()` returns, everything expired at the call is gone from disk as well as from the table - as when the
|
|
317
|
+
* keeper queued every call.
|
|
318
|
+
*/
|
|
319
|
+
async expire() {
|
|
320
|
+
while (this.sweeping)
|
|
321
|
+
await this.sweeping;
|
|
322
|
+
let done;
|
|
323
|
+
this.sweeping = new Promise(resolve => { done = resolve; });
|
|
324
|
+
try {
|
|
325
|
+
for (const upload of [...this.uploads.values()])
|
|
326
|
+
if (Date.now() - upload.touched >= this.ttl)
|
|
327
|
+
await this.retire(upload);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
this.sweeping = undefined;
|
|
331
|
+
done();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async dispose() {
|
|
335
|
+
while (this.sweeping)
|
|
336
|
+
await this.sweeping;
|
|
337
|
+
for (const upload of [...this.uploads.values()])
|
|
338
|
+
await this.retire(upload);
|
|
339
|
+
}
|
|
340
|
+
async retire(upload) {
|
|
341
|
+
await pinned(path.dirname(upload.target), async () => {
|
|
342
|
+
// Another caller may have finished, cancelled or retired it while this one waited for the pin.
|
|
343
|
+
if (this.uploads.get(upload.id) !== upload)
|
|
344
|
+
return;
|
|
345
|
+
const stat = await fs.stat('.');
|
|
346
|
+
if (stat.dev !== upload.parent.dev || stat.ino !== upload.parent.ino)
|
|
347
|
+
throw new Error('Upload parent changed');
|
|
348
|
+
await this.clean(upload);
|
|
349
|
+
}).catch(() => this.uploads.get(upload.id) === upload ? this.abandon(upload) : undefined);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Legacy, read-only. Remove in axom-cli 0.5.0.**
|
|
3
|
+
*
|
|
4
|
+
* Until Task 20 (spec 2026-10-11 §5.3) the daemon recorded every outbound `agent_event` / `agent_turn_done` of a session in
|
|
5
|
+
* `<configDir>/agents/sessions/<encodeURIComponent(sessionRef)>.jsonl` before sending it (`SessionJournal`), replayed the tail on
|
|
6
|
+
* reconnect and truncated it on `agent_event_ack`. That job moved to the transport's outbox stream (keeper-acp, or the local
|
|
7
|
+
* fallback's `<configDir>/agents/local/<sessionRef>/`), so nothing writes these files any more.
|
|
8
|
+
*
|
|
9
|
+
* What is left is reading the files an older CLI left behind: on the first start of a new CLI the manager migrates each one into
|
|
10
|
+
* a local outbox under the same ref (`AgentSessionManager.migrateLegacyJournals`) — offered as an orphan and resent on a reopen
|
|
11
|
+
* exactly as the journal was — and deletes the file. The release after the one that ships keepers drops this module (every
|
|
12
|
+
* daemon that could have left a journal has started a migrating CLI by then; a file still there is simply ignored).
|
|
13
|
+
*
|
|
14
|
+
* File layout: one `{seq, frame, src?}` JSON object per line; a torn trailing line (a crash mid-append) is skipped.
|
|
15
|
+
*/
|
|
1
16
|
import fs from 'node:fs/promises';
|
|
2
17
|
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
export const DEFAULT_JOURNAL_MAX_BYTES = 64 * 1024 * 1024;
|
|
5
|
-
function serializeEntry(entry) {
|
|
6
|
-
return `${JSON.stringify({ seq: entry.seq, frame: entry.frame })}\n`;
|
|
7
|
-
}
|
|
18
|
+
const SUFFIX = '.jsonl';
|
|
8
19
|
function parseEntry(line) {
|
|
9
20
|
try {
|
|
10
21
|
const parsed = JSON.parse(line);
|
|
@@ -12,246 +23,64 @@ function parseEntry(line) {
|
|
|
12
23
|
return null;
|
|
13
24
|
if (typeof parsed.frame !== 'object' || parsed.frame === null)
|
|
14
25
|
return null;
|
|
15
|
-
|
|
26
|
+
const src = parsed.src;
|
|
27
|
+
const valid = src !== null && typeof src === 'object' && Number.isSafeInteger(src.o) && Number.isSafeInteger(src.i) &&
|
|
28
|
+
src.o >= 0 && src.i >= 0;
|
|
29
|
+
return { seq: parsed.seq, frame: parsed.frame, ...(valid ? { src: { o: src.o, i: src.i } } : {}) };
|
|
16
30
|
}
|
|
17
31
|
catch {
|
|
18
32
|
return null;
|
|
19
33
|
}
|
|
20
34
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
* It exists because the tunnel is not reliable: the websocket can drop mid-turn and the daemon can
|
|
29
|
-
* restart while the native agent keeps running. Every outbound `agent_event` / `agent_turn_done` is
|
|
30
|
-
* written here *before* it is sent, so on reconnect the server can say "I have applied through N"
|
|
31
|
-
* and get everything after N replayed verbatim. The server acks with `agent_event_ack`, which is
|
|
32
|
-
* what keeps the file from growing without bound in the steady state.
|
|
33
|
-
*
|
|
34
|
-
* **Scope of the guarantee: process crash, not power loss.** Writes go through `appendFile` /
|
|
35
|
-
* `writeFile` with no `fsync`, so an entry that has returned from {@link append} survives the daemon
|
|
36
|
-
* being killed, but bytes still sitting in the OS page cache can be lost to a kernel panic or a
|
|
37
|
-
* power cut. That is the right trade for streamed agent output — an fsync per event would dominate
|
|
38
|
-
* the cost of the event — and the failure mode degrades to the same seq gap a dropped socket causes.
|
|
39
|
-
*
|
|
40
|
-
* The seq allocator lives here rather than in the manager for the daemon-restart case: seq must not
|
|
41
|
-
* restart at 1 when the process comes back, or the server's `seq > bridgeSeq` de-duplication would
|
|
42
|
-
* silently swallow the whole next turn. {@link init} recovers the high-water mark from disk *and*
|
|
43
|
-
* takes the reopening server's watermark as a floor (the file can be empty precisely because
|
|
44
|
-
* everything in it was acked), and {@link nextSeq} continues from there.
|
|
45
|
-
*
|
|
46
|
-
* File layout: `<dir>/<sessionRef>.jsonl`, one `{seq, frame}` JSON object per line. `dir` is the
|
|
47
|
-
* directory that *directly* holds the files — `AgentSessionManager` passes its `sessionsDir`
|
|
48
|
-
* (`<journalDir>/sessions`). The file is created on the first {@link append}, not by {@link init},
|
|
49
|
-
* so a session that opens and is abandoned before producing anything leaves nothing behind.
|
|
50
|
-
*
|
|
51
|
-
* Every public method is serialized on an internal queue: a rewrite (ack truncation, overflow drop)
|
|
52
|
-
* must never interleave with an append, and callers hand work in from unordered bridge callbacks.
|
|
53
|
-
*/
|
|
54
|
-
export class SessionJournal {
|
|
55
|
-
/** Absolute path of the `.jsonl`. Exposed for logging and tests; nothing else should read it directly. */
|
|
56
|
-
filePath;
|
|
57
|
-
dir;
|
|
58
|
-
tmpPath;
|
|
59
|
-
maxBytes;
|
|
60
|
-
maxSeq = 0;
|
|
61
|
-
/** Byte size of the file as of the last write; kept in memory so append does not stat every time. */
|
|
62
|
-
bytes = 0;
|
|
63
|
-
dropped = false;
|
|
64
|
-
ready = false;
|
|
65
|
-
queue = Promise.resolve();
|
|
66
|
-
constructor(dir, sessionRef, maxBytes = DEFAULT_JOURNAL_MAX_BYTES) {
|
|
67
|
-
this.dir = dir;
|
|
68
|
-
// sessionRefs are server-supplied strings, not filenames: percent-encoding keeps '/' and '..'
|
|
69
|
-
// from steering the write out of the journal directory, and keeps the mapping reversible.
|
|
70
|
-
this.filePath = path.join(dir, `${encodeURIComponent(sessionRef)}.jsonl`);
|
|
71
|
-
this.tmpPath = `${this.filePath}.tmp`;
|
|
72
|
-
this.maxBytes = maxBytes;
|
|
73
|
-
}
|
|
74
|
-
/** True once an overflow has discarded entries. The manager turns this into a visible gap notice on resume. */
|
|
75
|
-
get droppedOldest() {
|
|
76
|
-
return this.dropped;
|
|
77
|
-
}
|
|
78
|
-
/** Highest seq ever handed out or recovered. `0` means nothing has been journaled for this session. */
|
|
79
|
-
get lastSeq() {
|
|
80
|
-
return this.maxSeq;
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Creates the journal directory and recovers `maxSeq` (and the byte size) from any existing file.
|
|
84
|
-
* Must be awaited before {@link nextSeq} / {@link append}; both throw otherwise, because a missed
|
|
85
|
-
* init would restart seq at 1 and desynchronize the server's watermark.
|
|
86
|
-
*
|
|
87
|
-
* `resumeFromSeq` is the server's watermark from the `agent_open` that is (re)creating this
|
|
88
|
-
* session, and it is a **floor** under the recovered value — never a ceiling. The file alone is
|
|
89
|
-
* not a sufficient source for the allocator: acked entries are truncated away, so between turns
|
|
90
|
-
* the journal is legitimately empty. A daemon restart in that window recovers `maxSeq = 0` while
|
|
91
|
-
* the server is still at N, `nextSeq()` starts back at 1, and every event the session produces
|
|
92
|
-
* from then on is `<= lastSeenSeq` on the server — silently swallowed, no error, no recovery
|
|
93
|
-
* path, the run hangs forever. Seeding from the watermark closes that hole without asking the
|
|
94
|
-
* server to lower anything (its watermark is advance-only at the storage layer, deliberately).
|
|
95
|
-
*
|
|
96
|
-
* A trailing line torn by a crash mid-append is dropped and the file rewritten, so a later append
|
|
97
|
-
* cannot glue new bytes onto half a record.
|
|
98
|
-
*/
|
|
99
|
-
async init(resumeFromSeq = 0) {
|
|
100
|
-
await this.serialize(async () => {
|
|
101
|
-
await fs.mkdir(this.dir, { recursive: true });
|
|
102
|
-
const { entries, bytes, clean } = await this.readEntries();
|
|
103
|
-
const recovered = entries.reduce((max, entry) => Math.max(max, entry.seq), 0);
|
|
104
|
-
this.maxSeq = Math.max(recovered, resumeFromSeq);
|
|
105
|
-
this.bytes = clean ? bytes : await this.rewrite(entries);
|
|
106
|
-
this.ready = true;
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
/** Next seq for this session. Synchronous by design: bridge callbacks must number events in arrival order. */
|
|
110
|
-
nextSeq() {
|
|
111
|
-
this.requireReady();
|
|
112
|
-
this.maxSeq += 1;
|
|
113
|
-
return this.maxSeq;
|
|
114
|
-
}
|
|
115
|
-
/** Writes the entry to disk. Resolves only once the bytes are handed to the OS — callers send after this. */
|
|
116
|
-
async append(entry) {
|
|
117
|
-
await this.serialize(async () => {
|
|
118
|
-
this.requireReady();
|
|
119
|
-
const line = serializeEntry(entry);
|
|
120
|
-
const size = Buffer.byteLength(line);
|
|
121
|
-
if (this.bytes + size > this.maxBytes)
|
|
122
|
-
await this.dropOldestHalf(size);
|
|
123
|
-
await fs.appendFile(this.filePath, line);
|
|
124
|
-
this.bytes += size;
|
|
125
|
-
if (entry.seq > this.maxSeq)
|
|
126
|
-
this.maxSeq = entry.seq;
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Everything the server has not applied yet, in append order. `seq` is the server's watermark.
|
|
131
|
-
*
|
|
132
|
-
* This is the one method that parses the whole file, so it is O(file) in both time and peak
|
|
133
|
-
* memory. That is affordable only because it runs on the reconnect path — once per `agent_open` —
|
|
134
|
-
* and never on the append path. Keep it that way.
|
|
135
|
-
*/
|
|
136
|
-
async entriesAfter(seq) {
|
|
137
|
-
return this.serialize(async () => {
|
|
138
|
-
const { entries } = await this.readEntries();
|
|
139
|
-
return entries.filter((entry) => entry.seq > seq);
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* Forgets everything the server has acknowledged (`seq <= throughSeq`) by rewriting the file.
|
|
144
|
-
* `maxSeq` is deliberately untouched: acking is not a reason to reissue seqs.
|
|
145
|
-
*/
|
|
146
|
-
async truncateThrough(seq) {
|
|
147
|
-
await this.serialize(async () => {
|
|
148
|
-
if (seq <= 0)
|
|
149
|
-
return;
|
|
150
|
-
const { entries } = await this.readEntries();
|
|
151
|
-
const kept = entries.filter((entry) => entry.seq > seq);
|
|
152
|
-
if (kept.length === entries.length)
|
|
153
|
-
return; // nothing acked that we still hold; don't churn the file
|
|
154
|
-
this.bytes = await this.rewrite(kept);
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
/** Removes the file for good (agent_close). The journal is unusable afterwards. */
|
|
158
|
-
async destroy() {
|
|
159
|
-
await this.serialize(async () => {
|
|
160
|
-
await fs.rm(this.filePath, { force: true });
|
|
161
|
-
await fs.rm(this.tmpPath, { force: true });
|
|
162
|
-
this.bytes = 0;
|
|
163
|
-
this.ready = false;
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
// --- internals (never call from inside another serialized op except via the private helpers) ---
|
|
167
|
-
requireReady() {
|
|
168
|
-
if (!this.ready) {
|
|
169
|
-
throw new Error(`SessionJournal ${this.filePath} used before init() (or after destroy())`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Chains `op` after whatever is already queued — including a failed predecessor, so one bad write
|
|
174
|
-
* does not wedge the session forever.
|
|
175
|
-
*/
|
|
176
|
-
serialize(op) {
|
|
177
|
-
const run = this.queue.then(op, op);
|
|
178
|
-
this.queue = run.then(() => undefined, () => undefined);
|
|
179
|
-
return run;
|
|
180
|
-
}
|
|
181
|
-
/** `clean: false` means at least one line failed to parse and the file should be rewritten. */
|
|
182
|
-
async readEntries() {
|
|
183
|
-
let text;
|
|
184
|
-
try {
|
|
185
|
-
text = await fs.readFile(this.filePath, 'utf8');
|
|
186
|
-
}
|
|
187
|
-
catch (err) {
|
|
188
|
-
if (isEnoent(err))
|
|
189
|
-
return { entries: [], bytes: 0, clean: true };
|
|
190
|
-
throw err;
|
|
191
|
-
}
|
|
192
|
-
const entries = [];
|
|
193
|
-
let clean = true;
|
|
194
|
-
for (const line of text.split('\n')) {
|
|
195
|
-
if (line.length === 0)
|
|
196
|
-
continue;
|
|
197
|
-
const entry = parseEntry(line);
|
|
198
|
-
if (entry)
|
|
199
|
-
entries.push(entry);
|
|
200
|
-
else
|
|
201
|
-
clean = false;
|
|
202
|
-
}
|
|
203
|
-
return { entries, bytes: Buffer.byteLength(text), clean };
|
|
204
|
-
}
|
|
205
|
-
/** Replaces the file with exactly `entries`, atomically. Returns the new byte size. */
|
|
206
|
-
async rewrite(entries) {
|
|
207
|
-
return this.rewriteRaw(Buffer.from(entries.map(serializeEntry).join('')));
|
|
35
|
+
const fileOf = (dir, sessionRef) => path.join(dir, `${encodeURIComponent(sessionRef)}${SUFFIX}`);
|
|
36
|
+
/** The refs of the journal files in `dir` (`<journalDir>/sessions`), sorted. A missing directory is none. */
|
|
37
|
+
export async function listLegacyJournalRefs(dir) {
|
|
38
|
+
let names;
|
|
39
|
+
try {
|
|
40
|
+
names = await fs.readdir(dir);
|
|
208
41
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return data.length;
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error?.code === 'ENOENT')
|
|
44
|
+
return [];
|
|
45
|
+
throw error;
|
|
214
46
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
*
|
|
220
|
-
* This runs on the **append path**, so it deliberately never parses or re-serializes a record. It
|
|
221
|
-
* reads the file once as raw bytes, walks to a newline boundary past the half-way mark, and writes
|
|
222
|
-
* back a slice of that same buffer. At the 64MB cap that is one 64MB read plus one ≤32MB write —
|
|
223
|
-
* versus the several-hundred-MB transient and multi-second single-threaded stall an entry-level
|
|
224
|
-
* round-trip would cost, on a daemon that is also serving `exec_request` on the same loop.
|
|
225
|
-
*
|
|
226
|
-
* Halving by *bytes* rather than by record count is the same policy in a cheaper coordinate: the
|
|
227
|
-
* point is to reclaim about half the file in one pass so the trim is amortized over many appends.
|
|
228
|
-
*/
|
|
229
|
-
async dropOldestHalf(incoming) {
|
|
230
|
-
let buf;
|
|
47
|
+
const refs = [];
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
if (!name.endsWith(SUFFIX))
|
|
50
|
+
continue;
|
|
231
51
|
try {
|
|
232
|
-
|
|
52
|
+
refs.push(decodeURIComponent(name.slice(0, -SUFFIX.length)));
|
|
233
53
|
}
|
|
234
|
-
catch
|
|
235
|
-
|
|
236
|
-
this.bytes = 0; // tracked size was stale; nothing on disk to drop
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
throw err;
|
|
54
|
+
catch {
|
|
55
|
+
// Not a name a daemon wrote (`encodeURIComponent` never emits a malformed escape).
|
|
240
56
|
}
|
|
241
|
-
let start = 0;
|
|
242
|
-
while (start < buf.length && buf.length - start + incoming > this.maxBytes) {
|
|
243
|
-
// Cut at the first record boundary at or after the half-way point of what is left. Every line
|
|
244
|
-
// ends in '\n', so the newline search can only land on a real boundary; if there is none left
|
|
245
|
-
// the remainder is a single oversized record and we drop all of it.
|
|
246
|
-
const half = start + Math.ceil((buf.length - start) / 2);
|
|
247
|
-
const newline = buf.indexOf(0x0a, half);
|
|
248
|
-
start = newline === -1 ? buf.length : newline + 1;
|
|
249
|
-
}
|
|
250
|
-
// Nothing to drop means a single record larger than the whole cap; write it anyway rather than
|
|
251
|
-
// lose it, and let the next append trim back down.
|
|
252
|
-
if (start === 0)
|
|
253
|
-
return;
|
|
254
|
-
this.dropped = true;
|
|
255
|
-
this.bytes = await this.rewriteRaw(buf.subarray(start));
|
|
256
57
|
}
|
|
58
|
+
return refs.sort();
|
|
59
|
+
}
|
|
60
|
+
/** Every readable entry of `sessionRef`'s journal, in file order (unreadable lines skipped). A missing file is empty. */
|
|
61
|
+
export async function readLegacyJournal(dir, sessionRef) {
|
|
62
|
+
let text;
|
|
63
|
+
try {
|
|
64
|
+
text = await fs.readFile(fileOf(dir, sessionRef), 'utf8');
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error?.code === 'ENOENT')
|
|
68
|
+
return [];
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
const entries = [];
|
|
72
|
+
for (const line of text.split('\n')) {
|
|
73
|
+
if (line.length === 0)
|
|
74
|
+
continue;
|
|
75
|
+
const entry = parseEntry(line);
|
|
76
|
+
if (entry)
|
|
77
|
+
entries.push(entry);
|
|
78
|
+
}
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
/** Deletes `sessionRef`'s journal (and the `.tmp` a rewrite may have left). */
|
|
82
|
+
export async function removeLegacyJournal(dir, sessionRef) {
|
|
83
|
+
const file = fileOf(dir, sessionRef);
|
|
84
|
+
await fs.rm(file, { force: true });
|
|
85
|
+
await fs.rm(`${file}.tmp`, { force: true });
|
|
257
86
|
}
|