@platless/axom-cli 0.3.13 → 0.6.36
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 +18 -5
- package/dist/axom-patch/cli/README.md +119 -9
- 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 +123 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +81 -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/codex-terminal-output.json +16 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/kimi-quota.mjs +12 -0
- package/dist/axom-patch/cli/kimi-subagents.mjs +173 -0
- package/dist/axom-patch/cli/nightly-task.md +194 -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/codex-terminal-output.mjs +33 -0
- package/dist/axom-patch/cli/probes/kimi-subagent-subwork.mjs +82 -0
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +1591 -0
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +360 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +13 -5
- package/dist/axom-patch/cli/qoder-foreground-output.md +14 -7
- package/dist/axom-patch/cli/qoder-native-acp.json +227 -0
- package/dist/axom-patch/cli/qoder-steering.md +20 -25
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +17 -68
- package/dist/axom-patch/cli/runtime.json +39 -15
- package/dist/axom-patch/cli/zcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/zcode-models.json +37 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +72 -0
- package/dist/cli.js +4 -9
- package/dist/commands/knowledge.js +47 -0
- package/dist/commands/update.js +65 -3
- package/dist/commands/workstation.js +73 -7
- 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 +1 -1
- package/dist/native/darwin-arm64/axom-quic +0 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2192 -636
- package/dist/workstation/acp/acpClient.js +84 -495
- 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 +324 -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 +290 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +239 -159
- package/dist/workstation/acp/managedRuntime.js +314 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +60 -3
- package/dist/workstation/acp/permission.js +42 -10
- package/dist/workstation/acp/processor/checkpoint.js +78 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +282 -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 +105 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +496 -0
- package/dist/workstation/acp/processor/state.js +67 -0
- package/dist/workstation/acp/processor/step.js +442 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +58 -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 +390 -0
- package/dist/workstation/acp/processorSession.js +1348 -0
- package/dist/workstation/acp/registry/catalog.js +189 -41
- package/dist/workstation/acp/registry/launch.js +76 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +111 -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/fakeAgent.js +13 -0
- package/dist/workstation/acp/test/keeperWorld.js +295 -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/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +135 -0
- package/dist/workstation/acp/test/realAdapter.js +331 -0
- package/dist/workstation/acp/test/scriptAgent.js +214 -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 +1289 -544
- 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 +172 -8
- package/dist/workstation/channels/url.js +1 -1
- package/dist/workstation/config.js +16 -4
- package/dist/workstation/connection/encryptedChannel.js +4 -1
- package/dist/workstation/control/bindingStore.js +200 -0
- package/dist/workstation/control/commands.js +124 -0
- package/dist/workstation/control/daemonIdentity.js +53 -0
- package/dist/workstation/control/enrollment.js +106 -0
- package/dist/workstation/control/inspect.js +71 -0
- package/dist/workstation/control/locks.js +63 -0
- package/dist/workstation/control/protocol.js +40 -0
- package/dist/workstation/control/restartGuard.js +46 -0
- package/dist/workstation/control/runtimeClient.js +97 -0
- package/dist/workstation/control/runtimeHost.js +148 -0
- package/dist/workstation/control/serverIdentity.js +35 -0
- package/dist/workstation/daemon.js +393 -12
- package/dist/workstation/daemonctl.js +103 -14
- package/dist/workstation/desktopControl/audio.js +99 -0
- package/dist/workstation/desktopControl/capabilities.js +92 -0
- package/dist/workstation/desktopControl/captureModes.js +9 -0
- package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
- package/dist/workstation/desktopControl/deviceSockets.js +246 -0
- package/dist/workstation/desktopControl/helperProcess.js +353 -0
- package/dist/workstation/desktopControl/lobby.js +129 -0
- package/dist/workstation/desktopControl/macProvider.js +155 -0
- package/dist/workstation/desktopControl/media.js +331 -0
- package/dist/workstation/desktopControl/nativeProvider.js +1018 -0
- package/dist/workstation/desktopControl/networkEvidence.js +43 -0
- package/dist/workstation/desktopControl/overview.js +58 -0
- package/dist/workstation/desktopControl/protocol.js +302 -0
- package/dist/workstation/desktopControl/quicMedia.js +240 -0
- package/dist/workstation/desktopControl/rateController.js +226 -0
- package/dist/workstation/desktopControl/supervisor.js +1681 -0
- package/dist/workstation/desktopControl/transport.js +27 -0
- package/dist/workstation/desktopControl/wallpaper.js +78 -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/credentials.js +142 -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} +152 -39
- 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 +124 -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 +289 -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 +76 -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/managedInstall.js +57 -0
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +50 -19
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +141 -4
- package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
- package/dist/workstation/rd/artifact.js +119 -0
- package/dist/workstation/rd/control.js +119 -0
- package/dist/workstation/rd/display.js +29 -0
- package/dist/workstation/rd/provider.js +222 -0
- package/dist/workstation/rd/sessions.js +391 -0
- package/dist/workstation/rd/state.js +45 -0
- package/dist/workstation/rd/update.js +171 -0
- package/dist/workstation/rd/upkeep.js +38 -0
- package/dist/workstation/register.js +27 -37
- package/dist/workstation/service/installer.js +104 -13
- package/dist/workstation/service/launchd.js +1 -0
- package/dist/workstation/storageMetrics.js +118 -0
- package/dist/workstation/systemMetrics.js +154 -93
- package/dist/workstation/toolchain.js +94 -0
- package/dist/workstation/tools/browser.js +28 -4
- 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/artifact.js +2 -1
- package/dist/workstation/update/coordinator.js +3 -0
- package/dist/workstation/update/globalInstall.js +116 -0
- package/dist/workstation/update/npmInstaller.js +49 -23
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/rdRollback.js +145 -0
- package/dist/workstation/update/runUpdate.js +3 -2
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/dist/workstation/update/watchdog.js +16790 -334
- package/package.json +11 -8
- package/skills/axom/SKILL.md +11 -11
- package/skills/axom-definition/SKILL.md +3 -3
- package/skills/axom-platform/SKILL.md +3 -3
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +0 -89
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +0 -49
- package/dist/axom-patch/cli/qoder-steering.json +0 -52
- package/dist/axom-patch/cli/qoder-subagent-origin.json +0 -73
- package/dist/commands/guide.js +0 -38
- package/dist/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/delegationMcp.js +0 -24
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- package/dist/workstation/acp/qoderStartup.js +0 -45
- 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/backgroundProcess.js +0 -106
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +0 -262
- package/dist/workstation/qoderAdapter/completedContinuation.js +0 -139
- package/dist/workstation/qoderAdapter/contract.js +0 -78
- package/dist/workstation/qoderAdapter/foregroundOutput.js +0 -30
- package/dist/workstation/qoderAdapter/history.js +0 -87
- package/dist/workstation/qoderAdapter/identityIndex.js +0 -191
- package/dist/workstation/qoderAdapter/inputQueue.js +0 -42
- package/dist/workstation/qoderAdapter/main.js +0 -93
- package/dist/workstation/qoderAdapter/mapper.js +0 -117
- package/dist/workstation/qoderAdapter/observerPlugin.js +0 -245
- package/dist/workstation/qoderAdapter/observerReceiver.js +0 -187
- package/dist/workstation/qoderAdapter/permissions.js +0 -61
- package/dist/workstation/qoderAdapter/profile.js +0 -93
- package/dist/workstation/qoderAdapter/sdkDriver.js +0 -49
- package/dist/workstation/qoderAdapter/session.js +0 -319
- package/dist/workstation/qoderAdapter/subagentMeta.js +0 -28
- package/dist/workstation/qoderAdapter/subwork.js +0 -77
- package/dist/workstation/qoderAdapter/taskControl.js +0 -38
- package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
- package/dist/workstation/qoderAdapter/taskLedger.js +0 -163
- package/dist/workstation/qoderAdapter/usage.js +0 -37
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- package/dist/workstation/tools/terminal.js +0 -214
- /package/dist/workstation/{qoderAdapter/types.js → desktopControl/provider.js} +0 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import lockfile from 'proper-lockfile';
|
|
6
|
+
import { remuxRange } from './encoding.js';
|
|
7
|
+
const hex64 = /^[a-f0-9]{64}$/;
|
|
8
|
+
const hex32 = /^[a-f0-9]{32}$/;
|
|
9
|
+
const MAX_META = 4 * 1024 * 1024;
|
|
10
|
+
// Reclaim old seek windows eagerly, but advertised windows must survive until
|
|
11
|
+
// their leases expire. Byte/metadata budgets remain the admission limits.
|
|
12
|
+
const GENERATION_RECLAIM_TARGET = 64;
|
|
13
|
+
const MAX_GENERATIONS = 6000;
|
|
14
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
15
|
+
const stampOf = (s) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].join(':');
|
|
16
|
+
/** Private immutable objects; per-resource pins and file locks protect concurrent readers/writers. */
|
|
17
|
+
export class MediaCache {
|
|
18
|
+
directory;
|
|
19
|
+
policy;
|
|
20
|
+
versions = new Map();
|
|
21
|
+
claims = new Map();
|
|
22
|
+
pins = new Map();
|
|
23
|
+
retainedUntil = new Map();
|
|
24
|
+
activeReads = new Map();
|
|
25
|
+
readerId = randomUUID();
|
|
26
|
+
queue = Promise.resolve();
|
|
27
|
+
constructor(directory, policy) {
|
|
28
|
+
this.directory = directory;
|
|
29
|
+
this.policy = policy;
|
|
30
|
+
}
|
|
31
|
+
static async open(directory, policy) {
|
|
32
|
+
await safeDirectory(directory);
|
|
33
|
+
return new MediaCache(directory, policy);
|
|
34
|
+
}
|
|
35
|
+
serial(run) {
|
|
36
|
+
const result = this.queue.then(async () => {
|
|
37
|
+
const release = await lockfile.lock(this.directory, { lockfilePath: path.join(this.directory, '.cache.guard'), stale: 60_000, update: 10_000, retries: { retries: 120, minTimeout: 25, maxTimeout: 250 } });
|
|
38
|
+
try {
|
|
39
|
+
return await run();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await release();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
this.queue = result.catch(() => { });
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
versionPath(key) {
|
|
49
|
+
if (!hex64.test(key.version))
|
|
50
|
+
throw new Error('Invalid media version');
|
|
51
|
+
return path.join(this.directory, `v3-${key.version}`);
|
|
52
|
+
}
|
|
53
|
+
async createGeneration(key, generationId) {
|
|
54
|
+
if (!hex32.test(generationId))
|
|
55
|
+
throw new Error('Invalid generation identity');
|
|
56
|
+
const directory = this.versionPath(key);
|
|
57
|
+
await safeDirectory(directory);
|
|
58
|
+
const output = path.join(directory, generationId);
|
|
59
|
+
await safeDirectory(output);
|
|
60
|
+
return output;
|
|
61
|
+
}
|
|
62
|
+
async claimWriter(key) {
|
|
63
|
+
const directory = this.versionPath(key);
|
|
64
|
+
await safeDirectory(directory);
|
|
65
|
+
if (this.claims.has(key.version))
|
|
66
|
+
throw new Error('Media cache writer is busy');
|
|
67
|
+
const lockPath = `${directory}.lock`;
|
|
68
|
+
try {
|
|
69
|
+
const s = await fs.lstat(lockPath);
|
|
70
|
+
if (!s.isDirectory() || s.isSymbolicLink() || (process.getuid && s.uid !== process.getuid()))
|
|
71
|
+
throw new Error('Unsafe cache writer lock');
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code !== 'ENOENT')
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const claim = { valid: true, release: async () => { } };
|
|
78
|
+
try {
|
|
79
|
+
claim.release = await lockfile.lock(directory, { stale: 60_000, update: 10_000, retries: 0, onCompromised: () => { claim.valid = false; } });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new Error('Media cache writer is busy');
|
|
83
|
+
}
|
|
84
|
+
this.claims.set(key.version, claim);
|
|
85
|
+
return { release: async () => {
|
|
86
|
+
if (this.claims.get(key.version) !== claim)
|
|
87
|
+
return;
|
|
88
|
+
this.claims.delete(key.version);
|
|
89
|
+
claim.valid = false;
|
|
90
|
+
await claim.release();
|
|
91
|
+
} };
|
|
92
|
+
}
|
|
93
|
+
requireWriter(key) { if (!this.claims.get(key.version)?.valid)
|
|
94
|
+
throw new Error('Media cache writer lease was lost'); }
|
|
95
|
+
objectPath(key, generationId, object) {
|
|
96
|
+
if (!hex32.test(generationId) || !hex32.test(object.id) || !['ts', 'mp3', 'mp4', 'png'].includes(object.extension))
|
|
97
|
+
throw new Error('Invalid media object');
|
|
98
|
+
return path.join(this.versionPath(key), generationId, `${object.id}.${object.extension}`);
|
|
99
|
+
}
|
|
100
|
+
async load(key) {
|
|
101
|
+
const memory = this.versions.get(key.version);
|
|
102
|
+
if (memory && this.claims.has(key.version))
|
|
103
|
+
return same(memory.key, key) ? memory : null;
|
|
104
|
+
try {
|
|
105
|
+
const directory = this.versionPath(key);
|
|
106
|
+
const s = await fs.lstat(directory);
|
|
107
|
+
if (!s.isDirectory() || s.isSymbolicLink())
|
|
108
|
+
return null;
|
|
109
|
+
const handle = await fs.open(path.join(directory, 'index.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
const stat = await handle.stat();
|
|
113
|
+
if (!stat.isFile() || stat.size > MAX_META)
|
|
114
|
+
return null;
|
|
115
|
+
data = JSON.parse(await handle.readFile('utf8'));
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await handle.close();
|
|
119
|
+
}
|
|
120
|
+
if (!data || !same(data.key, key) || !Array.isArray(data.generations) || data.generations.length > MAX_GENERATIONS)
|
|
121
|
+
return null;
|
|
122
|
+
for (const g of data.generations) {
|
|
123
|
+
if (!hex32.test(g.id) || !Array.isArray(g.objects) || g.objects.length > 6000)
|
|
124
|
+
return null;
|
|
125
|
+
for (const o of g.objects) {
|
|
126
|
+
this.objectPath(key, g.id, o);
|
|
127
|
+
if (!Number.isSafeInteger(o.size) || o.size <= 0 || o.size > this.policy.maxVersionBytes || !hex64.test(o.etag) || typeof o.stamp !== 'string' || !Number.isFinite(o.accessedAtMs))
|
|
128
|
+
return null;
|
|
129
|
+
if (!Number.isSafeInteger(o.startMs) || !Number.isSafeInteger(o.endMs) || o.startMs < 0 || o.endMs < o.startMs || o.endMs > 21_600_001)
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
this.versions.set(key.version, data);
|
|
134
|
+
return data;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async save(version) {
|
|
141
|
+
this.requireWriter(version.key);
|
|
142
|
+
const directory = this.versionPath(version.key);
|
|
143
|
+
const temp = path.join(directory, `index-${randomUUID()}.tmp`);
|
|
144
|
+
const text = JSON.stringify(version);
|
|
145
|
+
if (Buffer.byteLength(text) > MAX_META)
|
|
146
|
+
throw new Error('Media cache metadata exceeds budget');
|
|
147
|
+
try {
|
|
148
|
+
await fs.writeFile(temp, text, { mode: 0o600, flag: 'wx' });
|
|
149
|
+
this.requireWriter(version.key);
|
|
150
|
+
await fs.rename(temp, path.join(directory, 'index.json'));
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await fs.unlink(temp).catch(() => { });
|
|
154
|
+
}
|
|
155
|
+
this.versions.set(version.key.version, version);
|
|
156
|
+
}
|
|
157
|
+
async lookup(key) {
|
|
158
|
+
return this.serial(async () => {
|
|
159
|
+
const state = await this.load(key);
|
|
160
|
+
if (!state)
|
|
161
|
+
return [];
|
|
162
|
+
return state.generations.map(g => ({ ...g, objects: g.objects.map(({ stamp, accessedAtMs, ...o }) => ({ ...o, file: this.objectPath(key, g.id, o) })) }));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async publish(key, generation, object) {
|
|
166
|
+
return this.serial(async () => {
|
|
167
|
+
this.requireWriter(key);
|
|
168
|
+
const file = this.objectPath(key, generation.id, object);
|
|
169
|
+
if (path.resolve(object.file) !== file)
|
|
170
|
+
throw new Error('Media object escaped cache generation');
|
|
171
|
+
const stat = await fs.lstat(file, { bigint: true });
|
|
172
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== BigInt(object.size) || object.size > this.policy.maxVersionBytes)
|
|
173
|
+
throw new Error('Invalid cached media object size');
|
|
174
|
+
const state = await this.load(key) ?? { key, generations: [] };
|
|
175
|
+
let stored = state.generations.find(g => g.id === generation.id);
|
|
176
|
+
if (!stored) {
|
|
177
|
+
if (state.generations.length >= GENERATION_RECLAIM_TARGET) {
|
|
178
|
+
const foreignPins = await this.foreignProtection(Date.now());
|
|
179
|
+
const victim = state.generations.find(g => g.objects.every(o => !this.protected(o.id, Date.now()) && !foreignPins.has(o.id)));
|
|
180
|
+
if (victim) {
|
|
181
|
+
for (const o of victim.objects)
|
|
182
|
+
await fs.unlink(this.objectPath(key, victim.id, o)).catch(e => { if (e.code !== 'ENOENT')
|
|
183
|
+
throw e; });
|
|
184
|
+
state.generations = state.generations.filter(g => g !== victim);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (state.generations.length >= MAX_GENERATIONS)
|
|
188
|
+
throw new Error('Media cache pressure: generation metadata budget exceeded');
|
|
189
|
+
stored = { ...generation, objects: [] };
|
|
190
|
+
state.generations.push(stored);
|
|
191
|
+
}
|
|
192
|
+
if (stored.objects.some(o => o.id === object.id))
|
|
193
|
+
throw new Error('Media objects are immutable');
|
|
194
|
+
const { file: ignored, ...wire } = object;
|
|
195
|
+
stored.objects.push({ ...wire, stamp: stampOf(stat), accessedAtMs: Date.now() });
|
|
196
|
+
this.versions.set(key.version, state);
|
|
197
|
+
this.activeReads.set(object.id, (this.activeReads.get(object.id) ?? 0) + 1);
|
|
198
|
+
try {
|
|
199
|
+
await this.trimInternal(Date.now());
|
|
200
|
+
await this.save(state);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
stored.objects = stored.objects.filter(o => o.id !== object.id);
|
|
204
|
+
await fs.unlink(file).catch(() => { });
|
|
205
|
+
await this.save(state);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
this.activeReads.delete(object.id);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async finishGeneration(key, generation) {
|
|
214
|
+
return this.serial(async () => {
|
|
215
|
+
const state = await this.load(key);
|
|
216
|
+
const stored = state?.generations.find(g => g.id === generation.id);
|
|
217
|
+
if (!state || !stored)
|
|
218
|
+
return;
|
|
219
|
+
stored.complete = generation.complete;
|
|
220
|
+
stored.sourceComplete = generation.sourceComplete;
|
|
221
|
+
await this.save(state);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async read(key, objectId, offset, length) {
|
|
225
|
+
if (!hex32.test(objectId) || !Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 1 || length > 256 * 1024)
|
|
226
|
+
throw new Error('Invalid media object read');
|
|
227
|
+
return this.serial(async () => {
|
|
228
|
+
const state = await this.load(key);
|
|
229
|
+
const generation = state?.generations.find(g => g.objects.some(o => o.id === objectId));
|
|
230
|
+
const object = generation?.objects.find(o => o.id === objectId);
|
|
231
|
+
if (!object || !generation)
|
|
232
|
+
throw new Error('Media cache interval is unavailable');
|
|
233
|
+
const file = this.objectPath(key, generation.id, object);
|
|
234
|
+
this.activeReads.set(objectId, (this.activeReads.get(objectId) ?? 0) + 1);
|
|
235
|
+
let handle;
|
|
236
|
+
try {
|
|
237
|
+
handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
238
|
+
const stat = await handle.stat({ bigint: true });
|
|
239
|
+
if (!stat.isFile() || stampOf(stat) !== object.stamp)
|
|
240
|
+
throw new Error('Media preview cache changed');
|
|
241
|
+
const bytes = Buffer.alloc(Math.min(length, Math.max(0, object.size - offset)));
|
|
242
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, offset);
|
|
243
|
+
if (stampOf(await handle.stat({ bigint: true })) !== object.stamp)
|
|
244
|
+
throw new Error('Media preview cache changed');
|
|
245
|
+
object.accessedAtMs = Date.now();
|
|
246
|
+
return bytes.subarray(0, bytesRead);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
if (handle)
|
|
250
|
+
await handle.close();
|
|
251
|
+
const count = (this.activeReads.get(objectId) ?? 1) - 1;
|
|
252
|
+
if (count)
|
|
253
|
+
this.activeReads.set(objectId, count);
|
|
254
|
+
else
|
|
255
|
+
this.activeReads.delete(objectId);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
async pin(consumerId, objectIds, retainUntilMs) {
|
|
260
|
+
if (objectIds.some(id => !hex32.test(id)) || !Number.isFinite(retainUntilMs))
|
|
261
|
+
throw new Error('Invalid cache retention');
|
|
262
|
+
return this.serial(async () => {
|
|
263
|
+
this.pins.set(consumerId, new Set(objectIds));
|
|
264
|
+
for (const id of objectIds)
|
|
265
|
+
this.retainedUntil.set(id, Math.max(this.retainedUntil.get(id) ?? 0, retainUntilMs));
|
|
266
|
+
await this.saveReader();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async unpin(consumerId) { return this.serial(async () => { this.pins.delete(consumerId); await this.saveReader(); }); }
|
|
270
|
+
async saveReader() {
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
for (const [id, deadline] of this.retainedUntil)
|
|
273
|
+
if (deadline <= now)
|
|
274
|
+
this.retainedUntil.delete(id);
|
|
275
|
+
const file = path.join(this.directory, `reader-${this.readerId}.json`);
|
|
276
|
+
const data = { pid: process.pid, expires: now + this.policy.consumerLeaseMs + 5000, pins: [...this.pins.values()].flatMap(ids => [...ids]), retained: [...this.retainedUntil] };
|
|
277
|
+
const temp = file + '.tmp';
|
|
278
|
+
await fs.writeFile(temp, JSON.stringify(data), { mode: 0o600 });
|
|
279
|
+
await fs.rename(temp, file);
|
|
280
|
+
}
|
|
281
|
+
async foreignProtection(now) {
|
|
282
|
+
const protectedIds = new Set();
|
|
283
|
+
for (const name of await fs.readdir(this.directory)) {
|
|
284
|
+
if (!/^reader-[a-f0-9-]{36}\.json$/.test(name))
|
|
285
|
+
continue;
|
|
286
|
+
const file = path.join(this.directory, name);
|
|
287
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
288
|
+
try {
|
|
289
|
+
const stat = await handle.stat();
|
|
290
|
+
if (!stat.isFile() || stat.size > MAX_META || (process.getuid && stat.uid !== process.getuid()))
|
|
291
|
+
throw new Error('Unsafe cache reader lease');
|
|
292
|
+
const data = JSON.parse(await handle.readFile('utf8'));
|
|
293
|
+
if (!Array.isArray(data.pins) || !Array.isArray(data.retained) || !Number.isFinite(data.expires))
|
|
294
|
+
throw new Error('Invalid cache reader lease');
|
|
295
|
+
let live = false;
|
|
296
|
+
try {
|
|
297
|
+
if (Number.isSafeInteger(data.pid) && data.pid > 0) {
|
|
298
|
+
process.kill(data.pid, 0);
|
|
299
|
+
live = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch { }
|
|
303
|
+
if (live && data.expires > now)
|
|
304
|
+
for (const id of data.pins)
|
|
305
|
+
if (hex32.test(id))
|
|
306
|
+
protectedIds.add(id);
|
|
307
|
+
for (const [id, deadline] of data.retained)
|
|
308
|
+
if (hex32.test(id) && Number.isFinite(deadline) && deadline > now)
|
|
309
|
+
protectedIds.add(id);
|
|
310
|
+
if ((!live || data.expires <= now) && !data.retained.some((r) => r[1] > now))
|
|
311
|
+
await fs.unlink(file);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await handle.close();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return protectedIds;
|
|
318
|
+
}
|
|
319
|
+
/** Reserve legacy encoder headroom in the same physical quota used by v3 publishers. */
|
|
320
|
+
async reserveLegacy(output, bytes) {
|
|
321
|
+
const name = path.basename(output);
|
|
322
|
+
if (path.dirname(output) !== this.directory || !/^\.part-[a-f0-9-]{36}\.(mp3|mp4|png)$/.test(name) || !Number.isSafeInteger(bytes) || bytes < 1)
|
|
323
|
+
throw new Error('Invalid legacy reservation');
|
|
324
|
+
const file = path.join(this.directory, `reservation-${randomUUID()}.json`);
|
|
325
|
+
await this.serial(async () => {
|
|
326
|
+
await fs.writeFile(file, JSON.stringify({ pid: process.pid, output: name, bytes }), { mode: 0o600, flag: 'wx' });
|
|
327
|
+
try {
|
|
328
|
+
await this.trimInternal(Date.now());
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
await fs.unlink(file);
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
return () => this.serial(async () => { await fs.unlink(file).catch(e => { if (e.code !== 'ENOENT')
|
|
336
|
+
throw e; }); await this.trimInternal(Date.now()); });
|
|
337
|
+
}
|
|
338
|
+
async reservedBytes() {
|
|
339
|
+
let reserved = 0;
|
|
340
|
+
for (const name of await fs.readdir(this.directory)) {
|
|
341
|
+
if (!/^reservation-[a-f0-9-]{36}\.json$/.test(name))
|
|
342
|
+
continue;
|
|
343
|
+
const file = path.join(this.directory, name);
|
|
344
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
345
|
+
try {
|
|
346
|
+
if ((await handle.stat()).size > 4096)
|
|
347
|
+
throw new Error('Invalid cache reservation');
|
|
348
|
+
const r = JSON.parse(await handle.readFile('utf8'));
|
|
349
|
+
if (!Number.isSafeInteger(r.pid) || r.pid < 1 || !Number.isSafeInteger(r.bytes) || r.bytes < 1 || !/^\.part-[a-f0-9-]{36}\.(mp3|mp4|png)$/.test(r.output))
|
|
350
|
+
throw new Error('Invalid cache reservation');
|
|
351
|
+
try {
|
|
352
|
+
process.kill(r.pid, 0);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
await fs.unlink(file);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const size = await fs.lstat(path.join(this.directory, r.output)).then(s => s.isFile() ? s.size : 0).catch(() => 0);
|
|
359
|
+
reserved += Math.max(0, r.bytes - size);
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
await handle.close();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return reserved;
|
|
366
|
+
}
|
|
367
|
+
protected(id, now) {
|
|
368
|
+
return this.activeReads.has(id) || [...this.pins.values()].some(ids => ids.has(id)) || (this.retainedUntil.get(id) ?? 0) > now;
|
|
369
|
+
}
|
|
370
|
+
async trim(nowMs) { return this.serial(() => this.trimInternal(nowMs)); }
|
|
371
|
+
async trimInternal(now) {
|
|
372
|
+
for (const [id, expiry] of this.retainedUntil)
|
|
373
|
+
if (expiry <= now)
|
|
374
|
+
this.retainedUntil.delete(id);
|
|
375
|
+
const foreignPins = await this.foreignProtection(now);
|
|
376
|
+
await this.reclaimOrphans(now);
|
|
377
|
+
for (const name of await fs.readdir(this.directory))
|
|
378
|
+
if (/^\.part-[a-f0-9-]{36}\.(mp3|mp4|png|json)$/.test(name)) {
|
|
379
|
+
const file = path.join(this.directory, name);
|
|
380
|
+
const stat = await fs.lstat(file);
|
|
381
|
+
if (stat.isFile() && stat.mtimeMs < now - this.policy.ttlMs)
|
|
382
|
+
await fs.unlink(file);
|
|
383
|
+
}
|
|
384
|
+
for (const name of await fs.readdir(this.directory))
|
|
385
|
+
if (/^v3-[a-f0-9]{64}$/.test(name)) {
|
|
386
|
+
const version = name.slice(3);
|
|
387
|
+
if (this.claims.has(version))
|
|
388
|
+
continue;
|
|
389
|
+
try {
|
|
390
|
+
const handle = await fs.open(path.join(this.directory, name, 'index.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
391
|
+
let key;
|
|
392
|
+
try {
|
|
393
|
+
if ((await handle.stat()).size <= MAX_META)
|
|
394
|
+
key = JSON.parse(await handle.readFile('utf8')).key;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
await handle.close();
|
|
398
|
+
}
|
|
399
|
+
if (key?.version === version)
|
|
400
|
+
await this.load(key);
|
|
401
|
+
}
|
|
402
|
+
catch { /* An unpublished or corrupt directory is not a ready cache entry. */ }
|
|
403
|
+
}
|
|
404
|
+
const records = [];
|
|
405
|
+
for (const state of this.versions.values()) {
|
|
406
|
+
const foreign = !this.claims.has(state.key.version) && await lockfile.check(this.versionPath(state.key)).catch(() => true);
|
|
407
|
+
for (const generation of state.generations)
|
|
408
|
+
for (const object of generation.objects)
|
|
409
|
+
records.push({ state, generation, object, foreign });
|
|
410
|
+
}
|
|
411
|
+
const usage = new Map();
|
|
412
|
+
let total = (await this.diskUsage()).total + await this.reservedBytes();
|
|
413
|
+
for (const { state, object } of records) {
|
|
414
|
+
usage.set(state.key.version, (usage.get(state.key.version) ?? 0) + object.size);
|
|
415
|
+
}
|
|
416
|
+
// Legacy derivatives participate in the same quota and reader protection.
|
|
417
|
+
const legacy = [];
|
|
418
|
+
for (const name of await fs.readdir(this.directory))
|
|
419
|
+
if (/^[a-f0-9]{64}\.(mp3|mp4|png|json)$/.test(name)) {
|
|
420
|
+
const stat = await fs.lstat(path.join(this.directory, name));
|
|
421
|
+
if (stat.isFile())
|
|
422
|
+
legacy.push({ name, size: stat.size, time: stat.mtimeMs });
|
|
423
|
+
}
|
|
424
|
+
for (const entry of legacy.sort((a, b) => a.time - b.time)) {
|
|
425
|
+
const id = entry.name.slice(0, 32);
|
|
426
|
+
if (this.protected(id, now) || foreignPins.has(id))
|
|
427
|
+
continue;
|
|
428
|
+
if (total <= this.policy.maxCacheBytes && now - entry.time <= this.policy.ttlMs)
|
|
429
|
+
continue;
|
|
430
|
+
await fs.unlink(path.join(this.directory, entry.name)).catch(e => { if (e.code !== 'ENOENT')
|
|
431
|
+
throw e; });
|
|
432
|
+
total -= entry.size;
|
|
433
|
+
}
|
|
434
|
+
const changed = new Set();
|
|
435
|
+
for (const record of records.sort((a, b) => a.object.accessedAtMs - b.object.accessedAtMs || a.object.sequence - b.object.sequence)) {
|
|
436
|
+
const { state, generation, object, foreign } = record;
|
|
437
|
+
if (foreign || this.protected(object.id, now) || foreignPins.has(object.id))
|
|
438
|
+
continue;
|
|
439
|
+
if (total <= this.policy.maxCacheBytes && (usage.get(state.key.version) ?? 0) <= this.policy.maxVersionBytes && now - object.accessedAtMs <= this.policy.ttlMs)
|
|
440
|
+
continue;
|
|
441
|
+
// A read-only cache instance never edits another keeper's index.
|
|
442
|
+
let release;
|
|
443
|
+
if (!this.claims.has(state.key.version)) {
|
|
444
|
+
try {
|
|
445
|
+
release = (await this.claimWriter(state.key)).release;
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
await fs.unlink(this.objectPath(state.key, generation.id, object)).catch(e => { if (e.code !== 'ENOENT')
|
|
453
|
+
throw e; });
|
|
454
|
+
generation.objects = generation.objects.filter(o => o.id !== object.id);
|
|
455
|
+
total -= object.size;
|
|
456
|
+
usage.set(state.key.version, (usage.get(state.key.version) ?? 0) - object.size);
|
|
457
|
+
changed.add(state);
|
|
458
|
+
if (release) {
|
|
459
|
+
await this.save(state);
|
|
460
|
+
changed.delete(state);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
if (release)
|
|
465
|
+
await release();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
for (const state of changed)
|
|
469
|
+
await this.save(state);
|
|
470
|
+
total = (await this.diskUsage()).total + await this.reservedBytes();
|
|
471
|
+
if (total > this.policy.maxCacheBytes || [...usage.values()].some(bytes => bytes > this.policy.maxVersionBytes))
|
|
472
|
+
throw new Error('Media cache pressure: protected data fills the budget');
|
|
473
|
+
}
|
|
474
|
+
async reclaimOrphans(now) {
|
|
475
|
+
for (const name of await fs.readdir(this.directory)) {
|
|
476
|
+
if (!/^v3-[a-f0-9]{64}$/.test(name) || this.claims.has(name.slice(3)))
|
|
477
|
+
continue;
|
|
478
|
+
const directory = path.join(this.directory, name);
|
|
479
|
+
const stat = await fs.lstat(directory);
|
|
480
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
|
|
481
|
+
continue;
|
|
482
|
+
let release;
|
|
483
|
+
try {
|
|
484
|
+
release = await lockfile.lock(directory, { stale: 60_000, update: 10_000, retries: 0 });
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
continue;
|
|
488
|
+
} // Never collect a fresh keeper's unpublished output.
|
|
489
|
+
try {
|
|
490
|
+
const indexed = new Set();
|
|
491
|
+
let readable = true;
|
|
492
|
+
try {
|
|
493
|
+
const handle = await fs.open(path.join(directory, 'index.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
494
|
+
try {
|
|
495
|
+
const info = await handle.stat();
|
|
496
|
+
if (!info.isFile() || info.size > MAX_META)
|
|
497
|
+
throw new Error('Invalid cache metadata');
|
|
498
|
+
const data = JSON.parse(await handle.readFile('utf8'));
|
|
499
|
+
if (!Array.isArray(data.generations))
|
|
500
|
+
throw new Error('Invalid cache metadata');
|
|
501
|
+
for (const g of data.generations)
|
|
502
|
+
if (hex32.test(g.id) && Array.isArray(g.objects))
|
|
503
|
+
for (const o of g.objects) {
|
|
504
|
+
if (hex32.test(o.id) && ['ts', 'mp3', 'mp4', 'png'].includes(o.extension))
|
|
505
|
+
indexed.add(`${g.id}/${o.id}.${o.extension}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
await handle.close();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
if (error.code !== 'ENOENT')
|
|
514
|
+
readable = false;
|
|
515
|
+
}
|
|
516
|
+
// Corrupt metadata cannot prove that a file is unreferenced.
|
|
517
|
+
if (!readable)
|
|
518
|
+
continue;
|
|
519
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
520
|
+
const file = path.join(directory, entry.name);
|
|
521
|
+
if (entry.isFile() && /^index-[a-f0-9-]{36}\.tmp$/.test(entry.name)) {
|
|
522
|
+
if ((await fs.lstat(file)).mtimeMs < now - this.policy.ttlMs)
|
|
523
|
+
await fs.unlink(file);
|
|
524
|
+
}
|
|
525
|
+
else if (entry.isDirectory() && hex32.test(entry.name)) {
|
|
526
|
+
for (const leaf of await fs.readdir(file, { withFileTypes: true })) {
|
|
527
|
+
if (!leaf.isFile() || indexed.has(`${entry.name}/${leaf.name}`))
|
|
528
|
+
continue;
|
|
529
|
+
if (!/^([a-f0-9]{32}\.(ts|mp3|mp4|png)(\.[a-f0-9]{32})?(\.tmp)?|[a-f0-9]{32}\.ffconcat|raw_\d{6}\.(ts|mp3)(\.tmp)?|encoder\.(csv|m3u8)(\.tmp)?)$/.test(leaf.name))
|
|
530
|
+
continue;
|
|
531
|
+
const orphan = path.join(file, leaf.name);
|
|
532
|
+
if ((await fs.lstat(orphan)).mtimeMs < now - this.policy.ttlMs)
|
|
533
|
+
await fs.unlink(orphan);
|
|
534
|
+
}
|
|
535
|
+
await fs.rmdir(file).catch(error => { if (!['ENOTEMPTY', 'ENOENT'].includes(error.code))
|
|
536
|
+
throw error; });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
finally {
|
|
541
|
+
await release();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async diskUsage() {
|
|
546
|
+
let total = 0;
|
|
547
|
+
const visit = async (directory, depth) => {
|
|
548
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
549
|
+
if (entry.isSymbolicLink())
|
|
550
|
+
continue;
|
|
551
|
+
const file = path.join(directory, entry.name);
|
|
552
|
+
if (entry.isFile()) {
|
|
553
|
+
try {
|
|
554
|
+
total += (await fs.lstat(file)).size;
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
if (e.code !== 'ENOENT')
|
|
558
|
+
throw e;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else if (entry.isDirectory() && ((depth === 0 && /^v3-[a-f0-9]{64}$/.test(entry.name)) || (depth === 1 && hex32.test(entry.name))))
|
|
562
|
+
await visit(file, depth + 1);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
await visit(this.directory, 0);
|
|
566
|
+
return { total };
|
|
567
|
+
}
|
|
568
|
+
async finalizeRange(key, generationId, signal = new AbortController().signal) {
|
|
569
|
+
const generations = await this.lookup(key);
|
|
570
|
+
const g = generations.find(g => g.id === generationId);
|
|
571
|
+
if (!g?.complete || !g.sourceComplete || g.sourceOffsetMs !== 0 || !g.objects.length || g.objects[0].sequence !== 0)
|
|
572
|
+
return null;
|
|
573
|
+
if (g.objects.some((o, i) => o.type !== 'segment' || (i > 0 && (o.sequence !== g.objects[i - 1].sequence + 1 || o.startMs !== g.objects[i - 1].endMs))))
|
|
574
|
+
return null;
|
|
575
|
+
const bytes = g.objects.reduce((n, o) => n + o.size, 0);
|
|
576
|
+
if (bytes * 2 > this.policy.maxVersionBytes || (await this.diskUsage()).total + bytes > this.policy.maxCacheBytes)
|
|
577
|
+
return null;
|
|
578
|
+
const id = randomUUID().replaceAll('-', '');
|
|
579
|
+
const extension = g.objects[0].extension === 'ts' ? 'mp4' : 'mp3';
|
|
580
|
+
const file = path.join(await this.createGeneration(key, g.id), `${id}.${extension}`);
|
|
581
|
+
const object = { ...await remuxRange(g, file, signal), id };
|
|
582
|
+
await this.publish(key, g, object);
|
|
583
|
+
return object;
|
|
584
|
+
}
|
|
585
|
+
async lookupLegacy(version) {
|
|
586
|
+
if (!hex64.test(version))
|
|
587
|
+
return null;
|
|
588
|
+
try {
|
|
589
|
+
const meta = await fs.open(path.join(this.directory, `${version}.json`), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
590
|
+
let data;
|
|
591
|
+
try {
|
|
592
|
+
const s = await meta.stat();
|
|
593
|
+
if (!s.isFile() || s.size > 4096)
|
|
594
|
+
return null;
|
|
595
|
+
data = JSON.parse(await meta.readFile('utf8'));
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
await meta.close();
|
|
599
|
+
}
|
|
600
|
+
if (data.version !== version || data.state !== 'ready' || !['mp3', 'mp4', 'png'].includes(data.extension) || !['audio', 'video', 'image'].includes(data.kind) || !Number.isSafeInteger(data.size) || data.size <= 0 || data.size > this.policy.maxVersionBytes || !Number.isFinite(data.durationSeconds) || data.durationSeconds < 0 || data.durationSeconds > 21600)
|
|
601
|
+
return null;
|
|
602
|
+
const file = path.join(this.directory, `${version}.${data.extension}`);
|
|
603
|
+
const s = await fs.lstat(file);
|
|
604
|
+
if (!s.isFile() || s.isSymbolicLink() || s.size !== data.size)
|
|
605
|
+
return null;
|
|
606
|
+
const durationMs = Math.round(data.durationSeconds * 1000);
|
|
607
|
+
return { kind: data.kind, durationMs, object: { id: version.slice(0, 32), type: data.kind === 'image' ? 'image' : 'range', extension: data.extension, mimeType: data.mimeType, size: data.size, etag: version, startMs: 0, endMs: durationMs, sequence: 0, timestamp90k: 0, file } };
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async function safeDirectory(directory) {
|
|
615
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
616
|
+
const stat = await fs.lstat(directory);
|
|
617
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
|
|
618
|
+
throw new Error('Unsafe media cache directory');
|
|
619
|
+
await fs.chmod(directory, 0o700);
|
|
620
|
+
}
|