@guilz-dev/belay 0.6.0 → 0.8.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 +39 -20
- 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 +443 -225
- package/dist/bundle/claude-runtime.mjs +13027 -7271
- package/dist/bundle/codex-runtime.mjs +12700 -6977
- package/dist/bundle/cursor-runtime.mjs +12695 -6977
- package/dist/cli.js +37 -1
- package/dist/commands/approve.js +34 -10
- package/dist/commands/doctor.js +58 -0
- package/dist/commands/dogfood.js +1 -1
- package/dist/commands/explain.js +2 -2
- package/dist/commands/harvest.d.ts +0 -1
- package/dist/commands/harvest.js +1 -4
- package/dist/commands/metrics.js +6 -0
- package/dist/commands/quality.js +1 -3
- 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 +137 -9
- package/dist/conformance/types.d.ts +2 -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-metrics.d.ts +1 -0
- package/dist/core/audit-metrics.js +12 -4
- package/dist/core/audit-types.d.ts +16 -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 +2 -2
- package/dist/core/capability/index.js +1 -1
- package/dist/core/capability/policy-engine.d.ts +38 -1
- package/dist/core/capability/policy-engine.js +388 -15
- package/dist/core/capability/request.d.ts +12 -0
- package/dist/core/capability/resolver.d.ts +0 -1
- package/dist/core/capability/resolver.js +0 -1
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.js +62 -2
- package/dist/core/effect-ir/audit.d.ts +9 -0
- package/dist/core/effect-ir/audit.js +87 -0
- package/dist/core/effect-ir/build.d.ts +31 -0
- package/dist/core/effect-ir/build.js +281 -0
- package/dist/core/effect-ir/index.d.ts +13 -0
- package/dist/core/effect-ir/index.js +9 -0
- package/dist/core/effect-ir/normalize.d.ts +4 -0
- package/dist/core/effect-ir/normalize.js +110 -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 +14 -0
- package/dist/core/effect-ir/policy.js +164 -0
- package/dist/core/effect-ir/shell-build.d.ts +43 -0
- package/dist/core/effect-ir/shell-build.js +77 -0
- package/dist/core/effect-ir/shell-lower.d.ts +14 -0
- package/dist/core/effect-ir/shell-lower.js +1509 -0
- package/dist/core/effect-ir/types.d.ts +62 -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 +82 -25
- package/dist/core/git-resource-identity.d.ts +26 -0
- package/dist/core/git-resource-identity.js +284 -0
- package/dist/core/harvest.d.ts +3 -7
- package/dist/core/harvest.js +4 -27
- package/dist/core/index.d.ts +5 -1
- package/dist/core/index.js +3 -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/path-utils.js +42 -23
- 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/shell-substitution.d.ts +1 -0
- package/dist/core/shell-substitution.js +61 -0
- package/dist/core/standing-allow.d.ts +1 -2
- package/dist/core/standing-allow.js +3 -20
- 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 +13 -1
- package/dist/core/verdict/adapter.js +23 -15
- package/dist/core/verdict/containment.js +4 -0
- package/dist/core/verdict/egress-classify.d.ts +12 -0
- package/dist/core/verdict/egress-classify.js +746 -0
- package/dist/core/verdict/git-classifier.d.ts +12 -0
- package/dist/core/verdict/git-classifier.js +320 -0
- package/dist/core/verdict/launcher-resolve.js +79 -37
- package/dist/core/verdict/parser.d.ts +2 -0
- package/dist/core/verdict/parser.js +95 -1
- package/dist/core/verdict/types.d.ts +6 -2
- package/dist/core/verdict/verdict.js +103 -935
- package/dist/corpus/must-allow-commands.d.ts +2 -1
- package/dist/corpus/must-allow-commands.js +2 -1
- package/dist/corpus/runtime-match.d.ts +2 -1
- package/dist/corpus/runtime-match.js +2 -1
- package/dist/corpus/types.d.ts +2 -2
- package/dist/templates.js +7 -2
- package/dist/types.d.ts +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/skills/belay/SKILL.md +8 -3
- package/skills/belay/belay-approve.md +10 -7
- package/dist/core/verdict/overrides.d.ts +0 -7
- package/dist/core/verdict/overrides.js +0 -37
- package/dist/core/verdict/shell-policy.d.ts +0 -25
- package/dist/core/verdict/shell-policy.js +0 -40
- package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
- package/dist/corpus/standing-allow-catalog.generated.js +0 -99
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const PROCESS_OUTPUT_TAIL_BYTES = 16_384;
|
|
3
|
+
const EXIT_OUTPUT_GRACE_MS = 25;
|
|
4
|
+
const TIMEOUT_KILL_GRACE_MS = 250;
|
|
5
|
+
const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000;
|
|
6
|
+
function appendOutputTail(current, chunk) {
|
|
7
|
+
const next = Buffer.concat([current, Buffer.from(chunk)]);
|
|
8
|
+
return next.length > PROCESS_OUTPUT_TAIL_BYTES ? next.subarray(-PROCESS_OUTPUT_TAIL_BYTES) : next;
|
|
9
|
+
}
|
|
10
|
+
export function windowsProcessTreeKillArgs(pid) {
|
|
11
|
+
return ['/pid', String(pid), '/t', '/f'];
|
|
12
|
+
}
|
|
13
|
+
function forceKillWindowsProcessTree(pid, fallback) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const taskkill = spawn('taskkill', windowsProcessTreeKillArgs(pid), {
|
|
16
|
+
stdio: 'ignore',
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
});
|
|
19
|
+
let settled = false;
|
|
20
|
+
const finish = (succeeded) => {
|
|
21
|
+
if (settled) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
settled = true;
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
if (!succeeded) {
|
|
27
|
+
fallback();
|
|
28
|
+
}
|
|
29
|
+
resolve();
|
|
30
|
+
};
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
taskkill.kill();
|
|
33
|
+
finish(false);
|
|
34
|
+
}, WINDOWS_TASKKILL_TIMEOUT_MS);
|
|
35
|
+
taskkill.on('error', () => finish(false));
|
|
36
|
+
taskkill.on('close', (code) => finish(code === 0));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function runProcessWithBoundedOutput(file, args, options, timeoutMs) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const detached = process.platform !== 'win32';
|
|
42
|
+
const child = spawn(file, args, {
|
|
43
|
+
...options,
|
|
44
|
+
detached,
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
let stdout = Buffer.alloc(0);
|
|
48
|
+
let stderr = Buffer.alloc(0);
|
|
49
|
+
let timedOut = false;
|
|
50
|
+
let settled = false;
|
|
51
|
+
let windowsCleanupPending = false;
|
|
52
|
+
let exitTimer;
|
|
53
|
+
child.stdout.on('data', (chunk) => {
|
|
54
|
+
stdout = appendOutputTail(stdout, chunk);
|
|
55
|
+
});
|
|
56
|
+
child.stderr.on('data', (chunk) => {
|
|
57
|
+
stderr = appendOutputTail(stderr, chunk);
|
|
58
|
+
});
|
|
59
|
+
const finish = (exitCode, signal) => {
|
|
60
|
+
if (settled) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(timeoutTimer);
|
|
65
|
+
if (exitTimer) {
|
|
66
|
+
clearTimeout(exitTimer);
|
|
67
|
+
}
|
|
68
|
+
child.stdout.destroy();
|
|
69
|
+
child.stderr.destroy();
|
|
70
|
+
resolve({
|
|
71
|
+
exitCode,
|
|
72
|
+
signal: signal ? String(signal) : null,
|
|
73
|
+
timedOut,
|
|
74
|
+
stdout: stdout.toString('utf8'),
|
|
75
|
+
stderr: stderr.toString('utf8'),
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const kill = (signal) => {
|
|
79
|
+
try {
|
|
80
|
+
if (detached && child.pid) {
|
|
81
|
+
process.kill(-child.pid, signal);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
child.kill(signal);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// The process may already have exited between the state check and kill.
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const timeoutTimer = setTimeout(() => {
|
|
91
|
+
timedOut = true;
|
|
92
|
+
if (process.platform === 'win32' && child.pid) {
|
|
93
|
+
windowsCleanupPending = true;
|
|
94
|
+
void forceKillWindowsProcessTree(child.pid, () => child.kill('SIGKILL')).then(() => {
|
|
95
|
+
windowsCleanupPending = false;
|
|
96
|
+
finish(child.exitCode, child.signalCode ?? 'SIGKILL');
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
kill('SIGTERM');
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
kill('SIGKILL');
|
|
103
|
+
finish(null, 'SIGKILL');
|
|
104
|
+
}, TIMEOUT_KILL_GRACE_MS);
|
|
105
|
+
}, timeoutMs);
|
|
106
|
+
child.on('error', (error) => {
|
|
107
|
+
stderr = appendOutputTail(stderr, error.message);
|
|
108
|
+
finish(1, null);
|
|
109
|
+
});
|
|
110
|
+
child.on('exit', (exitCode, signal) => {
|
|
111
|
+
clearTimeout(timeoutTimer);
|
|
112
|
+
if (windowsCleanupPending) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
exitTimer = setTimeout(() => finish(exitCode, signal), EXIT_OUTPUT_GRACE_MS);
|
|
116
|
+
});
|
|
117
|
+
child.on('close', (exitCode, signal) => {
|
|
118
|
+
if (windowsCleanupPending) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
finish(exitCode, signal);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const RECOVER_DISCLAIMER = [
|
|
2
|
-
'Advisory only —
|
|
2
|
+
'Advisory only — this report is not proof that the selected action executed. Only a checkpoint receipt shown by `belay recover list` is an executable recovery point.',
|
|
3
|
+
'`belay recover advice` does not run recovery commands; checkpoint restore requires a separate one-shot human approval.',
|
|
3
4
|
'Advice is based on what belay observed through hooks; actions outside hook scope may not be visible.',
|
|
4
5
|
'Recovery commands themselves pass through belay hooks — destructive undo steps may be blocked again.',
|
|
5
6
|
];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RecoveryCheckpointManifest, RecoveryCheckpointState, RecoveryCheckpointStateV1, RecoveryReceiptV1 } from './types.js';
|
|
2
|
+
export declare const RECOVERY_CHECKPOINT_CORRUPT = "recovery_checkpoint_corrupt";
|
|
3
|
+
export interface PreparedRecoveryCheckpoint {
|
|
4
|
+
checkpointId: string;
|
|
5
|
+
manifest: RecoveryCheckpointManifest;
|
|
6
|
+
manifestHash: string;
|
|
7
|
+
proofHash: string;
|
|
8
|
+
}
|
|
9
|
+
export interface RecoveryArtifact {
|
|
10
|
+
artifactDir: string;
|
|
11
|
+
manifest: RecoveryCheckpointManifest;
|
|
12
|
+
state: RecoveryCheckpointStateV1;
|
|
13
|
+
manifestHash: string;
|
|
14
|
+
receipt?: RecoveryReceiptV1;
|
|
15
|
+
}
|
|
16
|
+
export declare function checkpointsRoot(stateDir: string): string;
|
|
17
|
+
export declare function checkpointDir(stateDir: string, checkpointId: string): string;
|
|
18
|
+
export declare function fsyncPath(filePath: string): Promise<void>;
|
|
19
|
+
export declare function atomicWriteJson(filePath: string, value: unknown): Promise<void>;
|
|
20
|
+
export declare function writeRecoveryState(artifactDir: string, state: RecoveryCheckpointState, manifestHash: string, detail?: string): Promise<void>;
|
|
21
|
+
export declare function directorySize(root: string): Promise<number>;
|
|
22
|
+
export declare function readRecoveryArtifact(stateDir: string, checkpointId: string): Promise<RecoveryArtifact>;
|
|
23
|
+
export declare function ensureRecoveryReceipt(artifactDir: string, manifest: RecoveryCheckpointManifest, manifestHash: string): Promise<RecoveryReceiptV1>;
|
|
24
|
+
export declare function checkpointIds(stateDir: string): Promise<string[]>;
|
|
25
|
+
export declare function artifactRepoRoot(stateDir: string, checkpointId: string): Promise<string | null>;
|
|
26
|
+
export declare function checkpointIdsForRepo(stateDir: string, repoRoot: string): Promise<string[]>;
|
|
27
|
+
export declare function cleanupOrphanedStaging(stateDir: string): Promise<void>;
|
|
28
|
+
export declare function markRecoveryCheckpointApplying(stateDir: string, checkpoint: PreparedRecoveryCheckpoint): Promise<void>;
|
|
29
|
+
export declare function markRecoveryCheckpointApplied(stateDir: string, checkpoint: PreparedRecoveryCheckpoint): Promise<RecoveryReceiptV1>;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { canonicalStringify, hashValue } from '../fingerprint.js';
|
|
6
|
+
import { canonicalPath } from '../path-utils.js';
|
|
7
|
+
import { recoveryStateHash, validateRecoverySnapshot, validRecoveryRelativePath, } from './snapshot-node.js';
|
|
8
|
+
export const RECOVERY_CHECKPOINT_CORRUPT = 'recovery_checkpoint_corrupt';
|
|
9
|
+
const RECOVERY_STATES = new Set([
|
|
10
|
+
'prepared',
|
|
11
|
+
'applying',
|
|
12
|
+
'applied',
|
|
13
|
+
'restoring',
|
|
14
|
+
'restored',
|
|
15
|
+
'conflict',
|
|
16
|
+
'corrupt',
|
|
17
|
+
'needs_manual_repair',
|
|
18
|
+
]);
|
|
19
|
+
const STAGING_STALE_MS = 5 * 60_000;
|
|
20
|
+
export function checkpointsRoot(stateDir) {
|
|
21
|
+
return path.join(stateDir, 'recovery', 'checkpoints');
|
|
22
|
+
}
|
|
23
|
+
export function checkpointDir(stateDir, checkpointId) {
|
|
24
|
+
if (!/^cp_[a-f0-9]{24}$/.test(checkpointId)) {
|
|
25
|
+
throw new Error('invalid_recovery_checkpoint_id');
|
|
26
|
+
}
|
|
27
|
+
return path.join(checkpointsRoot(stateDir), checkpointId);
|
|
28
|
+
}
|
|
29
|
+
export async function fsyncPath(filePath) {
|
|
30
|
+
const handle = await open(filePath, 'r');
|
|
31
|
+
try {
|
|
32
|
+
await handle.sync();
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await handle.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function atomicWriteJson(filePath, value) {
|
|
39
|
+
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
40
|
+
const temporary = `${filePath}.tmp-${randomUUID()}`;
|
|
41
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
await fsyncPath(temporary);
|
|
43
|
+
await rename(temporary, filePath);
|
|
44
|
+
await fsyncPath(path.dirname(filePath));
|
|
45
|
+
}
|
|
46
|
+
export async function writeRecoveryState(artifactDir, state, manifestHash, detail) {
|
|
47
|
+
const value = {
|
|
48
|
+
version: 1,
|
|
49
|
+
state,
|
|
50
|
+
updatedAt: new Date().toISOString(),
|
|
51
|
+
manifestHash,
|
|
52
|
+
...(detail ? { detail } : {}),
|
|
53
|
+
};
|
|
54
|
+
await atomicWriteJson(path.join(artifactDir, 'state.json'), value);
|
|
55
|
+
}
|
|
56
|
+
export async function directorySize(root) {
|
|
57
|
+
if (!existsSync(root))
|
|
58
|
+
return 0;
|
|
59
|
+
let total = 0;
|
|
60
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
61
|
+
const entryPath = path.join(root, entry.name);
|
|
62
|
+
if (entry.isDirectory())
|
|
63
|
+
total += await directorySize(entryPath);
|
|
64
|
+
else
|
|
65
|
+
total += (await lstat(entryPath)).size;
|
|
66
|
+
}
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
function isManifestShape(value, checkpointId) {
|
|
70
|
+
if (!value || typeof value !== 'object')
|
|
71
|
+
return false;
|
|
72
|
+
const manifest = value;
|
|
73
|
+
const proof = manifest.proof;
|
|
74
|
+
const manifestKeys = [
|
|
75
|
+
'version',
|
|
76
|
+
'checkpointId',
|
|
77
|
+
'backend',
|
|
78
|
+
'repoRoot',
|
|
79
|
+
'repoIdentity',
|
|
80
|
+
'commandFingerprint',
|
|
81
|
+
'createdAt',
|
|
82
|
+
'expiresAt',
|
|
83
|
+
'proof',
|
|
84
|
+
'entries',
|
|
85
|
+
...(manifest.version === 2 ? ['resourceKind'] : []),
|
|
86
|
+
];
|
|
87
|
+
const proofKeys = [
|
|
88
|
+
'version',
|
|
89
|
+
'backend',
|
|
90
|
+
'inputFingerprint',
|
|
91
|
+
'resourceScope',
|
|
92
|
+
'baseStateHash',
|
|
93
|
+
'effectClosure',
|
|
94
|
+
'issuedAt',
|
|
95
|
+
'expiresAt',
|
|
96
|
+
'probeSignals',
|
|
97
|
+
];
|
|
98
|
+
if (![1, 2].includes(manifest.version) ||
|
|
99
|
+
Object.keys(manifest).length !== manifestKeys.length ||
|
|
100
|
+
!Object.keys(manifest).every((key) => manifestKeys.includes(key)) ||
|
|
101
|
+
manifest.checkpointId !== checkpointId ||
|
|
102
|
+
!['git_worktree', 'file_checkpoint'].includes(String(manifest.backend)) ||
|
|
103
|
+
typeof manifest.repoRoot !== 'string' ||
|
|
104
|
+
typeof manifest.repoIdentity !== 'string' ||
|
|
105
|
+
typeof manifest.commandFingerprint !== 'string' ||
|
|
106
|
+
typeof manifest.createdAt !== 'string' ||
|
|
107
|
+
!Number.isFinite(Date.parse(manifest.createdAt)) ||
|
|
108
|
+
typeof manifest.expiresAt !== 'string' ||
|
|
109
|
+
!Number.isFinite(Date.parse(manifest.expiresAt)) ||
|
|
110
|
+
!Array.isArray(manifest.entries) ||
|
|
111
|
+
!proof ||
|
|
112
|
+
Object.keys(proof).length !== proofKeys.length ||
|
|
113
|
+
!Object.keys(proof).every((key) => proofKeys.includes(key)) ||
|
|
114
|
+
proof.version !== 1 ||
|
|
115
|
+
proof.backend !== manifest.backend ||
|
|
116
|
+
typeof proof.inputFingerprint !== 'string' ||
|
|
117
|
+
proof.inputFingerprint !== manifest.commandFingerprint ||
|
|
118
|
+
typeof proof.resourceScope !== 'string' ||
|
|
119
|
+
proof.resourceScope !== manifest.repoRoot ||
|
|
120
|
+
typeof proof.baseStateHash !== 'string' ||
|
|
121
|
+
proof.effectClosure !== 'repo_local_fs_observed' ||
|
|
122
|
+
typeof proof.issuedAt !== 'string' ||
|
|
123
|
+
!Number.isFinite(Date.parse(proof.issuedAt)) ||
|
|
124
|
+
proof.issuedAt !== manifest.createdAt ||
|
|
125
|
+
typeof proof.expiresAt !== 'string' ||
|
|
126
|
+
!Number.isFinite(Date.parse(proof.expiresAt)) ||
|
|
127
|
+
proof.expiresAt !== manifest.expiresAt ||
|
|
128
|
+
!Array.isArray(proof.probeSignals) ||
|
|
129
|
+
!proof.probeSignals.every((signal) => typeof signal === 'string')) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (manifest.version === 1)
|
|
133
|
+
return !('resourceKind' in manifest);
|
|
134
|
+
return ['git_repository', 'directory'].includes(String(manifest.resourceKind));
|
|
135
|
+
}
|
|
136
|
+
export async function readRecoveryArtifact(stateDir, checkpointId) {
|
|
137
|
+
const artifactDir = checkpointDir(stateDir, checkpointId);
|
|
138
|
+
let rawManifest;
|
|
139
|
+
let state;
|
|
140
|
+
try {
|
|
141
|
+
rawManifest = JSON.parse(await readFile(path.join(artifactDir, 'manifest.json'), 'utf8'));
|
|
142
|
+
state = JSON.parse(await readFile(path.join(artifactDir, 'state.json'), 'utf8'));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new Error(RECOVERY_CHECKPOINT_CORRUPT);
|
|
146
|
+
}
|
|
147
|
+
if (!isManifestShape(rawManifest, checkpointId))
|
|
148
|
+
throw new Error(RECOVERY_CHECKPOINT_CORRUPT);
|
|
149
|
+
const manifest = rawManifest;
|
|
150
|
+
const manifestHash = hashValue(canonicalStringify(manifest));
|
|
151
|
+
const stateKeys = state && typeof state === 'object' ? Object.keys(state) : [];
|
|
152
|
+
const expectedStateKeys = ['version', 'state', 'updatedAt', 'manifestHash'];
|
|
153
|
+
if (state && typeof state === 'object' && 'detail' in state)
|
|
154
|
+
expectedStateKeys.push('detail');
|
|
155
|
+
if (!state ||
|
|
156
|
+
typeof state !== 'object' ||
|
|
157
|
+
Array.isArray(state) ||
|
|
158
|
+
stateKeys.length !== expectedStateKeys.length ||
|
|
159
|
+
!stateKeys.every((key) => expectedStateKeys.includes(key)) ||
|
|
160
|
+
state.version !== 1 ||
|
|
161
|
+
!RECOVERY_STATES.has(state.state) ||
|
|
162
|
+
typeof state.updatedAt !== 'string' ||
|
|
163
|
+
!Number.isFinite(Date.parse(state.updatedAt)) ||
|
|
164
|
+
(state.detail !== undefined && typeof state.detail !== 'string') ||
|
|
165
|
+
state.manifestHash !== manifestHash) {
|
|
166
|
+
throw new Error(RECOVERY_CHECKPOINT_CORRUPT);
|
|
167
|
+
}
|
|
168
|
+
const entryPaths = new Set();
|
|
169
|
+
for (const entry of manifest.entries) {
|
|
170
|
+
if (!entry ||
|
|
171
|
+
typeof entry !== 'object' ||
|
|
172
|
+
Array.isArray(entry) ||
|
|
173
|
+
Object.keys(entry).length !== 3 ||
|
|
174
|
+
!Object.keys(entry).every((key) => ['path', 'before', 'after'].includes(key)) ||
|
|
175
|
+
typeof entry.path !== 'string' ||
|
|
176
|
+
!('before' in entry) ||
|
|
177
|
+
!('after' in entry) ||
|
|
178
|
+
!validRecoveryRelativePath(entry.path) ||
|
|
179
|
+
entryPaths.has(path.normalize(entry.path))) {
|
|
180
|
+
throw new Error(RECOVERY_CHECKPOINT_CORRUPT);
|
|
181
|
+
}
|
|
182
|
+
entryPaths.add(path.normalize(entry.path));
|
|
183
|
+
for (const [side, snapshot] of [
|
|
184
|
+
['before', entry.before],
|
|
185
|
+
['after', entry.after],
|
|
186
|
+
]) {
|
|
187
|
+
await validateRecoverySnapshot({
|
|
188
|
+
snapshot,
|
|
189
|
+
side,
|
|
190
|
+
manifestVersion: manifest.version,
|
|
191
|
+
artifactDir,
|
|
192
|
+
corruptReason: RECOVERY_CHECKPOINT_CORRUPT,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const receiptPath = path.join(artifactDir, 'receipt.json');
|
|
197
|
+
let receipt;
|
|
198
|
+
if (['applied', 'restoring', 'restored', 'conflict'].includes(state.state) ||
|
|
199
|
+
existsSync(receiptPath)) {
|
|
200
|
+
receipt = await readAndValidateRecoveryReceipt(artifactDir, manifest, manifestHash);
|
|
201
|
+
}
|
|
202
|
+
return { artifactDir, manifest, state, manifestHash, ...(receipt ? { receipt } : {}) };
|
|
203
|
+
}
|
|
204
|
+
async function readAndValidateRecoveryReceipt(artifactDir, manifest, manifestHash) {
|
|
205
|
+
let rawReceipt;
|
|
206
|
+
try {
|
|
207
|
+
rawReceipt = JSON.parse(await readFile(path.join(artifactDir, 'receipt.json'), 'utf8'));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new Error(RECOVERY_CHECKPOINT_CORRUPT);
|
|
211
|
+
}
|
|
212
|
+
if (!rawReceipt || typeof rawReceipt !== 'object' || Array.isArray(rawReceipt)) {
|
|
213
|
+
throw new Error(RECOVERY_CHECKPOINT_CORRUPT);
|
|
214
|
+
}
|
|
215
|
+
const receipt = rawReceipt;
|
|
216
|
+
const receiptKeys = [
|
|
217
|
+
'version',
|
|
218
|
+
'checkpointId',
|
|
219
|
+
'manifestHash',
|
|
220
|
+
'proofHash',
|
|
221
|
+
'postStateHash',
|
|
222
|
+
'appliedAt',
|
|
223
|
+
'changeCount',
|
|
224
|
+
];
|
|
225
|
+
if (Object.keys(receipt).length !== receiptKeys.length ||
|
|
226
|
+
!Object.keys(receipt).every((key) => receiptKeys.includes(key)) ||
|
|
227
|
+
receipt.version !== 1 ||
|
|
228
|
+
receipt.checkpointId !== manifest.checkpointId ||
|
|
229
|
+
receipt.manifestHash !== manifestHash ||
|
|
230
|
+
receipt.proofHash !== hashValue(canonicalStringify(manifest.proof)) ||
|
|
231
|
+
receipt.postStateHash !== recoveryStateHash(manifest.entries, 'after') ||
|
|
232
|
+
receipt.changeCount !== manifest.entries.length ||
|
|
233
|
+
typeof receipt.appliedAt !== 'string' ||
|
|
234
|
+
!Number.isFinite(Date.parse(receipt.appliedAt))) {
|
|
235
|
+
throw new Error(RECOVERY_CHECKPOINT_CORRUPT);
|
|
236
|
+
}
|
|
237
|
+
return receipt;
|
|
238
|
+
}
|
|
239
|
+
export async function ensureRecoveryReceipt(artifactDir, manifest, manifestHash) {
|
|
240
|
+
const receiptPath = path.join(artifactDir, 'receipt.json');
|
|
241
|
+
if (existsSync(receiptPath)) {
|
|
242
|
+
return readAndValidateRecoveryReceipt(artifactDir, manifest, manifestHash);
|
|
243
|
+
}
|
|
244
|
+
const receipt = {
|
|
245
|
+
version: 1,
|
|
246
|
+
checkpointId: manifest.checkpointId,
|
|
247
|
+
manifestHash,
|
|
248
|
+
proofHash: hashValue(canonicalStringify(manifest.proof)),
|
|
249
|
+
postStateHash: recoveryStateHash(manifest.entries, 'after'),
|
|
250
|
+
appliedAt: new Date().toISOString(),
|
|
251
|
+
changeCount: manifest.entries.length,
|
|
252
|
+
};
|
|
253
|
+
await atomicWriteJson(receiptPath, receipt);
|
|
254
|
+
return receipt;
|
|
255
|
+
}
|
|
256
|
+
export async function checkpointIds(stateDir) {
|
|
257
|
+
const root = checkpointsRoot(stateDir);
|
|
258
|
+
if (!existsSync(root))
|
|
259
|
+
return [];
|
|
260
|
+
return (await readdir(root, { withFileTypes: true }))
|
|
261
|
+
.filter((entry) => entry.isDirectory() && /^cp_[a-f0-9]{24}$/.test(entry.name))
|
|
262
|
+
.map((entry) => entry.name);
|
|
263
|
+
}
|
|
264
|
+
export async function artifactRepoRoot(stateDir, checkpointId) {
|
|
265
|
+
const artifactDir = checkpointDir(stateDir, checkpointId);
|
|
266
|
+
try {
|
|
267
|
+
const manifest = JSON.parse(await readFile(path.join(artifactDir, 'manifest.json'), 'utf8'));
|
|
268
|
+
if (typeof manifest.repoRoot === 'string' && manifest.repoRoot) {
|
|
269
|
+
return canonicalPath(manifest.repoRoot);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Fall through to the immutable staging owner used for quota attribution.
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const owner = JSON.parse(await readFile(path.join(artifactDir, 'owner.json'), 'utf8'));
|
|
277
|
+
return typeof owner.repoRoot === 'string' && owner.repoRoot
|
|
278
|
+
? canonicalPath(owner.repoRoot)
|
|
279
|
+
: null;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
export async function checkpointIdsForRepo(stateDir, repoRoot) {
|
|
286
|
+
const expected = canonicalPath(repoRoot);
|
|
287
|
+
const matching = [];
|
|
288
|
+
for (const id of await checkpointIds(stateDir)) {
|
|
289
|
+
if ((await artifactRepoRoot(stateDir, id)) === expected)
|
|
290
|
+
matching.push(id);
|
|
291
|
+
}
|
|
292
|
+
return matching;
|
|
293
|
+
}
|
|
294
|
+
export async function cleanupOrphanedStaging(stateDir) {
|
|
295
|
+
const root = checkpointsRoot(stateDir);
|
|
296
|
+
if (!existsSync(root))
|
|
297
|
+
return;
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
300
|
+
if (!entry.isDirectory() || !/^\.tmp-cp_[a-f0-9]{24}$/.test(entry.name))
|
|
301
|
+
continue;
|
|
302
|
+
const stagingPath = path.join(root, entry.name);
|
|
303
|
+
let stale = false;
|
|
304
|
+
try {
|
|
305
|
+
const owner = JSON.parse(await readFile(path.join(stagingPath, 'owner.json'), 'utf8'));
|
|
306
|
+
const pid = typeof owner.pid === 'number' ? owner.pid : Number.NaN;
|
|
307
|
+
const createdAt = typeof owner.createdAt === 'string' ? Date.parse(owner.createdAt) : NaN;
|
|
308
|
+
let alive = false;
|
|
309
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
310
|
+
try {
|
|
311
|
+
process.kill(pid, 0);
|
|
312
|
+
alive = true;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
alive = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
stale = !alive || !Number.isFinite(createdAt);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
const info = await lstat(stagingPath);
|
|
322
|
+
stale = now - info.mtimeMs >= STAGING_STALE_MS;
|
|
323
|
+
}
|
|
324
|
+
if (stale)
|
|
325
|
+
await rm(stagingPath, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
export async function markRecoveryCheckpointApplying(stateDir, checkpoint) {
|
|
329
|
+
await writeRecoveryState(checkpointDir(stateDir, checkpoint.checkpointId), 'applying', checkpoint.manifestHash);
|
|
330
|
+
}
|
|
331
|
+
export async function markRecoveryCheckpointApplied(stateDir, checkpoint) {
|
|
332
|
+
const artifactDir = checkpointDir(stateDir, checkpoint.checkpointId);
|
|
333
|
+
const receipt = await ensureRecoveryReceipt(artifactDir, checkpoint.manifest, checkpoint.manifestHash);
|
|
334
|
+
await writeRecoveryState(artifactDir, 'applied', checkpoint.manifestHash);
|
|
335
|
+
return receipt;
|
|
336
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BelayTransactionalConfig } from '../config.js';
|
|
2
|
+
import type { TransactionalFileChange } from '../transactional/types.js';
|
|
3
|
+
import { markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, type PreparedRecoveryCheckpoint, RECOVERY_CHECKPOINT_CORRUPT } from './artifact-store.js';
|
|
4
|
+
import { reconcileRecoveryCheckpoint } from './reconcile.js';
|
|
5
|
+
import { RECOVERY_RESTORE_CONFLICT, RECOVERY_RESTORE_REASON, recoveryRestoreBinding, restoreRecoveryCheckpoint } from './restore.js';
|
|
6
|
+
import type { RecoveryBackend, RecoveryCheckpointSummary } from './types.js';
|
|
7
|
+
export declare const RECOVERY_CHECKPOINT_QUOTA = "recovery_checkpoint_quota_exceeded";
|
|
8
|
+
type CheckpointConfig = NonNullable<BelayTransactionalConfig['checkpoint']>;
|
|
9
|
+
export declare function prepareRecoveryCheckpoint(params: {
|
|
10
|
+
stateDir: string;
|
|
11
|
+
repoRoot: string;
|
|
12
|
+
/** Immutable pre-command source for recovery blobs, when available. */
|
|
13
|
+
baselinePath?: string;
|
|
14
|
+
worktreePath: string;
|
|
15
|
+
commandFingerprint: string;
|
|
16
|
+
changes: TransactionalFileChange[];
|
|
17
|
+
protectedRoots?: string[];
|
|
18
|
+
config: CheckpointConfig;
|
|
19
|
+
backend?: RecoveryBackend;
|
|
20
|
+
}): Promise<PreparedRecoveryCheckpoint>;
|
|
21
|
+
export declare function discardPreparedRecoveryCheckpoint(stateDir: string, checkpointId: string): Promise<boolean>;
|
|
22
|
+
export declare function listRecoveryCheckpoints(stateDir: string, repoRoot?: string): Promise<RecoveryCheckpointSummary[]>;
|
|
23
|
+
export declare function recoveryCheckpointStorageBytes(stateDir: string, repoRoot?: string): Promise<number>;
|
|
24
|
+
export declare function showRecoveryCheckpoint(stateDir: string, checkpointId: string, expectedRepoRoot?: string): Promise<import("./artifact-store.js").RecoveryArtifact>;
|
|
25
|
+
export { markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_RESTORE_CONFLICT, RECOVERY_RESTORE_REASON, reconcileRecoveryCheckpoint, recoveryRestoreBinding, restoreRecoveryCheckpoint, };
|