@guilz-dev/belay 0.6.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.
- package/README.md +20 -1
- package/dist/adapters/claude/runtime-entry.js +54 -25
- package/dist/adapters/shared/gate-runtime.d.ts +7 -0
- package/dist/adapters/shared/gate-runtime.js +420 -220
- package/dist/bundle/claude-runtime.mjs +13215 -8864
- package/dist/bundle/codex-runtime.mjs +10771 -6453
- package/dist/bundle/cursor-runtime.mjs +10797 -6484
- package/dist/cli.js +37 -1
- package/dist/commands/approve.js +34 -10
- package/dist/commands/doctor.js +51 -0
- package/dist/commands/recovery-checkpoints.d.ts +164 -0
- package/dist/commands/recovery-checkpoints.js +347 -0
- package/dist/commands/revoke.js +19 -7
- package/dist/commands/status.js +7 -0
- package/dist/config-io.js +49 -18
- package/dist/conformance/guarantee-table.js +6 -0
- package/dist/conformance/types.d.ts +1 -0
- package/dist/core/approval-replay.d.ts +1 -1
- package/dist/core/approval-replay.js +4 -1
- package/dist/core/approval-service.d.ts +6 -1
- package/dist/core/approval-service.js +86 -27
- package/dist/core/approval.d.ts +2 -0
- package/dist/core/approval.js +6 -3
- package/dist/core/audit-types.d.ts +13 -0
- package/dist/core/capability/approval-state-mutation.d.ts +19 -0
- package/dist/core/capability/approval-state-mutation.js +76 -3
- package/dist/core/capability/approval-v3.d.ts +18 -1
- package/dist/core/capability/approval-v3.js +63 -23
- package/dist/core/capability/attestation.d.ts +3 -0
- package/dist/core/capability/attestation.js +7 -0
- package/dist/core/capability/boundary-driver-container.d.ts +12 -0
- package/dist/core/capability/boundary-driver-container.js +101 -34
- package/dist/core/capability/boundary-driver.js +1 -0
- package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
- package/dist/core/capability/boundary-grant-materialize.js +15 -0
- package/dist/core/capability/boundary-run.d.ts +25 -0
- package/dist/core/capability/boundary-run.js +37 -9
- package/dist/core/capability/boundary-session.d.ts +4 -0
- package/dist/core/capability/boundary-session.js +9 -0
- package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
- package/dist/core/capability/boundary-workspace-mount.js +68 -0
- package/dist/core/capability/gate-policy-shadow.js +23 -1
- package/dist/core/capability/grant-consumption.d.ts +4 -0
- package/dist/core/capability/grant-consumption.js +11 -0
- package/dist/core/capability/grant-lease.d.ts +36 -1
- package/dist/core/capability/grant-lease.js +213 -16
- package/dist/core/capability/grant-match.js +3 -0
- package/dist/core/capability/index.d.ts +1 -1
- package/dist/core/capability/index.js +1 -1
- package/dist/core/capability/policy-engine.d.ts +7 -0
- package/dist/core/capability/policy-engine.js +202 -15
- package/dist/core/capability/request.d.ts +3 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.js +62 -0
- package/dist/core/effect-ir/audit.d.ts +9 -0
- package/dist/core/effect-ir/audit.js +86 -0
- package/dist/core/effect-ir/build.d.ts +31 -0
- package/dist/core/effect-ir/build.js +277 -0
- package/dist/core/effect-ir/index.d.ts +8 -0
- package/dist/core/effect-ir/index.js +7 -0
- package/dist/core/effect-ir/normalize.d.ts +4 -0
- package/dist/core/effect-ir/normalize.js +104 -0
- package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
- package/dist/core/effect-ir/package-exec-eval.js +152 -0
- package/dist/core/effect-ir/package-exec.d.ts +29 -0
- package/dist/core/effect-ir/package-exec.js +292 -0
- package/dist/core/effect-ir/policy.d.ts +13 -0
- package/dist/core/effect-ir/policy.js +77 -0
- package/dist/core/effect-ir/types.d.ts +55 -0
- package/dist/core/effect-ir/types.js +1 -0
- package/dist/core/egress/allowlist.d.ts +1 -0
- package/dist/core/egress/allowlist.js +8 -0
- package/dist/core/egress-approval.js +45 -51
- package/dist/core/gate-contract.d.ts +2 -0
- package/dist/core/gate-contract.js +2 -0
- package/dist/core/gate-engine.js +26 -9
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +2 -1
- package/dist/core/judge-cloud-consent.js +29 -15
- package/dist/core/network-endpoint.d.ts +10 -0
- package/dist/core/network-endpoint.js +51 -0
- package/dist/core/process-runner.d.ts +10 -0
- package/dist/core/process-runner.js +124 -0
- package/dist/core/recover-advice.js +2 -1
- package/dist/core/recovery/artifact-store.d.ts +29 -0
- package/dist/core/recovery/artifact-store.js +336 -0
- package/dist/core/recovery/checkpoint.d.ts +25 -0
- package/dist/core/recovery/checkpoint.js +260 -0
- package/dist/core/recovery/index.d.ts +3 -1
- package/dist/core/recovery/index.js +2 -0
- package/dist/core/recovery/operator-guidance.d.ts +7 -0
- package/dist/core/recovery/operator-guidance.js +39 -0
- package/dist/core/recovery/reconcile.d.ts +3 -0
- package/dist/core/recovery/reconcile.js +48 -0
- package/dist/core/recovery/resource-identity.d.ts +4 -0
- package/dist/core/recovery/resource-identity.js +34 -0
- package/dist/core/recovery/restore.d.ts +13 -0
- package/dist/core/recovery/restore.js +114 -0
- package/dist/core/recovery/snapshot-node.d.ts +27 -0
- package/dist/core/recovery/snapshot-node.js +261 -0
- package/dist/core/recovery/types.d.ts +85 -1
- package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
- package/dist/core/transactional/apply-observed-changes.js +275 -0
- package/dist/core/transactional/backend-selector.d.ts +8 -0
- package/dist/core/transactional/backend-selector.js +118 -0
- package/dist/core/transactional/backend.d.ts +55 -0
- package/dist/core/transactional/backend.js +1 -0
- package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
- package/dist/core/transactional/file-checkpoint-backend.js +278 -0
- package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
- package/dist/core/transactional/file-checkpoint-git.js +217 -0
- package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
- package/dist/core/transactional/file-checkpoint-staging.js +54 -0
- package/dist/core/transactional/file-clone.d.ts +15 -0
- package/dist/core/transactional/file-clone.js +139 -0
- package/dist/core/transactional/file-tree-path.d.ts +7 -0
- package/dist/core/transactional/file-tree-path.js +50 -0
- package/dist/core/transactional/file-tree.d.ts +41 -0
- package/dist/core/transactional/file-tree.js +186 -0
- package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
- package/dist/core/transactional/git-worktree-backend.js +50 -0
- package/dist/core/transactional/git-worktree.d.ts +9 -11
- package/dist/core/transactional/git-worktree.js +24 -119
- package/dist/core/transactional/index.d.ts +11 -0
- package/dist/core/transactional/index.js +10 -0
- package/dist/core/transactional/reasons.js +1 -0
- package/dist/core/transactional/runner.js +157 -25
- package/dist/core/transactional/snapshot-node.d.ts +26 -0
- package/dist/core/transactional/snapshot-node.js +87 -0
- package/dist/core/transactional/types.d.ts +17 -1
- package/dist/core/types.d.ts +10 -1
- package/dist/core/verdict/adapter.js +8 -0
- package/dist/core/verdict/launcher-resolve.js +2 -20
- package/dist/core/verdict/shell-policy.d.ts +1 -0
- package/dist/core/verdict/shell-policy.js +1 -1
- package/dist/core/verdict/types.d.ts +5 -0
- package/dist/core/verdict/verdict.js +37 -20
- package/dist/types.d.ts +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/belay/SKILL.md +8 -3
- package/skills/belay/belay-approve.md +10 -7
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, readlink, rm, symlink, utimes, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { buildFileTreeIndex, FILE_CHECKPOINT_QUOTA_EXCEEDED, FileCheckpointDiagnosticError, } from './file-tree.js';
|
|
6
|
+
import { compareRelativePathsBytewise, joinRelativePath } from './file-tree-path.js';
|
|
7
|
+
export const FILE_CHECKPOINT_COPY_FAILED = 'file_checkpoint_copy_failed';
|
|
8
|
+
async function chmodSafe(target, mode) {
|
|
9
|
+
try {
|
|
10
|
+
const { chmod } = await import('node:fs/promises');
|
|
11
|
+
await chmod(target, mode & 0o777);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// best effort
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function copyRegularFile(sourcePath, destinationPath, mode, strategy) {
|
|
18
|
+
await mkdir(path.dirname(destinationPath), { recursive: true });
|
|
19
|
+
if (strategy === 'clonefile' && fsConstants.COPYFILE_FICLONE !== undefined) {
|
|
20
|
+
try {
|
|
21
|
+
await copyFile(sourcePath, destinationPath, fsConstants.COPYFILE_FICLONE);
|
|
22
|
+
await chmodSafe(destinationPath, mode);
|
|
23
|
+
return 'clonefile';
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
await copyFile(sourcePath, destinationPath);
|
|
27
|
+
await chmodSafe(destinationPath, mode);
|
|
28
|
+
return 'copy';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
await copyFile(sourcePath, destinationPath);
|
|
32
|
+
await chmodSafe(destinationPath, mode);
|
|
33
|
+
return strategy;
|
|
34
|
+
}
|
|
35
|
+
async function copyNode(sourceRoot, destinationRoot, relativePath, strategy) {
|
|
36
|
+
const sourcePath = joinRelativePath(sourceRoot, relativePath);
|
|
37
|
+
const destinationPath = joinRelativePath(destinationRoot, relativePath);
|
|
38
|
+
const info = await lstat(sourcePath);
|
|
39
|
+
await rm(destinationPath, { force: true, recursive: false });
|
|
40
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
41
|
+
await mkdir(destinationPath, { recursive: true, mode: info.mode & 0o777 });
|
|
42
|
+
return strategy;
|
|
43
|
+
}
|
|
44
|
+
if (info.isSymbolicLink()) {
|
|
45
|
+
await mkdir(path.dirname(destinationPath), { recursive: true });
|
|
46
|
+
await symlink(await readlink(sourcePath), destinationPath);
|
|
47
|
+
return strategy;
|
|
48
|
+
}
|
|
49
|
+
if (!info.isFile()) {
|
|
50
|
+
throw new Error(FILE_CHECKPOINT_COPY_FAILED);
|
|
51
|
+
}
|
|
52
|
+
const usedStrategy = await copyRegularFile(sourcePath, destinationPath, info.mode, strategy);
|
|
53
|
+
try {
|
|
54
|
+
await utimes(destinationPath, info.atime, info.mtime);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// best effort
|
|
58
|
+
}
|
|
59
|
+
return usedStrategy;
|
|
60
|
+
}
|
|
61
|
+
export async function cloneDirectoryTree(sourceRoot, destinationRoot, options = {}) {
|
|
62
|
+
const sourceIndex = await buildFileTreeIndex({
|
|
63
|
+
resourceRoot: sourceRoot,
|
|
64
|
+
excludedRoots: options.excludedRoots,
|
|
65
|
+
quotas: options.quotas,
|
|
66
|
+
deadlineMs: options.deadlineMs,
|
|
67
|
+
});
|
|
68
|
+
if (options.quotas && sourceIndex.totalFileBytes * 2 > options.quotas.maxWorkspaceBytes) {
|
|
69
|
+
throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_QUOTA_EXCEEDED, `estimated workspaceBytes=${sourceIndex.totalFileBytes * 2} exceeds maxWorkspaceBytes=${options.quotas.maxWorkspaceBytes}`);
|
|
70
|
+
}
|
|
71
|
+
const sortedPaths = [...sourceIndex.entries]
|
|
72
|
+
.map((entry) => entry.relativePath)
|
|
73
|
+
.sort(compareRelativePathsBytewise);
|
|
74
|
+
const concurrency = options.quotas?.copyConcurrency ?? 1;
|
|
75
|
+
const strategyState = { value: 'clonefile' };
|
|
76
|
+
await mapWithConcurrency(sortedPaths, concurrency, async (relativePath) => {
|
|
77
|
+
const used = await copyNode(sourceRoot, destinationRoot, relativePath, strategyState.value);
|
|
78
|
+
if (used === 'copy') {
|
|
79
|
+
strategyState.value = 'copy';
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return { strategy: strategyState.value, sourceIndex };
|
|
83
|
+
}
|
|
84
|
+
async function mapWithConcurrency(items, concurrency, worker) {
|
|
85
|
+
if (items.length === 0) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
89
|
+
let nextIndex = 0;
|
|
90
|
+
async function runWorker() {
|
|
91
|
+
while (nextIndex < items.length) {
|
|
92
|
+
const index = nextIndex;
|
|
93
|
+
nextIndex += 1;
|
|
94
|
+
await worker(items[index]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
await Promise.all(Array.from({ length: limit }, () => runWorker()));
|
|
98
|
+
}
|
|
99
|
+
export async function probeFileCloneStrategy() {
|
|
100
|
+
let tempDir = null;
|
|
101
|
+
try {
|
|
102
|
+
tempDir = await mkdtemp(path.join(os.tmpdir(), 'belay-clone-probe-'));
|
|
103
|
+
const source = path.join(tempDir, 'source.txt');
|
|
104
|
+
const destination = path.join(tempDir, 'dest.txt');
|
|
105
|
+
await writeFile(source, 'probe\n');
|
|
106
|
+
if (fsConstants.COPYFILE_FICLONE_FORCE !== undefined) {
|
|
107
|
+
try {
|
|
108
|
+
await copyFile(source, destination, fsConstants.COPYFILE_FICLONE_FORCE);
|
|
109
|
+
return 'clonefile';
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// fall through to clonefile or copy
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (fsConstants.COPYFILE_FICLONE !== undefined) {
|
|
116
|
+
try {
|
|
117
|
+
await copyFile(source, destination, fsConstants.COPYFILE_FICLONE);
|
|
118
|
+
return 'clonefile';
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return 'copy';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return 'copy';
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return 'copy';
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
if (tempDir) {
|
|
131
|
+
try {
|
|
132
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// best effort cleanup
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const FILE_CHECKPOINT_PATH_ESCAPE = "file_checkpoint_path_escape";
|
|
2
|
+
export declare function validateRelativePath(relativePath: string): void;
|
|
3
|
+
export declare function joinRelativePath(root: string, relativePath: string): string;
|
|
4
|
+
export declare function isRootGitMetadataPath(relativePath: string): boolean;
|
|
5
|
+
export declare function isNestedGitPath(relativePath: string): boolean;
|
|
6
|
+
export declare function isExcludedTreePath(relativePath: string, excludedRoots: string[], resourceRoot: string): boolean;
|
|
7
|
+
export declare function compareRelativePathsBytewise(left: string, right: string): number;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { canonicalPath, isPathOutsideRoot, pathWithinRoot } from '../path-utils.js';
|
|
3
|
+
export const FILE_CHECKPOINT_PATH_ESCAPE = 'file_checkpoint_path_escape';
|
|
4
|
+
export function validateRelativePath(relativePath) {
|
|
5
|
+
if (!relativePath || relativePath.includes('\0')) {
|
|
6
|
+
throw new Error(FILE_CHECKPOINT_PATH_ESCAPE);
|
|
7
|
+
}
|
|
8
|
+
if (path.isAbsolute(relativePath)) {
|
|
9
|
+
throw new Error(FILE_CHECKPOINT_PATH_ESCAPE);
|
|
10
|
+
}
|
|
11
|
+
if (isPathOutsideRoot(relativePath)) {
|
|
12
|
+
throw new Error(FILE_CHECKPOINT_PATH_ESCAPE);
|
|
13
|
+
}
|
|
14
|
+
const normalized = path.normalize(relativePath);
|
|
15
|
+
if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) {
|
|
16
|
+
throw new Error(FILE_CHECKPOINT_PATH_ESCAPE);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function joinRelativePath(root, relativePath) {
|
|
20
|
+
validateRelativePath(relativePath);
|
|
21
|
+
return path.join(canonicalPath(root), relativePath);
|
|
22
|
+
}
|
|
23
|
+
export function isRootGitMetadataPath(relativePath) {
|
|
24
|
+
return relativePath === '.git' || relativePath.startsWith(`.git${path.sep}`);
|
|
25
|
+
}
|
|
26
|
+
export function isNestedGitPath(relativePath) {
|
|
27
|
+
const segments = relativePath.split(path.sep).filter(Boolean);
|
|
28
|
+
if (segments.length === 0) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (segments[0] === '.git') {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return segments.includes('.git');
|
|
35
|
+
}
|
|
36
|
+
export function isExcludedTreePath(relativePath, excludedRoots, resourceRoot) {
|
|
37
|
+
if (isRootGitMetadataPath(relativePath)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (excludedRoots.length === 0) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const absolutePath = joinRelativePath(resourceRoot, relativePath);
|
|
44
|
+
return excludedRoots.some((root) => pathWithinRoot(root, absolutePath) ||
|
|
45
|
+
root === absolutePath ||
|
|
46
|
+
pathWithinRoot(absolutePath, root));
|
|
47
|
+
}
|
|
48
|
+
export function compareRelativePathsBytewise(left, right) {
|
|
49
|
+
return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8'));
|
|
50
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { BelayFileCheckpointConfig } from '../config.js';
|
|
2
|
+
import type { PresentSnapshotNode, SnapshotNode } from './snapshot-node.js';
|
|
3
|
+
import type { TransactionalFileChangeKind } from './types.js';
|
|
4
|
+
export declare const FILE_CHECKPOINT_HARDLINK_UNSUPPORTED = "file_checkpoint_hardlink_unsupported";
|
|
5
|
+
export declare const FILE_CHECKPOINT_UNSUPPORTED_NODE = "file_checkpoint_unsupported_node";
|
|
6
|
+
export declare const FILE_CHECKPOINT_NESTED_REPOSITORY = "file_checkpoint_nested_repository";
|
|
7
|
+
export declare const FILE_CHECKPOINT_QUOTA_EXCEEDED = "file_checkpoint_quota_exceeded";
|
|
8
|
+
export declare const FILE_CHECKPOINT_PREPARE_TIMEOUT = "file_checkpoint_prepare_timeout";
|
|
9
|
+
export declare class FileCheckpointDiagnosticError extends Error {
|
|
10
|
+
readonly diagnostic: string;
|
|
11
|
+
constructor(message: string, diagnostic: string);
|
|
12
|
+
}
|
|
13
|
+
export interface FileTreeEntry {
|
|
14
|
+
relativePath: string;
|
|
15
|
+
node: PresentSnapshotNode;
|
|
16
|
+
}
|
|
17
|
+
export interface FileTreeIndex {
|
|
18
|
+
version: 1;
|
|
19
|
+
entries: FileTreeEntry[];
|
|
20
|
+
treeHash: string;
|
|
21
|
+
fileCount: number;
|
|
22
|
+
directoryCount: number;
|
|
23
|
+
totalFileBytes: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ObservedFileChange {
|
|
26
|
+
relativePath: string;
|
|
27
|
+
kind: TransactionalFileChangeKind;
|
|
28
|
+
before: SnapshotNode;
|
|
29
|
+
after: SnapshotNode;
|
|
30
|
+
}
|
|
31
|
+
export interface FileTreeBuildOptions {
|
|
32
|
+
resourceRoot: string;
|
|
33
|
+
excludedRoots?: string[];
|
|
34
|
+
quotas?: Pick<BelayFileCheckpointConfig, 'maxFiles' | 'maxSourceBytes' | 'prepareTimeoutMs'>;
|
|
35
|
+
deadlineMs?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function computeTreeHash(entries: FileTreeEntry[]): string;
|
|
38
|
+
export declare function buildFileTreeIndex(options: FileTreeBuildOptions): Promise<FileTreeIndex>;
|
|
39
|
+
export declare function diffFileTreeIndices(baseline: FileTreeIndex, observed: FileTreeIndex): ObservedFileChange[];
|
|
40
|
+
export declare function readObservedChanges(baselineRoot: string, executionRoot: string, relativePaths: string[]): Promise<ObservedFileChange[]>;
|
|
41
|
+
export { hashFileContent, hashFileNode, hashSymlinkTarget } from './snapshot-node.js';
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { compareRelativePathsBytewise, isExcludedTreePath, isNestedGitPath, joinRelativePath, validateRelativePath, } from './file-tree-path.js';
|
|
5
|
+
import { hashDirectoryNode, readSnapshotNode, snapshotNodesEqual } from './snapshot-node.js';
|
|
6
|
+
export const FILE_CHECKPOINT_HARDLINK_UNSUPPORTED = 'file_checkpoint_hardlink_unsupported';
|
|
7
|
+
export const FILE_CHECKPOINT_UNSUPPORTED_NODE = 'file_checkpoint_unsupported_node';
|
|
8
|
+
export const FILE_CHECKPOINT_NESTED_REPOSITORY = 'file_checkpoint_nested_repository';
|
|
9
|
+
export const FILE_CHECKPOINT_QUOTA_EXCEEDED = 'file_checkpoint_quota_exceeded';
|
|
10
|
+
export const FILE_CHECKPOINT_PREPARE_TIMEOUT = 'file_checkpoint_prepare_timeout';
|
|
11
|
+
export class FileCheckpointDiagnosticError extends Error {
|
|
12
|
+
diagnostic;
|
|
13
|
+
constructor(message, diagnostic) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.diagnostic = diagnostic;
|
|
16
|
+
this.name = 'FileCheckpointDiagnosticError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function assertWithinDeadline(deadlineMs) {
|
|
20
|
+
if (deadlineMs !== undefined && Date.now() > deadlineMs) {
|
|
21
|
+
throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_PREPARE_TIMEOUT, `snapshot preparation exceeded deadlineMs=${deadlineMs}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function assertWithinQuotas(counters, quotas) {
|
|
25
|
+
if (!quotas) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const nodeCount = counters.fileCount + counters.directoryCount;
|
|
29
|
+
if (nodeCount > quotas.maxFiles) {
|
|
30
|
+
throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_QUOTA_EXCEEDED, `snapshot nodeCount=${nodeCount} exceeds maxFiles=${quotas.maxFiles}`);
|
|
31
|
+
}
|
|
32
|
+
if (counters.totalFileBytes > quotas.maxSourceBytes) {
|
|
33
|
+
throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_QUOTA_EXCEEDED, `snapshot sourceBytes=${counters.totalFileBytes} exceeds maxSourceBytes=${quotas.maxSourceBytes}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function readPresentNode(absolutePath, counters) {
|
|
37
|
+
const info = await lstat(absolutePath);
|
|
38
|
+
if (info.nlink > 1 && info.isFile()) {
|
|
39
|
+
throw new Error(FILE_CHECKPOINT_HARDLINK_UNSUPPORTED);
|
|
40
|
+
}
|
|
41
|
+
if (info.isSocket() || info.isFIFO() || info.isBlockDevice() || info.isCharacterDevice()) {
|
|
42
|
+
throw new Error(FILE_CHECKPOINT_UNSUPPORTED_NODE);
|
|
43
|
+
}
|
|
44
|
+
const node = await readSnapshotNode(absolutePath);
|
|
45
|
+
if (node.kind === 'absent') {
|
|
46
|
+
throw new Error(FILE_CHECKPOINT_UNSUPPORTED_NODE);
|
|
47
|
+
}
|
|
48
|
+
if (node.kind === 'file') {
|
|
49
|
+
counters.fileCount += 1;
|
|
50
|
+
counters.totalFileBytes += node.size;
|
|
51
|
+
}
|
|
52
|
+
else if (node.kind === 'directory') {
|
|
53
|
+
counters.directoryCount += 1;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
counters.fileCount += 1;
|
|
57
|
+
}
|
|
58
|
+
return node;
|
|
59
|
+
}
|
|
60
|
+
async function walkDirectory(resourceRoot, relativeDir, excludedRoots, counters, quotas, deadlineMs, entries) {
|
|
61
|
+
assertWithinDeadline(deadlineMs);
|
|
62
|
+
assertWithinQuotas(counters, quotas);
|
|
63
|
+
const absoluteDir = relativeDir
|
|
64
|
+
? joinRelativePath(resourceRoot, relativeDir)
|
|
65
|
+
: path.resolve(resourceRoot);
|
|
66
|
+
const dirInfo = await lstat(absoluteDir);
|
|
67
|
+
if (!dirInfo.isDirectory()) {
|
|
68
|
+
throw new Error(FILE_CHECKPOINT_UNSUPPORTED_NODE);
|
|
69
|
+
}
|
|
70
|
+
if (relativeDir) {
|
|
71
|
+
validateRelativePath(relativeDir);
|
|
72
|
+
if (isNestedGitPath(relativeDir)) {
|
|
73
|
+
throw new Error(FILE_CHECKPOINT_NESTED_REPOSITORY);
|
|
74
|
+
}
|
|
75
|
+
if (!isExcludedTreePath(relativeDir, excludedRoots, resourceRoot)) {
|
|
76
|
+
entries.push({
|
|
77
|
+
relativePath: relativeDir,
|
|
78
|
+
node: {
|
|
79
|
+
kind: 'directory',
|
|
80
|
+
mode: dirInfo.mode,
|
|
81
|
+
hash: hashDirectoryNode(dirInfo.mode),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
counters.directoryCount += 1;
|
|
85
|
+
assertWithinQuotas(counters, quotas);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const names = await readdir(absoluteDir);
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
assertWithinDeadline(deadlineMs);
|
|
91
|
+
const childRelative = relativeDir ? path.join(relativeDir, name) : name;
|
|
92
|
+
validateRelativePath(childRelative);
|
|
93
|
+
if (isNestedGitPath(childRelative)) {
|
|
94
|
+
throw new Error(FILE_CHECKPOINT_NESTED_REPOSITORY);
|
|
95
|
+
}
|
|
96
|
+
if (isExcludedTreePath(childRelative, excludedRoots, resourceRoot)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const childAbsolute = path.join(absoluteDir, name);
|
|
100
|
+
const childInfo = await lstat(childAbsolute);
|
|
101
|
+
if (childInfo.isDirectory() && !childInfo.isSymbolicLink()) {
|
|
102
|
+
await walkDirectory(resourceRoot, childRelative, excludedRoots, counters, quotas, deadlineMs, entries);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const node = await readPresentNode(childAbsolute, counters);
|
|
106
|
+
entries.push({ relativePath: childRelative, node });
|
|
107
|
+
assertWithinQuotas(counters, quotas);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function computeTreeHash(entries) {
|
|
111
|
+
const sorted = [...entries].sort((left, right) => compareRelativePathsBytewise(left.relativePath, right.relativePath));
|
|
112
|
+
const hash = createHash('sha256');
|
|
113
|
+
for (const entry of sorted) {
|
|
114
|
+
hash.update(entry.relativePath);
|
|
115
|
+
hash.update('\0');
|
|
116
|
+
hash.update(entry.node.hash);
|
|
117
|
+
hash.update('\0');
|
|
118
|
+
}
|
|
119
|
+
return hash.digest('hex');
|
|
120
|
+
}
|
|
121
|
+
export async function buildFileTreeIndex(options) {
|
|
122
|
+
const excludedRoots = options.excludedRoots ?? [];
|
|
123
|
+
const counters = { fileCount: 0, directoryCount: 0, totalFileBytes: 0 };
|
|
124
|
+
const deadlineMs = options.deadlineMs ??
|
|
125
|
+
(options.quotas?.prepareTimeoutMs ? Date.now() + options.quotas.prepareTimeoutMs : undefined);
|
|
126
|
+
const entries = [];
|
|
127
|
+
await walkDirectory(options.resourceRoot, '', excludedRoots, counters, options.quotas, deadlineMs, entries);
|
|
128
|
+
const sortedEntries = [...entries].sort((left, right) => compareRelativePathsBytewise(left.relativePath, right.relativePath));
|
|
129
|
+
return {
|
|
130
|
+
version: 1,
|
|
131
|
+
entries: sortedEntries,
|
|
132
|
+
treeHash: computeTreeHash(sortedEntries),
|
|
133
|
+
fileCount: counters.fileCount,
|
|
134
|
+
directoryCount: counters.directoryCount,
|
|
135
|
+
totalFileBytes: counters.totalFileBytes,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function inferChangeKind(before, after) {
|
|
139
|
+
if (before.kind === 'absent') {
|
|
140
|
+
return 'added';
|
|
141
|
+
}
|
|
142
|
+
if (after.kind === 'absent') {
|
|
143
|
+
return 'deleted';
|
|
144
|
+
}
|
|
145
|
+
return 'modified';
|
|
146
|
+
}
|
|
147
|
+
export function diffFileTreeIndices(baseline, observed) {
|
|
148
|
+
const baselineMap = new Map(baseline.entries.map((entry) => [entry.relativePath, entry.node]));
|
|
149
|
+
const observedMap = new Map(observed.entries.map((entry) => [entry.relativePath, entry.node]));
|
|
150
|
+
const paths = new Set([...baselineMap.keys(), ...observedMap.keys()]);
|
|
151
|
+
const changes = [];
|
|
152
|
+
for (const relativePath of [...paths].sort(compareRelativePathsBytewise)) {
|
|
153
|
+
const before = baselineMap.get(relativePath) ?? { kind: 'absent' };
|
|
154
|
+
const after = observedMap.get(relativePath) ?? { kind: 'absent' };
|
|
155
|
+
if (snapshotNodesEqual(before, after)) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
changes.push({
|
|
159
|
+
relativePath,
|
|
160
|
+
kind: inferChangeKind(before, after),
|
|
161
|
+
before,
|
|
162
|
+
after,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return changes;
|
|
166
|
+
}
|
|
167
|
+
export async function readObservedChanges(baselineRoot, executionRoot, relativePaths) {
|
|
168
|
+
const changes = [];
|
|
169
|
+
for (const relativePath of relativePaths) {
|
|
170
|
+
validateRelativePath(relativePath);
|
|
171
|
+
const before = await readSnapshotNode(joinRelativePath(baselineRoot, relativePath));
|
|
172
|
+
const after = await readSnapshotNode(joinRelativePath(executionRoot, relativePath));
|
|
173
|
+
if (snapshotNodesEqual(before, after)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
changes.push({
|
|
177
|
+
relativePath,
|
|
178
|
+
kind: inferChangeKind(before, after),
|
|
179
|
+
before,
|
|
180
|
+
after,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return changes;
|
|
184
|
+
}
|
|
185
|
+
// Re-export for tests that need direct content hashing without full walk.
|
|
186
|
+
export { hashFileContent, hashFileNode, hashSymlinkTarget } from './snapshot-node.js';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { collectWorktreeChanges, createGitWorktreeSnapshot, isDirtyWorktree, isGitWorktreeAvailable, } from './git-worktree.js';
|
|
2
|
+
async function probeGitWorktreeBackend(context) {
|
|
3
|
+
if (!(await isGitWorktreeAvailable(context.repoRoot))) {
|
|
4
|
+
return {
|
|
5
|
+
eligible: false,
|
|
6
|
+
backend: 'git_worktree',
|
|
7
|
+
reason: 'git_worktree_unavailable',
|
|
8
|
+
signals: [],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
if (await isDirtyWorktree(context.repoRoot, { ignoreRoots: context.dirtyIgnoreRoots })) {
|
|
12
|
+
return {
|
|
13
|
+
eligible: false,
|
|
14
|
+
backend: 'git_worktree',
|
|
15
|
+
reason: 'dirty_worktree',
|
|
16
|
+
signals: ['dirty_git_worktree'],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
eligible: true,
|
|
21
|
+
backend: 'git_worktree',
|
|
22
|
+
signals: ['clean_git_worktree'],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export const gitWorktreeBackend = {
|
|
26
|
+
id: 'git_worktree',
|
|
27
|
+
probe: probeGitWorktreeBackend,
|
|
28
|
+
async prepare(context) {
|
|
29
|
+
const probe = await probeGitWorktreeBackend(context);
|
|
30
|
+
if (!probe.eligible) {
|
|
31
|
+
throw new Error(probe.reason ?? 'git_worktree_unavailable');
|
|
32
|
+
}
|
|
33
|
+
const snapshot = await createGitWorktreeSnapshot(context.repoRoot, context.stateDir);
|
|
34
|
+
return {
|
|
35
|
+
backend: 'git_worktree',
|
|
36
|
+
resourceRoot: context.repoRoot,
|
|
37
|
+
executionRoot: snapshot.worktreePath,
|
|
38
|
+
resourceKind: 'git_repository',
|
|
39
|
+
resourceIdentity: '',
|
|
40
|
+
baselineTreeHash: '',
|
|
41
|
+
excludedRoots: context.dirtyIgnoreRoots ?? [],
|
|
42
|
+
async collectChanges() {
|
|
43
|
+
return collectWorktreeChanges(snapshot.worktreePath);
|
|
44
|
+
},
|
|
45
|
+
async cleanup() {
|
|
46
|
+
await snapshot.cleanup();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export
|
|
1
|
+
import { type ShellRunResult } from '../process-runner.js';
|
|
2
|
+
import { buildObservedChangesFromTransactional, TRANSACTIONAL_APPLY_ROLLBACK_FAILED, TRANSACTIONAL_APPLY_TOCTOU } from './apply-observed-changes.js';
|
|
3
|
+
import type { GitWorktreeSnapshot, TransactionalFileChange } from './types.js';
|
|
4
|
+
export { TRANSACTIONAL_APPLY_ROLLBACK_FAILED, TRANSACTIONAL_APPLY_TOCTOU };
|
|
5
5
|
export declare function isGitWorktreeAvailable(repoRoot: string): Promise<boolean>;
|
|
6
6
|
export declare function isDirtyWorktree(repoRoot: string, options?: {
|
|
7
7
|
ignoreRoots?: string[];
|
|
8
8
|
}): Promise<boolean>;
|
|
9
|
-
export declare function createGitWorktreeSnapshot(repoRoot: string, _stateDir: string): Promise<
|
|
9
|
+
export declare function createGitWorktreeSnapshot(repoRoot: string, _stateDir: string): Promise<GitWorktreeSnapshot>;
|
|
10
10
|
export declare function resolveWorktreeCwd(repoRoot: string, worktreePath: string, cwd: string): string;
|
|
11
|
-
export
|
|
12
|
-
exitCode: number | null;
|
|
13
|
-
signal: string | null;
|
|
14
|
-
timedOut: boolean;
|
|
15
|
-
}
|
|
11
|
+
export type { ShellRunResult } from '../process-runner.js';
|
|
16
12
|
export declare function runShellCommand(command: string, cwd: string, timeoutMs: number): Promise<ShellRunResult>;
|
|
17
13
|
export declare function collectWorktreeChanges(worktreePath: string): Promise<TransactionalFileChange[]>;
|
|
18
14
|
export declare function applyWorktreeChanges(worktreePath: string, repoRoot: string, changes: TransactionalFileChange[], options?: {
|
|
19
|
-
|
|
15
|
+
/** Runs while rollback backups are still available. */
|
|
16
|
+
afterApply?: () => Promise<void>;
|
|
17
|
+
observedChanges?: Awaited<ReturnType<typeof buildObservedChangesFromTransactional>>;
|
|
20
18
|
}): Promise<void>;
|