openmausbot 0.1.95 → 0.1.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{DesktopViewer-BwWpc4RK.js → DesktopViewer-CPidMCtt.js} +1 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-jsQkIqTn.js → abnfDiagram-O67JEVCF-k1UCNXb7.js} +1 -1
- package/dist/assets/{arc-7kei4Hsu.js → arc-BUWGa9UU.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CRWDFDMq.js → architectureDiagram-NJMV4G6O-DiPor3UT.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CTKW_IKs.js → blockDiagram-BEXU5L5S-Ag9nLW4H.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-BBTW7-6M.js → c4Diagram-YGBWAQC7-Bh0sjgUa.js} +1 -1
- package/dist/assets/channel-DR8eD6QW.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-M_Ox-MkD.js → chunk-2Q5K7J3B-BFBcIIqu.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-D2YiN-Ud.js → chunk-3FUC2YCW-Dvfpex-f.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-BmVYJ0f8.js → chunk-5VM5RSS4-CMjP2YZW.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-BtiLVVnZ.js → chunk-GTNCS2PH-BryoeTc7.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-DFYSb6J6.js → chunk-GWA4HPMP-BhfvcchO.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-mknKBzgV.js → chunk-JWPE2WC7-bcCVp8rp.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-DgSYGiOu.js → chunk-NETBCI7D-yKIjIlkQ.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-C7ZvuguH.js → chunk-WEXAMYUT-D3Zqbmup.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-CPqxINpA.js → chunk-XXDRQBXY-CjVoIMG6.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-Cd6xfJaC.js → classDiagram-v2-NBCMYWYE-mUCWdVyH.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-BSXY5qRh.js → cose-bilkent-JH36ORCC-BBmR1bbX.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-D_lhEMj1.js → cynefin-OW5HDTMX-BpRrxoO4.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Da9FrFwk.js → cynefinDiagram-VND7K2PF-D3VBWCPK.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-T77jMOXe.js → dagre-6A5THRUB-DxEIACOO.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-Dkj96X5E.js → diagram-22UHCM2B-BANgZEOS.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CMi9zlD_.js → diagram-3UASUU5V-Cnq7nJir.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-CXK6YUfS.js → diagram-ATOU4E4O-4TBDIWnz.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-BE7B1u9Q.js → diagram-CDSNMT55-CD_B39D4.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-CCMQphKr.js → diagram-MLGK6HIB-CzvnFhEC.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-iHJ_-tGH.js → diagram-MPIPVDR6-C4w2SNml.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-DONsCWQ7.js → ebnfDiagram-ZINNZB2B-gkUEBm2s.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CU_WOaXk.js → elk-276RUBZZ-B_56X6mw.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BeYwM04G.js → erDiagram-OPXOYQCR-C_oByoDS.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Bdw9DqEl.js → flowDiagram-KWPJA3E3-CR9xXJkC.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-C9iwq3qm.js → ganttDiagram-FUAMR5RP-gtYBTu0B.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Dx-VWzUg.js → gitGraphDiagram-X574FWY7-VJ6-Tp_p.js} +1 -1
- package/dist/assets/{index-KygHRE3E.js → index-Cp4S4NOL.js} +1 -1
- package/dist/assets/index-CpaIXRLp.js +614 -0
- package/dist/assets/index-Z81SMsgR.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-BG7uaEd4.js → infoDiagram-VRGFBTTK-2Lu9te4D.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-D72WAXnM.js → ishikawaDiagram-OU5B5YK6-DR49XY-e.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DKH1gjYT.js → journeyDiagram-ZHPQQLJL-ZoR0M305.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-3upm9JUT.js → kanban-definition-PNTS6WVX-DLKHxrO-.js} +1 -1
- package/dist/assets/{linear-CcR4pc0-.js → linear-BASsfWly.js} +1 -1
- package/dist/assets/{mermaid.core-C6yU5FSS.js → mermaid.core-BVidiPkk.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DOI2xEuv.js → mindmap-definition-NLK3R4M7-g_Qx0U5C.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BVWJSgLd.js → pegDiagram-GJSIUBJH-DI9Cjs8w.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-jtbYezhq.js → pieDiagram-5QR66LMP-sRI0Uhr5.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-g0u8mtPX.js → quadrantDiagram-O4NWA36T-CamKTby3.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-k8689Wny.js → railroadDiagram-XR7U4H2S-DOLlkbf3.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DsJ22PDt.js → requirementDiagram-PLB6GJNP-DrRQ5r97.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CneWBRgG.js → sankeyDiagram-IPEJSGJF-DGS_lXcX.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-DWsFCWap.js → sequenceDiagram-PO4LG4MO-CkjWqwtf.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-Bvf0YE3S.js → sizeCapture-INFHLROL-CepRgful.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-Bxb3Iei8.js → stateDiagram-v2-GCMORJYK-hykr-9RS.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DLkk7W4A.js → swimlanes-2SLR337P-CKOHEZbI.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-D-8vqZ4-.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-KR9bIHYC.js → timeline-definition-EJHVYXUP-CB8pJ6AC.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-DfsOrj8t.js → usecaseDiagram-POWQR4AR-DrclJHdl.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-DRSTzG3h.js → vennDiagram-UO4OBE2U-zS23qjv8.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DtEdhPgT.js → wardleyDiagram-VNRHLVJA-52jgeKxz.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-B-XhTxjG.js → xychartDiagram-PMCCYNJV-DSdl9rQ8.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +26 -13
- package/dist-server/companion/src/listener.js +39 -18
- package/dist-server/container-mcp.js +14 -27
- package/dist-server/drivers/agents-proxy.js +108 -49
- package/dist-server/harness-mcp-proxy.js +1 -2
- package/dist-server/index.js +4664 -4644
- package/dist-server/openmausbot.js +1425 -1437
- package/dist-server/pair-cli.js +1425 -1437
- package/dist-server/permission-proxy.js +0 -4
- package/dist-server/server/admin-activity.js +1 -4
- package/dist-server/server/auto-approve.js +20 -5
- package/dist-server/server/auto-vm-claims.js +13 -1
- package/dist-server/server/automatic-recovery.js +1 -2
- package/dist-server/server/boat.js +71 -87
- package/dist-server/server/bot-overview.js +1 -1
- package/dist-server/server/browser-engine.js +38 -1
- package/dist-server/server/browser-live.js +112 -25
- package/dist-server/server/browser-runtime.js +31 -1
- package/dist-server/server/checkpoints.js +340 -167
- package/dist-server/server/chief-of-staff.js +7 -6
- package/dist-server/server/cli-setup.js +3 -3
- package/dist-server/server/cli.js +33 -26
- package/dist-server/server/cloud-computer-tools.js +11 -4
- package/dist-server/server/cloud-home-start.js +13 -16
- package/dist-server/server/cloud-home.js +3 -13
- package/dist-server/server/cloud-move-http.js +44 -30
- package/dist-server/server/cloud-move.js +20 -18
- package/dist-server/server/cloud-owner.js +1 -1
- package/dist-server/server/composio.js +10 -3
- package/dist-server/server/computer-engine-removal.js +70 -0
- package/dist-server/server/computer-selection.js +18 -0
- package/dist-server/server/config.js +99 -75
- package/dist-server/server/connector-scope-hint.js +83 -0
- package/dist-server/server/container-computer.js +16 -5
- package/dist-server/server/contracts.js +0 -9
- package/dist-server/server/decider/jev.js +1 -4
- package/dist-server/server/decider/log.js +1 -3
- package/dist-server/server/decision-log.js +1 -4
- package/dist-server/server/default-model-selection.js +20 -14
- package/dist-server/server/delegations.js +14 -7
- package/dist-server/server/delta-context.js +6 -1
- package/dist-server/server/drivers/acp/core.js +5 -4
- package/dist-server/server/drivers/acp/opencode-go.js +19 -23
- package/dist-server/server/drivers/agents-call.js +47 -64
- package/dist-server/server/drivers/agents-catalog.js +69 -3
- package/dist-server/server/drivers/agents-proxy.js +2 -1
- package/dist-server/server/drivers/builtIn.js +0 -2
- package/dist-server/server/drivers/claude-login-auth.js +2 -11
- package/dist-server/server/drivers/claude.js +49 -30
- package/dist-server/server/drivers/codex-device-auth.js +3 -1
- package/dist-server/server/drivers/codex.js +7 -6
- package/dist-server/server/drivers/grok.js +1 -1
- package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
- package/dist-server/server/drivers/openai-chat.js +113 -11
- package/dist-server/server/drivers/pi.js +184 -49
- package/dist-server/server/drivers/prompt-split.js +4 -4
- package/dist-server/server/harness/bus.js +49 -0
- package/dist-server/server/harness-mcp-proxy.js +1 -1
- package/dist-server/server/index.js +942 -980
- package/dist-server/server/live-call.js +11 -6
- package/dist-server/server/managed-policy.js +1 -1
- package/dist-server/server/mcp-registry.js +35 -0
- package/dist-server/server/message-file.js +4 -4
- package/dist-server/server/model-catalog/presets.js +8 -0
- package/dist-server/server/peer-delivery.js +4 -19
- package/dist-server/server/peer-roster.js +40 -6
- package/dist-server/server/profile-versions.js +0 -31
- package/dist-server/server/provider-key-check.js +2 -1
- package/dist-server/server/restart.js +40 -0
- package/dist-server/server/room-handoffs.js +12 -0
- package/dist-server/server/room-turn-end.js +15 -0
- package/dist-server/server/routes/bot-memory.js +0 -24
- package/dist-server/server/routes/live.js +3 -3
- package/dist-server/server/routine-requests.js +2 -5
- package/dist-server/server/routines.js +6 -5
- package/dist-server/server/screen-frame-gate.js +1 -3
- package/dist-server/server/server-launcher.js +53 -0
- package/dist-server/server/store.js +126 -2
- package/dist-server/server/surface.js +40 -64
- package/dist-server/server/system-prompt.js +23 -13
- package/dist-server/server/team-manifest.js +1 -6
- package/dist-server/server/tunnel.js +4 -1
- package/dist-server/server/turn-continuation.js +162 -0
- package/dist-server/server/turn-images.js +1 -21
- package/dist-server/server/turn-resources.js +19 -150
- package/dist-server/server/usage-ledger.js +3 -1
- package/dist-server/server/workspace.js +3 -4
- package/dist-server/server-launcher.js +72 -0
- package/dist-server/shared/ask-question.js +1 -71
- package/dist-server/shared/cloud-computer.js +10 -0
- package/dist-server/shared/credential-request.js +1 -1
- package/dist-server/shared/failed-turn.js +22 -0
- package/dist-server/shared/live-approval.js +2 -2
- package/dist-server/shared/package-format.js +3 -3
- package/dist-server/shared/pairing-link.js +144 -0
- package/dist-server/shared/place-view.js +409 -0
- package/dist-server/shared/tool-scope-support.js +1 -2
- package/dist-server/vps-container-mcp.js +14 -27
- package/dist-server/workspace-backup.worker.js +14 -27
- package/package.json +1 -1
- package/dist/assets/channel-DS2NbT5x.js +0 -1
- package/dist/assets/index-NF7XCHAy.css +0 -1
- package/dist/assets/index-X_5ydXIE.js +0 -610
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +0 -8
- package/dist-server/server/claim-idle.js +0 -33
- package/dist-server/server/cloud-overflow.js +0 -167
- package/dist-server/server/drivers/boatagent.js +0 -443
- package/dist-server/server/tightening-requests.js +0 -435
- package/dist-server/shared/tightening-request.js +0 -87
|
@@ -2,43 +2,43 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Before a turn's engine can touch files, the working folder is snapshotted
|
|
4
4
|
// into a shadow repo at DATA_DIR/checkpoints/<botId>/<sha256(cwd)[..16]>/.git.
|
|
5
|
+
// When the turn settles, its digest diffs the folder against that snapshot to
|
|
6
|
+
// list the files the turn changed, added and deleted.
|
|
5
7
|
// The user's own .git (if the folder is a repository) is never read, written,
|
|
6
8
|
// or locked: every git call runs with GIT_DIR pointing at the shadow repo and
|
|
7
9
|
// GIT_WORK_TREE pointing at the folder, so the shadow index is the only index
|
|
8
10
|
// involved — and git always skips directories named .git when walking a work
|
|
9
11
|
// tree, so the user's repository internals are invisible to the snapshot.
|
|
10
12
|
//
|
|
11
|
-
//
|
|
12
|
-
// the index and work tree back with `git restore --source` + `git clean -fd`
|
|
13
|
-
// while HEAD keeps the safety point — so the state just replaced is the one
|
|
14
|
-
// retained checkpoint, and "undo the undo" is one more restore. Ignored/excluded files
|
|
15
|
-
// (node_modules, .env, media) are never snapshotted and never removed by one.
|
|
13
|
+
// Ignored/excluded files (node_modules, .env, media) are never snapshotted.
|
|
16
14
|
//
|
|
17
15
|
// Failure policy: checkpointing is best-effort convenience, never load-bearing.
|
|
18
16
|
// Any git failure disables the feature for that bot for the rest of the
|
|
19
|
-
// session and logs — nothing here ever throws into the turn path.
|
|
17
|
+
// session and logs — nothing here ever throws into the turn path. A git call
|
|
18
|
+
// that hits the timeout disables only that bot+folder, so a slow folder costs
|
|
19
|
+
// one wait and one log line, not one wait per turn.
|
|
20
20
|
//
|
|
21
21
|
// Adapted from the checkpoint designs of Cline, Roo-Code, and Gemini CLI
|
|
22
22
|
// (all Apache-2.0): the per-call GIT_DIR/GIT_WORK_TREE/GIT_CONFIG_* env
|
|
23
|
-
// override
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// override follows Gemini CLI's gitService, the sanitized GIT_* env list and
|
|
24
|
+
// the exclude categories follow Roo-Code's checkpoint service, and the
|
|
25
|
+
// snapshot-before-every-turn cadence follows Cline.
|
|
26
26
|
import { execFile } from "node:child_process";
|
|
27
27
|
import { createHash } from "node:crypto";
|
|
28
28
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { readdir } from "node:fs/promises";
|
|
29
30
|
import { homedir } from "node:os";
|
|
30
31
|
import { isAbsolute, join, parse, resolve } from "node:path";
|
|
31
32
|
import { DATA_DIR } from "./config.js";
|
|
32
33
|
export const CHECKPOINTS_DIR = join(DATA_DIR, "checkpoints");
|
|
33
|
-
/** Full sha1 hex only
|
|
34
|
-
*
|
|
34
|
+
/** Full sha1 hex only: accepting anything looser would let arbitrary
|
|
35
|
+
* revspecs ("HEAD~3", "main@{u}") reach git. */
|
|
35
36
|
const COMMIT_HASH = /^[0-9a-f]{40}$/;
|
|
36
|
-
// What a checkpoint deliberately does not carry
|
|
37
|
-
// these either
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
const EXCLUDES = `# OpenMausBot checkpoint excludes — never snapshotted, never removed by restore
|
|
37
|
+
// What a checkpoint deliberately does not carry, so a digest never lists
|
|
38
|
+
// these either. Categories follow Roo-Code's checkpoint excludes: VCS
|
|
39
|
+
// internals, dependency trees, build output, caches, logs, secrets, media,
|
|
40
|
+
// archives, databases, model weights.
|
|
41
|
+
const EXCLUDES = `# OpenMausBot checkpoint excludes — never snapshotted
|
|
42
42
|
.git/
|
|
43
43
|
.svn/
|
|
44
44
|
.hg/
|
|
@@ -120,9 +120,20 @@ Thumbs.db
|
|
|
120
120
|
`;
|
|
121
121
|
// gpgsign off: the user's global config may demand signing, and a shadow
|
|
122
122
|
// commit must never block on a passphrase prompt. Automatic GC stays off:
|
|
123
|
-
// explicit GC runs
|
|
124
|
-
//
|
|
123
|
+
// explicit GC runs in the same per-folder queue as snapshots and diffs, on
|
|
124
|
+
// the throttle below, after the objects it drops are no longer needed.
|
|
125
125
|
const GITCONFIG = "[commit]\n\tgpgsign = false\n[core]\n\tautocrlf = false\n[gc]\n\tauto = 0\n";
|
|
126
|
+
/** Hard ceiling on one git call. A hung git (index lock, dead network
|
|
127
|
+
* filesystem, cloud placeholders being downloaded) would otherwise jam the
|
|
128
|
+
* per-repo queue for the whole session. */
|
|
129
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
130
|
+
// The snapshot is awaited before every turn's dispatch, so GC does not run on
|
|
131
|
+
// every snapshot: collect after this many HEAD-advancing snapshots of one
|
|
132
|
+
// folder, or sooner when loose objects (git's own gc.auto default) or new
|
|
133
|
+
// object data pile up. Counters are per process and reset on restart.
|
|
134
|
+
export const GC_EVERY_COMMITS = 25;
|
|
135
|
+
const GC_LOOSE_OBJECTS = 6_700;
|
|
136
|
+
const GC_NEW_DATA_KIB = 262_144;
|
|
126
137
|
/** One failed git call disables checkpoints for that bot until restart —
|
|
127
138
|
* a broken shadow repo must cost the user one log line, not a failed turn. */
|
|
128
139
|
const disabledBots = new Set();
|
|
@@ -130,6 +141,30 @@ function disable(botId, message) {
|
|
|
130
141
|
disabledBots.add(botId);
|
|
131
142
|
console.warn(`workspace checkpoints disabled for bot ${botId} this session: ${message}`);
|
|
132
143
|
}
|
|
144
|
+
/** A git call that hit GIT_TIMEOUT_MS (not a cancellation). The folder is
|
|
145
|
+
* too slow to snapshot — waiting that long again on every turn is worse than
|
|
146
|
+
* having no checkpoint, so only that bot+folder is switched off. */
|
|
147
|
+
class GitTimeoutError extends Error {
|
|
148
|
+
}
|
|
149
|
+
/** Bot+folder pairs (keyed by shadow dir) switched off after a timeout. */
|
|
150
|
+
const disabledFolders = new Set();
|
|
151
|
+
function disableFolder(shadow, botId, worktree, message) {
|
|
152
|
+
if (disabledFolders.has(shadow))
|
|
153
|
+
return;
|
|
154
|
+
disabledFolders.add(shadow);
|
|
155
|
+
console.warn(`workspace checkpoints disabled for bot ${botId} in ${worktree} this session: ${message}`);
|
|
156
|
+
}
|
|
157
|
+
/** The shadow dir doubles as the bot+folder key. Null when the folder cannot
|
|
158
|
+
* be resolved (the refusal check reports that case). */
|
|
159
|
+
function folderShadow(botId, cwd) {
|
|
160
|
+
try {
|
|
161
|
+
const worktree = realpathSync(resolve(cwd));
|
|
162
|
+
return { worktree, shadow: shadowDir(botId, worktree) };
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
133
168
|
// probed once: either the system has a usable git or checkpoints stay off
|
|
134
169
|
let gitProbe = null;
|
|
135
170
|
function gitAvailable() {
|
|
@@ -140,8 +175,7 @@ function gitAvailable() {
|
|
|
140
175
|
}
|
|
141
176
|
/** Folders a checkpoint must never be taken in: missing paths, the sprawling
|
|
142
177
|
* personal folders (home, Desktop, Documents, Downloads), and the filesystem
|
|
143
|
-
* root — snapshotting those would trawl unbounded personal data into a repo
|
|
144
|
-
* and a restore's `git clean -fd` there would be an act of vandalism. */
|
|
178
|
+
* root — snapshotting those would trawl unbounded personal data into a repo. */
|
|
145
179
|
export function refusalReason(cwd) {
|
|
146
180
|
if (!isAbsolute(cwd))
|
|
147
181
|
return "the working folder must be an absolute path";
|
|
@@ -161,7 +195,7 @@ export function refusalReason(cwd) {
|
|
|
161
195
|
// the refusal while git still follows the symlink into the protected tree.
|
|
162
196
|
// The native realpath also settles Windows' spellings of one folder —
|
|
163
197
|
// c:\users\me, C:\Users\me\DOCUME~1 — and names compare case-insensitively
|
|
164
|
-
// there
|
|
198
|
+
// there.
|
|
165
199
|
const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
166
200
|
if (dir === parse(dir).root)
|
|
167
201
|
return "checkpoints are not taken at the filesystem root";
|
|
@@ -202,16 +236,23 @@ function gitEnv(shadow, cwd) {
|
|
|
202
236
|
env.GIT_COMMITTER_EMAIL = "checkpoint@openmausbot.local";
|
|
203
237
|
return env;
|
|
204
238
|
}
|
|
239
|
+
/** execFile reports its own timeout as a killed child; a cancellation via
|
|
240
|
+
* `signal` arrives as an AbortError instead and is not a timeout. */
|
|
241
|
+
function gitFailure(command, err, stderr, signal) {
|
|
242
|
+
if (err.killed && err.name !== "AbortError" && !signal?.aborted) {
|
|
243
|
+
return new GitTimeoutError(`git ${command} timed out after ${GIT_TIMEOUT_MS / 1000} s`);
|
|
244
|
+
}
|
|
245
|
+
return new Error(`git ${command}: ${(stderr || err.message).trim().slice(0, 400)}`);
|
|
246
|
+
}
|
|
205
247
|
/** Run one git command against the shadow repo. cwd is the WORK TREE — the
|
|
206
|
-
* "." pathspec in add
|
|
207
|
-
*
|
|
208
|
-
* the whole session, so every call carries a hard timeout. */
|
|
248
|
+
* "." pathspec in add resolves relative to it. Every call carries the
|
|
249
|
+
* GIT_TIMEOUT_MS hard timeout. */
|
|
209
250
|
function runGit(args, cwd, env, signal) {
|
|
210
251
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
211
252
|
signal?.throwIfAborted();
|
|
212
|
-
execFile("git", args, { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout:
|
|
253
|
+
execFile("git", args, { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
213
254
|
if (err)
|
|
214
|
-
rejectPromise(
|
|
255
|
+
rejectPromise(gitFailure(args[0], err, stderr, signal));
|
|
215
256
|
else
|
|
216
257
|
resolvePromise(stdout);
|
|
217
258
|
});
|
|
@@ -224,20 +265,20 @@ function runGit(args, cwd, env, signal) {
|
|
|
224
265
|
function hasStagedChanges(cwd, env, signal) {
|
|
225
266
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
226
267
|
signal?.throwIfAborted();
|
|
227
|
-
execFile("git", ["diff", "--cached", "--quiet", "--ignore-submodules=dirty"], { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout:
|
|
268
|
+
execFile("git", ["diff", "--cached", "--quiet", "--ignore-submodules=dirty"], { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
|
228
269
|
if (err === null)
|
|
229
270
|
resolvePromise(false);
|
|
230
271
|
else if (err.code === 1)
|
|
231
272
|
resolvePromise(true);
|
|
232
273
|
else
|
|
233
|
-
rejectPromise(
|
|
274
|
+
rejectPromise(gitFailure("diff", err, stderr, signal));
|
|
234
275
|
});
|
|
235
276
|
});
|
|
236
277
|
}
|
|
237
|
-
// One operation at a time per shadow repo: snapshots and
|
|
238
|
-
// same folder queue behind each other (git's index lock would
|
|
239
|
-
// anyway — this turns a crash into a wait). The stored tail
|
|
240
|
-
// one failed operation can't poison the queue.
|
|
278
|
+
// One operation at a time per shadow repo: snapshots, diffs and pin releases
|
|
279
|
+
// against the same folder queue behind each other (git's index lock would
|
|
280
|
+
// fail the loser anyway — this turns a crash into a wait). The stored tail
|
|
281
|
+
// never rejects, so one failed operation can't poison the queue.
|
|
241
282
|
const chains = new Map();
|
|
242
283
|
function serialize(key, fn) {
|
|
243
284
|
const tail = chains.get(key) ?? Promise.resolve();
|
|
@@ -245,12 +286,36 @@ function serialize(key, fn) {
|
|
|
245
286
|
chains.set(key, run.then(() => undefined, () => undefined));
|
|
246
287
|
return run;
|
|
247
288
|
}
|
|
289
|
+
// A ref per running turn. Every snapshot parents only the empty base and a
|
|
290
|
+
// collection prunes whatever HEAD no longer reaches — so when a bot's threads
|
|
291
|
+
// work side by side in one folder, a collection after a sibling's snapshot
|
|
292
|
+
// would discard the pre-turn commit a still-running turn diffs against at its
|
|
293
|
+
// end. A pin keeps that commit reachable until release(). The pin's name is
|
|
294
|
+
// hashed so any token is a legal ref name.
|
|
295
|
+
const LIVE_REFS = "refs/omb-live/";
|
|
296
|
+
function liveRef(pin) {
|
|
297
|
+
return LIVE_REFS + createHash("sha256").update(pin).digest("hex").slice(0, 16);
|
|
298
|
+
}
|
|
299
|
+
// Pins outlive the process that wrote them (a crash mid-turn), so the first
|
|
300
|
+
// use of each shadow in this process drops every pin left behind: no turn of
|
|
301
|
+
// ours can be live in a shadow this process has not touched yet. Dropping one
|
|
302
|
+
// makes the next snapshot's collection due (see collectionDue).
|
|
303
|
+
const sweptShadows = new Set();
|
|
304
|
+
const droppedPins = new Set();
|
|
305
|
+
async function sweepLiveRefs(cwd, env, shadow, signal) {
|
|
306
|
+
if (sweptShadows.has(shadow))
|
|
307
|
+
return;
|
|
308
|
+
const stale = (await runGit(["for-each-ref", "--format=%(refname)", LIVE_REFS], cwd, env, signal)).split("\n").filter(Boolean);
|
|
309
|
+
for (const ref of stale)
|
|
310
|
+
await runGit(["update-ref", "-d", ref], cwd, env, signal);
|
|
311
|
+
if (stale.length > 0)
|
|
312
|
+
droppedPins.add(shadow);
|
|
313
|
+
sweptShadows.add(shadow);
|
|
314
|
+
}
|
|
248
315
|
/** Create the shadow repo on first use; self-heal its config files on every
|
|
249
316
|
* use (they are tiny, and rewriting them lets exclude-list updates reach
|
|
250
317
|
* shadows that already exist). The base commit is an EMPTY marker so HEAD
|
|
251
|
-
* always resolves
|
|
252
|
-
* target, because "restore to empty" on a user's own project folder would
|
|
253
|
-
* delete their files. */
|
|
318
|
+
* always resolves; every snapshot parents only it. */
|
|
254
319
|
async function ensureShadow(cwd, env, shadow, signal) {
|
|
255
320
|
signal?.throwIfAborted();
|
|
256
321
|
mkdirSync(shadow, { recursive: true, mode: 0o700 });
|
|
@@ -291,49 +356,212 @@ async function ensureShadow(cwd, env, shadow, signal) {
|
|
|
291
356
|
// brand-new repo (or a crash between init and first commit)
|
|
292
357
|
await runGit(["commit", "--no-verify", "--allow-empty", "-m", "checkpoint base"], cwd, env, signal);
|
|
293
358
|
}
|
|
359
|
+
await sweepLiveRefs(cwd, env, shadow, signal);
|
|
360
|
+
}
|
|
361
|
+
/** Folder listings in flight at once (libuv's pool has four threads). */
|
|
362
|
+
const LISTING_CONCURRENCY = 4;
|
|
363
|
+
/** Case-insensitive file systems only (APFS/HFS+ as macOS formats them,
|
|
364
|
+
* NTFS): does a staged path, or one of its folders, now exist on disk under a
|
|
365
|
+
* different case? git init sets core.ignorecase there, so an incremental
|
|
366
|
+
* `add -A` matches `R0` to the staged `r0` and keeps the old spelling
|
|
367
|
+
* forever. Reads folder listings only — each folder holding a staged path
|
|
368
|
+
* once, asynchronously, so a big tree never stalls the event loop — never
|
|
369
|
+
* file contents. Names compare in NFC: git stores precomposed paths on macOS,
|
|
370
|
+
* readdir returns names as they were created. */
|
|
371
|
+
async function caseRenamed(cwd, paths) {
|
|
372
|
+
if (paths.length === 0)
|
|
373
|
+
return false;
|
|
374
|
+
const pending = new Set([""]);
|
|
375
|
+
for (const path of paths) {
|
|
376
|
+
for (let slash = path.indexOf("/"); slash > 0; slash = path.indexOf("/", slash + 1))
|
|
377
|
+
pending.add(path.slice(0, slash));
|
|
378
|
+
}
|
|
379
|
+
const listings = new Map();
|
|
380
|
+
const queue = [...pending];
|
|
381
|
+
const read = async () => {
|
|
382
|
+
for (let dir = queue.pop(); dir !== undefined; dir = queue.pop()) {
|
|
383
|
+
try {
|
|
384
|
+
const exact = new Set((await readdir(join(cwd, dir))).map((name) => name.normalize("NFC")));
|
|
385
|
+
listings.set(dir, { exact, folded: new Set([...exact].map((name) => name.toLowerCase())) });
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
listings.set(dir, null); // gone or unreadable: add -A handles deletions itself
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
await Promise.all(Array.from({ length: Math.min(LISTING_CONCURRENCY, queue.length) }, read));
|
|
393
|
+
const folders = new Map();
|
|
394
|
+
const renamed = (path, isFolder) => {
|
|
395
|
+
const known = isFolder ? folders.get(path) : undefined;
|
|
396
|
+
if (known !== undefined)
|
|
397
|
+
return known;
|
|
398
|
+
const slash = path.lastIndexOf("/");
|
|
399
|
+
const parent = slash < 0 ? "" : path.slice(0, slash);
|
|
400
|
+
const name = path.slice(slash + 1).normalize("NFC");
|
|
401
|
+
const names = listings.get(parent) ?? null;
|
|
402
|
+
let verdict;
|
|
403
|
+
if (names === null)
|
|
404
|
+
verdict = false;
|
|
405
|
+
else if (!names.exact.has(name))
|
|
406
|
+
verdict = names.folded.has(name.toLowerCase()); // else simply deleted
|
|
407
|
+
else
|
|
408
|
+
verdict = parent !== "" && renamed(parent, true);
|
|
409
|
+
if (isFolder)
|
|
410
|
+
folders.set(path, verdict);
|
|
411
|
+
return verdict;
|
|
412
|
+
};
|
|
413
|
+
return paths.some((path) => renamed(path, false));
|
|
294
414
|
}
|
|
295
|
-
/**
|
|
296
|
-
*
|
|
415
|
+
/** Would an incremental `git add -A` leave the shadow index different from
|
|
416
|
+
* one rebuilt from empty? Only in four cases, all detected here: a tracked
|
|
417
|
+
* path is now ignored or inside a cache-tagged directory (add -A never drops
|
|
418
|
+
* tracked files), a folder holding tracked files became a nested repository
|
|
419
|
+
* (add -A keeps the files instead of a gitlink), a recorded nested
|
|
420
|
+
* repository lost its .git (add -A keeps the stale gitlink), or — on a
|
|
421
|
+
* case-insensitive file system — a tracked path was renamed by case only
|
|
422
|
+
* (add -A keeps the old spelling). */
|
|
423
|
+
async function indexNeedsRebuild(cwd, env, signal) {
|
|
424
|
+
// If the index cannot be inspected (say, a listing past maxBuffer on a huge
|
|
425
|
+
// folder), rebuild: that is always correct, just slower.
|
|
426
|
+
const list = (args) => runGit(args, cwd, env, signal).catch((e) => {
|
|
427
|
+
if (e instanceof GitTimeoutError || signal?.aborted)
|
|
428
|
+
throw e;
|
|
429
|
+
return null;
|
|
430
|
+
});
|
|
431
|
+
const ignored = await list(["ls-files", "--cached", "--ignored", "--exclude-standard", "-z"]);
|
|
432
|
+
if (ignored === null || ignored.length > 0)
|
|
433
|
+
return true;
|
|
434
|
+
const staged = await list(["ls-files", "--stage", "-z"]);
|
|
435
|
+
if (staged === null)
|
|
436
|
+
return true;
|
|
437
|
+
const paths = [];
|
|
438
|
+
const parents = new Set();
|
|
439
|
+
for (const entry of staged.split("\0")) {
|
|
440
|
+
const tab = entry.indexOf("\t");
|
|
441
|
+
if (tab < 0)
|
|
442
|
+
continue;
|
|
443
|
+
const path = entry.slice(tab + 1);
|
|
444
|
+
paths.push(path);
|
|
445
|
+
if (entry.startsWith("160000 ")) {
|
|
446
|
+
if (!existsSync(join(cwd, path, ".git")))
|
|
447
|
+
return true;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
for (let slash = path.indexOf("/"); slash > 0; slash = path.indexOf("/", slash + 1))
|
|
451
|
+
parents.add(path.slice(0, slash));
|
|
452
|
+
}
|
|
453
|
+
for (const dir of parents)
|
|
454
|
+
if (existsSync(join(cwd, dir, ".git")))
|
|
455
|
+
return true;
|
|
456
|
+
return (process.platform === "darwin" || process.platform === "win32") && (await caseRenamed(cwd, paths));
|
|
457
|
+
}
|
|
458
|
+
/** Stage the work tree into the shadow index. The index persists between
|
|
459
|
+
* snapshots so `git add -A` only re-reads files whose stat data changed;
|
|
460
|
+
* rebuilding it from empty re-reads and re-hashes every file (and downloads
|
|
461
|
+
* every cloud placeholder). It is rebuilt only when indexNeedsRebuild says an
|
|
462
|
+
* incremental add would differ, so the staged content always matches a
|
|
463
|
+
* from-empty rebuild. Only the shadow index changes; no user index or
|
|
464
|
+
* work-tree file does. */
|
|
465
|
+
async function stageWorkTree(cwd, env, signal) {
|
|
466
|
+
if (await indexNeedsRebuild(cwd, env, signal))
|
|
467
|
+
await runGit(["read-tree", "--empty"], cwd, env, signal);
|
|
468
|
+
await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal);
|
|
469
|
+
}
|
|
470
|
+
/** Capture fully before advancing HEAD: null when the add was partial, so a
|
|
471
|
+
* partial capture never replaces the last usable checkpoint or authorizes
|
|
472
|
+
* removal of its objects. */
|
|
297
473
|
async function commitAll(cwd, env, label, signal) {
|
|
298
474
|
const previous = (await runGit(["rev-parse", "HEAD"], cwd, env, signal)).trim();
|
|
299
475
|
try {
|
|
300
|
-
|
|
301
|
-
// pinning old blobs; no user index or work-tree file is changed.
|
|
302
|
-
await runGit(["read-tree", "--empty"], cwd, env, signal);
|
|
303
|
-
await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal);
|
|
476
|
+
await stageWorkTree(cwd, env, signal);
|
|
304
477
|
}
|
|
305
|
-
catch {
|
|
306
|
-
|
|
478
|
+
catch (e) {
|
|
479
|
+
// A timeout is not an unreadable file: the caller switches the folder off.
|
|
480
|
+
if (e instanceof GitTimeoutError)
|
|
481
|
+
throw e;
|
|
482
|
+
return null;
|
|
307
483
|
}
|
|
308
484
|
const changed = await hasStagedChanges(cwd, env, signal);
|
|
309
485
|
const base = (await runGit(["rev-list", "--max-parents=0", "HEAD"], cwd, env, signal)).trim();
|
|
310
486
|
const count = (await runGit(["rev-list", "--count", "HEAD"], cwd, env, signal)).trim();
|
|
311
487
|
if (!changed && Number(count) <= 2)
|
|
312
|
-
return { hash: previous,
|
|
488
|
+
return { hash: previous, advanced: false, compacted: false };
|
|
313
489
|
const tree = (await runGit(["write-tree"], cwd, env, signal)).trim();
|
|
314
490
|
const message = changed ? label : (await runGit(["show", "-s", "--format=%B", "HEAD"], cwd, env, signal)).trim();
|
|
315
491
|
// Parent only the empty marker, never the previous snapshot: keeping the
|
|
316
492
|
// previous commit as an ancestor would retain every old tree indefinitely.
|
|
317
493
|
const hash = (await runGit(["commit-tree", tree, "-p", base, "-m", message], cwd, env, signal)).trim();
|
|
318
494
|
await runGit(["update-ref", "HEAD", hash, previous], cwd, env, signal);
|
|
319
|
-
return { hash,
|
|
495
|
+
return { hash, advanced: true, compacted: Number(count) > 2 };
|
|
496
|
+
}
|
|
497
|
+
/** HEAD-advancing snapshots per shadow since this process last collected. */
|
|
498
|
+
const commitsSinceGc = new Map();
|
|
499
|
+
/** Pack size (KiB) first counted after the last collection — the live
|
|
500
|
+
* snapshot. Packs also grow between collections: `git add` streams files over
|
|
501
|
+
* core.bigFileThreshold straight into new packs. */
|
|
502
|
+
const packBaselineKib = new Map();
|
|
503
|
+
/** A collection the throttle asked for, held until that turn's settled diff
|
|
504
|
+
* has been queued, or — when the turn settled without one — until the next
|
|
505
|
+
* snapshot, which runs it first (see snapshot and diffWorkingTree). */
|
|
506
|
+
const pendingCollections = new Map();
|
|
507
|
+
/** Queue the folder's pending collection behind whatever was just queued for
|
|
508
|
+
* it. Nothing awaits it. */
|
|
509
|
+
function releaseCollection(shadow) {
|
|
510
|
+
const collect = pendingCollections.get(shadow);
|
|
511
|
+
if (collect === undefined)
|
|
512
|
+
return;
|
|
513
|
+
pendingCollections.delete(shadow);
|
|
514
|
+
void serialize(shadow, collect);
|
|
515
|
+
}
|
|
516
|
+
/** Throttle for collectObsolete (see GC_EVERY_COMMITS). */
|
|
517
|
+
async function collectionDue(shadow, result, cwd, env, signal) {
|
|
518
|
+
// A compaction or a swept leftover pin frees a whole tree at once: reclaim it now.
|
|
519
|
+
const freed = droppedPins.delete(shadow) || result.compacted;
|
|
520
|
+
if (!result.advanced && !freed)
|
|
521
|
+
return false;
|
|
522
|
+
const commits = (commitsSinceGc.get(shadow) ?? 0) + (result.advanced ? 1 : 0);
|
|
523
|
+
commitsSinceGc.set(shadow, commits);
|
|
524
|
+
let due = freed || commits >= GC_EVERY_COMMITS;
|
|
525
|
+
if (!due) {
|
|
526
|
+
// Advisory only: a failed count must not cost the snapshot just taken.
|
|
527
|
+
const counted = await runGit(["count-objects", "-v"], cwd, env, signal).catch(() => "");
|
|
528
|
+
if (counted !== "") {
|
|
529
|
+
const field = (name) => Number(new RegExp(`^${name}: (\\d+)$`, "m").exec(counted)?.[1] ?? 0);
|
|
530
|
+
const pack = field("size-pack");
|
|
531
|
+
if (!packBaselineKib.has(shadow))
|
|
532
|
+
packBaselineKib.set(shadow, pack);
|
|
533
|
+
// Every loose object is new since the last collection (gc packs or
|
|
534
|
+
// prunes them all). Only pack growth counts, so a folder whose live
|
|
535
|
+
// snapshot alone passes the ceiling does not collect every turn.
|
|
536
|
+
const newData = field("size") + Math.max(0, pack - packBaselineKib.get(shadow));
|
|
537
|
+
due = field("count") >= GC_LOOSE_OBJECTS || newData >= GC_NEW_DATA_KIB;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (due) {
|
|
541
|
+
commitsSinceGc.set(shadow, 0);
|
|
542
|
+
packBaselineKib.delete(shadow);
|
|
543
|
+
}
|
|
544
|
+
return due;
|
|
320
545
|
}
|
|
321
546
|
async function collectObsolete(cwd, env) {
|
|
322
|
-
// No caller may still need
|
|
323
|
-
//
|
|
547
|
+
// No caller may still need an unpinned previous tree: HEAD stays reachable,
|
|
548
|
+
// and so does every commit a live turn still pins.
|
|
324
549
|
try {
|
|
325
550
|
await runGit(["reflog", "expire", "--expire=now", "--all"], cwd, env);
|
|
326
551
|
await runGit(["gc", "--prune=now", "--quiet"], cwd, env);
|
|
327
552
|
}
|
|
328
553
|
catch {
|
|
329
|
-
// A busy/full disk must not hide an otherwise usable
|
|
330
|
-
console.warn("checkpoint cleanup deferred; the latest
|
|
554
|
+
// A busy/full disk must not hide an otherwise usable snapshot.
|
|
555
|
+
console.warn("checkpoint cleanup deferred; the latest snapshot is preserved");
|
|
331
556
|
}
|
|
332
557
|
}
|
|
333
558
|
/** Snapshot the folder. Returns the checkpoint hash, or null when the
|
|
334
|
-
* feature is off for this bot, git is missing,
|
|
335
|
-
*
|
|
336
|
-
|
|
559
|
+
* feature is off for this bot or folder, git is missing, the folder is
|
|
560
|
+
* refused, or a file could not be read.
|
|
561
|
+
* With `pin`, the returned commit stays reachable through later snapshots of
|
|
562
|
+
* the same folder until release(pin) — for a turn that will diff against it
|
|
563
|
+
* when it ends. Never throws — this is called fire-and-forget on the turn path. */
|
|
564
|
+
export async function snapshot(botId, cwd, label, signal, opts) {
|
|
337
565
|
if (signal?.aborted)
|
|
338
566
|
return null;
|
|
339
567
|
if (disabledBots.has(botId))
|
|
@@ -342,40 +570,80 @@ export async function snapshot(botId, cwd, label, signal) {
|
|
|
342
570
|
return null;
|
|
343
571
|
if (refusalReason(cwd) !== null)
|
|
344
572
|
return null;
|
|
573
|
+
const folder = folderShadow(botId, cwd);
|
|
574
|
+
if (folder === null || disabledFolders.has(folder.shadow))
|
|
575
|
+
return null;
|
|
576
|
+
const { worktree, shadow } = folder;
|
|
345
577
|
try {
|
|
346
|
-
|
|
347
|
-
|
|
578
|
+
// Still pending from the previous snapshot: that turn settled without a
|
|
579
|
+
// diff. Collect ahead of this snapshot, not behind it, so this turn's own
|
|
580
|
+
// diff never queues behind GC and the object counts below see the
|
|
581
|
+
// collected repo (a stale count would make GC due again at once).
|
|
582
|
+
releaseCollection(shadow);
|
|
348
583
|
return await serialize(shadow, async () => {
|
|
349
584
|
const env = gitEnv(shadow, worktree);
|
|
350
585
|
await ensureShadow(worktree, env, shadow, signal);
|
|
351
586
|
const result = await commitAll(worktree, env, label, signal);
|
|
352
|
-
if (!result
|
|
587
|
+
if (!result)
|
|
353
588
|
return null;
|
|
354
|
-
|
|
589
|
+
if (opts?.pin)
|
|
590
|
+
await runGit(["update-ref", liveRef(opts.pin), result.hash], worktree, env, signal);
|
|
591
|
+
// Never awaited by the turn waiting on this snapshot, and held for that
|
|
592
|
+
// turn's settled diff: diffWorkingTree queues it behind itself, so GC
|
|
593
|
+
// never eats into the digest's capture window.
|
|
594
|
+
if (await collectionDue(shadow, result, worktree, env, signal)) {
|
|
595
|
+
pendingCollections.set(shadow, () => collectObsolete(worktree, env));
|
|
596
|
+
}
|
|
355
597
|
return result.hash;
|
|
356
598
|
});
|
|
357
599
|
}
|
|
358
600
|
catch (e) {
|
|
359
|
-
if (
|
|
360
|
-
|
|
601
|
+
if (signal?.aborted)
|
|
602
|
+
return null;
|
|
603
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
604
|
+
if (e instanceof GitTimeoutError)
|
|
605
|
+
disableFolder(shadow, botId, worktree, message);
|
|
606
|
+
else
|
|
607
|
+
disable(botId, message);
|
|
361
608
|
return null;
|
|
362
609
|
}
|
|
363
610
|
}
|
|
364
|
-
/**
|
|
611
|
+
/** Let go of a pinned pre-turn commit: the next collection may reclaim it.
|
|
612
|
+
* Queued behind the shadow's pending operations, so a digest's diff already
|
|
613
|
+
* in that queue still finds its commit. Best-effort like the
|
|
614
|
+
* rest of this module — a pin that cannot be dropped costs one log line and
|
|
615
|
+
* is swept on the next start. */
|
|
616
|
+
export async function release(botId, cwd, pin) {
|
|
617
|
+
if (!(await gitAvailable()))
|
|
618
|
+
return;
|
|
619
|
+
try {
|
|
620
|
+
const worktree = realpathSync(resolve(cwd));
|
|
621
|
+
const shadow = shadowDir(botId, worktree);
|
|
622
|
+
if (!existsSync(join(shadow, ".git", "HEAD")))
|
|
623
|
+
return;
|
|
624
|
+
const env = gitEnv(shadow, worktree);
|
|
625
|
+
await serialize(shadow, () => runGit(["update-ref", "-d", liveRef(pin)], worktree, env));
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
console.warn(`checkpoint pin not released for bot ${botId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/** Diff the settled workspace without replacing its pre-turn snapshot.
|
|
365
632
|
* Staging is confined to the shadow index; the user's files and Git are untouched. */
|
|
366
633
|
export async function diffWorkingTree(botId, cwd, fromHash, signal) {
|
|
367
634
|
if (signal?.aborted || !COMMIT_HASH.test(fromHash))
|
|
368
635
|
return null;
|
|
369
636
|
if (disabledBots.has(botId) || !(await gitAvailable()) || refusalReason(cwd) !== null)
|
|
370
637
|
return null;
|
|
638
|
+
const folder = folderShadow(botId, cwd);
|
|
639
|
+
if (folder === null || disabledFolders.has(folder.shadow))
|
|
640
|
+
return null;
|
|
641
|
+
const { worktree, shadow } = folder;
|
|
371
642
|
try {
|
|
372
|
-
const worktree = realpathSync(resolve(cwd));
|
|
373
|
-
const shadow = shadowDir(botId, worktree);
|
|
374
643
|
const env = gitEnv(shadow, worktree);
|
|
375
|
-
|
|
644
|
+
const run = serialize(shadow, async () => {
|
|
376
645
|
await ensureShadow(worktree, env, shadow, signal);
|
|
377
|
-
await
|
|
378
|
-
await runGit(["add", "-A", "--ignore-errors", "."], worktree, env, signal);
|
|
646
|
+
await stageWorkTree(worktree, env, signal);
|
|
379
647
|
const out = await runGit(["diff", "--cached", "--name-status", "--no-renames", "-z", fromHash], worktree, env, signal);
|
|
380
648
|
const diff = { changed: [], added: [], deleted: [] };
|
|
381
649
|
const fields = out.split("\0").filter((field) => field.length > 0);
|
|
@@ -391,109 +659,14 @@ export async function diffWorkingTree(botId, cwd, fromHash, signal) {
|
|
|
391
659
|
}
|
|
392
660
|
return diff;
|
|
393
661
|
});
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
/** The latest usable checkpoint (plus legacy history until the next snapshot). Empty when nothing
|
|
400
|
-
* was ever snapshotted (listing never creates the shadow repo). The empty
|
|
401
|
-
* base marker is omitted — it is not a state anyone should return to. */
|
|
402
|
-
export async function listCheckpoints(botId, cwd) {
|
|
403
|
-
if (disabledBots.has(botId))
|
|
404
|
-
return [];
|
|
405
|
-
if (!(await gitAvailable()))
|
|
406
|
-
return [];
|
|
407
|
-
if (refusalReason(cwd) !== null)
|
|
408
|
-
return [];
|
|
409
|
-
try {
|
|
410
|
-
const worktree = realpathSync(resolve(cwd));
|
|
411
|
-
const shadow = shadowDir(botId, worktree);
|
|
412
|
-
if (!existsSync(join(shadow, ".git", "HEAD")))
|
|
413
|
-
return [];
|
|
414
|
-
return await serialize(shadow, async () => {
|
|
415
|
-
const env = gitEnv(shadow, worktree);
|
|
416
|
-
const out = await runGit(["log", "--format=%H%x09%ct%x09%s"], worktree, env);
|
|
417
|
-
const lines = out.split("\n").filter((line) => line.trim() !== "");
|
|
418
|
-
lines.pop(); // the root of the log is always the empty base marker
|
|
419
|
-
return lines.flatMap((line) => {
|
|
420
|
-
const [hash, seconds, ...subject] = line.split("\t");
|
|
421
|
-
if (!hash || !COMMIT_HASH.test(hash))
|
|
422
|
-
return [];
|
|
423
|
-
return [{ hash, at: Number(seconds) * 1000, label: subject.join("\t") }];
|
|
424
|
-
});
|
|
425
|
-
});
|
|
662
|
+
// The collection this turn's snapshot left pending runs behind this
|
|
663
|
+
// diff, never ahead of it.
|
|
664
|
+
releaseCollection(shadow);
|
|
665
|
+
return await run;
|
|
426
666
|
}
|
|
427
667
|
catch (e) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
/** Can this bot take/restore checkpoints in this folder right now? */
|
|
433
|
-
export async function checkpointsEnabled(botId, cwd) {
|
|
434
|
-
return !disabledBots.has(botId) && (await gitAvailable()) && refusalReason(cwd) === null;
|
|
435
|
-
}
|
|
436
|
-
/** Move the folder's files back to a checkpoint. The current state is
|
|
437
|
-
* committed first ("before restore"), so the restore itself shows up as a
|
|
438
|
-
* checkpoint and can be undone; HEAD never moves backwards, only forward
|
|
439
|
-
* over the "restored" commit. Excluded and gitignored files are untouched. */
|
|
440
|
-
export async function restore(botId, cwd, hash) {
|
|
441
|
-
if (disabledBots.has(botId)) {
|
|
442
|
-
return { ok: false, error: "checkpoints are disabled for this bot until the app restarts (an earlier snapshot failed — see the server log)" };
|
|
443
|
-
}
|
|
444
|
-
if (!(await gitAvailable()))
|
|
445
|
-
return { ok: false, error: "git is not installed on this machine" };
|
|
446
|
-
const reason = refusalReason(cwd);
|
|
447
|
-
if (reason !== null)
|
|
448
|
-
return { ok: false, error: reason };
|
|
449
|
-
if (!COMMIT_HASH.test(hash))
|
|
450
|
-
return { ok: false, error: "hash must be a full 40-character checkpoint hash" };
|
|
451
|
-
try {
|
|
452
|
-
const worktree = realpathSync(resolve(cwd));
|
|
453
|
-
const shadow = shadowDir(botId, worktree);
|
|
454
|
-
if (!existsSync(join(shadow, ".git", "HEAD"))) {
|
|
455
|
-
return { ok: false, error: "no checkpoints exist for this folder" };
|
|
456
|
-
}
|
|
457
|
-
return await serialize(shadow, async () => {
|
|
458
|
-
const env = gitEnv(shadow, worktree);
|
|
459
|
-
try {
|
|
460
|
-
await runGit(["cat-file", "-e", `${hash}^{commit}`], worktree, env);
|
|
461
|
-
}
|
|
462
|
-
catch {
|
|
463
|
-
return { ok: false, error: "no such checkpoint" };
|
|
464
|
-
}
|
|
465
|
-
const base = (await runGit(["rev-list", "--max-parents=0", "HEAD"], worktree, env)).trim();
|
|
466
|
-
if (base === hash)
|
|
467
|
-
return { ok: false, error: "that is the empty base marker, not a checkpoint" };
|
|
468
|
-
// safety point: whatever is about to be overwritten becomes restorable
|
|
469
|
-
await ensureShadow(worktree, env, shadow);
|
|
470
|
-
const safety = await commitAll(worktree, env, "before restore");
|
|
471
|
-
if (!safety.complete) {
|
|
472
|
-
return {
|
|
473
|
-
ok: false,
|
|
474
|
-
error: "restore stopped because some current files could not be added to the safety checkpoint",
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
// Index AND work tree move to the source; HEAD stays put. --staged
|
|
478
|
-
// matters: with a work-tree-only restore, a file that exists in the
|
|
479
|
-
// source but not in the index (deleted in a later checkpoint, now
|
|
480
|
-
// resurrected) would be untracked the moment restore recreates it —
|
|
481
|
-
// and the clean below would delete it right back. Restoring the index
|
|
482
|
-
// too makes clean blind to everything the checkpoint owns; what clean
|
|
483
|
-
// then sweeps is exactly the strays the safety commit could not stage
|
|
484
|
-
// (unreadable files, add races) — never ignored/excluded files (no -x).
|
|
485
|
-
await runGit(["restore", "--source", hash, "--staged", "--worktree", "--", "."], worktree, env);
|
|
486
|
-
await runGit(["clean", "-fd"], worktree, env);
|
|
487
|
-
// Retain the pre-restore safety point, not another copy of the state
|
|
488
|
-
// now on disk. Reset only the shadow index so it cannot pin old blobs.
|
|
489
|
-
await runGit(["read-tree", "HEAD"], worktree, env);
|
|
490
|
-
await collectObsolete(worktree, env);
|
|
491
|
-
return { ok: true };
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
catch (e) {
|
|
495
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
496
|
-
disable(botId, message);
|
|
497
|
-
return { ok: false, error: `restore failed: ${message}` };
|
|
668
|
+
if (e instanceof GitTimeoutError && !signal?.aborted)
|
|
669
|
+
disableFolder(shadow, botId, worktree, e.message);
|
|
670
|
+
return null;
|
|
498
671
|
}
|
|
499
672
|
}
|