handmux 0.23.0 → 0.25.0
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 +31 -8
- package/README.zh-CN.md +31 -8
- package/bin/handmux.js +14 -688
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +1015 -0
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +64 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/dist/hooks/handmux-write.cjs +264 -0
- package/dist/package.json +127 -0
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +203 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +262 -0
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +39 -0
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +94 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +86 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +525 -0
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +290 -0
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +80 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +39 -0
- package/dist/src/cli/i18n/en.js +392 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +391 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +154 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +117 -0
- package/dist/src/cli/statusLine.js +115 -0
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +3436 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +137 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +835 -0
- package/dist/src/codexUsageSnapshot.js +228 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +284 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +96 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +167 -0
- package/dist/src/paneInput.js +42 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +124 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +89 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +244 -0
- package/dist/src/routes/system.js +220 -0
- package/dist/src/routes/terminal.js +244 -0
- package/dist/src/routes/workspace.js +267 -0
- package/dist/src/server.js +315 -0
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +185 -172
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +261 -0
- package/dist/src/transcriptReader.js +209 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +31 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +301 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +363 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +704 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +30 -17
- package/hooks/handmux-notify.sh +0 -22
- package/hooks/handmux-write.cjs +0 -78
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/claude.js +0 -159
- package/src/agents/codex.js +0 -168
- package/src/agents/index.js +0 -21
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/claudeHooks.js +0 -206
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/statusLine.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/server.js +0 -186
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
export const WORKSPACE_SCHEMA_VERSION = 1;
|
|
3
|
+
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
4
|
+
const byLink = (a, b) => (a.index - b.index || (a.windowId < b.windowId ? -1 : a.windowId > b.windowId ? 1 : 0));
|
|
5
|
+
const hash = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
6
|
+
function requireObject(value, label) {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
8
|
+
throw new Error(`${label} must be an object`);
|
|
9
|
+
}
|
|
10
|
+
function requireArray(value, label) {
|
|
11
|
+
if (!Array.isArray(value))
|
|
12
|
+
throw new Error(`${label} must be an array`);
|
|
13
|
+
}
|
|
14
|
+
function requireString(value, label) {
|
|
15
|
+
if (typeof value !== 'string' || !value)
|
|
16
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
17
|
+
}
|
|
18
|
+
function requireIndex(value, label) {
|
|
19
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
20
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function validateShape(input) {
|
|
24
|
+
requireString(input.capturedAt, 'capturedAt');
|
|
25
|
+
requireObject(input.environment, 'environment');
|
|
26
|
+
for (const field of ['id', 'bootIdentity'])
|
|
27
|
+
requireString(input.environment[field], `environment.${field}`);
|
|
28
|
+
if (input.environment.tmuxServerId !== null)
|
|
29
|
+
requireString(input.environment.tmuxServerId, 'environment.tmuxServerId');
|
|
30
|
+
requireString(input.tmuxVersion, 'tmuxVersion');
|
|
31
|
+
requireArray(input.sessions, 'sessions');
|
|
32
|
+
requireArray(input.windows, 'windows');
|
|
33
|
+
const empty = input.sessions.length === 0 && input.windows.length === 0;
|
|
34
|
+
if ((input.sessions.length === 0) !== (input.windows.length === 0))
|
|
35
|
+
throw new Error('sessions and windows must both be empty or non-empty');
|
|
36
|
+
if (empty) {
|
|
37
|
+
if (input.active !== null)
|
|
38
|
+
throw new Error('active must be null for an empty workspace');
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
requireString(input.environment.tmuxServerId, 'environment.tmuxServerId');
|
|
42
|
+
requireObject(input.active, 'active');
|
|
43
|
+
for (const field of ['sessionId', 'windowId', 'paneId'])
|
|
44
|
+
requireString(input.active[field], `active.${field}`);
|
|
45
|
+
}
|
|
46
|
+
for (const [index, session] of input.sessions.entries()) {
|
|
47
|
+
const label = `sessions[${index}]`;
|
|
48
|
+
requireObject(session, label);
|
|
49
|
+
for (const field of ['id', 'runtimeId', 'name', 'activeWindowId'])
|
|
50
|
+
requireString(session[field], `${label}.${field}`);
|
|
51
|
+
if (session.windowIds !== undefined)
|
|
52
|
+
throw new Error(`${label}.windowIds is unsupported; use windowLinks`);
|
|
53
|
+
requireArray(session.windowLinks, `${label}.windowLinks`);
|
|
54
|
+
for (const [linkIndex, link] of session.windowLinks.entries()) {
|
|
55
|
+
const linkLabel = `${label}.windowLinks[${linkIndex}]`;
|
|
56
|
+
requireObject(link, linkLabel);
|
|
57
|
+
requireString(link.windowId, `${linkLabel}.windowId`);
|
|
58
|
+
requireIndex(link.index, `${linkLabel}.index`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const [windowIndex, window] of input.windows.entries()) {
|
|
62
|
+
const label = `windows[${windowIndex}]`;
|
|
63
|
+
requireObject(window, label);
|
|
64
|
+
for (const field of ['id', 'runtimeId', 'name', 'layout', 'activePaneId'])
|
|
65
|
+
requireString(window[field], `${label}.${field}`);
|
|
66
|
+
requireIndex(window.index, `${label}.index`);
|
|
67
|
+
requireArray(window.panes, `${label}.panes`);
|
|
68
|
+
for (const [paneIndex, pane] of window.panes.entries()) {
|
|
69
|
+
const paneLabel = `${label}.panes[${paneIndex}]`;
|
|
70
|
+
requireObject(pane, paneLabel);
|
|
71
|
+
for (const field of ['id', 'runtimeId', 'cwd'])
|
|
72
|
+
requireString(pane[field], `${paneLabel}.${field}`);
|
|
73
|
+
requireIndex(pane.index, `${paneLabel}.index`);
|
|
74
|
+
if (pane.agent !== undefined && pane.agent !== null)
|
|
75
|
+
requireObject(pane.agent, `${paneLabel}.agent`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function validateReferences(input, sessions, windows) {
|
|
80
|
+
if (sessions.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
const windowsById = new Map(windows.map((window) => [window.id, window]));
|
|
83
|
+
const sessionsById = new Map(sessions.map((session) => [session.id, session]));
|
|
84
|
+
const active = input.active;
|
|
85
|
+
if (!active)
|
|
86
|
+
throw new Error('active must be an object');
|
|
87
|
+
for (const session of sessions) {
|
|
88
|
+
const windowIds = session.windowLinks.map((link) => link.windowId);
|
|
89
|
+
const windowIndexes = session.windowLinks.map((link) => link.index);
|
|
90
|
+
if (new Set(windowIds).size !== windowIds.length)
|
|
91
|
+
throw new Error(`duplicate window link in session ${session.id}`);
|
|
92
|
+
if (new Set(windowIndexes).size !== windowIndexes.length)
|
|
93
|
+
throw new Error(`duplicate window link index in session ${session.id}`);
|
|
94
|
+
for (const { windowId } of session.windowLinks) {
|
|
95
|
+
if (!windowsById.has(windowId))
|
|
96
|
+
throw new Error(`dangling windowLinks reference ${windowId}`);
|
|
97
|
+
}
|
|
98
|
+
if (!windowIds.includes(session.activeWindowId))
|
|
99
|
+
throw new Error(`dangling activeWindowId reference ${session.activeWindowId}`);
|
|
100
|
+
}
|
|
101
|
+
for (const window of windows) {
|
|
102
|
+
if (!window.panes.some((pane) => pane.id === window.activePaneId))
|
|
103
|
+
throw new Error(`dangling activePaneId reference ${window.activePaneId}`);
|
|
104
|
+
}
|
|
105
|
+
const activeSession = sessionsById.get(active.sessionId);
|
|
106
|
+
if (!activeSession)
|
|
107
|
+
throw new Error(`dangling active.sessionId reference ${active.sessionId}`);
|
|
108
|
+
if (!activeSession.windowLinks.some((link) => link.windowId === active.windowId))
|
|
109
|
+
throw new Error(`dangling active.windowId reference ${active.windowId}`);
|
|
110
|
+
const activeWindow = windowsById.get(active.windowId);
|
|
111
|
+
if (!activeWindow?.panes.some((pane) => pane.id === active.paneId))
|
|
112
|
+
throw new Error(`dangling active.paneId reference ${active.paneId}`);
|
|
113
|
+
}
|
|
114
|
+
export function canonicalizeSnapshot(input) {
|
|
115
|
+
requireObject(input, 'workspace');
|
|
116
|
+
if (input.schemaVersion !== WORKSPACE_SCHEMA_VERSION)
|
|
117
|
+
throw new Error('unsupported workspace schema');
|
|
118
|
+
validateShape(input);
|
|
119
|
+
const sessions = input.sessions.map((s) => ({ ...s, windowLinks: s.windowLinks.map((link) => ({ ...link })).sort(byLink) })).sort(byId);
|
|
120
|
+
const windows = input.windows.map((w) => ({ ...w, panes: [...w.panes].sort(byId) })).sort(byId);
|
|
121
|
+
const identitySets = [
|
|
122
|
+
['session', sessions.map((x) => x.id)],
|
|
123
|
+
['window', windows.map((x) => x.id)],
|
|
124
|
+
['pane', windows.flatMap((w) => w.panes.map((p) => p.id))],
|
|
125
|
+
];
|
|
126
|
+
for (const [label, ids] of identitySets) {
|
|
127
|
+
if (new Set(ids).size !== ids.length)
|
|
128
|
+
throw new Error(`duplicate ${label} id`);
|
|
129
|
+
}
|
|
130
|
+
validateReferences(input, sessions, windows);
|
|
131
|
+
return { ...input, sessions, windows };
|
|
132
|
+
}
|
|
133
|
+
export function fingerprintSnapshot(input) {
|
|
134
|
+
const { capturedAt, revision, payloadHash, ...payload } = canonicalizeSnapshot(input);
|
|
135
|
+
return hash(payload);
|
|
136
|
+
}
|
|
137
|
+
export function sealPayload(input) {
|
|
138
|
+
const payload = canonicalizeSnapshot(input);
|
|
139
|
+
return { ...payload, payloadHash: fingerprintSnapshot(payload) };
|
|
140
|
+
}
|
|
141
|
+
export function validateCheckpoint(input) {
|
|
142
|
+
try {
|
|
143
|
+
requireObject(input, 'checkpoint');
|
|
144
|
+
requireString(input.id, 'checkpoint id');
|
|
145
|
+
requireString(input.archivedAt, 'checkpoint archivedAt');
|
|
146
|
+
if (typeof input.payloadHash !== 'string' || !/^[0-9a-f]{64}$/.test(input.payloadHash))
|
|
147
|
+
throw new Error('invalid checkpoint payloadHash');
|
|
148
|
+
const expected = fingerprintSnapshot(input);
|
|
149
|
+
if (input.payloadHash !== expected)
|
|
150
|
+
throw new Error('checkpoint hash mismatch');
|
|
151
|
+
return { ok: true, value: canonicalizeSnapshot(input) };
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensurePrivateDir, writeJsonAtomic } from './atomicJson.js';
|
|
4
|
+
import { workspacePaths } from './paths.js';
|
|
5
|
+
import { canonicalizeSnapshot, fingerprintSnapshot, sealPayload, validateCheckpoint, } from './schema.js';
|
|
6
|
+
import { validateRecoveryMapping } from './mapping.js';
|
|
7
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const RECOVERY_WINDOW_MS = 60 * 60 * 1000;
|
|
9
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
|
+
const recordOf = (value) => (value && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
? value : null);
|
|
12
|
+
function hasErrorCode(error, code) {
|
|
13
|
+
return recordOf(error)?.code === code;
|
|
14
|
+
}
|
|
15
|
+
function requireSafeId(id) {
|
|
16
|
+
if (typeof id !== 'string' || !SAFE_ID.test(id) || id === '.' || id === '..')
|
|
17
|
+
throw new Error('workspace id must be a safe filename');
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
function errorMessage(error) {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
23
|
+
function projectObject(value, fields) {
|
|
24
|
+
const record = recordOf(value);
|
|
25
|
+
if (!record)
|
|
26
|
+
return value;
|
|
27
|
+
return Object.fromEntries(fields.map((field) => [field, record[field]]));
|
|
28
|
+
}
|
|
29
|
+
function projectSnapshot(input) {
|
|
30
|
+
const record = recordOf(input);
|
|
31
|
+
if (!record)
|
|
32
|
+
return input;
|
|
33
|
+
const environment = projectObject(record.environment, ['id', 'bootIdentity', 'tmuxServerId']);
|
|
34
|
+
const active = record.active === null ? null : projectObject(record.active, ['sessionId', 'windowId', 'paneId']);
|
|
35
|
+
const sessions = Array.isArray(record.sessions) ? record.sessions.map((session) => {
|
|
36
|
+
const projected = projectObject(session, ['id', 'runtimeId', 'name', 'windowLinks', 'activeWindowId']);
|
|
37
|
+
const projectedRecord = recordOf(projected);
|
|
38
|
+
if (!projectedRecord || !Array.isArray(projectedRecord.windowLinks))
|
|
39
|
+
return projected;
|
|
40
|
+
projectedRecord.windowLinks = projectedRecord.windowLinks.map((link) => projectObject(link, ['windowId', 'index']));
|
|
41
|
+
return projectedRecord;
|
|
42
|
+
}) : record.sessions;
|
|
43
|
+
const windows = Array.isArray(record.windows) ? record.windows.map((window) => {
|
|
44
|
+
const projected = projectObject(window, ['id', 'runtimeId', 'name', 'index', 'layout', 'activePaneId', 'panes']);
|
|
45
|
+
const projectedRecord = recordOf(projected);
|
|
46
|
+
if (!projectedRecord || !Array.isArray(projectedRecord.panes))
|
|
47
|
+
return projected;
|
|
48
|
+
projectedRecord.panes = projectedRecord.panes.map((pane) => {
|
|
49
|
+
const projectedPane = projectObject(pane, ['id', 'runtimeId', 'index', 'cwd', 'agent']);
|
|
50
|
+
const paneRecord = recordOf(projectedPane);
|
|
51
|
+
if (!paneRecord)
|
|
52
|
+
return projectedPane;
|
|
53
|
+
if (paneRecord.agent !== null && paneRecord.agent !== undefined) {
|
|
54
|
+
paneRecord.agent = projectObject(paneRecord.agent, ['id', 'sessionId', 'transcriptPath']);
|
|
55
|
+
}
|
|
56
|
+
return paneRecord;
|
|
57
|
+
});
|
|
58
|
+
return projectedRecord;
|
|
59
|
+
}) : record.windows;
|
|
60
|
+
return {
|
|
61
|
+
schemaVersion: record.schemaVersion,
|
|
62
|
+
capturedAt: record.capturedAt,
|
|
63
|
+
environment,
|
|
64
|
+
tmuxVersion: record.tmuxVersion,
|
|
65
|
+
active,
|
|
66
|
+
sessions,
|
|
67
|
+
windows,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function validateLive(value) {
|
|
71
|
+
try {
|
|
72
|
+
const record = recordOf(value);
|
|
73
|
+
if (!record || !Number.isInteger(record.revision) || record.revision < 1)
|
|
74
|
+
throw new Error('invalid live revision');
|
|
75
|
+
if (typeof record.payloadHash !== 'string' || !/^[0-9a-f]{64}$/.test(record.payloadHash))
|
|
76
|
+
throw new Error('invalid live payloadHash');
|
|
77
|
+
if (fingerprintSnapshot(value) !== record.payloadHash)
|
|
78
|
+
throw new Error('live hash mismatch');
|
|
79
|
+
const canonical = canonicalizeSnapshot(value);
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
value: {
|
|
83
|
+
...canonical,
|
|
84
|
+
revision: record.revision,
|
|
85
|
+
payloadHash: record.payloadHash,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return { ok: false, error: errorMessage(error) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function readJson(file, fs) {
|
|
94
|
+
try {
|
|
95
|
+
return { status: 'ok', value: JSON.parse(await fs.readFile(file, 'utf8')) };
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
99
|
+
return { status: 'missing' };
|
|
100
|
+
return { status: 'corrupt', error: errorMessage(error) };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function readValidated(file, fs, validate) {
|
|
104
|
+
const result = await readJson(file, fs);
|
|
105
|
+
if (result.status !== 'ok')
|
|
106
|
+
return result;
|
|
107
|
+
try {
|
|
108
|
+
const value = validate(result.value);
|
|
109
|
+
return { status: 'ok', value };
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return { status: 'corrupt', error: errorMessage(error) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function validateRecovery(value, checkpointId) {
|
|
116
|
+
const record = recordOf(value);
|
|
117
|
+
if (!record)
|
|
118
|
+
throw new Error('invalid recovery state');
|
|
119
|
+
if (record.checkpointId !== checkpointId)
|
|
120
|
+
throw new Error('recovery checkpoint id mismatch');
|
|
121
|
+
for (const field of ['detectedAt', 'expiresAt']) {
|
|
122
|
+
const entry = record[field];
|
|
123
|
+
if (typeof entry !== 'string' || Number.isNaN(Date.parse(entry)))
|
|
124
|
+
throw new Error(`invalid recovery ${field}`);
|
|
125
|
+
}
|
|
126
|
+
for (const field of ['initialSessionIds', 'pendingSessionIds']) {
|
|
127
|
+
const entry = record[field];
|
|
128
|
+
if (!Array.isArray(entry) || entry.some((id) => typeof id !== 'string' || !id))
|
|
129
|
+
throw new Error(`invalid recovery ${field}`);
|
|
130
|
+
if (new Set(entry).size !== entry.length)
|
|
131
|
+
throw new Error(`duplicate recovery ${field}`);
|
|
132
|
+
}
|
|
133
|
+
const initialSessionIds = record.initialSessionIds;
|
|
134
|
+
const pendingSessionIds = record.pendingSessionIds;
|
|
135
|
+
const initial = new Set(initialSessionIds);
|
|
136
|
+
if (pendingSessionIds.some((id) => !initial.has(id)))
|
|
137
|
+
throw new Error('recovery pending ids must be initial ids');
|
|
138
|
+
if (record.resolvedAt !== null && (typeof record.resolvedAt !== 'string' || Number.isNaN(Date.parse(record.resolvedAt))))
|
|
139
|
+
throw new Error('invalid recovery resolvedAt');
|
|
140
|
+
if (record.resolvedAt !== null && pendingSessionIds.length > 0)
|
|
141
|
+
throw new Error('resolved recovery cannot contain pending session ids');
|
|
142
|
+
let mapping = null;
|
|
143
|
+
if (record.mapping !== null) {
|
|
144
|
+
mapping = validateRecoveryMapping(record.mapping, checkpointId);
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
checkpointId,
|
|
148
|
+
detectedAt: record.detectedAt,
|
|
149
|
+
expiresAt: record.expiresAt,
|
|
150
|
+
initialSessionIds,
|
|
151
|
+
pendingSessionIds,
|
|
152
|
+
resolvedAt: record.resolvedAt,
|
|
153
|
+
mapping,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function selectRetainedCheckpoints(rows, now, latestId) {
|
|
157
|
+
const valid = rows
|
|
158
|
+
.filter((row) => (row.status === 'ok' && Number.isFinite(Date.parse(row.value.archivedAt))))
|
|
159
|
+
.sort((a, b) => Date.parse(b.value.archivedAt) - Date.parse(a.value.archivedAt));
|
|
160
|
+
const retained = [];
|
|
161
|
+
const retainedIds = new Set();
|
|
162
|
+
const keep = (row) => {
|
|
163
|
+
if (!row || retainedIds.has(row.id))
|
|
164
|
+
return;
|
|
165
|
+
retained.push(row);
|
|
166
|
+
retainedIds.add(row.id);
|
|
167
|
+
};
|
|
168
|
+
for (const row of valid) {
|
|
169
|
+
if (Date.parse(row.value.archivedAt) >= now - DAY_MS)
|
|
170
|
+
keep(row);
|
|
171
|
+
}
|
|
172
|
+
for (const row of valid) {
|
|
173
|
+
if (retained.length >= 10)
|
|
174
|
+
break;
|
|
175
|
+
keep(row);
|
|
176
|
+
}
|
|
177
|
+
keep(rows.find((row) => row.id === latestId));
|
|
178
|
+
return retained;
|
|
179
|
+
}
|
|
180
|
+
export function createWorkspaceStore({ home, now = Date.now, fs = fsp }) {
|
|
181
|
+
const paths = workspacePaths(home);
|
|
182
|
+
async function ensureDirectories() {
|
|
183
|
+
for (const dir of [path.dirname(paths.root), paths.root, paths.liveDir, paths.checkpointsDir, paths.recoveryDir, paths.operationsDir]) {
|
|
184
|
+
await ensurePrivateDir(dir, { fs });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function readLiveCopy(file) {
|
|
188
|
+
const result = await readJson(file, fs);
|
|
189
|
+
if (result.status !== 'ok')
|
|
190
|
+
return result;
|
|
191
|
+
const validated = validateLive(result.value);
|
|
192
|
+
return validated.ok ? { status: 'ok', value: validated.value } : { status: 'corrupt', error: validated.error };
|
|
193
|
+
}
|
|
194
|
+
async function readLive() {
|
|
195
|
+
const copies = await Promise.all([paths.liveCurrent, paths.liveMirror].map(readLiveCopy));
|
|
196
|
+
const valid = copies
|
|
197
|
+
.map((result, index) => ({ ...result, index }))
|
|
198
|
+
.filter((result) => result.status === 'ok')
|
|
199
|
+
.sort((a, b) => b.value.revision - a.value.revision || a.index - b.index);
|
|
200
|
+
if (valid.length === 0) {
|
|
201
|
+
if (copies.every((copy) => copy.status === 'missing'))
|
|
202
|
+
return { status: 'empty' };
|
|
203
|
+
return { status: 'corrupt', errors: copies.map((copy) => copy.status === 'corrupt' ? copy.error : copy.status) };
|
|
204
|
+
}
|
|
205
|
+
const chosenCopy = valid[0];
|
|
206
|
+
if (!chosenCopy)
|
|
207
|
+
return { status: 'corrupt', errors: ['missing valid live copy'] };
|
|
208
|
+
const chosen = chosenCopy.value;
|
|
209
|
+
let repaired = false;
|
|
210
|
+
await ensureDirectories();
|
|
211
|
+
for (let index = 0; index < copies.length; index += 1) {
|
|
212
|
+
const copy = copies[index];
|
|
213
|
+
if (copy?.status === 'ok' && copy.value.revision === chosen.revision && copy.value.payloadHash === chosen.payloadHash)
|
|
214
|
+
continue;
|
|
215
|
+
await writeJsonAtomic(index === 0 ? paths.liveCurrent : paths.liveMirror, chosen, { fs });
|
|
216
|
+
repaired = true;
|
|
217
|
+
}
|
|
218
|
+
return { status: 'ok', value: chosen, repaired };
|
|
219
|
+
}
|
|
220
|
+
async function writeLive(snapshot) {
|
|
221
|
+
const payload = canonicalizeSnapshot(projectSnapshot(snapshot));
|
|
222
|
+
await ensureDirectories();
|
|
223
|
+
const copies = await Promise.all([paths.liveCurrent, paths.liveMirror].map(readLiveCopy));
|
|
224
|
+
const revision = Math.max(0, ...copies
|
|
225
|
+
.filter((copy) => copy.status === 'ok')
|
|
226
|
+
.map((copy) => copy.value.revision)) + 1;
|
|
227
|
+
const value = { ...sealPayload({ ...payload, revision }), revision };
|
|
228
|
+
await writeJsonAtomic(paths.liveCurrent, value, { fs });
|
|
229
|
+
await writeJsonAtomic(paths.liveMirror, value, { fs });
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
async function readCheckpoint(id) {
|
|
233
|
+
requireSafeId(id);
|
|
234
|
+
const result = await readJson(path.join(paths.checkpointsDir, `${id}.json`), fs);
|
|
235
|
+
if (result.status !== 'ok')
|
|
236
|
+
return result;
|
|
237
|
+
const validation = validateCheckpoint(result.value);
|
|
238
|
+
if (!validation.ok)
|
|
239
|
+
return { status: 'corrupt', error: validation.error };
|
|
240
|
+
if (validation.value.id !== id)
|
|
241
|
+
return { status: 'corrupt', error: 'checkpoint id does not match filename' };
|
|
242
|
+
if (validation.value.environment.id !== id)
|
|
243
|
+
return { status: 'corrupt', error: 'checkpoint environment id does not match filename' };
|
|
244
|
+
return { status: 'ok', value: validation.value };
|
|
245
|
+
}
|
|
246
|
+
async function listCheckpoints() {
|
|
247
|
+
let names;
|
|
248
|
+
try {
|
|
249
|
+
names = await fs.readdir(paths.checkpointsDir);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
253
|
+
return [];
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
const rows = await Promise.all(names.filter((name) => name.endsWith('.json')).map(async (name) => {
|
|
257
|
+
const id = name.slice(0, -'.json'.length);
|
|
258
|
+
if (!SAFE_ID.test(id) || id === '.' || id === '..') {
|
|
259
|
+
return { status: 'corrupt', id, error: 'checkpoint filename is not a safe id' };
|
|
260
|
+
}
|
|
261
|
+
const result = await readCheckpoint(id);
|
|
262
|
+
if (result.status === 'ok')
|
|
263
|
+
return { status: 'ok', id, value: result.value };
|
|
264
|
+
return { status: 'corrupt', id, error: result.status === 'corrupt' ? result.error : result.status };
|
|
265
|
+
}));
|
|
266
|
+
return rows.sort((a, b) => {
|
|
267
|
+
if (a.status === 'ok' && b.status === 'ok')
|
|
268
|
+
return Date.parse(b.value.archivedAt) - Date.parse(a.value.archivedAt);
|
|
269
|
+
if (a.status === 'ok')
|
|
270
|
+
return -1;
|
|
271
|
+
if (b.status === 'ok')
|
|
272
|
+
return 1;
|
|
273
|
+
return a.id.localeCompare(b.id);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async function readLatestCheckpoint() {
|
|
277
|
+
const pointer = await readJson(paths.latest, fs);
|
|
278
|
+
let warning;
|
|
279
|
+
if (pointer.status === 'ok') {
|
|
280
|
+
try {
|
|
281
|
+
const pointerRecord = recordOf(pointer.value);
|
|
282
|
+
const checkpointId = requireSafeId(pointerRecord?.checkpointId);
|
|
283
|
+
if (typeof pointerRecord?.payloadHash !== 'string')
|
|
284
|
+
throw new Error('invalid latest payloadHash');
|
|
285
|
+
const pointed = await readCheckpoint(checkpointId);
|
|
286
|
+
if (pointed.status === 'ok' && pointed.value.payloadHash === pointerRecord.payloadHash)
|
|
287
|
+
return pointed;
|
|
288
|
+
warning = `latest checkpoint is ${pointed.status === 'ok' ? 'hash-mismatched' : pointed.status}`;
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
warning = `latest pointer is corrupt: ${errorMessage(error)}`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else if (pointer.status === 'corrupt') {
|
|
295
|
+
warning = `latest pointer is corrupt: ${pointer.error}`;
|
|
296
|
+
}
|
|
297
|
+
const rows = await listCheckpoints();
|
|
298
|
+
const fallback = rows.find((row) => row.status === 'ok');
|
|
299
|
+
if (fallback)
|
|
300
|
+
return { status: 'ok', value: fallback.value, ...(warning ? { warning } : {}) };
|
|
301
|
+
if (pointer.status === 'missing' && rows.length === 0)
|
|
302
|
+
return { status: 'missing' };
|
|
303
|
+
return { status: 'corrupt', error: warning ?? 'no valid checkpoint' };
|
|
304
|
+
}
|
|
305
|
+
async function readRecovery(checkpointId) {
|
|
306
|
+
requireSafeId(checkpointId);
|
|
307
|
+
return readValidated(path.join(paths.recoveryDir, `${checkpointId}.json`), fs, (value) => validateRecovery(value, checkpointId));
|
|
308
|
+
}
|
|
309
|
+
async function createRecovery(checkpointId, detectedAt) {
|
|
310
|
+
requireSafeId(checkpointId);
|
|
311
|
+
const existing = await readRecovery(checkpointId);
|
|
312
|
+
if (existing.status === 'ok')
|
|
313
|
+
return existing;
|
|
314
|
+
if (existing.status === 'corrupt')
|
|
315
|
+
throw new Error(existing.error);
|
|
316
|
+
const checkpoint = await readCheckpoint(checkpointId);
|
|
317
|
+
if (checkpoint.status !== 'ok')
|
|
318
|
+
return checkpoint;
|
|
319
|
+
if (typeof detectedAt !== 'string' || Number.isNaN(Date.parse(detectedAt)))
|
|
320
|
+
throw new Error('detectedAt must be an ISO timestamp');
|
|
321
|
+
const initialSessionIds = checkpoint.value.sessions.map((session) => session.id);
|
|
322
|
+
const value = {
|
|
323
|
+
checkpointId,
|
|
324
|
+
detectedAt,
|
|
325
|
+
expiresAt: new Date(Date.parse(detectedAt) + RECOVERY_WINDOW_MS).toISOString(),
|
|
326
|
+
initialSessionIds,
|
|
327
|
+
pendingSessionIds: [...initialSessionIds],
|
|
328
|
+
resolvedAt: null,
|
|
329
|
+
mapping: null,
|
|
330
|
+
};
|
|
331
|
+
await ensureDirectories();
|
|
332
|
+
await writeJsonAtomic(path.join(paths.recoveryDir, `${checkpointId}.json`), value, { fs });
|
|
333
|
+
return { status: 'ok', value };
|
|
334
|
+
}
|
|
335
|
+
async function resolveSessions(checkpointId, ids) {
|
|
336
|
+
if (!Array.isArray(ids))
|
|
337
|
+
throw new Error('resolved session ids must be an array');
|
|
338
|
+
const recovery = await readRecovery(checkpointId);
|
|
339
|
+
if (recovery.status !== 'ok')
|
|
340
|
+
return recovery;
|
|
341
|
+
if (recovery.value.resolvedAt !== null)
|
|
342
|
+
return recovery;
|
|
343
|
+
const resolved = new Set(ids.filter((id) => typeof id === 'string'));
|
|
344
|
+
const pendingSessionIds = recovery.value.pendingSessionIds.filter((id) => !resolved.has(id));
|
|
345
|
+
if (pendingSessionIds.length === recovery.value.pendingSessionIds.length && pendingSessionIds.length > 0)
|
|
346
|
+
return recovery;
|
|
347
|
+
const value = {
|
|
348
|
+
...recovery.value,
|
|
349
|
+
pendingSessionIds,
|
|
350
|
+
resolvedAt: pendingSessionIds.length === 0 ? new Date(now()).toISOString() : null,
|
|
351
|
+
};
|
|
352
|
+
await writeJsonAtomic(path.join(paths.recoveryDir, `${checkpointId}.json`), value, { fs });
|
|
353
|
+
return { status: 'ok', value };
|
|
354
|
+
}
|
|
355
|
+
async function mergeRecoveryMapping(checkpointId, mapping) {
|
|
356
|
+
const recovery = await readRecovery(checkpointId);
|
|
357
|
+
if (recovery.status !== 'ok')
|
|
358
|
+
return recovery;
|
|
359
|
+
const validatedMapping = validateRecoveryMapping(mapping, checkpointId);
|
|
360
|
+
const value = validateRecovery({ ...recovery.value, mapping: validatedMapping }, checkpointId);
|
|
361
|
+
await writeJsonAtomic(path.join(paths.recoveryDir, `${checkpointId}.json`), value, { fs });
|
|
362
|
+
return { status: 'ok', value };
|
|
363
|
+
}
|
|
364
|
+
async function writeOperation(operation) {
|
|
365
|
+
requireSafeId(operation?.id);
|
|
366
|
+
await ensureDirectories();
|
|
367
|
+
await writeJsonAtomic(path.join(paths.operationsDir, `${operation.id}.json`), operation, { fs });
|
|
368
|
+
return operation;
|
|
369
|
+
}
|
|
370
|
+
async function readOperation(id) {
|
|
371
|
+
requireSafeId(id);
|
|
372
|
+
const result = await readJson(path.join(paths.operationsDir, `${id}.json`), fs);
|
|
373
|
+
return result.status === 'ok'
|
|
374
|
+
? { status: 'ok', value: result.value }
|
|
375
|
+
: result;
|
|
376
|
+
}
|
|
377
|
+
async function listOperations() {
|
|
378
|
+
let names;
|
|
379
|
+
try {
|
|
380
|
+
names = await fs.readdir(paths.operationsDir);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
384
|
+
return [];
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
return Promise.all(names.filter((name) => name.endsWith('.json')).sort().map(async (name) => {
|
|
388
|
+
const id = name.slice(0, -'.json'.length);
|
|
389
|
+
if (!SAFE_ID.test(id) || id === '.' || id === '..')
|
|
390
|
+
return { status: 'corrupt', id, error: 'operation filename is not a safe id' };
|
|
391
|
+
const result = await readOperation(id);
|
|
392
|
+
if (result.status !== 'ok')
|
|
393
|
+
return { ...result, id };
|
|
394
|
+
const value = recordOf(result.value);
|
|
395
|
+
if (value?.id !== id)
|
|
396
|
+
return { status: 'corrupt', id, error: 'operation id does not match filename' };
|
|
397
|
+
return { status: 'ok', id, value: value };
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
async function prune() {
|
|
401
|
+
const pointer = await readJson(paths.latest, fs);
|
|
402
|
+
const pointerRecord = pointer.status === 'ok' ? recordOf(pointer.value) : null;
|
|
403
|
+
const latestId = typeof pointerRecord?.checkpointId === 'string' ? pointerRecord.checkpointId : undefined;
|
|
404
|
+
const rows = await listCheckpoints();
|
|
405
|
+
const retained = new Set(selectRetainedCheckpoints(rows, now(), latestId).map((row) => row.id));
|
|
406
|
+
const canDeleteCorrupt = pointer.status === 'ok' && SAFE_ID.test(latestId ?? '');
|
|
407
|
+
await Promise.all(rows.map(async (row) => {
|
|
408
|
+
if (retained.has(row.id) || (row.status === 'corrupt' && !canDeleteCorrupt))
|
|
409
|
+
return;
|
|
410
|
+
await fs.unlink(path.join(paths.checkpointsDir, `${row.id}.json`));
|
|
411
|
+
}));
|
|
412
|
+
return rows.filter((row) => retained.has(row.id));
|
|
413
|
+
}
|
|
414
|
+
async function archiveEnvironment({ endedReason, detectedAt }) {
|
|
415
|
+
if (typeof endedReason !== 'string' || !endedReason)
|
|
416
|
+
throw new Error('endedReason must be a non-empty string');
|
|
417
|
+
if (typeof detectedAt !== 'string' || Number.isNaN(Date.parse(detectedAt)))
|
|
418
|
+
throw new Error('detectedAt must be an ISO timestamp');
|
|
419
|
+
const live = await readLive();
|
|
420
|
+
if (live.status !== 'ok')
|
|
421
|
+
return live;
|
|
422
|
+
if (live.value.sessions.length === 0)
|
|
423
|
+
return { status: 'empty' };
|
|
424
|
+
const id = requireSafeId(live.value.environment.id);
|
|
425
|
+
await ensureDirectories();
|
|
426
|
+
let checkpoint = await readCheckpoint(id);
|
|
427
|
+
if (checkpoint.status !== 'ok') {
|
|
428
|
+
const { payloadHash: ignoredHash, revision: ignoredRevision, ...unsealed } = live.value;
|
|
429
|
+
const sealed = sealPayload({
|
|
430
|
+
...unsealed,
|
|
431
|
+
id,
|
|
432
|
+
archivedAt: new Date(now()).toISOString(),
|
|
433
|
+
environment: { ...unsealed.environment, endedReason },
|
|
434
|
+
});
|
|
435
|
+
const validation = validateCheckpoint(sealed);
|
|
436
|
+
if (!validation.ok)
|
|
437
|
+
throw new Error(validation.error);
|
|
438
|
+
const value = validation.value;
|
|
439
|
+
await writeJsonAtomic(path.join(paths.checkpointsDir, `${id}.json`), value, { fs });
|
|
440
|
+
checkpoint = { status: 'ok', value };
|
|
441
|
+
}
|
|
442
|
+
await writeJsonAtomic(paths.latest, { checkpointId: id, payloadHash: checkpoint.value.payloadHash }, { fs });
|
|
443
|
+
await createRecovery(id, detectedAt);
|
|
444
|
+
await prune().catch(() => { });
|
|
445
|
+
return checkpoint;
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
paths,
|
|
449
|
+
readLive,
|
|
450
|
+
writeLive,
|
|
451
|
+
archiveEnvironment,
|
|
452
|
+
listCheckpoints,
|
|
453
|
+
readCheckpoint,
|
|
454
|
+
readLatestCheckpoint,
|
|
455
|
+
readRecovery,
|
|
456
|
+
createRecovery,
|
|
457
|
+
resolveSessions,
|
|
458
|
+
mergeRecoveryMapping,
|
|
459
|
+
writeOperation,
|
|
460
|
+
readOperation,
|
|
461
|
+
listOperations,
|
|
462
|
+
prune,
|
|
463
|
+
};
|
|
464
|
+
}
|