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,244 @@
|
|
|
1
|
+
// Terminal pane I/O routes: read the screen (/history), type text/keys, resize/reflow, and forward
|
|
2
|
+
// wheel scroll to full-screen apps. This is the hot path the phone polls. Mounted under /api.
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { gzipSync } from 'node:zlib';
|
|
6
|
+
import { isPaneId, isWindowId } from '../tmux/commands.js';
|
|
7
|
+
import { capTrailingBlankRows } from '../trimCapture.js';
|
|
8
|
+
import { isAllowedKey } from '../keyNames.js';
|
|
9
|
+
import { restoreCaptureBackgrounds } from '../captureBackground.js';
|
|
10
|
+
import { serializePaneInput } from '../paneInput.js';
|
|
11
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
const requestBody = (req) => isRecord(req.body) ? req.body : {};
|
|
13
|
+
export { isAllowedKey } from '../keyNames.js';
|
|
14
|
+
// Keys the mobile keyboard may send via /keys. A controlled vocabulary of named tmux keys, PLUS
|
|
15
|
+
// live-modifier combinations (Ctrl/Alt + a single letter or digit) so the keyboard's Ctrl modifier
|
|
16
|
+
// can compose any readline/tmux binding (C-r, C-w, C-a, the tmux prefix, …) without enumerating each
|
|
17
|
+
// one here. tmux send-keys key names are themselves a closed set, so this stays a strict allowlist
|
|
18
|
+
// (never a passthrough): a key either names an approved token or matches the modifier shape, or it's
|
|
19
|
+
// rejected. The old fixed C-c/C-d/C-z/C-l/C-r/C-o/C-e all still match `C-[a-z0-9]`.
|
|
20
|
+
// Keep a short fallback gap for applications that do not negotiate bracketed paste. Supporting TUIs get
|
|
21
|
+
// an explicit paste boundary from commands.sendText; the delay is no longer the correctness mechanism.
|
|
22
|
+
const RAW_INPUT_MAX_BYTES = 16 * 1024;
|
|
23
|
+
const SUBMIT_GAP_MS = 120;
|
|
24
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
const validHexInput = (hex) => typeof hex === 'string'
|
|
26
|
+
&& hex.length >= 2
|
|
27
|
+
&& hex.length <= RAW_INPUT_MAX_BYTES * 2
|
|
28
|
+
&& hex.length % 2 === 0
|
|
29
|
+
&& /^[0-9a-f]+$/i.test(hex);
|
|
30
|
+
const isMissingPane = (error) => /(?:can't find|cannot find|no such|unknown|not found).*\bpane\b|\bpane\b.*(?:can't find|cannot find|no such|unknown|not found)/i
|
|
31
|
+
.test(error instanceof Error ? error.message : String(error));
|
|
32
|
+
export function terminalRoutes({ commands }) {
|
|
33
|
+
const r = express.Router();
|
|
34
|
+
r.get('/history', async (req, res, next) => {
|
|
35
|
+
const paneId = req.query.pane;
|
|
36
|
+
if (!isPaneId(paneId))
|
|
37
|
+
return res.status(400).json({ error: 'bad pane id' });
|
|
38
|
+
const lines = Math.min(Math.max(Number(req.query.lines) || 1000, 1), 5000);
|
|
39
|
+
try {
|
|
40
|
+
// Read the pane's state FIRST — whether it's on the alternate screen decides how we capture it.
|
|
41
|
+
const { width, height, cursorX, cursorY, cursorVisible, altScreen, mouseAware } = await commands.paneInfo(paneId);
|
|
42
|
+
// An ALT-screen pane (a full-screen app: vim/less/htop) is a fixed height×width screen with NO
|
|
43
|
+
// scrollback. Asking tmux for history (-S -lines) there bleeds the MAIN screen's scrollback in
|
|
44
|
+
// ABOVE the app — you'd scroll up into terminal history that isn't the app's — and capping its
|
|
45
|
+
// trailing blank rows mangles the fixed screen (those blanks are real cells). So capture an alt
|
|
46
|
+
// pane as exactly its visible screen (lines=0) and skip the blank-trim. A normal pane still pulls
|
|
47
|
+
// `lines` of scrollback and caps the empty grid below the cursor (fresh shell = "prompt + a wall of
|
|
48
|
+
// blank rows") so the phone's bottom-anchored render shows content, not blank. See trimCapture.js.
|
|
49
|
+
const captured = await commands.capturePane(paneId, altScreen ? 0 : lines);
|
|
50
|
+
const capturePaneRow = commands.capturePaneRow;
|
|
51
|
+
const restored = typeof capturePaneRow === 'function'
|
|
52
|
+
? await restoreCaptureBackgrounds(captured, height, (row) => capturePaneRow(paneId, row))
|
|
53
|
+
: {
|
|
54
|
+
ansi: captured,
|
|
55
|
+
historyLines: Math.max(0, (captured.endsWith('\n') ? captured.slice(0, -1) : captured).split('\n').length - height),
|
|
56
|
+
};
|
|
57
|
+
const raw = restored.ansi;
|
|
58
|
+
const ansi = altScreen ? raw : capTrailingBlankRows(raw);
|
|
59
|
+
// The cursor's row counted from the BOTTOM of the (trimmed) capture. The live screen is the
|
|
60
|
+
// capture's last `height` rows, so the cursor sits `height-1-cursorY` rows above the bottom —
|
|
61
|
+
// less however many trailing blank rows capTrailingBlankRows dropped (all of them below the
|
|
62
|
+
// cursor). The client re-places xterm's cursor this many rows up from the seed's last row.
|
|
63
|
+
const rowsOf = (value) => (value.endsWith('\n') ? value.slice(0, -1) : value).split('\n').length;
|
|
64
|
+
const historyLines = altScreen ? 0 : restored.historyLines;
|
|
65
|
+
const cur = {
|
|
66
|
+
row: Math.max(0, (height - 1 - cursorY) - (rowsOf(raw) - rowsOf(ansi))),
|
|
67
|
+
col: cursorX, vis: cursorVisible,
|
|
68
|
+
};
|
|
69
|
+
// Short content hash over size + cursor + ansi. The client echoes its last hash as ?since=… ;
|
|
70
|
+
// an unchanged screen returns 204 (empty) so an idle pane stops re-sending the whole capture.
|
|
71
|
+
// The cursor is folded in so a bare left/right (which moves the cursor but not the text) still
|
|
72
|
+
// yields a fresh frame — otherwise the move would 204 and the cursor would never visibly track.
|
|
73
|
+
// mouseAware is folded in too: toggling an app's mouse mode (e.g. `:set mouse=a` in vim) changes
|
|
74
|
+
// whether a swipe should wheel-scroll or just hint, and the client only re-reads it on a fresh frame.
|
|
75
|
+
const hash = createHash('sha1')
|
|
76
|
+
.update(`${width}x${height}\n${cur.col},${cursorY},${cur.vis ? 1 : 0}\n${altScreen ? 1 : 0}${mouseAware ? 'm' : ''}\n${ansi}`)
|
|
77
|
+
.digest('hex').slice(0, 16);
|
|
78
|
+
if (req.query.since === hash)
|
|
79
|
+
return res.status(204).end();
|
|
80
|
+
const json = JSON.stringify({
|
|
81
|
+
ansi, width, height, historyLines, hash, cur, alt: altScreen, mouseAware,
|
|
82
|
+
});
|
|
83
|
+
res.set('Content-Type', 'application/json');
|
|
84
|
+
res.set('Vary', 'Accept-Encoding'); // both 200 branches vary on encoding (correct for any caching proxy)
|
|
85
|
+
// Capture text is mostly SGR codes + spaces — gzip crushes it ~10x. (204s are empty, never gzipped.)
|
|
86
|
+
if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) {
|
|
87
|
+
res.set('Content-Encoding', 'gzip');
|
|
88
|
+
// gzipSync is fine here — captures are KBs; the event-loop stall is sub-ms.
|
|
89
|
+
return res.end(gzipSync(json));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
return res.end(json);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return next(e);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
r.post('/send', async (req, res, next) => {
|
|
100
|
+
const { pane, text, enter } = requestBody(req);
|
|
101
|
+
if (!isPaneId(pane))
|
|
102
|
+
return res.status(400).json({ error: 'bad pane id' });
|
|
103
|
+
const body = typeof text === 'string' ? text : '';
|
|
104
|
+
try {
|
|
105
|
+
await serializePaneInput(pane, async () => {
|
|
106
|
+
await commands.exitCopyModeIfActive(pane);
|
|
107
|
+
await commands.sendText(pane, body);
|
|
108
|
+
if (enter) {
|
|
109
|
+
if (body)
|
|
110
|
+
await delay(SUBMIT_GAP_MS); // a bare Enter has nothing to settle — send at once
|
|
111
|
+
await commands.sendEnter(pane);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return res.json({ ok: true });
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return next(e);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
r.post('/input', async (req, res, next) => {
|
|
121
|
+
const { pane, hex } = requestBody(req);
|
|
122
|
+
if (!isPaneId(pane))
|
|
123
|
+
return res.status(400).json({ error: 'bad pane id' });
|
|
124
|
+
if (!validHexInput(hex))
|
|
125
|
+
return res.status(400).json({ error: 'bad input bytes' });
|
|
126
|
+
try {
|
|
127
|
+
await serializePaneInput(pane, async () => {
|
|
128
|
+
await commands.exitCopyModeIfActive(pane);
|
|
129
|
+
await commands.sendHexInput(pane, hex);
|
|
130
|
+
});
|
|
131
|
+
return res.json({ ok: true });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (isMissingPane(error))
|
|
135
|
+
return res.status(404).json({ error: 'pane not found' });
|
|
136
|
+
return next(error);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
// Resize the tmux window so it reflows to the phone (auto:false), or hand sizing back to
|
|
140
|
+
// attached clients (auto:true). NOTE: this mutates the shared window — the PC's view of it
|
|
141
|
+
// changes too.
|
|
142
|
+
r.get('/layout', async (req, res, next) => {
|
|
143
|
+
if (!isWindowId(req.query.window))
|
|
144
|
+
return res.status(400).json({ error: 'bad window id' });
|
|
145
|
+
try {
|
|
146
|
+
return res.json({ layout: await commands.getWindowLayout(req.query.window) });
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
return next(e);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
// Restore only the pane arrangement. Window sizing remains untouched; handing the whole window
|
|
153
|
+
// back to an attached client is the separate `auto` resize operation below.
|
|
154
|
+
r.post('/layout', async (req, res, next) => {
|
|
155
|
+
const { window, layout } = requestBody(req);
|
|
156
|
+
if (!isWindowId(window))
|
|
157
|
+
return res.status(400).json({ error: 'bad window id' });
|
|
158
|
+
if (typeof layout !== 'string' || !layout)
|
|
159
|
+
return res.status(400).json({ error: 'bad window layout' });
|
|
160
|
+
try {
|
|
161
|
+
await commands.applyWindowLayout(window, layout);
|
|
162
|
+
return res.json({ ok: true });
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
return next(e);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
r.post('/resize', async (req, res, next) => {
|
|
169
|
+
const { window, pane, cols, rows, auto, layout } = requestBody(req);
|
|
170
|
+
const c = Math.min(Math.max(Number(cols) || 0, 20), 500);
|
|
171
|
+
try {
|
|
172
|
+
if (auto) {
|
|
173
|
+
if (!isWindowId(window))
|
|
174
|
+
return res.status(400).json({ error: 'bad window id' });
|
|
175
|
+
// restore the split arrangement (resizePane) then hand window sizing back (resizeWindow)
|
|
176
|
+
if (typeof layout === 'string' && layout)
|
|
177
|
+
await commands.applyWindowLayout(window, layout);
|
|
178
|
+
await commands.restoreWindowSize(window);
|
|
179
|
+
}
|
|
180
|
+
else if (isPaneId(pane)) {
|
|
181
|
+
await commands.resizePane(pane, c); // a pane in a split — resize only it
|
|
182
|
+
}
|
|
183
|
+
else if (isWindowId(window)) {
|
|
184
|
+
const rw = rows != null ? Math.min(Math.max(Number(rows) || 0, 5), 500) : null;
|
|
185
|
+
await commands.resizeWindow(window, c, rw); // a lone pane fills the window
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
return res.status(400).json({ error: 'bad resize target' });
|
|
189
|
+
}
|
|
190
|
+
return res.json({ ok: true });
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
return next(e);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
r.post('/keys', async (req, res, next) => {
|
|
197
|
+
const { pane, keys } = requestBody(req);
|
|
198
|
+
if (!isPaneId(pane))
|
|
199
|
+
return res.status(400).json({ error: 'bad pane id' });
|
|
200
|
+
if (!Array.isArray(keys) || keys.some((k) => !isAllowedKey(k))) {
|
|
201
|
+
return res.status(400).json({ error: 'disallowed key' });
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
await serializePaneInput(pane, async () => {
|
|
205
|
+
await commands.exitCopyModeIfActive(pane);
|
|
206
|
+
for (const k of keys)
|
|
207
|
+
await commands.sendKey(pane, k);
|
|
208
|
+
});
|
|
209
|
+
return res.json({ ok: true });
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
return next(e);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
// Swipe-to-scroll for full-screen (ALT-screen) apps: the client can't scroll the alt buffer itself
|
|
216
|
+
// (no scrollback), so it forwards the finger travel here and we inject wheel events the app scrolls on.
|
|
217
|
+
// SAFETY: we re-read the pane's mouse state at inject time and REFUSE unless it's actually reporting
|
|
218
|
+
// mouse — otherwise the raw escape bytes would print into the shell as literal `<65;…M` garbage. The
|
|
219
|
+
// client's cached mouseAware is only a UX hint; this check is the real guard (mouse mode can toggle
|
|
220
|
+
// between the poll and the gesture). Positioned at the pane centre so a split-aware app scrolls the
|
|
221
|
+
// region the finger is over; a lone full-screen app ignores the position.
|
|
222
|
+
r.post('/scroll', async (req, res, next) => {
|
|
223
|
+
const { pane, dir, lines } = requestBody(req);
|
|
224
|
+
if (!isPaneId(pane))
|
|
225
|
+
return res.status(400).json({ error: 'bad pane id' });
|
|
226
|
+
if (dir !== 'up' && dir !== 'down')
|
|
227
|
+
return res.status(400).json({ error: 'bad dir' });
|
|
228
|
+
try {
|
|
229
|
+
const { altScreen, mouseAware, mouseSgr, width, height } = await commands.paneInfo(pane);
|
|
230
|
+
if (!altScreen || !mouseAware)
|
|
231
|
+
return res.json({ ok: false, reason: 'no-mouse' });
|
|
232
|
+
await commands.sendWheel(pane, dir, Number(lines), {
|
|
233
|
+
sgr: mouseSgr,
|
|
234
|
+
col: Math.max(1, Math.floor((width || 2) / 2)),
|
|
235
|
+
row: Math.max(1, Math.floor((height || 2) / 2)),
|
|
236
|
+
});
|
|
237
|
+
return res.json({ ok: true });
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
return next(e);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return r;
|
|
244
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { validateRecoveryMapping } from '../workspace/mapping.js';
|
|
3
|
+
import { isWorkspaceSafeId, parseWorkspaceRestoreRequest, } from '../workspaceProtocol.js';
|
|
4
|
+
const CHANGE_REASONS = new Set(['boot-changed', 'tmux-changed']);
|
|
5
|
+
const OPERATION_STATUSES = new Set(['pending', 'running', 'succeeded', 'partial', 'failed', 'interrupted']);
|
|
6
|
+
const RESULT_STATUSES = new Set(['restored', 'already-present', 'failed']);
|
|
7
|
+
const RESULT_STAGES = new Set(['plan', 'topology', 'agent', 'restore', 'reconcile']);
|
|
8
|
+
const WARNING_CODES = new Set(['cwd-fallback', 'layout-fallback', 'agent-warning', 'live-reconcile-failed', 'workspace-unavailable', 'restore-warning']);
|
|
9
|
+
const ERROR_MESSAGES = {
|
|
10
|
+
'restore-interrupted': 'restore was interrupted; retry the restore',
|
|
11
|
+
'checkpoint-not-found': 'checkpoint is unavailable; choose another checkpoint',
|
|
12
|
+
'storage-full': 'workspace storage is full; free disk space and retry',
|
|
13
|
+
'permission-denied': 'workspace storage is not writable; check permissions and retry',
|
|
14
|
+
'plan-failed': 'restore plan is no longer usable; refresh and retry',
|
|
15
|
+
'agent-unavailable': 'agent resume failed; open the restored shell and retry manually',
|
|
16
|
+
'tmux-unavailable': 'tmux is unavailable; retry the restore',
|
|
17
|
+
'restore-failed': 'workspace restore failed; retry the restore',
|
|
18
|
+
};
|
|
19
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
const record = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
21
|
+
? value
|
|
22
|
+
: null);
|
|
23
|
+
function isCheckpointMissing(error) {
|
|
24
|
+
const details = record(error);
|
|
25
|
+
if (details?.code === 'WORKSPACE_CHECKPOINT_NOT_FOUND' || details?.status === 404)
|
|
26
|
+
return true;
|
|
27
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
28
|
+
return /(?:checkpoint.*(?:missing|not found)|no valid checkpoint)/i.test(message);
|
|
29
|
+
}
|
|
30
|
+
function sendFailure(res, error, { checkpoint = false } = {}) {
|
|
31
|
+
if (checkpoint && isCheckpointMissing(error)) {
|
|
32
|
+
return res.status(404).json({ error: 'checkpoint not found' });
|
|
33
|
+
}
|
|
34
|
+
return res.status(500).json({ error: 'workspace unavailable' });
|
|
35
|
+
}
|
|
36
|
+
function asyncHandler(handler, options = {}) {
|
|
37
|
+
return async (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
await handler(req, res);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
sendFailure(res, error, options);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function checkpointId(value, fallback) {
|
|
47
|
+
const id = value === undefined ? fallback : value;
|
|
48
|
+
return isWorkspaceSafeId(id) ? id : null;
|
|
49
|
+
}
|
|
50
|
+
function timestamp(value) {
|
|
51
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null;
|
|
52
|
+
}
|
|
53
|
+
function count(value) {
|
|
54
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
55
|
+
}
|
|
56
|
+
function displayName(value) {
|
|
57
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 256
|
|
58
|
+
&& !/[\x00-\x1f\x7f]/.test(value) ? value : null;
|
|
59
|
+
}
|
|
60
|
+
function projectCheckpoint(row) {
|
|
61
|
+
const source = record(row);
|
|
62
|
+
const id = isWorkspaceSafeId(source?.id) ? source.id : null;
|
|
63
|
+
const value = record(source?.value);
|
|
64
|
+
if (source?.status !== 'ok' || !value) {
|
|
65
|
+
const corrupt = source?.status === 'corrupt';
|
|
66
|
+
return {
|
|
67
|
+
status: corrupt ? 'corrupt' : 'unavailable',
|
|
68
|
+
id,
|
|
69
|
+
capturedAt: null,
|
|
70
|
+
archivedAt: null,
|
|
71
|
+
sessionCount: 0,
|
|
72
|
+
windowCount: 0,
|
|
73
|
+
paneCount: 0,
|
|
74
|
+
agentCount: 0,
|
|
75
|
+
endedReason: null,
|
|
76
|
+
errorCode: corrupt ? 'checkpoint-corrupt' : 'checkpoint-unavailable',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const sessions = Array.isArray(value.sessions) ? value.sessions : [];
|
|
80
|
+
const windows = Array.isArray(value.windows) ? value.windows : [];
|
|
81
|
+
const panes = windows.flatMap((window) => {
|
|
82
|
+
const item = record(window);
|
|
83
|
+
return Array.isArray(item?.panes) ? item.panes : [];
|
|
84
|
+
});
|
|
85
|
+
const environment = record(value.environment);
|
|
86
|
+
return {
|
|
87
|
+
status: 'ok',
|
|
88
|
+
id,
|
|
89
|
+
capturedAt: timestamp(value.capturedAt),
|
|
90
|
+
archivedAt: timestamp(value.archivedAt),
|
|
91
|
+
sessionCount: sessions.length,
|
|
92
|
+
windowCount: windows.length,
|
|
93
|
+
paneCount: panes.length,
|
|
94
|
+
agentCount: panes.filter((pane) => record(record(pane)?.agent) !== null).length,
|
|
95
|
+
endedReason: typeof environment?.endedReason === 'string' && CHANGE_REASONS.has(environment.endedReason) ? environment.endedReason : null,
|
|
96
|
+
errorCode: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function errorText(error) {
|
|
100
|
+
if (typeof error === 'string')
|
|
101
|
+
return error.toLowerCase();
|
|
102
|
+
if (error instanceof Error)
|
|
103
|
+
return error.message.toLowerCase();
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
function errorCode(error, { stage = null, status = null } = {}) {
|
|
107
|
+
const text = errorText(error);
|
|
108
|
+
if (status === 'interrupted' || /interrupt|process restart/.test(text))
|
|
109
|
+
return 'restore-interrupted';
|
|
110
|
+
if (/enospc|disk full|no space/.test(text))
|
|
111
|
+
return 'storage-full';
|
|
112
|
+
if (/eacces|eperm|permission|not writable/.test(text))
|
|
113
|
+
return 'permission-denied';
|
|
114
|
+
if (/checkpoint.*(?:missing|not found|unavailable)/.test(text))
|
|
115
|
+
return 'checkpoint-not-found';
|
|
116
|
+
if (stage === 'plan' || /restore plan|planning/.test(text))
|
|
117
|
+
return 'plan-failed';
|
|
118
|
+
if (stage === 'agent' || /\b(?:agent|claude|codex)\b|command not found|cli unavailable/.test(text))
|
|
119
|
+
return 'agent-unavailable';
|
|
120
|
+
if (stage === 'topology' || /\btmux\b|topology|server disappeared/.test(text))
|
|
121
|
+
return 'tmux-unavailable';
|
|
122
|
+
if (text || status === 'failed')
|
|
123
|
+
return 'restore-failed';
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
function warningCode(warning) {
|
|
127
|
+
const text = errorText(warning);
|
|
128
|
+
if (/live reconcile|reconcile.*(?:fail|unavailable)/.test(text))
|
|
129
|
+
return 'live-reconcile-failed';
|
|
130
|
+
if (/\blayout\b/.test(text))
|
|
131
|
+
return 'layout-fallback';
|
|
132
|
+
if (/\b(?:agent|claude|codex)\b|command not found|cli unavailable/.test(text))
|
|
133
|
+
return 'agent-warning';
|
|
134
|
+
if (/\bcwd\b|\bdirectory\b|fallback.*home|missing.*path/.test(text))
|
|
135
|
+
return 'cwd-fallback';
|
|
136
|
+
if (/enospc|disk full|no space|eacces|eperm|permission|not writable/.test(text))
|
|
137
|
+
return 'workspace-unavailable';
|
|
138
|
+
return 'restore-warning';
|
|
139
|
+
}
|
|
140
|
+
function projectWarningCodes(value, existing) {
|
|
141
|
+
const codes = [];
|
|
142
|
+
if (Array.isArray(existing)) {
|
|
143
|
+
for (const code of existing) {
|
|
144
|
+
if (typeof code === 'string' && WARNING_CODES.has(code))
|
|
145
|
+
codes.push(code);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (Array.isArray(value))
|
|
149
|
+
for (const warning of value)
|
|
150
|
+
codes.push(warningCode(warning));
|
|
151
|
+
return [...new Set(codes)];
|
|
152
|
+
}
|
|
153
|
+
function projectMapping(value, id) {
|
|
154
|
+
if (!value || !id)
|
|
155
|
+
return null;
|
|
156
|
+
try {
|
|
157
|
+
const mapping = validateRecoveryMapping(value, id);
|
|
158
|
+
const copyEntries = (entries) => Object.fromEntries(Object.entries(entries));
|
|
159
|
+
return {
|
|
160
|
+
id: mapping.id,
|
|
161
|
+
checkpointId: mapping.checkpointId,
|
|
162
|
+
restoredAt: mapping.restoredAt,
|
|
163
|
+
names: copyEntries(mapping.names),
|
|
164
|
+
runtime: {
|
|
165
|
+
sessions: copyEntries(mapping.runtime.sessions),
|
|
166
|
+
windows: copyEntries(mapping.runtime.windows),
|
|
167
|
+
panes: copyEntries(mapping.runtime.panes),
|
|
168
|
+
},
|
|
169
|
+
logical: {
|
|
170
|
+
sessions: copyEntries(mapping.logical.sessions),
|
|
171
|
+
windows: copyEntries(mapping.logical.windows),
|
|
172
|
+
panes: copyEntries(mapping.logical.panes),
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function projectResult(result) {
|
|
181
|
+
const source = record(result);
|
|
182
|
+
const status = typeof source?.status === 'string' && RESULT_STATUSES.has(source.status)
|
|
183
|
+
? source.status : 'failed';
|
|
184
|
+
const stage = typeof source?.stage === 'string' && RESULT_STAGES.has(source.stage)
|
|
185
|
+
? source.stage : null;
|
|
186
|
+
const code = errorCode(source?.error, { stage, status });
|
|
187
|
+
return {
|
|
188
|
+
logicalId: typeof source?.logicalId === 'string' && UUID.test(source.logicalId) ? source.logicalId : null,
|
|
189
|
+
sourceName: displayName(source?.sourceName),
|
|
190
|
+
targetName: displayName(source?.targetName),
|
|
191
|
+
status,
|
|
192
|
+
stage,
|
|
193
|
+
errorCode: code,
|
|
194
|
+
errorMessage: code ? ERROR_MESSAGES[code] : null,
|
|
195
|
+
warningCodes: projectWarningCodes(source?.warnings, source?.warningCodes),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function projectOperation(operation) {
|
|
199
|
+
const source = record(operation);
|
|
200
|
+
const request = record(source?.request) ?? {};
|
|
201
|
+
const id = checkpointId(request.checkpointId, null);
|
|
202
|
+
const status = typeof source?.status === 'string' && OPERATION_STATUSES.has(source.status)
|
|
203
|
+
? source.status : 'failed';
|
|
204
|
+
const code = errorCode(source?.error, { status });
|
|
205
|
+
const progress = record(source?.progress);
|
|
206
|
+
const summary = record(source?.summary);
|
|
207
|
+
return {
|
|
208
|
+
id: isWorkspaceSafeId(source?.id) ? source.id : null,
|
|
209
|
+
status,
|
|
210
|
+
request: {
|
|
211
|
+
checkpointId: id,
|
|
212
|
+
sessions: Array.isArray(request.sessions) ? request.sessions.map(displayName).filter(Boolean) : [],
|
|
213
|
+
historical: request.historical === true,
|
|
214
|
+
},
|
|
215
|
+
createdAt: timestamp(source?.createdAt),
|
|
216
|
+
updatedAt: timestamp(source?.updatedAt),
|
|
217
|
+
startedAt: timestamp(source?.startedAt),
|
|
218
|
+
completedAt: timestamp(source?.completedAt),
|
|
219
|
+
progress: {
|
|
220
|
+
completed: count(progress?.completed),
|
|
221
|
+
total: count(progress?.total),
|
|
222
|
+
},
|
|
223
|
+
summary: {
|
|
224
|
+
sessions: count(summary?.sessions),
|
|
225
|
+
windows: count(summary?.windows),
|
|
226
|
+
panes: count(summary?.panes),
|
|
227
|
+
},
|
|
228
|
+
results: Array.isArray(source?.results) ? source.results.map(projectResult) : [],
|
|
229
|
+
errorCode: code,
|
|
230
|
+
errorMessage: code ? ERROR_MESSAGES[code] : null,
|
|
231
|
+
warningCodes: projectWarningCodes(source?.warnings, source?.warningCodes),
|
|
232
|
+
mapping: projectMapping(source?.mapping, id),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
export function workspaceRoutes({ workspace }) {
|
|
236
|
+
const r = express.Router();
|
|
237
|
+
r.get('/workspace/status', asyncHandler(async (_req, res) => {
|
|
238
|
+
return res.json(await workspace.getProtectionStatus());
|
|
239
|
+
}));
|
|
240
|
+
r.get('/workspace/checkpoints', asyncHandler(async (_req, res) => {
|
|
241
|
+
const checkpoints = await workspace.listCheckpoints();
|
|
242
|
+
return res.json(Array.isArray(checkpoints) ? checkpoints.map(projectCheckpoint) : []);
|
|
243
|
+
}));
|
|
244
|
+
r.get('/workspace/restore-plan', asyncHandler(async (req, res) => {
|
|
245
|
+
const id = checkpointId(req.query.checkpoint, 'latest');
|
|
246
|
+
if (!id)
|
|
247
|
+
return res.status(400).json({ error: 'bad checkpoint id' });
|
|
248
|
+
// serverNow, promptEligible, pending recovery state, and mapping are runtime-authored. The route
|
|
249
|
+
// deliberately passes them through without interpreting expiresAt using a client-supplied clock.
|
|
250
|
+
return res.json(await workspace.getRestorePlan({ checkpointId: id }));
|
|
251
|
+
}, { checkpoint: true }));
|
|
252
|
+
r.post('/workspace/restore', asyncHandler(async (req, res) => {
|
|
253
|
+
const restoreRequest = parseWorkspaceRestoreRequest(req.body);
|
|
254
|
+
if (!restoreRequest)
|
|
255
|
+
return res.status(400).json({ error: 'bad request' });
|
|
256
|
+
return res.status(202).json(await workspace.startRestore(restoreRequest));
|
|
257
|
+
}, { checkpoint: true }));
|
|
258
|
+
r.get('/workspace/restore/:operationId', asyncHandler(async (req, res) => {
|
|
259
|
+
if (!isWorkspaceSafeId(req.params.operationId))
|
|
260
|
+
return res.status(400).json({ error: 'bad operation id' });
|
|
261
|
+
const operation = await workspace.getOperation(req.params.operationId);
|
|
262
|
+
if (!operation)
|
|
263
|
+
return res.status(404).json({ error: 'operation not found' });
|
|
264
|
+
return res.json(projectOperation(operation));
|
|
265
|
+
}));
|
|
266
|
+
return r;
|
|
267
|
+
}
|