@guilz-dev/belay 0.4.0 → 0.7.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.
Files changed (240) hide show
  1. package/README.md +27 -2
  2. package/dist/adapters/claude/runtime-entry.js +54 -25
  3. package/dist/adapters/layouts/scope.js +3 -2
  4. package/dist/adapters/shared/gate-runtime.d.ts +8 -2
  5. package/dist/adapters/shared/gate-runtime.js +579 -181
  6. package/dist/bundle/claude-runtime.mjs +13574 -6334
  7. package/dist/bundle/codex-runtime.mjs +13108 -5903
  8. package/dist/bundle/cursor-runtime.mjs +12676 -5476
  9. package/dist/cli.js +80 -2
  10. package/dist/commands/approve.js +34 -10
  11. package/dist/commands/classify-for-report.js +1 -1
  12. package/dist/commands/config.d.ts +8 -0
  13. package/dist/commands/config.js +134 -11
  14. package/dist/commands/doctor.js +87 -1
  15. package/dist/commands/explain.js +11 -1
  16. package/dist/commands/judge.js +3 -2
  17. package/dist/commands/recovery-checkpoints.d.ts +164 -0
  18. package/dist/commands/recovery-checkpoints.js +347 -0
  19. package/dist/commands/revoke.js +19 -7
  20. package/dist/commands/session.d.ts +20 -0
  21. package/dist/commands/session.js +51 -0
  22. package/dist/commands/status.js +7 -0
  23. package/dist/config-io.js +91 -23
  24. package/dist/conformance/guarantee-posture.d.ts +21 -0
  25. package/dist/conformance/guarantee-posture.js +46 -0
  26. package/dist/conformance/guarantee-table.d.ts +1 -0
  27. package/dist/conformance/guarantee-table.js +20 -9
  28. package/dist/conformance/types.d.ts +1 -0
  29. package/dist/core/approval-replay.d.ts +1 -1
  30. package/dist/core/approval-replay.js +4 -1
  31. package/dist/core/approval-service.d.ts +6 -1
  32. package/dist/core/approval-service.js +87 -24
  33. package/dist/core/approval.d.ts +4 -0
  34. package/dist/core/approval.js +19 -5
  35. package/dist/core/audit-types.d.ts +13 -0
  36. package/dist/core/capability/approval-state-mutation.d.ts +32 -0
  37. package/dist/core/capability/approval-state-mutation.js +165 -0
  38. package/dist/core/capability/approval-v3.d.ts +32 -0
  39. package/dist/core/capability/approval-v3.js +114 -0
  40. package/dist/core/capability/attestation.d.ts +16 -0
  41. package/dist/core/capability/attestation.js +62 -0
  42. package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
  43. package/dist/core/capability/boundary-attestation-sign.js +84 -0
  44. package/dist/core/capability/boundary-driver-container.d.ts +19 -0
  45. package/dist/core/capability/boundary-driver-container.js +189 -0
  46. package/dist/core/capability/boundary-driver.d.ts +29 -0
  47. package/dist/core/capability/boundary-driver.js +44 -0
  48. package/dist/core/capability/boundary-egress.d.ts +21 -0
  49. package/dist/core/capability/boundary-egress.js +109 -0
  50. package/dist/core/capability/boundary-grant-materialize.d.ts +14 -0
  51. package/dist/core/capability/boundary-grant-materialize.js +93 -0
  52. package/dist/core/capability/boundary-profile.d.ts +15 -0
  53. package/dist/core/capability/boundary-profile.js +25 -0
  54. package/dist/core/capability/boundary-run.d.ts +48 -0
  55. package/dist/core/capability/boundary-run.js +51 -0
  56. package/dist/core/capability/boundary-session.d.ts +61 -0
  57. package/dist/core/capability/boundary-session.js +138 -0
  58. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  59. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  60. package/dist/core/capability/capability-request-hash.d.ts +2 -0
  61. package/dist/core/capability/capability-request-hash.js +8 -0
  62. package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
  63. package/dist/core/capability/gate-latency-ratchet.js +18 -0
  64. package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
  65. package/dist/core/capability/gate-policy-shadow.js +130 -0
  66. package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
  67. package/dist/core/capability/gate-shadow-audit.js +1 -0
  68. package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
  69. package/dist/core/capability/gate-shadow-ratchet.js +81 -0
  70. package/dist/core/capability/grant-consumption.d.ts +4 -0
  71. package/dist/core/capability/grant-consumption.js +11 -0
  72. package/dist/core/capability/grant-lease.d.ts +44 -0
  73. package/dist/core/capability/grant-lease.js +243 -0
  74. package/dist/core/capability/grant-loader.d.ts +12 -0
  75. package/dist/core/capability/grant-loader.js +23 -0
  76. package/dist/core/capability/grant-match.d.ts +6 -0
  77. package/dist/core/capability/grant-match.js +96 -0
  78. package/dist/core/capability/grant.d.ts +16 -0
  79. package/dist/core/capability/grant.js +16 -0
  80. package/dist/core/capability/index.d.ts +8 -0
  81. package/dist/core/capability/index.js +7 -0
  82. package/dist/core/capability/limits.d.ts +7 -0
  83. package/dist/core/capability/limits.js +55 -0
  84. package/dist/core/capability/policy-bridge.d.ts +18 -0
  85. package/dist/core/capability/policy-bridge.js +51 -0
  86. package/dist/core/capability/policy-engine.d.ts +77 -0
  87. package/dist/core/capability/policy-engine.js +663 -0
  88. package/dist/core/capability/policy-types.d.ts +20 -0
  89. package/dist/core/capability/policy-types.js +1 -0
  90. package/dist/core/capability/request.d.ts +48 -0
  91. package/dist/core/capability/request.js +1 -0
  92. package/dist/core/capability/resolver.d.ts +4 -0
  93. package/dist/core/capability/resolver.js +4 -0
  94. package/dist/core/classify-subagent.d.ts +2 -1
  95. package/dist/core/classify-subagent.js +38 -2
  96. package/dist/core/classify-tool.js +126 -62
  97. package/dist/core/config.d.ts +34 -3
  98. package/dist/core/config.js +118 -3
  99. package/dist/core/effect-ir/audit.d.ts +9 -0
  100. package/dist/core/effect-ir/audit.js +86 -0
  101. package/dist/core/effect-ir/build.d.ts +31 -0
  102. package/dist/core/effect-ir/build.js +277 -0
  103. package/dist/core/effect-ir/index.d.ts +8 -0
  104. package/dist/core/effect-ir/index.js +7 -0
  105. package/dist/core/effect-ir/normalize.d.ts +4 -0
  106. package/dist/core/effect-ir/normalize.js +104 -0
  107. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  108. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  109. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  110. package/dist/core/effect-ir/package-exec.js +292 -0
  111. package/dist/core/effect-ir/policy.d.ts +13 -0
  112. package/dist/core/effect-ir/policy.js +77 -0
  113. package/dist/core/effect-ir/types.d.ts +55 -0
  114. package/dist/core/effect-ir/types.js +1 -0
  115. package/dist/core/egress/allowlist.d.ts +1 -0
  116. package/dist/core/egress/allowlist.js +8 -0
  117. package/dist/core/egress-approval.js +45 -51
  118. package/dist/core/gate-contract.d.ts +5 -0
  119. package/dist/core/gate-contract.js +5 -0
  120. package/dist/core/gate-engine.js +30 -8
  121. package/dist/core/index.d.ts +3 -1
  122. package/dist/core/index.js +2 -1
  123. package/dist/core/judge-cloud-consent.js +29 -15
  124. package/dist/core/judge-doctor.js +39 -14
  125. package/dist/core/judge-fallback-hints.d.ts +13 -0
  126. package/dist/core/judge-fallback-hints.js +108 -0
  127. package/dist/core/network-endpoint.d.ts +10 -0
  128. package/dist/core/network-endpoint.js +51 -0
  129. package/dist/core/path-utils.d.ts +1 -0
  130. package/dist/core/path-utils.js +5 -2
  131. package/dist/core/process-runner.d.ts +10 -0
  132. package/dist/core/process-runner.js +124 -0
  133. package/dist/core/recover-advice.js +2 -1
  134. package/dist/core/recovery/artifact-store.d.ts +29 -0
  135. package/dist/core/recovery/artifact-store.js +336 -0
  136. package/dist/core/recovery/capability.d.ts +3 -0
  137. package/dist/core/recovery/capability.js +22 -0
  138. package/dist/core/recovery/checkpoint.d.ts +25 -0
  139. package/dist/core/recovery/checkpoint.js +260 -0
  140. package/dist/core/recovery/fail-closed.d.ts +6 -0
  141. package/dist/core/recovery/fail-closed.js +25 -0
  142. package/dist/core/recovery/index.d.ts +5 -0
  143. package/dist/core/recovery/index.js +4 -0
  144. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  145. package/dist/core/recovery/operator-guidance.js +39 -0
  146. package/dist/core/recovery/reconcile.d.ts +3 -0
  147. package/dist/core/recovery/reconcile.js +48 -0
  148. package/dist/core/recovery/resource-identity.d.ts +4 -0
  149. package/dist/core/recovery/resource-identity.js +34 -0
  150. package/dist/core/recovery/restore.d.ts +13 -0
  151. package/dist/core/recovery/restore.js +114 -0
  152. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  153. package/dist/core/recovery/snapshot-node.js +261 -0
  154. package/dist/core/recovery/types.d.ts +104 -0
  155. package/dist/core/recovery/types.js +1 -0
  156. package/dist/core/standing-allow.js +3 -0
  157. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  158. package/dist/core/transactional/apply-observed-changes.js +275 -0
  159. package/dist/core/transactional/backend-selector.d.ts +8 -0
  160. package/dist/core/transactional/backend-selector.js +118 -0
  161. package/dist/core/transactional/backend.d.ts +55 -0
  162. package/dist/core/transactional/backend.js +1 -0
  163. package/dist/core/transactional/diff-evaluator.js +4 -1
  164. package/dist/core/transactional/eligibility.js +4 -0
  165. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  166. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  167. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  168. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  169. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  170. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  171. package/dist/core/transactional/file-clone.d.ts +15 -0
  172. package/dist/core/transactional/file-clone.js +139 -0
  173. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  174. package/dist/core/transactional/file-tree-path.js +50 -0
  175. package/dist/core/transactional/file-tree.d.ts +41 -0
  176. package/dist/core/transactional/file-tree.js +186 -0
  177. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  178. package/dist/core/transactional/git-worktree-backend.js +50 -0
  179. package/dist/core/transactional/git-worktree.d.ts +14 -9
  180. package/dist/core/transactional/git-worktree.js +62 -87
  181. package/dist/core/transactional/index.d.ts +11 -0
  182. package/dist/core/transactional/index.js +10 -0
  183. package/dist/core/transactional/reasons.d.ts +1 -0
  184. package/dist/core/transactional/reasons.js +5 -0
  185. package/dist/core/transactional/runner.js +171 -23
  186. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  187. package/dist/core/transactional/snapshot-node.js +87 -0
  188. package/dist/core/transactional/types.d.ts +21 -1
  189. package/dist/core/types.d.ts +30 -3
  190. package/dist/core/verdict/adapter.d.ts +3 -4
  191. package/dist/core/verdict/adapter.js +24 -12
  192. package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
  193. package/dist/core/verdict/cursor-acp-client.js +345 -0
  194. package/dist/core/verdict/egress-classify.d.ts +2 -2
  195. package/dist/core/verdict/egress-classify.js +2 -2
  196. package/dist/core/verdict/git-classifier.d.ts +26 -0
  197. package/dist/core/verdict/git-classifier.js +533 -0
  198. package/dist/core/verdict/judge-audit.js +1 -0
  199. package/dist/core/verdict/judge-baseline.d.ts +1 -1
  200. package/dist/core/verdict/judge-baseline.js +1 -0
  201. package/dist/core/verdict/judge-broker-service.js +12 -0
  202. package/dist/core/verdict/judge-catalog.js +1 -0
  203. package/dist/core/verdict/judge-cli-fingerprint.d.ts +1 -0
  204. package/dist/core/verdict/judge-cli-fingerprint.js +6 -0
  205. package/dist/core/verdict/judge-cli.js +5 -3
  206. package/dist/core/verdict/judge-provider-matrix.js +1 -1
  207. package/dist/core/verdict/judge-runtime-config.d.ts +2 -0
  208. package/dist/core/verdict/judge-runtime-config.js +10 -6
  209. package/dist/core/verdict/judge-session-broker.d.ts +1 -1
  210. package/dist/core/verdict/judge-session-broker.js +10 -5
  211. package/dist/core/verdict/judge-transport.d.ts +5 -1
  212. package/dist/core/verdict/judge-transport.js +24 -14
  213. package/dist/core/verdict/judge.d.ts +2 -15
  214. package/dist/core/verdict/judge.js +5 -68
  215. package/dist/core/verdict/launcher-resolve.d.ts +2 -0
  216. package/dist/core/verdict/launcher-resolve.js +14 -16
  217. package/dist/core/verdict/prescan.d.ts +15 -0
  218. package/dist/core/verdict/prescan.js +68 -0
  219. package/dist/core/verdict/shell-policy.d.ts +26 -0
  220. package/dist/core/verdict/shell-policy.js +40 -0
  221. package/dist/core/verdict/shell-semantics.d.ts +24 -0
  222. package/dist/core/verdict/shell-semantics.js +225 -0
  223. package/dist/core/verdict/types.d.ts +18 -1
  224. package/dist/core/verdict/verdict.js +313 -178
  225. package/dist/corpus/evaluate.js +3 -7
  226. package/dist/corpus/gate-latency-budget.d.ts +39 -0
  227. package/dist/corpus/gate-latency-budget.js +33 -0
  228. package/dist/corpus/mutators.d.ts +1 -1
  229. package/dist/corpus/mutators.js +11 -0
  230. package/dist/corpus/runtime-match.js +1 -3
  231. package/dist/corpus/standing-allow-catalog.generated.js +29 -9
  232. package/dist/judge-broker-daemon.js +15 -1
  233. package/dist/services/sandbox-service.d.ts +4 -0
  234. package/dist/services/sandbox-service.js +28 -2
  235. package/dist/types.d.ts +13 -2
  236. package/dist/version.d.ts +1 -1
  237. package/dist/version.js +1 -1
  238. package/package.json +1 -1
  239. package/skills/belay/SKILL.md +8 -3
  240. package/skills/belay/belay-approve.md +10 -7
@@ -0,0 +1,278 @@
1
+ import { cp, lstat, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { attestsWorkspaceMountIsolation } from '../capability/attestation.js';
5
+ import { currentRecoveryResourceIdentity } from '../recovery/resource-identity.js';
6
+ import { FILE_CHECKPOINT_DISABLED, FILE_CHECKPOINT_DURABLE_REQUIRED, FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, } from './backend-selector.js';
7
+ import { cloneBareWorktreeCopy, computeGitMetadataFingerprint, copyGitIndexState, execGit, FILE_CHECKPOINT_CWD_OUTSIDE_ROOT, FILE_CHECKPOINT_GIT_METADATA_CHANGED, FILE_CHECKPOINT_PREPARE_FAILED, FILE_CHECKPOINT_SOURCE_CHANGED, removeAllGitRemotes, resolveExecutionCwdRelative, } from './file-checkpoint-git.js';
8
+ import { removeDeadOwnerStaging, writeOwnerMarker } from './file-checkpoint-staging.js';
9
+ import { cloneDirectoryTree, probeFileCloneStrategy } from './file-clone.js';
10
+ import { buildFileTreeIndex, diffFileTreeIndices, FILE_CHECKPOINT_PREPARE_TIMEOUT, FILE_CHECKPOINT_QUOTA_EXCEEDED, FileCheckpointDiagnosticError, } from './file-tree.js';
11
+ import { isDirtyWorktree, isGitWorktreeAvailable } from './git-worktree.js';
12
+ import { readSnapshotNode } from './snapshot-node.js';
13
+ export const FILE_CHECKPOINT_PROTECTED_PATH_CHANGED = 'file_checkpoint_protected_path_changed';
14
+ function fileCheckpointIsolationReason(context) {
15
+ const attestation = context.boundaryAttestation;
16
+ const fresh = context.boundaryAttestationFresh === true;
17
+ const driverId = context.boundaryDriverId;
18
+ if (!attestation || !fresh || !driverId) {
19
+ return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
20
+ }
21
+ if (attestation.driver !== driverId) {
22
+ return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
23
+ }
24
+ if (!attestsWorkspaceMountIsolation(attestation)) {
25
+ return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
26
+ }
27
+ return null;
28
+ }
29
+ async function probeDirtyGitFileCheckpoint(context) {
30
+ const signals = ['git_repository', 'dirty_git_worktree'];
31
+ if (!(await isGitWorktreeAvailable(context.repoRoot))) {
32
+ return {
33
+ eligible: false,
34
+ backend: 'file_checkpoint',
35
+ reason: 'git_worktree_unavailable',
36
+ signals: ['git_repository'],
37
+ };
38
+ }
39
+ if (!(await isDirtyWorktree(context.repoRoot, { ignoreRoots: context.dirtyIgnoreRoots }))) {
40
+ return {
41
+ eligible: false,
42
+ backend: 'file_checkpoint',
43
+ reason: 'clean_git_worktree',
44
+ signals: ['git_repository', 'clean_git_worktree'],
45
+ };
46
+ }
47
+ if (!context.fileCheckpoint.enabled) {
48
+ return {
49
+ eligible: false,
50
+ backend: 'file_checkpoint',
51
+ reason: FILE_CHECKPOINT_DISABLED,
52
+ signals,
53
+ };
54
+ }
55
+ if (!context.durableCheckpointEnabled) {
56
+ return {
57
+ eligible: false,
58
+ backend: 'file_checkpoint',
59
+ reason: FILE_CHECKPOINT_DURABLE_REQUIRED,
60
+ signals,
61
+ };
62
+ }
63
+ const isolationReason = fileCheckpointIsolationReason(context);
64
+ if (isolationReason) {
65
+ return {
66
+ eligible: false,
67
+ backend: 'file_checkpoint',
68
+ reason: isolationReason,
69
+ signals: [...signals, 'isolation_unavailable'],
70
+ };
71
+ }
72
+ const copyStrategy = await probeFileCloneStrategy();
73
+ return {
74
+ eligible: true,
75
+ backend: 'file_checkpoint',
76
+ signals: [...signals, 'dirty_git_file_checkpoint', copyStrategy],
77
+ };
78
+ }
79
+ async function protectedRootState(root) {
80
+ const node = await readSnapshotNode(root);
81
+ if (node.kind !== 'directory') {
82
+ return `${node.kind}:${node.kind === 'absent' ? '' : node.hash}`;
83
+ }
84
+ const index = await buildFileTreeIndex({ resourceRoot: root });
85
+ return `directory:${node.hash}:${index.treeHash}`;
86
+ }
87
+ function executionProtectedRoot(resourceRoot, executionRoot, protectedRoot) {
88
+ const relative = path.relative(path.resolve(resourceRoot), path.resolve(protectedRoot));
89
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
90
+ return null;
91
+ }
92
+ return path.join(executionRoot, relative);
93
+ }
94
+ async function captureProtectedRootStates(resourceRoot, executionRoot, protectedRoots) {
95
+ const states = new Map();
96
+ for (const protectedRoot of protectedRoots) {
97
+ const executionPath = executionProtectedRoot(resourceRoot, executionRoot, protectedRoot);
98
+ if (executionPath) {
99
+ states.set(executionPath, await protectedRootState(executionPath));
100
+ }
101
+ }
102
+ return states;
103
+ }
104
+ async function directoryByteSize(root, deadlineMs) {
105
+ if (Date.now() > deadlineMs) {
106
+ throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_PREPARE_TIMEOUT, `workspace accounting exceeded deadlineMs=${deadlineMs}`);
107
+ }
108
+ const info = await lstat(root);
109
+ if (!info.isDirectory() || info.isSymbolicLink()) {
110
+ return info.size;
111
+ }
112
+ let total = 0;
113
+ for (const name of await readdir(root)) {
114
+ total += await directoryByteSize(path.join(root, name), deadlineMs);
115
+ }
116
+ return total;
117
+ }
118
+ async function copyGitMetadataDirectory(sourceRoot, destinationRoot) {
119
+ const gitDirRel = (await execGit(sourceRoot, ['rev-parse', '--git-dir'])).trim();
120
+ const sourceGitDir = path.isAbsolute(gitDirRel) ? gitDirRel : path.join(sourceRoot, gitDirRel);
121
+ const relativeGitDir = path.relative(path.resolve(sourceRoot), path.resolve(sourceGitDir));
122
+ const destinationGitDir = relativeGitDir && !relativeGitDir.startsWith('..')
123
+ ? path.join(destinationRoot, relativeGitDir)
124
+ : path.join(destinationRoot, '.git');
125
+ await cp(sourceGitDir, destinationGitDir, { recursive: true, force: true });
126
+ }
127
+ async function prepareDirtyGitSnapshot(context) {
128
+ const prepareStartedAt = Date.now();
129
+ const excludedRoots = context.dirtyIgnoreRoots ?? [];
130
+ const quotas = context.fileCheckpoint;
131
+ const deadlineMs = Date.now() + quotas.prepareTimeoutMs;
132
+ await removeDeadOwnerStaging(os.tmpdir());
133
+ const stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'belay-file-checkpoint-'));
134
+ await writeOwnerMarker(stagingRoot, {
135
+ version: 1,
136
+ pid: process.pid,
137
+ createdAt: new Date().toISOString(),
138
+ resourceRoot: context.repoRoot,
139
+ backend: 'file_checkpoint',
140
+ });
141
+ const baselineRoot = path.join(stagingRoot, 'baseline');
142
+ const executionRoot = path.join(stagingRoot, 'execution');
143
+ try {
144
+ resolveExecutionCwdRelative(context.repoRoot, context.cwd);
145
+ const sourceGitMetadataFingerprint = await computeGitMetadataFingerprint(context.repoRoot);
146
+ await cloneBareWorktreeCopy(context.repoRoot, baselineRoot);
147
+ await removeAllGitRemotes(baselineRoot);
148
+ const baselineCopy = await cloneDirectoryTree(context.repoRoot, baselineRoot, {
149
+ excludedRoots,
150
+ quotas,
151
+ deadlineMs,
152
+ });
153
+ await copyGitIndexState(context.repoRoot, baselineRoot);
154
+ const baselineIndex = await buildFileTreeIndex({
155
+ resourceRoot: baselineRoot,
156
+ excludedRoots,
157
+ quotas,
158
+ deadlineMs,
159
+ });
160
+ const sourceIndex = await buildFileTreeIndex({
161
+ resourceRoot: context.repoRoot,
162
+ excludedRoots,
163
+ quotas,
164
+ deadlineMs,
165
+ });
166
+ if (sourceIndex.treeHash !== baselineIndex.treeHash) {
167
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
168
+ }
169
+ if ((await computeGitMetadataFingerprint(context.repoRoot)) !== sourceGitMetadataFingerprint) {
170
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
171
+ }
172
+ await writeFile(path.join(stagingRoot, 'baseline-index.json'), `${JSON.stringify(baselineIndex)}\n`, 'utf8');
173
+ const executionCopy = await cloneDirectoryTree(baselineRoot, executionRoot, {
174
+ excludedRoots,
175
+ quotas,
176
+ deadlineMs,
177
+ });
178
+ await copyGitMetadataDirectory(baselineRoot, executionRoot);
179
+ const gitMetadataFingerprint = await computeGitMetadataFingerprint(executionRoot);
180
+ const protectedRootStates = await captureProtectedRootStates(context.repoRoot, executionRoot, excludedRoots);
181
+ const workspaceBytes = await directoryByteSize(stagingRoot, deadlineMs);
182
+ if (workspaceBytes > quotas.maxWorkspaceBytes) {
183
+ throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_QUOTA_EXCEEDED, `snapshot workspaceBytes=${workspaceBytes} exceeds maxWorkspaceBytes=${quotas.maxWorkspaceBytes}`);
184
+ }
185
+ return {
186
+ stagingRoot,
187
+ baselineRoot,
188
+ executionRoot,
189
+ baselineIndex,
190
+ sourceGitMetadataFingerprint,
191
+ gitMetadataFingerprint,
192
+ copyStrategy: executionCopy.strategy === baselineCopy.strategy ? executionCopy.strategy : 'copy',
193
+ workspaceBytes,
194
+ prepareMs: Date.now() - prepareStartedAt,
195
+ protectedRootStates,
196
+ };
197
+ }
198
+ catch (error) {
199
+ await rm(stagingRoot, { recursive: true, force: true });
200
+ if (error instanceof FileCheckpointDiagnosticError) {
201
+ throw error;
202
+ }
203
+ if (error instanceof Error && error.message === FILE_CHECKPOINT_CWD_OUTSIDE_ROOT) {
204
+ throw error;
205
+ }
206
+ if (error instanceof Error && error.message === FILE_CHECKPOINT_SOURCE_CHANGED) {
207
+ throw error;
208
+ }
209
+ throw new Error(error instanceof Error ? error.message : FILE_CHECKPOINT_PREPARE_FAILED, {
210
+ cause: error,
211
+ });
212
+ }
213
+ }
214
+ export const fileCheckpointBackend = {
215
+ id: 'file_checkpoint',
216
+ probe: probeDirtyGitFileCheckpoint,
217
+ async prepare(context) {
218
+ const probe = await probeDirtyGitFileCheckpoint(context);
219
+ if (!probe.eligible) {
220
+ throw new Error(probe.reason ?? FILE_CHECKPOINT_PREPARE_FAILED);
221
+ }
222
+ const prepared = await prepareDirtyGitSnapshot(context);
223
+ const executionCwdRelative = resolveExecutionCwdRelative(context.repoRoot, context.cwd);
224
+ const resourceIdentity = await currentRecoveryResourceIdentity(context.repoRoot, 'git_repository');
225
+ return {
226
+ backend: 'file_checkpoint',
227
+ resourceRoot: context.repoRoot,
228
+ executionRoot: prepared.executionRoot,
229
+ baselineRoot: prepared.baselineRoot,
230
+ resourceKind: 'git_repository',
231
+ resourceIdentity,
232
+ baselineTreeHash: prepared.baselineIndex.treeHash,
233
+ excludedRoots: context.dirtyIgnoreRoots ?? [],
234
+ copyStrategy: prepared.copyStrategy,
235
+ snapshotFileCount: prepared.baselineIndex.fileCount,
236
+ snapshotSourceBytes: prepared.baselineIndex.totalFileBytes,
237
+ snapshotWorkspaceBytes: prepared.workspaceBytes,
238
+ snapshotPrepareMs: prepared.prepareMs,
239
+ executionCwdRelative,
240
+ async validateSourceState() {
241
+ const sourceIndex = await buildFileTreeIndex({
242
+ resourceRoot: context.repoRoot,
243
+ excludedRoots: context.dirtyIgnoreRoots ?? [],
244
+ quotas: context.fileCheckpoint,
245
+ });
246
+ if (sourceIndex.treeHash !== prepared.baselineIndex.treeHash ||
247
+ (await computeGitMetadataFingerprint(context.repoRoot)) !==
248
+ prepared.sourceGitMetadataFingerprint) {
249
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
250
+ }
251
+ },
252
+ async collectChanges() {
253
+ const afterFingerprint = await computeGitMetadataFingerprint(prepared.executionRoot);
254
+ if (afterFingerprint !== prepared.gitMetadataFingerprint) {
255
+ throw new Error(FILE_CHECKPOINT_GIT_METADATA_CHANGED);
256
+ }
257
+ for (const [protectedRoot, before] of prepared.protectedRootStates) {
258
+ if ((await protectedRootState(protectedRoot)) !== before) {
259
+ throw new Error(FILE_CHECKPOINT_PROTECTED_PATH_CHANGED);
260
+ }
261
+ }
262
+ const executionIndex = await buildFileTreeIndex({
263
+ resourceRoot: prepared.executionRoot,
264
+ excludedRoots: context.dirtyIgnoreRoots ?? [],
265
+ quotas: context.fileCheckpoint,
266
+ });
267
+ const observed = diffFileTreeIndices(prepared.baselineIndex, executionIndex);
268
+ return observed.map((change) => ({
269
+ relativePath: change.relativePath,
270
+ kind: change.kind,
271
+ }));
272
+ },
273
+ async cleanup() {
274
+ await rm(prepared.stagingRoot, { recursive: true, force: true });
275
+ },
276
+ };
277
+ },
278
+ };
@@ -0,0 +1,11 @@
1
+ export declare const FILE_CHECKPOINT_GIT_METADATA_CHANGED = "file_checkpoint_git_metadata_changed";
2
+ export declare const FILE_CHECKPOINT_SOURCE_CHANGED = "file_checkpoint_source_changed";
3
+ export declare const FILE_CHECKPOINT_CWD_OUTSIDE_ROOT = "file_checkpoint_cwd_outside_root";
4
+ export declare const FILE_CHECKPOINT_PREPARE_FAILED = "file_checkpoint_prepare_failed";
5
+ export declare const FILE_CHECKPOINT_SPLIT_INDEX_UNSUPPORTED = "file_checkpoint_split_index_unsupported";
6
+ export declare function execGit(repoRoot: string, args: string[]): Promise<string>;
7
+ export declare function cloneBareWorktreeCopy(sourceRoot: string, destinationRoot: string): Promise<void>;
8
+ export declare function removeAllGitRemotes(repoRoot: string): Promise<void>;
9
+ export declare function copyGitIndexState(sourceRoot: string, destinationRoot: string): Promise<void>;
10
+ export declare function computeGitMetadataFingerprint(repoRoot: string): Promise<string>;
11
+ export declare function resolveExecutionCwdRelative(resourceRoot: string, cwd: string): string;
@@ -0,0 +1,217 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { copyFile, lstat, readdir, readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { canonicalPath } from '../path-utils.js';
6
+ export const FILE_CHECKPOINT_GIT_METADATA_CHANGED = 'file_checkpoint_git_metadata_changed';
7
+ export const FILE_CHECKPOINT_SOURCE_CHANGED = 'file_checkpoint_source_changed';
8
+ export const FILE_CHECKPOINT_CWD_OUTSIDE_ROOT = 'file_checkpoint_cwd_outside_root';
9
+ export const FILE_CHECKPOINT_PREPARE_FAILED = 'file_checkpoint_prepare_failed';
10
+ export const FILE_CHECKPOINT_SPLIT_INDEX_UNSUPPORTED = 'file_checkpoint_split_index_unsupported';
11
+ export function execGit(repoRoot, args) {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn('git', ['-C', repoRoot, ...args], {
14
+ stdio: ['ignore', 'pipe', 'pipe'],
15
+ });
16
+ const stdout = [];
17
+ const stderr = [];
18
+ child.stdout.on('data', (chunk) => stdout.push(Buffer.from(chunk)));
19
+ child.stderr.on('data', (chunk) => stderr.push(Buffer.from(chunk)));
20
+ child.on('error', reject);
21
+ child.on('close', (code) => {
22
+ if (code === 0) {
23
+ resolve(Buffer.concat(stdout).toString('utf8'));
24
+ return;
25
+ }
26
+ reject(new Error(`git ${args.join(' ')} failed (${code}): ${Buffer.concat(stderr).toString('utf8').trim()}`));
27
+ });
28
+ });
29
+ }
30
+ async function resolveGitPath(repoRoot, gitPath) {
31
+ const trimmed = gitPath.trim();
32
+ if (path.isAbsolute(trimmed)) {
33
+ return trimmed;
34
+ }
35
+ return path.join(repoRoot, trimmed);
36
+ }
37
+ export async function cloneBareWorktreeCopy(sourceRoot, destinationRoot) {
38
+ await execGit(sourceRoot, [
39
+ 'clone',
40
+ '--local',
41
+ '--no-hardlinks',
42
+ '--no-checkout',
43
+ sourceRoot,
44
+ destinationRoot,
45
+ ]);
46
+ }
47
+ export async function removeAllGitRemotes(repoRoot) {
48
+ const remotes = (await execGit(repoRoot, ['remote']))
49
+ .split('\n')
50
+ .map((line) => line.trim())
51
+ .filter(Boolean);
52
+ for (const remote of remotes) {
53
+ await execGit(repoRoot, ['remote', 'remove', remote]);
54
+ }
55
+ }
56
+ export async function copyGitIndexState(sourceRoot, destinationRoot) {
57
+ const sourceIndex = await resolveGitPath(sourceRoot, await execGit(sourceRoot, ['rev-parse', '--git-path', 'index']));
58
+ const destinationIndex = await resolveGitPath(destinationRoot, await execGit(destinationRoot, ['rev-parse', '--git-path', 'index']));
59
+ await copyFile(sourceIndex, destinationIndex);
60
+ let sharedIndex;
61
+ try {
62
+ sharedIndex = (await execGit(sourceRoot, ['rev-parse', '--shared-index-path'])).trim();
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ if (!sharedIndex) {
68
+ return;
69
+ }
70
+ const sourceShared = await resolveGitPath(sourceRoot, sharedIndex);
71
+ const destinationGitDir = await resolveGitPath(destinationRoot, await execGit(destinationRoot, ['rev-parse', '--git-dir']));
72
+ const destinationShared = path.join(destinationGitDir, path.basename(sourceShared));
73
+ try {
74
+ await copyFile(sourceShared, destinationShared);
75
+ }
76
+ catch (error) {
77
+ throw new Error(FILE_CHECKPOINT_SPLIT_INDEX_UNSUPPORTED, { cause: error });
78
+ }
79
+ }
80
+ async function readGitFile(gitDir, relativePath) {
81
+ try {
82
+ return await readFile(path.join(gitDir, relativePath));
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ async function readAbsoluteGitFile(absolutePath) {
89
+ try {
90
+ return await readFile(absolutePath);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ function hashGitFileContent(hash, label, content) {
97
+ hash.update(label.replace(/\\/g, '/'));
98
+ hash.update('\0');
99
+ hash.update(content);
100
+ hash.update('\0');
101
+ }
102
+ async function hashResolvedGitPath(repoRoot, gitDir, gitPath, hash) {
103
+ try {
104
+ const resolved = await resolveGitPath(repoRoot, await execGit(repoRoot, ['rev-parse', '--git-path', gitPath]));
105
+ const relative = path.resolve(resolved).startsWith(path.resolve(gitDir))
106
+ ? path.relative(gitDir, resolved)
107
+ : resolved;
108
+ const content = typeof relative === 'string' && !relative.startsWith('..') && !path.isAbsolute(relative)
109
+ ? await readGitFile(gitDir, relative)
110
+ : await readAbsoluteGitFile(resolved);
111
+ if (content !== null) {
112
+ hashGitFileContent(hash, gitPath, content);
113
+ }
114
+ }
115
+ catch {
116
+ // optional git-path
117
+ }
118
+ }
119
+ async function hashGitTree(gitDir, relativeDir, hash) {
120
+ let names;
121
+ try {
122
+ names = await readdir(path.join(gitDir, relativeDir));
123
+ }
124
+ catch {
125
+ return;
126
+ }
127
+ for (const name of names.sort()) {
128
+ const relativePath = relativeDir ? path.join(relativeDir, name) : name;
129
+ const absolutePath = path.join(gitDir, relativePath);
130
+ let childNames = null;
131
+ try {
132
+ childNames = await readdir(absolutePath);
133
+ }
134
+ catch {
135
+ childNames = null;
136
+ }
137
+ if (childNames) {
138
+ await hashGitTree(gitDir, relativePath, hash);
139
+ continue;
140
+ }
141
+ const content = await readGitFile(gitDir, relativePath);
142
+ if (content !== null) {
143
+ hash.update(relativePath.replace(/\\/g, '/'));
144
+ hash.update('\0');
145
+ hash.update(content);
146
+ hash.update('\0');
147
+ }
148
+ }
149
+ }
150
+ export async function computeGitMetadataFingerprint(repoRoot) {
151
+ const gitDirRel = (await execGit(repoRoot, ['rev-parse', '--git-dir'])).trim();
152
+ const gitDir = path.isAbsolute(gitDirRel) ? gitDirRel : path.join(repoRoot, gitDirRel);
153
+ const hash = createHash('sha256');
154
+ for (const file of [
155
+ 'HEAD',
156
+ 'config',
157
+ 'index',
158
+ 'packed-refs',
159
+ 'COMMIT_EDITMSG',
160
+ 'MERGE_HEAD',
161
+ 'CHERRY_PICK_HEAD',
162
+ 'ORIG_HEAD',
163
+ 'FETCH_HEAD',
164
+ 'REBASE_HEAD',
165
+ ]) {
166
+ const content = await readGitFile(gitDir, file);
167
+ if (content !== null) {
168
+ hashGitFileContent(hash, file, content);
169
+ }
170
+ }
171
+ try {
172
+ const sharedIndex = (await execGit(repoRoot, ['rev-parse', '--shared-index-path'])).trim();
173
+ if (sharedIndex) {
174
+ const resolved = await resolveGitPath(repoRoot, sharedIndex);
175
+ const relative = path.relative(gitDir, resolved);
176
+ const content = relative && !relative.startsWith('..') && !path.isAbsolute(relative)
177
+ ? await readGitFile(gitDir, relative)
178
+ : await readAbsoluteGitFile(resolved);
179
+ if (content !== null) {
180
+ hashGitFileContent(hash, 'shared-index', content);
181
+ }
182
+ }
183
+ }
184
+ catch {
185
+ // no split index
186
+ }
187
+ for (const gitPath of ['commondir', 'gitdir']) {
188
+ await hashResolvedGitPath(repoRoot, gitDir, gitPath, hash);
189
+ }
190
+ try {
191
+ const rootGitPath = path.join(repoRoot, '.git');
192
+ const rootGitInfo = await lstat(rootGitPath);
193
+ if (rootGitInfo.isFile()) {
194
+ const content = await readAbsoluteGitFile(rootGitPath);
195
+ if (content !== null) {
196
+ hashGitFileContent(hash, '.git', content);
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ // not a linked worktree
202
+ }
203
+ await hashGitTree(gitDir, 'refs', hash);
204
+ return hash.digest('hex');
205
+ }
206
+ export function resolveExecutionCwdRelative(resourceRoot, cwd) {
207
+ const resolvedCwd = canonicalPath(cwd);
208
+ const resourceCanonical = canonicalPath(resourceRoot);
209
+ const relative = path.relative(resourceCanonical, resolvedCwd);
210
+ if (relative === '' || relative === '.') {
211
+ return '';
212
+ }
213
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
214
+ throw new Error(FILE_CHECKPOINT_CWD_OUTSIDE_ROOT);
215
+ }
216
+ return relative.split(path.sep).join('/');
217
+ }
@@ -0,0 +1,13 @@
1
+ export interface FileCheckpointOwnerMarker {
2
+ version: 1;
3
+ pid: number;
4
+ createdAt: string;
5
+ resourceRoot: string;
6
+ backend: 'file_checkpoint';
7
+ }
8
+ export declare function isOwnerProcessAlive(pid: number): boolean;
9
+ export declare function writeOwnerMarker(stagingRoot: string, marker: FileCheckpointOwnerMarker): Promise<void>;
10
+ export declare function readOwnerMarker(stagingRoot: string): Promise<FileCheckpointOwnerMarker | null>;
11
+ /** Returns staging directories whose owner process is no longer alive. */
12
+ export declare function collectDeadOwnerStaging(parentDir: string): Promise<string[]>;
13
+ export declare function removeDeadOwnerStaging(parentDir: string): Promise<string[]>;
@@ -0,0 +1,54 @@
1
+ import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export function isOwnerProcessAlive(pid) {
4
+ try {
5
+ process.kill(pid, 0);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export async function writeOwnerMarker(stagingRoot, marker) {
13
+ await writeFile(path.join(stagingRoot, 'owner.json'), `${JSON.stringify(marker)}\n`, 'utf8');
14
+ }
15
+ export async function readOwnerMarker(stagingRoot) {
16
+ try {
17
+ const raw = await readFile(path.join(stagingRoot, 'owner.json'), 'utf8');
18
+ return JSON.parse(raw.trim());
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /** Returns staging directories whose owner process is no longer alive. */
25
+ export async function collectDeadOwnerStaging(parentDir) {
26
+ let names;
27
+ try {
28
+ names = await readdir(parentDir);
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ const dead = [];
34
+ for (const name of names) {
35
+ if (!name.startsWith('belay-file-checkpoint-')) {
36
+ continue;
37
+ }
38
+ const stagingRoot = path.join(parentDir, name);
39
+ const marker = await readOwnerMarker(stagingRoot);
40
+ if (!marker) {
41
+ dead.push(stagingRoot);
42
+ continue;
43
+ }
44
+ if (!isOwnerProcessAlive(marker.pid)) {
45
+ dead.push(stagingRoot);
46
+ }
47
+ }
48
+ return dead;
49
+ }
50
+ export async function removeDeadOwnerStaging(parentDir) {
51
+ const dead = await collectDeadOwnerStaging(parentDir);
52
+ await Promise.all(dead.map((stagingRoot) => rm(stagingRoot, { recursive: true, force: true })));
53
+ return dead;
54
+ }
@@ -0,0 +1,15 @@
1
+ import type { BelayFileCheckpointConfig } from '../config.js';
2
+ import { buildFileTreeIndex } from './file-tree.js';
3
+ export declare const FILE_CHECKPOINT_COPY_FAILED = "file_checkpoint_copy_failed";
4
+ export type FileCloneStrategy = 'clonefile' | 'reflink' | 'copy';
5
+ export interface FileCloneOptions {
6
+ excludedRoots?: string[];
7
+ quotas?: Pick<BelayFileCheckpointConfig, 'maxFiles' | 'maxSourceBytes' | 'maxWorkspaceBytes' | 'prepareTimeoutMs' | 'copyConcurrency'>;
8
+ deadlineMs?: number;
9
+ }
10
+ export interface FileCloneResult {
11
+ strategy: FileCloneStrategy;
12
+ sourceIndex: Awaited<ReturnType<typeof buildFileTreeIndex>>;
13
+ }
14
+ export declare function cloneDirectoryTree(sourceRoot: string, destinationRoot: string, options?: FileCloneOptions): Promise<FileCloneResult>;
15
+ export declare function probeFileCloneStrategy(): Promise<FileCloneStrategy>;