@guilz-dev/belay 0.8.0 → 0.9.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 (111) hide show
  1. package/README.md +60 -10
  2. package/dist/adapters/shared/gate-runtime.d.ts +5 -0
  3. package/dist/adapters/shared/gate-runtime.js +237 -33
  4. package/dist/bundle/claude-runtime.mjs +4082 -1230
  5. package/dist/bundle/codex-runtime.mjs +4057 -1205
  6. package/dist/bundle/cursor-runtime.mjs +4057 -1205
  7. package/dist/commands/classify-for-report.js +5 -0
  8. package/dist/commands/doctor.js +90 -27
  9. package/dist/commands/explain.js +3 -2
  10. package/dist/commands/metrics.js +75 -4
  11. package/dist/commands/quality.js +2 -6
  12. package/dist/commands/recovery-checkpoints.d.ts +8 -1
  13. package/dist/commands/recovery-checkpoints.js +44 -11
  14. package/dist/commands/session.d.ts +5 -0
  15. package/dist/commands/session.js +8 -1
  16. package/dist/commands/status.js +1 -1
  17. package/dist/config-io.js +2 -2
  18. package/dist/conformance/contained-execution-guarantee.d.ts +121 -0
  19. package/dist/conformance/contained-execution-guarantee.js +121 -0
  20. package/dist/conformance/guarantee-table.js +7 -0
  21. package/dist/core/audit-io.js +8 -0
  22. package/dist/core/audit-metrics.d.ts +34 -0
  23. package/dist/core/audit-metrics.js +87 -15
  24. package/dist/core/audit-recovery-metrics.d.ts +33 -0
  25. package/dist/core/audit-recovery-metrics.js +146 -0
  26. package/dist/core/audit-types.d.ts +7 -1
  27. package/dist/core/audit-types.js +1 -1
  28. package/dist/core/bounded-output.d.ts +11 -0
  29. package/dist/core/bounded-output.js +26 -0
  30. package/dist/core/capability/attestation.d.ts +56 -0
  31. package/dist/core/capability/attestation.js +110 -3
  32. package/dist/core/capability/boundary-session.d.ts +5 -0
  33. package/dist/core/capability/boundary-session.js +38 -1
  34. package/dist/core/capability/index.d.ts +1 -1
  35. package/dist/core/capability/index.js +1 -1
  36. package/dist/core/config.d.ts +18 -0
  37. package/dist/core/config.js +89 -10
  38. package/dist/core/contained-execution/docker-policy.d.ts +18 -0
  39. package/dist/core/contained-execution/docker-policy.js +97 -0
  40. package/dist/core/contained-execution/docker.d.ts +223 -0
  41. package/dist/core/contained-execution/docker.js +846 -0
  42. package/dist/core/contained-execution/eligibility.d.ts +9 -0
  43. package/dist/core/contained-execution/eligibility.js +103 -0
  44. package/dist/core/contained-execution/failure.d.ts +18 -0
  45. package/dist/core/contained-execution/failure.js +105 -0
  46. package/dist/core/contained-execution/mirror.d.ts +54 -0
  47. package/dist/core/contained-execution/mirror.js +587 -0
  48. package/dist/core/contained-execution/policy.d.ts +10 -0
  49. package/dist/core/contained-execution/policy.js +20 -0
  50. package/dist/core/effect-ir/index.d.ts +1 -1
  51. package/dist/core/effect-ir/index.js +1 -1
  52. package/dist/core/effect-ir/normalize.d.ts +5 -0
  53. package/dist/core/effect-ir/normalize.js +17 -7
  54. package/dist/core/effect-ir/shell-build.js +2 -14
  55. package/dist/core/effect-ir/shell-lower.js +100 -25
  56. package/dist/core/gate-contract.d.ts +4 -1
  57. package/dist/core/gate-contract.js +2 -0
  58. package/dist/core/process-runner.d.ts +10 -3
  59. package/dist/core/process-runner.js +66 -10
  60. package/dist/core/recovery/checkpoint.d.ts +3 -0
  61. package/dist/core/recovery/checkpoint.js +33 -4
  62. package/dist/core/recovery/types.d.ts +1 -0
  63. package/dist/core/scrub.d.ts +17 -0
  64. package/dist/core/scrub.js +499 -5
  65. package/dist/core/standing-allow.d.ts +0 -20
  66. package/dist/core/standing-allow.js +0 -71
  67. package/dist/core/transactional/apply-observed-changes.d.ts +1 -0
  68. package/dist/core/transactional/apply-observed-changes.js +11 -0
  69. package/dist/core/transactional/backend-selector.d.ts +2 -1
  70. package/dist/core/transactional/backend-selector.js +32 -29
  71. package/dist/core/transactional/backend.d.ts +3 -0
  72. package/dist/core/transactional/file-checkpoint-backend.js +242 -54
  73. package/dist/core/transactional/file-checkpoint-git.d.ts +3 -0
  74. package/dist/core/transactional/file-checkpoint-git.js +53 -0
  75. package/dist/core/transactional/file-checkpoint-isolation.d.ts +3 -0
  76. package/dist/core/transactional/file-checkpoint-isolation.js +16 -0
  77. package/dist/core/transactional/git-worktree-backend.js +3 -0
  78. package/dist/core/transactional/git-worktree.d.ts +1 -0
  79. package/dist/core/transactional/git-worktree.js +1 -0
  80. package/dist/core/transactional/runner.js +59 -13
  81. package/dist/core/types.d.ts +13 -0
  82. package/dist/core/verdict/parser.d.ts +10 -0
  83. package/dist/core/verdict/parser.js +271 -40
  84. package/dist/core/verdict/shell-semantics.js +21 -0
  85. package/dist/corpus/adversarial-probe.d.ts +1 -1
  86. package/dist/corpus/adversarial-probe.js +3 -3
  87. package/dist/corpus/benign-probe-cores.d.ts +6 -0
  88. package/dist/corpus/{must-allow-commands.js → benign-probe-cores.js} +8 -2
  89. package/dist/corpus/coverage-compare.d.ts +36 -0
  90. package/dist/corpus/coverage-compare.js +197 -0
  91. package/dist/corpus/coverage-contexts.d.ts +17 -0
  92. package/dist/corpus/coverage-contexts.js +88 -0
  93. package/dist/corpus/coverage-matrix.d.ts +37 -0
  94. package/dist/corpus/coverage-matrix.js +159 -0
  95. package/dist/corpus/coverage-probe.d.ts +91 -0
  96. package/dist/corpus/coverage-probe.js +341 -0
  97. package/dist/corpus/evaluate.d.ts +1 -1
  98. package/dist/corpus/evaluate.js +1 -1
  99. package/dist/corpus/structural-fixture-root.d.ts +2 -0
  100. package/dist/corpus/structural-fixture-root.js +5 -0
  101. package/dist/operational-insights.d.ts +3 -0
  102. package/dist/operational-insights.js +7 -3
  103. package/dist/runtime-provenance.d.ts +8 -0
  104. package/dist/runtime-provenance.js +34 -0
  105. package/dist/types.d.ts +3 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +3 -1
  109. package/skills/belay/SKILL.md +19 -0
  110. package/skills/belay/belay-status.md +5 -0
  111. package/dist/corpus/must-allow-commands.d.ts +0 -6
@@ -2,7 +2,8 @@ import type { TransactionalBackendContext, TransactionalBackendProbe, Transactio
2
2
  export declare const FILE_CHECKPOINT_DISABLED = "file_checkpoint_disabled";
3
3
  export declare const FILE_CHECKPOINT_NON_GIT_DISABLED = "file_checkpoint_non_git_disabled";
4
4
  export declare const FILE_CHECKPOINT_DURABLE_REQUIRED = "file_checkpoint_durable_checkpoint_required";
5
- export declare const FILE_CHECKPOINT_ISOLATION_UNAVAILABLE = "file_checkpoint_isolation_unavailable";
5
+ export { FILE_CHECKPOINT_ISOLATION_UNAVAILABLE } from './file-checkpoint-isolation.js';
6
6
  export declare const FILE_CHECKPOINT_NOT_IMPLEMENTED = "file_checkpoint_not_implemented";
7
7
  export declare function probeTransactionalBackends(context: TransactionalBackendContext): Promise<TransactionalBackendProbe[]>;
8
+ export declare function probeFileCheckpointBackend(context: TransactionalBackendContext): Promise<TransactionalBackendProbe>;
8
9
  export declare function selectTransactionalBackend(context: TransactionalBackendContext): Promise<TransactionalBackendSelection>;
@@ -1,35 +1,21 @@
1
- import { attestsWorkspaceMountIsolation } from '../capability/attestation.js';
2
1
  import { fileCheckpointBackend } from './file-checkpoint-backend.js';
2
+ import { fileCheckpointIsolationReason } from './file-checkpoint-isolation.js';
3
3
  import { isDirtyWorktree, isGitWorktreeAvailable } from './git-worktree.js';
4
4
  import { gitWorktreeBackend } from './git-worktree-backend.js';
5
5
  export const FILE_CHECKPOINT_DISABLED = 'file_checkpoint_disabled';
6
6
  export const FILE_CHECKPOINT_NON_GIT_DISABLED = 'file_checkpoint_non_git_disabled';
7
7
  export const FILE_CHECKPOINT_DURABLE_REQUIRED = 'file_checkpoint_durable_checkpoint_required';
8
- export const FILE_CHECKPOINT_ISOLATION_UNAVAILABLE = 'file_checkpoint_isolation_unavailable';
8
+ export { FILE_CHECKPOINT_ISOLATION_UNAVAILABLE } from './file-checkpoint-isolation.js';
9
9
  export const FILE_CHECKPOINT_NOT_IMPLEMENTED = 'file_checkpoint_not_implemented';
10
- function fileCheckpointProbe(reason, signals) {
10
+ function fileCheckpointProbe(reason, signals, resourceKind) {
11
11
  return {
12
12
  eligible: false,
13
13
  backend: 'file_checkpoint',
14
+ resourceKind,
14
15
  reason,
15
16
  signals,
16
17
  };
17
18
  }
18
- function fileCheckpointIsolationReason(context) {
19
- const attestation = context.boundaryAttestation;
20
- const fresh = context.boundaryAttestationFresh === true;
21
- const driverId = context.boundaryDriverId;
22
- if (!attestation || !fresh || !driverId) {
23
- return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
24
- }
25
- if (attestation.driver !== driverId) {
26
- return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
27
- }
28
- if (!attestsWorkspaceMountIsolation(attestation)) {
29
- return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
30
- }
31
- return null;
32
- }
33
19
  export async function probeTransactionalBackends(context) {
34
20
  const probes = [await gitWorktreeBackend.probe(context)];
35
21
  if (await isGitWorktreeAvailable(context.repoRoot)) {
@@ -40,6 +26,13 @@ export async function probeTransactionalBackends(context) {
40
26
  }
41
27
  return probes;
42
28
  }
29
+ export async function probeFileCheckpointBackend(context) {
30
+ const probes = await probeTransactionalBackends(context);
31
+ const probe = probes.find((candidate) => candidate.backend === 'file_checkpoint');
32
+ if (!probe)
33
+ throw new Error(FILE_CHECKPOINT_NOT_IMPLEMENTED);
34
+ return probe;
35
+ }
43
36
  async function gitWorkspaceSignals(context) {
44
37
  const signals = ['git_repository'];
45
38
  if (await isDirtyWorktree(context.repoRoot, { ignoreRoots: context.dirtyIgnoreRoots })) {
@@ -50,14 +43,14 @@ async function gitWorkspaceSignals(context) {
50
43
  async function probeFileCheckpointForGit(context) {
51
44
  const signals = await gitWorkspaceSignals(context);
52
45
  if (!context.fileCheckpoint.enabled) {
53
- return fileCheckpointProbe(FILE_CHECKPOINT_DISABLED, signals);
46
+ return fileCheckpointProbe(FILE_CHECKPOINT_DISABLED, signals, 'git_repository');
54
47
  }
55
48
  if (!context.durableCheckpointEnabled) {
56
- return fileCheckpointProbe(FILE_CHECKPOINT_DURABLE_REQUIRED, signals);
49
+ return fileCheckpointProbe(FILE_CHECKPOINT_DURABLE_REQUIRED, signals, 'git_repository');
57
50
  }
58
51
  const isolationReason = fileCheckpointIsolationReason(context);
59
52
  if (isolationReason) {
60
- return fileCheckpointProbe(isolationReason, [...signals, 'isolation_unavailable']);
53
+ return fileCheckpointProbe(isolationReason, [...signals, 'isolation_unavailable'], 'git_repository');
61
54
  }
62
55
  const backendProbe = await fileCheckpointBackend.probe(context);
63
56
  if (backendProbe.eligible) {
@@ -66,26 +59,30 @@ async function probeFileCheckpointForGit(context) {
66
59
  return fileCheckpointProbe(backendProbe.reason ?? FILE_CHECKPOINT_NOT_IMPLEMENTED, [
67
60
  ...signals,
68
61
  ...(backendProbe.reason === FILE_CHECKPOINT_NOT_IMPLEMENTED ? ['not_implemented'] : []),
69
- ]);
62
+ ], 'git_repository');
70
63
  }
71
64
  async function probeFileCheckpointForNonGit(context) {
72
65
  if (!context.fileCheckpoint.enabled) {
73
- return fileCheckpointProbe(FILE_CHECKPOINT_DISABLED, ['non_git_workspace']);
66
+ return fileCheckpointProbe(FILE_CHECKPOINT_DISABLED, ['non_git_workspace'], 'directory');
74
67
  }
75
68
  if (!context.fileCheckpoint.allowNonGit) {
76
- return fileCheckpointProbe(FILE_CHECKPOINT_NON_GIT_DISABLED, ['non_git_workspace']);
69
+ return fileCheckpointProbe(FILE_CHECKPOINT_NON_GIT_DISABLED, ['non_git_workspace'], 'directory');
77
70
  }
78
71
  if (!context.durableCheckpointEnabled) {
79
- return fileCheckpointProbe(FILE_CHECKPOINT_DURABLE_REQUIRED, ['non_git_workspace']);
72
+ return fileCheckpointProbe(FILE_CHECKPOINT_DURABLE_REQUIRED, ['non_git_workspace'], 'directory');
80
73
  }
81
74
  const isolationReason = fileCheckpointIsolationReason(context);
82
75
  if (isolationReason) {
83
- return fileCheckpointProbe(isolationReason, ['non_git_workspace', 'isolation_unavailable']);
76
+ return fileCheckpointProbe(isolationReason, ['non_git_workspace', 'isolation_unavailable'], 'directory');
84
77
  }
85
- return fileCheckpointProbe(FILE_CHECKPOINT_NOT_IMPLEMENTED, [
78
+ const backendProbe = await fileCheckpointBackend.probe(context);
79
+ if (backendProbe.eligible) {
80
+ return backendProbe;
81
+ }
82
+ return fileCheckpointProbe(backendProbe.reason ?? FILE_CHECKPOINT_NOT_IMPLEMENTED, [
86
83
  'non_git_workspace',
87
- 'not_implemented',
88
- ]);
84
+ ...(backendProbe.reason === FILE_CHECKPOINT_NOT_IMPLEMENTED ? ['not_implemented'] : []),
85
+ ], 'directory');
89
86
  }
90
87
  export async function selectTransactionalBackend(context) {
91
88
  const gitProbe = await gitWorktreeBackend.probe(context);
@@ -110,6 +107,12 @@ export async function selectTransactionalBackend(context) {
110
107
  };
111
108
  }
112
109
  const fileProbe = await probeFileCheckpointForNonGit(context);
110
+ if (fileProbe.eligible) {
111
+ return {
112
+ backend: fileCheckpointBackend,
113
+ probe: fileProbe,
114
+ };
115
+ }
113
116
  return {
114
117
  backend: null,
115
118
  probe: fileProbe,
@@ -5,6 +5,7 @@ export type TransactionalBackendId = 'git_worktree' | 'file_checkpoint';
5
5
  export interface TransactionalBackendProbe {
6
6
  eligible: boolean;
7
7
  backend: TransactionalBackendId;
8
+ resourceKind: 'git_repository' | 'directory';
8
9
  reason?: string;
9
10
  signals: string[];
10
11
  }
@@ -26,6 +27,8 @@ export interface TransactionalSnapshot {
26
27
  executionCwdRelative?: string;
27
28
  /** Revalidates the source tree and metadata immediately before apply. */
28
29
  validateSourceState?(): Promise<void>;
30
+ /** Revalidates resource identity immediately before each real mutation. */
31
+ validateResourceIdentity?(): Promise<void>;
29
32
  collectChanges(): Promise<TransactionalFileChange[]>;
30
33
  cleanup(): Promise<void>;
31
34
  }
@@ -1,37 +1,23 @@
1
- import { cp, lstat, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
1
+ import { cp, lstat, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { attestsWorkspaceMountIsolation } from '../capability/attestation.js';
5
4
  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';
5
+ import { FILE_CHECKPOINT_DISABLED, FILE_CHECKPOINT_DURABLE_REQUIRED, FILE_CHECKPOINT_NON_GIT_DISABLED, } from './backend-selector.js';
6
+ import { assertRootGitMetadataUnchanged, cloneBareWorktreeCopy, computeGitMetadataFingerprint, copyGitIndexState, execGit, FILE_CHECKPOINT_GIT_METADATA_CHANGED, FILE_CHECKPOINT_PREPARE_FAILED, FILE_CHECKPOINT_SOURCE_CHANGED, removeAllGitRemotes, resolveExecutionCwdRelative, rethrowStableFileCheckpointError, } from './file-checkpoint-git.js';
7
+ import { fileCheckpointIsolationReason } from './file-checkpoint-isolation.js';
8
8
  import { removeDeadOwnerStaging, writeOwnerMarker } from './file-checkpoint-staging.js';
9
9
  import { cloneDirectoryTree, probeFileCloneStrategy } from './file-clone.js';
10
10
  import { buildFileTreeIndex, diffFileTreeIndices, FILE_CHECKPOINT_PREPARE_TIMEOUT, FILE_CHECKPOINT_QUOTA_EXCEEDED, FileCheckpointDiagnosticError, } from './file-tree.js';
11
11
  import { isDirtyWorktree, isGitWorktreeAvailable } from './git-worktree.js';
12
12
  import { readSnapshotNode } from './snapshot-node.js';
13
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
14
  async function probeDirtyGitFileCheckpoint(context) {
30
15
  const signals = ['git_repository', 'dirty_git_worktree'];
31
16
  if (!(await isGitWorktreeAvailable(context.repoRoot))) {
32
17
  return {
33
18
  eligible: false,
34
19
  backend: 'file_checkpoint',
20
+ resourceKind: 'git_repository',
35
21
  reason: 'git_worktree_unavailable',
36
22
  signals: ['git_repository'],
37
23
  };
@@ -40,6 +26,7 @@ async function probeDirtyGitFileCheckpoint(context) {
40
26
  return {
41
27
  eligible: false,
42
28
  backend: 'file_checkpoint',
29
+ resourceKind: 'git_repository',
43
30
  reason: 'clean_git_worktree',
44
31
  signals: ['git_repository', 'clean_git_worktree'],
45
32
  };
@@ -48,6 +35,7 @@ async function probeDirtyGitFileCheckpoint(context) {
48
35
  return {
49
36
  eligible: false,
50
37
  backend: 'file_checkpoint',
38
+ resourceKind: 'git_repository',
51
39
  reason: FILE_CHECKPOINT_DISABLED,
52
40
  signals,
53
41
  };
@@ -56,6 +44,7 @@ async function probeDirtyGitFileCheckpoint(context) {
56
44
  return {
57
45
  eligible: false,
58
46
  backend: 'file_checkpoint',
47
+ resourceKind: 'git_repository',
59
48
  reason: FILE_CHECKPOINT_DURABLE_REQUIRED,
60
49
  signals,
61
50
  };
@@ -65,6 +54,7 @@ async function probeDirtyGitFileCheckpoint(context) {
65
54
  return {
66
55
  eligible: false,
67
56
  backend: 'file_checkpoint',
57
+ resourceKind: 'git_repository',
68
58
  reason: isolationReason,
69
59
  signals: [...signals, 'isolation_unavailable'],
70
60
  };
@@ -73,9 +63,66 @@ async function probeDirtyGitFileCheckpoint(context) {
73
63
  return {
74
64
  eligible: true,
75
65
  backend: 'file_checkpoint',
66
+ resourceKind: 'git_repository',
76
67
  signals: [...signals, 'dirty_git_file_checkpoint', copyStrategy],
77
68
  };
78
69
  }
70
+ async function probeNonGitFileCheckpoint(context) {
71
+ const signals = ['non_git_workspace'];
72
+ if (await isGitWorktreeAvailable(context.repoRoot)) {
73
+ return {
74
+ eligible: false,
75
+ backend: 'file_checkpoint',
76
+ resourceKind: 'directory',
77
+ reason: 'git_worktree_available',
78
+ signals: ['git_repository'],
79
+ };
80
+ }
81
+ if (!context.fileCheckpoint.enabled) {
82
+ return {
83
+ eligible: false,
84
+ backend: 'file_checkpoint',
85
+ resourceKind: 'directory',
86
+ reason: FILE_CHECKPOINT_DISABLED,
87
+ signals,
88
+ };
89
+ }
90
+ if (!context.fileCheckpoint.allowNonGit) {
91
+ return {
92
+ eligible: false,
93
+ backend: 'file_checkpoint',
94
+ resourceKind: 'directory',
95
+ reason: FILE_CHECKPOINT_NON_GIT_DISABLED,
96
+ signals,
97
+ };
98
+ }
99
+ if (!context.durableCheckpointEnabled) {
100
+ return {
101
+ eligible: false,
102
+ backend: 'file_checkpoint',
103
+ resourceKind: 'directory',
104
+ reason: FILE_CHECKPOINT_DURABLE_REQUIRED,
105
+ signals,
106
+ };
107
+ }
108
+ const isolationReason = fileCheckpointIsolationReason(context);
109
+ if (isolationReason) {
110
+ return {
111
+ eligible: false,
112
+ backend: 'file_checkpoint',
113
+ resourceKind: 'directory',
114
+ reason: isolationReason,
115
+ signals: [...signals, 'isolation_unavailable'],
116
+ };
117
+ }
118
+ const copyStrategy = await probeFileCloneStrategy();
119
+ return {
120
+ eligible: true,
121
+ backend: 'file_checkpoint',
122
+ resourceKind: 'directory',
123
+ signals: [...signals, 'non_git_file_checkpoint', copyStrategy],
124
+ };
125
+ }
79
126
  async function protectedRootState(root) {
80
127
  const node = await readSnapshotNode(root);
81
128
  if (node.kind !== 'directory') {
@@ -163,6 +210,9 @@ async function prepareDirtyGitSnapshot(context) {
163
210
  quotas,
164
211
  deadlineMs,
165
212
  });
213
+ if (sourceIndex.treeHash !== baselineCopy.sourceIndex.treeHash) {
214
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
215
+ }
166
216
  if (sourceIndex.treeHash !== baselineIndex.treeHash) {
167
217
  throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
168
218
  }
@@ -200,35 +250,190 @@ async function prepareDirtyGitSnapshot(context) {
200
250
  if (error instanceof FileCheckpointDiagnosticError) {
201
251
  throw error;
202
252
  }
203
- if (error instanceof Error && error.message === FILE_CHECKPOINT_CWD_OUTSIDE_ROOT) {
204
- throw error;
253
+ rethrowStableFileCheckpointError(error);
254
+ }
255
+ }
256
+ async function prepareNonGitSnapshot(context) {
257
+ const prepareStartedAt = Date.now();
258
+ const excludedRoots = context.dirtyIgnoreRoots ?? [];
259
+ const quotas = context.fileCheckpoint;
260
+ const deadlineMs = Date.now() + quotas.prepareTimeoutMs;
261
+ await removeDeadOwnerStaging(os.tmpdir());
262
+ const stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'belay-file-checkpoint-'));
263
+ await writeOwnerMarker(stagingRoot, {
264
+ version: 1,
265
+ pid: process.pid,
266
+ createdAt: new Date().toISOString(),
267
+ resourceRoot: context.repoRoot,
268
+ backend: 'file_checkpoint',
269
+ });
270
+ const baselineRoot = path.join(stagingRoot, 'baseline');
271
+ const executionRoot = path.join(stagingRoot, 'execution');
272
+ try {
273
+ resolveExecutionCwdRelative(context.repoRoot, context.cwd);
274
+ const resourceIdentity = await currentRecoveryResourceIdentity(context.repoRoot, 'directory');
275
+ const baselineCopy = await cloneDirectoryTree(context.repoRoot, baselineRoot, {
276
+ excludedRoots,
277
+ quotas,
278
+ deadlineMs,
279
+ });
280
+ await mkdir(baselineRoot, { recursive: true });
281
+ const baselineIndex = await buildFileTreeIndex({
282
+ resourceRoot: baselineRoot,
283
+ excludedRoots,
284
+ quotas,
285
+ deadlineMs,
286
+ });
287
+ const sourceIndex = await buildFileTreeIndex({
288
+ resourceRoot: context.repoRoot,
289
+ excludedRoots,
290
+ quotas,
291
+ deadlineMs,
292
+ });
293
+ if (sourceIndex.treeHash !== baselineCopy.sourceIndex.treeHash) {
294
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
205
295
  }
206
- if (error instanceof Error && error.message === FILE_CHECKPOINT_SOURCE_CHANGED) {
207
- throw error;
296
+ if (sourceIndex.treeHash !== baselineIndex.treeHash) {
297
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
208
298
  }
209
- throw new Error(error instanceof Error ? error.message : FILE_CHECKPOINT_PREPARE_FAILED, {
210
- cause: error,
299
+ await writeFile(path.join(stagingRoot, 'baseline-index.json'), `${JSON.stringify(baselineIndex)}\n`, 'utf8');
300
+ const executionCopy = await cloneDirectoryTree(baselineRoot, executionRoot, {
301
+ excludedRoots,
302
+ quotas,
303
+ deadlineMs,
211
304
  });
305
+ await mkdir(executionRoot, { recursive: true });
306
+ const finalSourceIndex = await buildFileTreeIndex({
307
+ resourceRoot: context.repoRoot,
308
+ excludedRoots,
309
+ quotas,
310
+ deadlineMs,
311
+ });
312
+ if (finalSourceIndex.treeHash !== baselineIndex.treeHash) {
313
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
314
+ }
315
+ if ((await currentRecoveryResourceIdentity(context.repoRoot, 'directory')) !== resourceIdentity) {
316
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
317
+ }
318
+ const protectedRootStates = await captureProtectedRootStates(context.repoRoot, executionRoot, excludedRoots);
319
+ const workspaceBytes = await directoryByteSize(stagingRoot, deadlineMs);
320
+ if (workspaceBytes > quotas.maxWorkspaceBytes) {
321
+ throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_QUOTA_EXCEEDED, `snapshot workspaceBytes=${workspaceBytes} exceeds maxWorkspaceBytes=${quotas.maxWorkspaceBytes}`);
322
+ }
323
+ return {
324
+ stagingRoot,
325
+ baselineRoot,
326
+ executionRoot,
327
+ baselineIndex,
328
+ resourceIdentity,
329
+ copyStrategy: executionCopy.strategy === baselineCopy.strategy ? executionCopy.strategy : 'copy',
330
+ workspaceBytes,
331
+ prepareMs: Date.now() - prepareStartedAt,
332
+ protectedRootStates,
333
+ };
334
+ }
335
+ catch (error) {
336
+ await rm(stagingRoot, { recursive: true, force: true });
337
+ if (error instanceof FileCheckpointDiagnosticError) {
338
+ throw error;
339
+ }
340
+ rethrowStableFileCheckpointError(error);
212
341
  }
213
342
  }
343
+ function collectObservedChanges(prepared, context) {
344
+ return async () => {
345
+ for (const [protectedRoot, before] of prepared.protectedRootStates) {
346
+ if ((await protectedRootState(protectedRoot)) !== before) {
347
+ throw new Error(FILE_CHECKPOINT_PROTECTED_PATH_CHANGED);
348
+ }
349
+ }
350
+ const executionIndex = await buildFileTreeIndex({
351
+ resourceRoot: prepared.executionRoot,
352
+ excludedRoots: context.dirtyIgnoreRoots ?? [],
353
+ quotas: context.fileCheckpoint,
354
+ });
355
+ const observed = diffFileTreeIndices(prepared.baselineIndex, executionIndex);
356
+ return observed.map((change) => ({
357
+ relativePath: change.relativePath,
358
+ kind: change.kind,
359
+ }));
360
+ };
361
+ }
214
362
  export const fileCheckpointBackend = {
215
363
  id: 'file_checkpoint',
216
- probe: probeDirtyGitFileCheckpoint,
364
+ async probe(context) {
365
+ if (await isGitWorktreeAvailable(context.repoRoot)) {
366
+ return probeDirtyGitFileCheckpoint(context);
367
+ }
368
+ return probeNonGitFileCheckpoint(context);
369
+ },
217
370
  async prepare(context) {
218
- const probe = await probeDirtyGitFileCheckpoint(context);
371
+ if (await isGitWorktreeAvailable(context.repoRoot)) {
372
+ const probe = await probeDirtyGitFileCheckpoint(context);
373
+ if (!probe.eligible) {
374
+ throw new Error(probe.reason ?? FILE_CHECKPOINT_PREPARE_FAILED);
375
+ }
376
+ const prepared = await prepareDirtyGitSnapshot(context);
377
+ const executionCwdRelative = resolveExecutionCwdRelative(context.repoRoot, context.cwd);
378
+ const resourceIdentity = await currentRecoveryResourceIdentity(context.repoRoot, 'git_repository');
379
+ return {
380
+ backend: 'file_checkpoint',
381
+ resourceRoot: context.repoRoot,
382
+ executionRoot: prepared.executionRoot,
383
+ baselineRoot: prepared.baselineRoot,
384
+ resourceKind: 'git_repository',
385
+ resourceIdentity,
386
+ baselineTreeHash: prepared.baselineIndex.treeHash,
387
+ excludedRoots: context.dirtyIgnoreRoots ?? [],
388
+ copyStrategy: prepared.copyStrategy,
389
+ snapshotFileCount: prepared.baselineIndex.fileCount,
390
+ snapshotSourceBytes: prepared.baselineIndex.totalFileBytes,
391
+ snapshotWorkspaceBytes: prepared.workspaceBytes,
392
+ snapshotPrepareMs: prepared.prepareMs,
393
+ executionCwdRelative,
394
+ async validateSourceState() {
395
+ const sourceIndex = await buildFileTreeIndex({
396
+ resourceRoot: context.repoRoot,
397
+ excludedRoots: context.dirtyIgnoreRoots ?? [],
398
+ quotas: context.fileCheckpoint,
399
+ });
400
+ if (sourceIndex.treeHash !== prepared.baselineIndex.treeHash ||
401
+ (await computeGitMetadataFingerprint(context.repoRoot)) !==
402
+ prepared.sourceGitMetadataFingerprint) {
403
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
404
+ }
405
+ },
406
+ async collectChanges() {
407
+ const afterFingerprint = await computeGitMetadataFingerprint(prepared.executionRoot);
408
+ if (afterFingerprint !== prepared.gitMetadataFingerprint) {
409
+ throw new Error(FILE_CHECKPOINT_GIT_METADATA_CHANGED);
410
+ }
411
+ return collectObservedChanges(prepared, context)();
412
+ },
413
+ async cleanup() {
414
+ await rm(prepared.stagingRoot, { recursive: true, force: true });
415
+ },
416
+ };
417
+ }
418
+ const probe = await probeNonGitFileCheckpoint(context);
219
419
  if (!probe.eligible) {
220
420
  throw new Error(probe.reason ?? FILE_CHECKPOINT_PREPARE_FAILED);
221
421
  }
222
- const prepared = await prepareDirtyGitSnapshot(context);
422
+ const prepared = await prepareNonGitSnapshot(context);
223
423
  const executionCwdRelative = resolveExecutionCwdRelative(context.repoRoot, context.cwd);
224
- const resourceIdentity = await currentRecoveryResourceIdentity(context.repoRoot, 'git_repository');
424
+ const validateResourceIdentity = async () => {
425
+ if ((await currentRecoveryResourceIdentity(context.repoRoot, 'directory')) !==
426
+ prepared.resourceIdentity) {
427
+ throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
428
+ }
429
+ };
225
430
  return {
226
431
  backend: 'file_checkpoint',
227
432
  resourceRoot: context.repoRoot,
228
433
  executionRoot: prepared.executionRoot,
229
434
  baselineRoot: prepared.baselineRoot,
230
- resourceKind: 'git_repository',
231
- resourceIdentity,
435
+ resourceKind: 'directory',
436
+ resourceIdentity: prepared.resourceIdentity,
232
437
  baselineTreeHash: prepared.baselineIndex.treeHash,
233
438
  excludedRoots: context.dirtyIgnoreRoots ?? [],
234
439
  copyStrategy: prepared.copyStrategy,
@@ -243,32 +448,15 @@ export const fileCheckpointBackend = {
243
448
  excludedRoots: context.dirtyIgnoreRoots ?? [],
244
449
  quotas: context.fileCheckpoint,
245
450
  });
246
- if (sourceIndex.treeHash !== prepared.baselineIndex.treeHash ||
247
- (await computeGitMetadataFingerprint(context.repoRoot)) !==
248
- prepared.sourceGitMetadataFingerprint) {
451
+ if (sourceIndex.treeHash !== prepared.baselineIndex.treeHash) {
249
452
  throw new Error(FILE_CHECKPOINT_SOURCE_CHANGED);
250
453
  }
454
+ await validateResourceIdentity();
251
455
  },
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
- }));
456
+ validateResourceIdentity,
457
+ collectChanges: async () => {
458
+ await assertRootGitMetadataUnchanged(prepared.baselineRoot, prepared.executionRoot);
459
+ return collectObservedChanges(prepared, context)();
272
460
  },
273
461
  async cleanup() {
274
462
  await rm(prepared.stagingRoot, { recursive: true, force: true });
@@ -3,6 +3,9 @@ export declare const FILE_CHECKPOINT_SOURCE_CHANGED = "file_checkpoint_source_ch
3
3
  export declare const FILE_CHECKPOINT_CWD_OUTSIDE_ROOT = "file_checkpoint_cwd_outside_root";
4
4
  export declare const FILE_CHECKPOINT_PREPARE_FAILED = "file_checkpoint_prepare_failed";
5
5
  export declare const FILE_CHECKPOINT_SPLIT_INDEX_UNSUPPORTED = "file_checkpoint_split_index_unsupported";
6
+ export declare function rethrowStableFileCheckpointError(error: unknown): never;
7
+ export declare function rootGitMetadataPresent(repoRoot: string): Promise<boolean>;
8
+ export declare function assertRootGitMetadataUnchanged(baselineRoot: string, executionRoot: string): Promise<void>;
6
9
  export declare function execGit(repoRoot: string, args: string[]): Promise<string>;
7
10
  export declare function cloneBareWorktreeCopy(sourceRoot: string, destinationRoot: string): Promise<void>;
8
11
  export declare function removeAllGitRemotes(repoRoot: string): Promise<void>;
@@ -8,6 +8,59 @@ export const FILE_CHECKPOINT_SOURCE_CHANGED = 'file_checkpoint_source_changed';
8
8
  export const FILE_CHECKPOINT_CWD_OUTSIDE_ROOT = 'file_checkpoint_cwd_outside_root';
9
9
  export const FILE_CHECKPOINT_PREPARE_FAILED = 'file_checkpoint_prepare_failed';
10
10
  export const FILE_CHECKPOINT_SPLIT_INDEX_UNSUPPORTED = 'file_checkpoint_split_index_unsupported';
11
+ const STABLE_FILE_CHECKPOINT_ERRORS = new Set([
12
+ FILE_CHECKPOINT_GIT_METADATA_CHANGED,
13
+ FILE_CHECKPOINT_SOURCE_CHANGED,
14
+ FILE_CHECKPOINT_CWD_OUTSIDE_ROOT,
15
+ FILE_CHECKPOINT_PREPARE_FAILED,
16
+ FILE_CHECKPOINT_SPLIT_INDEX_UNSUPPORTED,
17
+ 'file_checkpoint_disabled',
18
+ 'file_checkpoint_non_git_disabled',
19
+ 'file_checkpoint_durable_checkpoint_required',
20
+ 'file_checkpoint_isolation_unavailable',
21
+ 'file_checkpoint_not_implemented',
22
+ 'file_checkpoint_nested_repository',
23
+ 'file_checkpoint_unsupported_node',
24
+ 'file_checkpoint_hardlink_unsupported',
25
+ 'file_checkpoint_quota_exceeded',
26
+ 'file_checkpoint_prepare_timeout',
27
+ 'file_checkpoint_copy_failed',
28
+ 'file_checkpoint_protected_path_changed',
29
+ 'file_checkpoint_path_escape',
30
+ ]);
31
+ export function rethrowStableFileCheckpointError(error) {
32
+ if (error instanceof Error && STABLE_FILE_CHECKPOINT_ERRORS.has(error.message)) {
33
+ throw error;
34
+ }
35
+ throw new Error(FILE_CHECKPOINT_PREPARE_FAILED, { cause: error });
36
+ }
37
+ export async function rootGitMetadataPresent(repoRoot) {
38
+ try {
39
+ await lstat(path.join(repoRoot, '.git'));
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ export async function assertRootGitMetadataUnchanged(baselineRoot, executionRoot) {
47
+ const [baselinePresent, executionPresent] = await Promise.all([
48
+ rootGitMetadataPresent(baselineRoot),
49
+ rootGitMetadataPresent(executionRoot),
50
+ ]);
51
+ if (baselinePresent !== executionPresent) {
52
+ throw new Error(FILE_CHECKPOINT_GIT_METADATA_CHANGED);
53
+ }
54
+ if (executionPresent) {
55
+ const [baselineFingerprint, executionFingerprint] = await Promise.all([
56
+ computeGitMetadataFingerprint(baselineRoot),
57
+ computeGitMetadataFingerprint(executionRoot),
58
+ ]);
59
+ if (baselineFingerprint !== executionFingerprint) {
60
+ throw new Error(FILE_CHECKPOINT_GIT_METADATA_CHANGED);
61
+ }
62
+ }
63
+ }
11
64
  export function execGit(repoRoot, args) {
12
65
  return new Promise((resolve, reject) => {
13
66
  const child = spawn('git', ['-C', repoRoot, ...args], {
@@ -0,0 +1,3 @@
1
+ import type { TransactionalBackendContext } from './backend.js';
2
+ export declare const FILE_CHECKPOINT_ISOLATION_UNAVAILABLE = "file_checkpoint_isolation_unavailable";
3
+ export declare function fileCheckpointIsolationReason(context: TransactionalBackendContext): string | null;
@@ -0,0 +1,16 @@
1
+ import { attestsWorkspaceMountIsolation, isAttestationCurrent } from '../capability/attestation.js';
2
+ export const FILE_CHECKPOINT_ISOLATION_UNAVAILABLE = 'file_checkpoint_isolation_unavailable';
3
+ export function fileCheckpointIsolationReason(context) {
4
+ const attestation = context.boundaryAttestation;
5
+ const driverId = context.boundaryDriverId;
6
+ if (!attestation || !isAttestationCurrent(attestation) || !driverId) {
7
+ return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
8
+ }
9
+ if (attestation.driver !== driverId) {
10
+ return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
11
+ }
12
+ if (!attestsWorkspaceMountIsolation(attestation)) {
13
+ return FILE_CHECKPOINT_ISOLATION_UNAVAILABLE;
14
+ }
15
+ return null;
16
+ }
@@ -4,6 +4,7 @@ async function probeGitWorktreeBackend(context) {
4
4
  return {
5
5
  eligible: false,
6
6
  backend: 'git_worktree',
7
+ resourceKind: 'git_repository',
7
8
  reason: 'git_worktree_unavailable',
8
9
  signals: [],
9
10
  };
@@ -12,6 +13,7 @@ async function probeGitWorktreeBackend(context) {
12
13
  return {
13
14
  eligible: false,
14
15
  backend: 'git_worktree',
16
+ resourceKind: 'git_repository',
15
17
  reason: 'dirty_worktree',
16
18
  signals: ['dirty_git_worktree'],
17
19
  };
@@ -19,6 +21,7 @@ async function probeGitWorktreeBackend(context) {
19
21
  return {
20
22
  eligible: true,
21
23
  backend: 'git_worktree',
24
+ resourceKind: 'git_repository',
22
25
  signals: ['clean_git_worktree'],
23
26
  };
24
27
  }
@@ -15,4 +15,5 @@ export declare function applyWorktreeChanges(worktreePath: string, repoRoot: str
15
15
  /** Runs while rollback backups are still available. */
16
16
  afterApply?: () => Promise<void>;
17
17
  observedChanges?: Awaited<ReturnType<typeof buildObservedChangesFromTransactional>>;
18
+ beforeMutation?: () => Promise<void>;
18
19
  }): Promise<void>;