@cruxy/cli 0.8.0 → 0.10.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 +40 -13
- package/dist/agent/loop.d.ts +28 -1
- package/dist/agent/loop.js +36 -4
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/approval/classify.js +26 -0
- package/dist/approval/prompt.js +4 -27
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +96 -0
- package/dist/cli/commands/run.js +10 -2
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +7 -1
- package/dist/cli/repl.js +23 -3
- package/dist/cli/session-factory.d.ts +14 -1
- package/dist/cli/session-factory.js +87 -22
- package/dist/components/autocomplete.d.ts +32 -0
- package/dist/components/autocomplete.js +50 -0
- package/dist/components/frame.d.ts +25 -0
- package/dist/components/frame.js +49 -0
- package/dist/components/fuzzy.d.ts +61 -0
- package/dist/components/fuzzy.js +174 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +6 -0
- package/dist/components/input.d.ts +78 -0
- package/dist/components/input.js +111 -0
- package/dist/components/keys.d.ts +48 -0
- package/dist/components/keys.js +105 -0
- package/dist/components/select.d.ts +28 -0
- package/dist/components/select.js +69 -0
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +32 -0
- package/dist/errors/constructors.js +101 -0
- package/dist/errors/types.d.ts +8 -0
- package/dist/errors/types.js +18 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/onboarding/io.d.ts +3 -2
- package/dist/onboarding/io.js +35 -81
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.js +5 -1
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/diff.js +27 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.js +1 -0
- package/dist/render/plain-renderer.d.ts +7 -1
- package/dist/render/plain-renderer.js +26 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +41 -5
- package/dist/render/tty-renderer.js +150 -23
- package/dist/render/types.d.ts +85 -1
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import type { Checkpoint, CheckpointStore, RollbackApplied, RollbackPlan } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Diff the current working tree against a checkpoint by content address.
|
|
5
|
+
* External attribution: a differing path the tracked run never touched is
|
|
6
|
+
* flagged `external` — unless the run executed shell commands, in which case
|
|
7
|
+
* attribution is unknowable and the plan says so instead of guessing.
|
|
8
|
+
*/
|
|
9
|
+
export declare function computeRollbackPlan(root: string, checkpoint: Checkpoint, store: CheckpointStore, gitWorkTree: boolean): Promise<RollbackPlan>;
|
|
10
|
+
/**
|
|
11
|
+
* Build the U.2 preview for a plan: patch-style entries (byte-identical with
|
|
12
|
+
* apply_patch previews) plus the external-change and boundary annotations that
|
|
13
|
+
* `renderActionPreview` places *above* the diff.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildRollbackPreview(root: string, plan: RollbackPlan, store: CheckpointStore): Promise<Extract<ActionPreview, {
|
|
16
|
+
type: "rollback";
|
|
17
|
+
}>>;
|
|
18
|
+
/**
|
|
19
|
+
* Apply a plan: recreate deleted files, revert edited ones (content and mode),
|
|
20
|
+
* then remove run-created files (and any directories that emptied out). Any
|
|
21
|
+
* failure is a loud CRUXY_E_CHECKPOINT_FAILED; re-running rollback resumes.
|
|
22
|
+
*/
|
|
23
|
+
export declare function applyRollback(root: string, plan: RollbackPlan, store: CheckpointStore): Promise<RollbackApplied>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { promises as fsp } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { checkpointFailed, CruxyError } from "../errors/index.js";
|
|
4
|
+
import { isInside } from "../approval/classify.js";
|
|
5
|
+
import { isBinary } from "../indexing/util.js";
|
|
6
|
+
import { captureFiles } from "./capture.js";
|
|
7
|
+
/**
|
|
8
|
+
* Rollback planning + application (C.32). The plan is recomputed from disk on
|
|
9
|
+
* every invocation — never cached — which is what makes rollback idempotent:
|
|
10
|
+
* a partially-applied rollback re-run picks up exactly the remaining work.
|
|
11
|
+
*/
|
|
12
|
+
/** Per-file cap on preview content lines (the renderer collapses globally too). */
|
|
13
|
+
const FILE_PREVIEW_LINES = 20;
|
|
14
|
+
/**
|
|
15
|
+
* Diff the current working tree against a checkpoint by content address.
|
|
16
|
+
* External attribution: a differing path the tracked run never touched is
|
|
17
|
+
* flagged `external` — unless the run executed shell commands, in which case
|
|
18
|
+
* attribution is unknowable and the plan says so instead of guessing.
|
|
19
|
+
*/
|
|
20
|
+
export async function computeRollbackPlan(root, checkpoint, store, gitWorkTree) {
|
|
21
|
+
const current = await captureFiles(root, gitWorkTree);
|
|
22
|
+
const currentPaths = new Set(current.map((f) => f.path));
|
|
23
|
+
const manifestPaths = new Set(checkpoint.files.map((e) => e.path));
|
|
24
|
+
const touched = new Set(checkpoint.touchedPaths);
|
|
25
|
+
const attributionUnknown = checkpoint.hasShellMutations;
|
|
26
|
+
const isExternal = (p) => !attributionUnknown && !touched.has(p);
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const entry of checkpoint.files) {
|
|
29
|
+
const absPath = path.join(root, ...entry.path.split("/"));
|
|
30
|
+
if (!currentPaths.has(entry.path)) {
|
|
31
|
+
entries.push({
|
|
32
|
+
path: entry.path,
|
|
33
|
+
action: "recreate",
|
|
34
|
+
external: isExternal(entry.path),
|
|
35
|
+
entry,
|
|
36
|
+
});
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const content = await fsp.readFile(absPath);
|
|
40
|
+
const mode = await fileMode(absPath);
|
|
41
|
+
if (store.hashContent(content) !== entry.oid || mode !== entry.mode) {
|
|
42
|
+
entries.push({
|
|
43
|
+
path: entry.path,
|
|
44
|
+
action: "revert",
|
|
45
|
+
external: isExternal(entry.path),
|
|
46
|
+
entry,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const file of current) {
|
|
51
|
+
if (!manifestPaths.has(file.path)) {
|
|
52
|
+
entries.push({
|
|
53
|
+
path: file.path,
|
|
54
|
+
action: "delete",
|
|
55
|
+
external: isExternal(file.path),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
60
|
+
return {
|
|
61
|
+
checkpoint,
|
|
62
|
+
entries,
|
|
63
|
+
externalPaths: entries.filter((e) => e.external).map((e) => e.path),
|
|
64
|
+
attributionUnknown,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build the U.2 preview for a plan: patch-style entries (byte-identical with
|
|
69
|
+
* apply_patch previews) plus the external-change and boundary annotations that
|
|
70
|
+
* `renderActionPreview` places *above* the diff.
|
|
71
|
+
*/
|
|
72
|
+
export async function buildRollbackPreview(root, plan, store) {
|
|
73
|
+
const files = [];
|
|
74
|
+
for (const entry of plan.entries) {
|
|
75
|
+
if (entry.action === "delete") {
|
|
76
|
+
files.push({ op: "delete", path: entry.path });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// entry.entry is always present for recreate/revert (see RollbackEntry).
|
|
80
|
+
const restored = await store.readContent(entry.entry);
|
|
81
|
+
if (entry.action === "recreate") {
|
|
82
|
+
const { lines, omitted } = previewLines(restored);
|
|
83
|
+
files.push({
|
|
84
|
+
op: "create",
|
|
85
|
+
path: entry.path,
|
|
86
|
+
lines,
|
|
87
|
+
omittedLines: omitted,
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const currentPath = path.join(root, ...entry.path.split("/"));
|
|
92
|
+
const current = await fsp.readFile(currentPath);
|
|
93
|
+
files.push({
|
|
94
|
+
op: "update",
|
|
95
|
+
path: entry.path,
|
|
96
|
+
hunks: [
|
|
97
|
+
{
|
|
98
|
+
oldStr: previewText(current),
|
|
99
|
+
newStr: previewText(restored),
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
type: "rollback",
|
|
106
|
+
checkpointId: plan.checkpoint.id,
|
|
107
|
+
createdAt: plan.checkpoint.createdAt,
|
|
108
|
+
runSummary: plan.checkpoint.runSummary,
|
|
109
|
+
files,
|
|
110
|
+
externalPaths: plan.externalPaths,
|
|
111
|
+
attributionUnknown: plan.attributionUnknown,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Apply a plan: recreate deleted files, revert edited ones (content and mode),
|
|
116
|
+
* then remove run-created files (and any directories that emptied out). Any
|
|
117
|
+
* failure is a loud CRUXY_E_CHECKPOINT_FAILED; re-running rollback resumes.
|
|
118
|
+
*/
|
|
119
|
+
export async function applyRollback(root, plan, store) {
|
|
120
|
+
const applied = { recreated: 0, reverted: 0, deleted: 0 };
|
|
121
|
+
for (const entry of plan.entries) {
|
|
122
|
+
const absPath = safeAbsPath(root, entry.path);
|
|
123
|
+
try {
|
|
124
|
+
if (entry.action === "delete") {
|
|
125
|
+
await fsp.rm(absPath, { force: true });
|
|
126
|
+
await removeEmptyDirs(root, path.dirname(absPath));
|
|
127
|
+
applied.deleted += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const content = await store.readContent(entry.entry);
|
|
131
|
+
await fsp.mkdir(path.dirname(absPath), { recursive: true });
|
|
132
|
+
await fsp.writeFile(absPath, content);
|
|
133
|
+
await fsp.chmod(absPath, entry.entry.mode === "100755" ? 0o755 : 0o644);
|
|
134
|
+
applied[entry.action === "recreate" ? "recreated" : "reverted"] += 1;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (CruxyError.is(err))
|
|
138
|
+
throw err;
|
|
139
|
+
throw checkpointFailed(`restoring ${entry.path} failed (${entry.action})`, err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return applied;
|
|
143
|
+
}
|
|
144
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
145
|
+
/** Resolve a manifest path and refuse anything that escapes the project root. */
|
|
146
|
+
function safeAbsPath(root, relPath) {
|
|
147
|
+
const abs = path.resolve(root, ...relPath.split("/"));
|
|
148
|
+
if (!isInside(path.resolve(root), abs) || abs === path.resolve(root)) {
|
|
149
|
+
throw checkpointFailed(`manifest path escapes the project root: ${relPath}`);
|
|
150
|
+
}
|
|
151
|
+
return abs;
|
|
152
|
+
}
|
|
153
|
+
/** Remove now-empty directories from `dir` up to (never including) `root`. */
|
|
154
|
+
async function removeEmptyDirs(root, dir) {
|
|
155
|
+
const absRoot = path.resolve(root);
|
|
156
|
+
let current = path.resolve(dir);
|
|
157
|
+
while (current !== absRoot && isInside(absRoot, current)) {
|
|
158
|
+
try {
|
|
159
|
+
await fsp.rmdir(current); // fails on non-empty — exactly the stop signal
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
current = path.dirname(current);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function fileMode(absPath) {
|
|
168
|
+
const stat = await fsp.lstat(absPath);
|
|
169
|
+
return stat.mode & 0o100 ? "100755" : "100644";
|
|
170
|
+
}
|
|
171
|
+
/** First lines of restored content for a `create` preview; binary → placeholder. */
|
|
172
|
+
function previewLines(content) {
|
|
173
|
+
if (isBinary(content)) {
|
|
174
|
+
return { lines: [binaryPlaceholder(content)], omitted: 0 };
|
|
175
|
+
}
|
|
176
|
+
const all = content.toString("utf8").replace(/\n$/, "").split("\n");
|
|
177
|
+
return {
|
|
178
|
+
lines: all.slice(0, FILE_PREVIEW_LINES),
|
|
179
|
+
omitted: Math.max(0, all.length - FILE_PREVIEW_LINES),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Capped text for an `update` hunk side; binary → placeholder. */
|
|
183
|
+
function previewText(content) {
|
|
184
|
+
if (isBinary(content))
|
|
185
|
+
return binaryPlaceholder(content);
|
|
186
|
+
const all = content.toString("utf8").replace(/\n$/, "").split("\n");
|
|
187
|
+
if (all.length <= FILE_PREVIEW_LINES)
|
|
188
|
+
return all.join("\n");
|
|
189
|
+
const shown = all.slice(0, FILE_PREVIEW_LINES);
|
|
190
|
+
shown.push(`… (+${all.length - FILE_PREVIEW_LINES} more lines)`);
|
|
191
|
+
return shown.join("\n");
|
|
192
|
+
}
|
|
193
|
+
function binaryPlaceholder(content) {
|
|
194
|
+
return `«binary file, ${content.length} bytes»`;
|
|
195
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { ApprovalDecision } from "../approval/types.js";
|
|
3
|
+
import type { ApproveAction } from "../tools/types.js";
|
|
4
|
+
import type { Checkpoint, CheckpointStore, CheckpointStoreKind, RollbackApplied } from "./types.js";
|
|
5
|
+
/** Is `root` inside a git working tree? (Decides the checkpoint substrate.) */
|
|
6
|
+
export declare function isGitWorkTree(root: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* The substrate factory: git-object store inside a repo, shadow store outside.
|
|
9
|
+
* Pass `kind` to pin the substrate (rollback must read a checkpoint back with
|
|
10
|
+
* the store that wrote it, recorded in its manifest).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createCheckpointStore(root: string, kind?: CheckpointStoreKind): CheckpointStore;
|
|
13
|
+
export interface CheckpointServiceOptions {
|
|
14
|
+
root: string;
|
|
15
|
+
config: CruxyConfig;
|
|
16
|
+
/** Test seam: pin the content store (disables the git→shadow fallback). */
|
|
17
|
+
store?: CheckpointStore;
|
|
18
|
+
}
|
|
19
|
+
/** What `rollback()` needs from the caller: the U.3 gate and TTY interactivity. */
|
|
20
|
+
export interface RollbackDeps {
|
|
21
|
+
requestApproval(action: ApproveAction): Promise<ApprovalDecision>;
|
|
22
|
+
interactive: boolean;
|
|
23
|
+
}
|
|
24
|
+
export type RollbackResult =
|
|
25
|
+
/** The working tree already matches the checkpoint — nothing to do. */
|
|
26
|
+
{
|
|
27
|
+
kind: "noop";
|
|
28
|
+
checkpoint: Checkpoint;
|
|
29
|
+
}
|
|
30
|
+
/** The user saw the preview and declined; nothing was applied. */
|
|
31
|
+
| {
|
|
32
|
+
kind: "rejected";
|
|
33
|
+
feedback?: string;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "applied";
|
|
36
|
+
checkpoint: Checkpoint;
|
|
37
|
+
applied: RollbackApplied;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Checkpoint lifecycle (C.32): create once before a run's first mutation,
|
|
41
|
+
* record what the run touches, list/prune, and drive the gated rollback.
|
|
42
|
+
* Manifests are JSON under `.cruxy/checkpoints/` — they survive process exit,
|
|
43
|
+
* so `cruxy rollback` works in a later invocation.
|
|
44
|
+
*/
|
|
45
|
+
export declare class CheckpointService {
|
|
46
|
+
private readonly root;
|
|
47
|
+
private readonly config;
|
|
48
|
+
private readonly pinnedStore?;
|
|
49
|
+
private runSummary;
|
|
50
|
+
private active;
|
|
51
|
+
constructor(opts: CheckpointServiceOptions);
|
|
52
|
+
/** Start a new undo unit: reset the once-per-run latch and name the run. */
|
|
53
|
+
beginRun(summary: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* The auto-checkpoint hook, called from the approval seam after every allowed
|
|
56
|
+
* mutating action and latched to fire once per run — before the first
|
|
57
|
+
* mutation ever reaches disk. Returns the run's checkpoint, or `null` when
|
|
58
|
+
* the feature is disabled. Fail-loud: if a checkpoint cannot be written by
|
|
59
|
+
* either substrate, the run must not mutate without its undo protection.
|
|
60
|
+
*/
|
|
61
|
+
ensureCheckpoint(): Promise<Checkpoint | null>;
|
|
62
|
+
/** Attribute mutated paths to the current run (persisted for later rollback). */
|
|
63
|
+
recordTouched(absPaths: string[]): Promise<void>;
|
|
64
|
+
/** The run ran a shell command: per-path attribution is no longer possible. */
|
|
65
|
+
recordShellMutation(): Promise<void>;
|
|
66
|
+
/** All checkpoints, newest first. Corrupt manifests are warned about, not fatal. */
|
|
67
|
+
list(): Promise<Checkpoint[]>;
|
|
68
|
+
/** One checkpoint by id, or the newest when `id` is omitted. Fail-loud. */
|
|
69
|
+
read(id?: string): Promise<Checkpoint>;
|
|
70
|
+
/**
|
|
71
|
+
* The whole gated restore: preview → U.3 destructive approval → apply.
|
|
72
|
+
* Non-interactive callers are refused up front — there is no auto-rollback.
|
|
73
|
+
*/
|
|
74
|
+
rollback(id: string | undefined, deps: RollbackDeps): Promise<RollbackResult>;
|
|
75
|
+
/** Enforce `checkpoint.retention`: drop oldest manifests, then GC content. */
|
|
76
|
+
prune(): Promise<void>;
|
|
77
|
+
private dir;
|
|
78
|
+
private writeManifest;
|
|
79
|
+
private readManifestFile;
|
|
80
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { checkpointFailed, checkpointNotFound, rollbackApprovalRequired, CruxyError, } from "../errors/index.js";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { runGitCapture } from "../vcs/git.js";
|
|
7
|
+
import { captureFiles } from "./capture.js";
|
|
8
|
+
import { GitCheckpointStore } from "./git-store.js";
|
|
9
|
+
import { ShadowCheckpointStore } from "./shadow-store.js";
|
|
10
|
+
import { applyRollback, buildRollbackPreview, computeRollbackPlan, } from "./restore.js";
|
|
11
|
+
/** Is `root` inside a git working tree? (Decides the checkpoint substrate.) */
|
|
12
|
+
export function isGitWorkTree(root) {
|
|
13
|
+
const res = runGitCapture(["rev-parse", "--is-inside-work-tree"], root);
|
|
14
|
+
return res.ok && res.stdout.trim() === "true";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The substrate factory: git-object store inside a repo, shadow store outside.
|
|
18
|
+
* Pass `kind` to pin the substrate (rollback must read a checkpoint back with
|
|
19
|
+
* the store that wrote it, recorded in its manifest).
|
|
20
|
+
*/
|
|
21
|
+
export function createCheckpointStore(root, kind) {
|
|
22
|
+
const resolved = kind ?? (isGitWorkTree(root) ? "git" : "shadow");
|
|
23
|
+
return resolved === "git"
|
|
24
|
+
? new GitCheckpointStore(root)
|
|
25
|
+
: new ShadowCheckpointStore(root);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Checkpoint lifecycle (C.32): create once before a run's first mutation,
|
|
29
|
+
* record what the run touches, list/prune, and drive the gated rollback.
|
|
30
|
+
* Manifests are JSON under `.cruxy/checkpoints/` — they survive process exit,
|
|
31
|
+
* so `cruxy rollback` works in a later invocation.
|
|
32
|
+
*/
|
|
33
|
+
export class CheckpointService {
|
|
34
|
+
root;
|
|
35
|
+
config;
|
|
36
|
+
pinnedStore;
|
|
37
|
+
runSummary = "agent run";
|
|
38
|
+
active = null;
|
|
39
|
+
constructor(opts) {
|
|
40
|
+
this.root = path.resolve(opts.root);
|
|
41
|
+
this.config = opts.config;
|
|
42
|
+
this.pinnedStore = opts.store;
|
|
43
|
+
}
|
|
44
|
+
/** Start a new undo unit: reset the once-per-run latch and name the run. */
|
|
45
|
+
beginRun(summary) {
|
|
46
|
+
this.active = null;
|
|
47
|
+
const firstLine = summary.split("\n", 1)[0].trim();
|
|
48
|
+
this.runSummary =
|
|
49
|
+
firstLine.length > 80
|
|
50
|
+
? `${firstLine.slice(0, 79)}…`
|
|
51
|
+
: firstLine || "agent run";
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The auto-checkpoint hook, called from the approval seam after every allowed
|
|
55
|
+
* mutating action and latched to fire once per run — before the first
|
|
56
|
+
* mutation ever reaches disk. Returns the run's checkpoint, or `null` when
|
|
57
|
+
* the feature is disabled. Fail-loud: if a checkpoint cannot be written by
|
|
58
|
+
* either substrate, the run must not mutate without its undo protection.
|
|
59
|
+
*/
|
|
60
|
+
async ensureCheckpoint() {
|
|
61
|
+
if (!this.config.checkpoint.enabled)
|
|
62
|
+
return null;
|
|
63
|
+
if (this.active)
|
|
64
|
+
return this.active;
|
|
65
|
+
const gitWorkTree = this.pinnedStore
|
|
66
|
+
? this.pinnedStore.kind === "git"
|
|
67
|
+
: isGitWorkTree(this.root);
|
|
68
|
+
let store = this.pinnedStore ??
|
|
69
|
+
createCheckpointStore(this.root, gitWorkTree ? "git" : "shadow");
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await store.snapshot(await captureFiles(this.root, gitWorkTree));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// Git plumbing failed mid-snapshot → shadow-copy fallback (the manifest
|
|
76
|
+
// records which substrate actually wrote the checkpoint). A pinned store
|
|
77
|
+
// (tests) never falls back; a shadow failure has nowhere left to go.
|
|
78
|
+
if (store.kind !== "git" || this.pinnedStore) {
|
|
79
|
+
throw CruxyError.is(err)
|
|
80
|
+
? err
|
|
81
|
+
: checkpointFailed("snapshotting the working tree failed", err);
|
|
82
|
+
}
|
|
83
|
+
logger.warn(`git-backed checkpoint failed (${err.message}); falling back to a shadow copy`);
|
|
84
|
+
store = new ShadowCheckpointStore(this.root);
|
|
85
|
+
entries = await store.snapshot(await captureFiles(this.root, false));
|
|
86
|
+
}
|
|
87
|
+
const checkpoint = {
|
|
88
|
+
id: newCheckpointId(),
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
runSummary: this.runSummary,
|
|
91
|
+
store: store.kind,
|
|
92
|
+
files: entries,
|
|
93
|
+
touchedPaths: [],
|
|
94
|
+
hasShellMutations: false,
|
|
95
|
+
};
|
|
96
|
+
await this.writeManifest(checkpoint);
|
|
97
|
+
await this.prune();
|
|
98
|
+
this.active = checkpoint;
|
|
99
|
+
logger.debug(`checkpoint ${checkpoint.id} created (${entries.length} files, ${store.kind} store)`);
|
|
100
|
+
return checkpoint;
|
|
101
|
+
}
|
|
102
|
+
/** Attribute mutated paths to the current run (persisted for later rollback). */
|
|
103
|
+
async recordTouched(absPaths) {
|
|
104
|
+
if (!this.active || absPaths.length === 0)
|
|
105
|
+
return;
|
|
106
|
+
const known = new Set(this.active.touchedPaths);
|
|
107
|
+
let added = false;
|
|
108
|
+
for (const abs of absPaths) {
|
|
109
|
+
const rel = path.relative(this.root, abs).split(path.sep).join("/");
|
|
110
|
+
if (rel === "" || rel.startsWith("..") || known.has(rel))
|
|
111
|
+
continue;
|
|
112
|
+
known.add(rel);
|
|
113
|
+
this.active.touchedPaths.push(rel);
|
|
114
|
+
added = true;
|
|
115
|
+
}
|
|
116
|
+
if (added)
|
|
117
|
+
await this.writeManifest(this.active);
|
|
118
|
+
}
|
|
119
|
+
/** The run ran a shell command: per-path attribution is no longer possible. */
|
|
120
|
+
async recordShellMutation() {
|
|
121
|
+
if (!this.active || this.active.hasShellMutations)
|
|
122
|
+
return;
|
|
123
|
+
this.active.hasShellMutations = true;
|
|
124
|
+
await this.writeManifest(this.active);
|
|
125
|
+
}
|
|
126
|
+
/** All checkpoints, newest first. Corrupt manifests are warned about, not fatal. */
|
|
127
|
+
async list() {
|
|
128
|
+
let names;
|
|
129
|
+
try {
|
|
130
|
+
names = await fsp.readdir(this.dir());
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return []; // no checkpoints dir yet
|
|
134
|
+
}
|
|
135
|
+
const checkpoints = [];
|
|
136
|
+
for (const name of names) {
|
|
137
|
+
if (!name.endsWith(".json"))
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
checkpoints.push(await this.readManifestFile(path.join(this.dir(), name)));
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
logger.warn(`skipping unreadable checkpoint manifest ${name}: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
checkpoints.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
|
|
147
|
+
return checkpoints;
|
|
148
|
+
}
|
|
149
|
+
/** One checkpoint by id, or the newest when `id` is omitted. Fail-loud. */
|
|
150
|
+
async read(id) {
|
|
151
|
+
if (id === undefined) {
|
|
152
|
+
const newest = (await this.list())[0];
|
|
153
|
+
if (!newest)
|
|
154
|
+
throw checkpointNotFound();
|
|
155
|
+
return newest;
|
|
156
|
+
}
|
|
157
|
+
const file = path.join(this.dir(), `${id}.json`);
|
|
158
|
+
try {
|
|
159
|
+
await fsp.access(file);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw checkpointNotFound(id);
|
|
163
|
+
}
|
|
164
|
+
return this.readManifestFile(file);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The whole gated restore: preview → U.3 destructive approval → apply.
|
|
168
|
+
* Non-interactive callers are refused up front — there is no auto-rollback.
|
|
169
|
+
*/
|
|
170
|
+
async rollback(id, deps) {
|
|
171
|
+
if (!deps.interactive)
|
|
172
|
+
throw rollbackApprovalRequired();
|
|
173
|
+
const checkpoint = await this.read(id);
|
|
174
|
+
const store = this.pinnedStore ?? createCheckpointStore(this.root, checkpoint.store);
|
|
175
|
+
const plan = await computeRollbackPlan(this.root, checkpoint, store, isGitWorkTree(this.root));
|
|
176
|
+
if (plan.entries.length === 0)
|
|
177
|
+
return { kind: "noop", checkpoint };
|
|
178
|
+
const preview = await buildRollbackPreview(this.root, plan, store);
|
|
179
|
+
const decision = await deps.requestApproval({ kind: "rollback", preview });
|
|
180
|
+
if (!decision.allow) {
|
|
181
|
+
return { kind: "rejected", feedback: decision.feedback };
|
|
182
|
+
}
|
|
183
|
+
const applied = await applyRollback(this.root, plan, store);
|
|
184
|
+
return { kind: "applied", checkpoint, applied };
|
|
185
|
+
}
|
|
186
|
+
/** Enforce `checkpoint.retention`: drop oldest manifests, then GC content. */
|
|
187
|
+
async prune() {
|
|
188
|
+
const all = await this.list();
|
|
189
|
+
const doomed = all.slice(this.config.checkpoint.retention);
|
|
190
|
+
if (doomed.length === 0)
|
|
191
|
+
return;
|
|
192
|
+
for (const checkpoint of doomed) {
|
|
193
|
+
await fsp.rm(path.join(this.dir(), `${checkpoint.id}.json`), {
|
|
194
|
+
force: true,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const survivors = all.slice(0, this.config.checkpoint.retention);
|
|
198
|
+
const referenced = new Set(survivors.flatMap((c) => c.files.map((f) => f.oid)));
|
|
199
|
+
// The shadow pool is ours to sweep; git's dangling objects belong to git gc.
|
|
200
|
+
await new ShadowCheckpointStore(this.root).collect(referenced);
|
|
201
|
+
}
|
|
202
|
+
// ── manifest persistence ────────────────────────────────────────────────────
|
|
203
|
+
dir() {
|
|
204
|
+
return path.join(this.root, ".cruxy", "checkpoints");
|
|
205
|
+
}
|
|
206
|
+
async writeManifest(checkpoint) {
|
|
207
|
+
const dir = this.dir();
|
|
208
|
+
try {
|
|
209
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
210
|
+
// Self-ignoring: git never sees checkpoint state, and the user's own
|
|
211
|
+
// .gitignore is never edited. Written once, only if absent.
|
|
212
|
+
const ignoreFile = path.join(dir, ".gitignore");
|
|
213
|
+
try {
|
|
214
|
+
await fsp.access(ignoreFile);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
await fsp.writeFile(ignoreFile, "*\n");
|
|
218
|
+
}
|
|
219
|
+
const file = path.join(dir, `${checkpoint.id}.json`);
|
|
220
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
221
|
+
await fsp.writeFile(tmp, `${JSON.stringify(checkpoint, null, 2)}\n`);
|
|
222
|
+
await fsp.rename(tmp, file);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
throw checkpointFailed(`writing the checkpoint manifest for ${checkpoint.id} failed`, err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async readManifestFile(file) {
|
|
229
|
+
let raw;
|
|
230
|
+
try {
|
|
231
|
+
raw = await fsp.readFile(file, "utf8");
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
throw checkpointFailed(`reading checkpoint manifest ${file} failed`, err);
|
|
235
|
+
}
|
|
236
|
+
let parsed;
|
|
237
|
+
try {
|
|
238
|
+
parsed = JSON.parse(raw);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
throw checkpointFailed(`checkpoint manifest ${file} is not valid JSON`, err);
|
|
242
|
+
}
|
|
243
|
+
if (!isCheckpointShape(parsed)) {
|
|
244
|
+
throw checkpointFailed(`checkpoint manifest ${file} is malformed`);
|
|
245
|
+
}
|
|
246
|
+
return parsed;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** `ck-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
|
|
250
|
+
function newCheckpointId() {
|
|
251
|
+
const stamp = new Date()
|
|
252
|
+
.toISOString()
|
|
253
|
+
.replace(/[-:]/g, "")
|
|
254
|
+
.replace(/\..+$/, "");
|
|
255
|
+
return `ck-${stamp}-${randomBytes(2).toString("hex")}`;
|
|
256
|
+
}
|
|
257
|
+
/** Structural check for a parsed manifest — enough to fail loud on corruption. */
|
|
258
|
+
function isCheckpointShape(value) {
|
|
259
|
+
if (typeof value !== "object" || value === null)
|
|
260
|
+
return false;
|
|
261
|
+
const v = value;
|
|
262
|
+
return (typeof v.id === "string" &&
|
|
263
|
+
typeof v.createdAt === "string" &&
|
|
264
|
+
typeof v.runSummary === "string" &&
|
|
265
|
+
(v.store === "git" || v.store === "shadow") &&
|
|
266
|
+
Array.isArray(v.files) &&
|
|
267
|
+
v.files.every((f) => typeof f === "object" &&
|
|
268
|
+
f !== null &&
|
|
269
|
+
typeof f.path === "string" &&
|
|
270
|
+
typeof f.oid === "string" &&
|
|
271
|
+
(f.mode === "100644" ||
|
|
272
|
+
f.mode === "100755")) &&
|
|
273
|
+
Array.isArray(v.touchedPaths) &&
|
|
274
|
+
v.touchedPaths.every((p) => typeof p === "string") &&
|
|
275
|
+
typeof v.hasShellMutations === "boolean");
|
|
276
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CaptureFile, CheckpointStore, FileEntry } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shadow-copy checkpoint content store (C.32 fallback substrate): used outside
|
|
4
|
+
* a git repo, or when git plumbing fails mid-snapshot. File contents live in a
|
|
5
|
+
* content-addressed pool at `.cruxy/checkpoints/objects/<sha256>` — identical
|
|
6
|
+
* content across files or checkpoints is stored once, and pruning sweeps
|
|
7
|
+
* objects no longer referenced by any surviving manifest.
|
|
8
|
+
*
|
|
9
|
+
* Writes are temp-file-then-rename so a crash can never leave a torn object; a
|
|
10
|
+
* torn *read* is impossible because an object either exists complete or not at
|
|
11
|
+
* all, and a missing object fails loudly.
|
|
12
|
+
*/
|
|
13
|
+
export declare class ShadowCheckpointStore implements CheckpointStore {
|
|
14
|
+
readonly kind: "shadow";
|
|
15
|
+
private readonly objectsDir;
|
|
16
|
+
constructor(root: string);
|
|
17
|
+
hashContent(content: Buffer): string;
|
|
18
|
+
snapshot(files: CaptureFile[]): Promise<FileEntry[]>;
|
|
19
|
+
readContent(entry: FileEntry): Promise<Buffer>;
|
|
20
|
+
collect(referenced: ReadonlySet<string>): Promise<void>;
|
|
21
|
+
/** Content-addressed write: skip if present, else temp-then-rename (atomic). */
|
|
22
|
+
private writeObject;
|
|
23
|
+
}
|