@cruxy/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -13
- package/dist/agent/loop.d.ts +35 -6
- package/dist/agent/loop.js +84 -10
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/agent/session.d.ts +6 -4
- package/dist/agent/session.js +6 -5
- package/dist/approval/classify.js +26 -0
- package/dist/approval/prompt.d.ts +9 -0
- package/dist/approval/prompt.js +2 -77
- 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 +51 -0
- package/dist/cli/commands/run.js +24 -10
- package/dist/cli/onboard.js +9 -4
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +10 -4
- package/dist/cli/repl.js +26 -12
- package/dist/cli/session-factory.d.ts +15 -1
- package/dist/cli/session-factory.js +104 -18
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +86 -0
- package/dist/errors/types.d.ts +7 -0
- package/dist/errors/types.js +16 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.d.ts +2 -1
- package/dist/plan/service.js +7 -3
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/capabilities.d.ts +12 -0
- package/dist/render/capabilities.js +27 -0
- package/dist/render/diff.d.ts +19 -0
- package/dist/render/diff.js +107 -0
- package/dist/render/highlight.d.ts +47 -0
- package/dist/render/highlight.js +265 -0
- package/dist/render/index.d.ts +15 -0
- package/dist/render/index.js +21 -0
- package/dist/render/plain-renderer.d.ts +38 -0
- package/dist/render/plain-renderer.js +87 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +83 -0
- package/dist/render/tty-renderer.js +276 -0
- package/dist/render/types.d.ts +160 -0
- package/dist/render/types.js +1 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { checkpointFailed } from "../errors/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Shadow-copy checkpoint content store (C.32 fallback substrate): used outside
|
|
7
|
+
* a git repo, or when git plumbing fails mid-snapshot. File contents live in a
|
|
8
|
+
* content-addressed pool at `.cruxy/checkpoints/objects/<sha256>` — identical
|
|
9
|
+
* content across files or checkpoints is stored once, and pruning sweeps
|
|
10
|
+
* objects no longer referenced by any surviving manifest.
|
|
11
|
+
*
|
|
12
|
+
* Writes are temp-file-then-rename so a crash can never leave a torn object; a
|
|
13
|
+
* torn *read* is impossible because an object either exists complete or not at
|
|
14
|
+
* all, and a missing object fails loudly.
|
|
15
|
+
*/
|
|
16
|
+
export class ShadowCheckpointStore {
|
|
17
|
+
kind = "shadow";
|
|
18
|
+
objectsDir;
|
|
19
|
+
constructor(root) {
|
|
20
|
+
this.objectsDir = path.join(root, ".cruxy", "checkpoints", "objects");
|
|
21
|
+
}
|
|
22
|
+
hashContent(content) {
|
|
23
|
+
return createHash("sha256").update(content).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
async snapshot(files) {
|
|
26
|
+
await fsp.mkdir(this.objectsDir, { recursive: true });
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
let content;
|
|
30
|
+
let stat;
|
|
31
|
+
try {
|
|
32
|
+
stat = await fsp.lstat(file.absPath);
|
|
33
|
+
if (!stat.isFile())
|
|
34
|
+
continue; // raced from regular file to something else
|
|
35
|
+
content = await fsp.readFile(file.absPath);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw checkpointFailed(`could not read ${file.path} for snapshot`, err);
|
|
39
|
+
}
|
|
40
|
+
const oid = this.hashContent(content);
|
|
41
|
+
await this.writeObject(oid, content);
|
|
42
|
+
entries.push({
|
|
43
|
+
path: file.path,
|
|
44
|
+
mode: stat.mode & 0o100 ? "100755" : "100644",
|
|
45
|
+
oid,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
async readContent(entry) {
|
|
51
|
+
try {
|
|
52
|
+
return await fsp.readFile(path.join(this.objectsDir, entry.oid));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw checkpointFailed(`checkpoint content for ${entry.path} is missing from .cruxy/checkpoints/objects (${entry.oid})`, err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async collect(referenced) {
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
names = await fsp.readdir(this.objectsDir);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return; // no pool yet — nothing to sweep
|
|
65
|
+
}
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
if (referenced.has(name))
|
|
68
|
+
continue;
|
|
69
|
+
// Best-effort: a failed unlink just leaves an unreferenced object behind.
|
|
70
|
+
await fsp.rm(path.join(this.objectsDir, name), { force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Content-addressed write: skip if present, else temp-then-rename (atomic). */
|
|
74
|
+
async writeObject(oid, content) {
|
|
75
|
+
const dest = path.join(this.objectsDir, oid);
|
|
76
|
+
try {
|
|
77
|
+
await fsp.access(dest);
|
|
78
|
+
return; // already stored — content-addressing dedupes for free
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* not present — write it */
|
|
82
|
+
}
|
|
83
|
+
const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
|
|
84
|
+
try {
|
|
85
|
+
await fsp.writeFile(tmp, content);
|
|
86
|
+
await fsp.rename(tmp, dest);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
await fsp.rm(tmp, { force: true });
|
|
90
|
+
throw checkpointFailed(`could not store checkpoint object ${oid}`, err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working-tree checkpoints (C.32): a complete snapshot of everything the agent
|
|
3
|
+
* could touch, captured before a run's first mutation, so `cruxy rollback` can
|
|
4
|
+
* undo the entire run — creates, edits, deletes — in one gated operation.
|
|
5
|
+
*
|
|
6
|
+
* Two layers, deliberately separated:
|
|
7
|
+
* • {@link CheckpointStore} — the *content substrate* (where file bytes
|
|
8
|
+
* live), swappable like VectorStore: git-object-backed in a repo, a
|
|
9
|
+
* content-addressed shadow copy otherwise.
|
|
10
|
+
* • The manifest — uniform JSON under `.cruxy/checkpoints/`, owned by the
|
|
11
|
+
* service regardless of substrate, so list/prune/rollback never care which
|
|
12
|
+
* store wrote a checkpoint.
|
|
13
|
+
*
|
|
14
|
+
* Explicit boundary: checkpoints cover WORKING-TREE file state only. They can
|
|
15
|
+
* never undo git commits, pushes, or PRs made during a run (C.15) — that is
|
|
16
|
+
* stated in the rollback preview, not silently implied.
|
|
17
|
+
*/
|
|
18
|
+
/** A file selected for capture: project-relative POSIX path + where it is on disk. */
|
|
19
|
+
export interface CaptureFile {
|
|
20
|
+
path: string;
|
|
21
|
+
absPath: string;
|
|
22
|
+
}
|
|
23
|
+
/** Which content substrate a checkpoint was written with. */
|
|
24
|
+
export type CheckpointStoreKind = "git" | "shadow";
|
|
25
|
+
/**
|
|
26
|
+
* One captured file in a checkpoint manifest. `oid` is the content address in
|
|
27
|
+
* the store that wrote it: a git blob sha-1 for the git store, a sha-256 for
|
|
28
|
+
* the shadow store. `mode` preserves the executable bit across restore.
|
|
29
|
+
*/
|
|
30
|
+
export interface FileEntry {
|
|
31
|
+
path: string;
|
|
32
|
+
mode: "100644" | "100755";
|
|
33
|
+
oid: string;
|
|
34
|
+
}
|
|
35
|
+
/** A persisted checkpoint: identity, provenance, and the full pre-run manifest. */
|
|
36
|
+
export interface Checkpoint {
|
|
37
|
+
/** Stable id, e.g. `ck-20260703T141530-a4f2`. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** ISO-8601 creation time. */
|
|
40
|
+
createdAt: string;
|
|
41
|
+
/** One line describing the run this checkpoint protects. */
|
|
42
|
+
runSummary: string;
|
|
43
|
+
store: CheckpointStoreKind;
|
|
44
|
+
/** Every file that existed (and was capturable) before the run's first mutation. */
|
|
45
|
+
files: FileEntry[];
|
|
46
|
+
/**
|
|
47
|
+
* Project-relative paths the tracked run mutated through file tools, recorded
|
|
48
|
+
* as the run proceeds. At rollback time, a difference on a path NOT in this
|
|
49
|
+
* list is an *external* change — surfaced loudly in the preview.
|
|
50
|
+
*/
|
|
51
|
+
touchedPaths: string[];
|
|
52
|
+
/**
|
|
53
|
+
* True when the run executed approved shell commands: a shell command can
|
|
54
|
+
* touch any path, so per-file attribution becomes impossible and the preview
|
|
55
|
+
* says so instead of guessing.
|
|
56
|
+
*/
|
|
57
|
+
hasShellMutations: boolean;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The content substrate seam. Implementations persist and retrieve file
|
|
61
|
+
* *contents* only — manifests are the service's job.
|
|
62
|
+
*
|
|
63
|
+
* THE invariant (git store): snapshot/read must never disturb user-visible git
|
|
64
|
+
* state — HEAD, the index, the stash, or any ref. The git store self-checks
|
|
65
|
+
* this at runtime and fails loudly on drift.
|
|
66
|
+
*/
|
|
67
|
+
export interface CheckpointStore {
|
|
68
|
+
readonly kind: CheckpointStoreKind;
|
|
69
|
+
/** Persist the current contents of `files`; returns the manifest entries. */
|
|
70
|
+
snapshot(files: CaptureFile[]): Promise<FileEntry[]>;
|
|
71
|
+
/** The content address `snapshot` would give this buffer (for diffing). */
|
|
72
|
+
hashContent(content: Buffer): string;
|
|
73
|
+
/** Read one captured file's bytes back. Throws CRUXY_E_CHECKPOINT_FAILED if gone. */
|
|
74
|
+
readContent(entry: FileEntry): Promise<Buffer>;
|
|
75
|
+
/**
|
|
76
|
+
* Best-effort GC after prune: drop stored content whose oid is no longer
|
|
77
|
+
* referenced by any surviving manifest. The git store is a no-op (dangling
|
|
78
|
+
* objects belong to git's own gc); the shadow store sweeps its object pool.
|
|
79
|
+
*/
|
|
80
|
+
collect(referenced: ReadonlySet<string>): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
/** What rollback will do to one path. */
|
|
83
|
+
export type RollbackAction =
|
|
84
|
+
/** The run deleted it — recreate from the checkpoint. */
|
|
85
|
+
"recreate"
|
|
86
|
+
/** The run edited it — revert content (and mode) to the checkpoint. */
|
|
87
|
+
| "revert"
|
|
88
|
+
/** The run created it — delete it. */
|
|
89
|
+
| "delete";
|
|
90
|
+
export interface RollbackEntry {
|
|
91
|
+
path: string;
|
|
92
|
+
action: RollbackAction;
|
|
93
|
+
/**
|
|
94
|
+
* True when this path changed since the checkpoint but the tracked run never
|
|
95
|
+
* touched it — concurrent user (or other-tool) work that rollback would
|
|
96
|
+
* clobber. Always surfaced in the preview; never overwritten silently.
|
|
97
|
+
*/
|
|
98
|
+
external: boolean;
|
|
99
|
+
/** Checkpoint-side content to restore (recreate/revert; absent for delete). */
|
|
100
|
+
entry?: FileEntry;
|
|
101
|
+
}
|
|
102
|
+
/** The computed diff between the current working tree and a checkpoint. */
|
|
103
|
+
export interface RollbackPlan {
|
|
104
|
+
checkpoint: Checkpoint;
|
|
105
|
+
/** Sorted by path; empty means the tree already matches the checkpoint. */
|
|
106
|
+
entries: RollbackEntry[];
|
|
107
|
+
/** The `external: true` paths, for the preview's warning block. */
|
|
108
|
+
externalPaths: string[];
|
|
109
|
+
/** Mirrors {@link Checkpoint.hasShellMutations}: attribution is unknowable. */
|
|
110
|
+
attributionUnknown: boolean;
|
|
111
|
+
}
|
|
112
|
+
/** Counts of what a successful rollback actually did. */
|
|
113
|
+
export interface RollbackApplied {
|
|
114
|
+
recreated: number;
|
|
115
|
+
reverted: number;
|
|
116
|
+
deleted: number;
|
|
117
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working-tree checkpoints (C.32): a complete snapshot of everything the agent
|
|
3
|
+
* could touch, captured before a run's first mutation, so `cruxy rollback` can
|
|
4
|
+
* undo the entire run — creates, edits, deletes — in one gated operation.
|
|
5
|
+
*
|
|
6
|
+
* Two layers, deliberately separated:
|
|
7
|
+
* • {@link CheckpointStore} — the *content substrate* (where file bytes
|
|
8
|
+
* live), swappable like VectorStore: git-object-backed in a repo, a
|
|
9
|
+
* content-addressed shadow copy otherwise.
|
|
10
|
+
* • The manifest — uniform JSON under `.cruxy/checkpoints/`, owned by the
|
|
11
|
+
* service regardless of substrate, so list/prune/rollback never care which
|
|
12
|
+
* store wrote a checkpoint.
|
|
13
|
+
*
|
|
14
|
+
* Explicit boundary: checkpoints cover WORKING-TREE file state only. They can
|
|
15
|
+
* never undo git commits, pushes, or PRs made during a run (C.15) — that is
|
|
16
|
+
* stated in the rollback preview, not silently implied.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy checkpoint` (C.32) — inspect the working-tree snapshots that back
|
|
4
|
+
* `cruxy rollback`. Creation is automatic (before a run's first mutation);
|
|
5
|
+
* this command only lists.
|
|
6
|
+
*/
|
|
7
|
+
export declare function checkpointCommand(): Command;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { CheckpointService } from "../../checkpoint/index.js";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
6
|
+
/**
|
|
7
|
+
* `cruxy checkpoint` (C.32) — inspect the working-tree snapshots that back
|
|
8
|
+
* `cruxy rollback`. Creation is automatic (before a run's first mutation);
|
|
9
|
+
* this command only lists.
|
|
10
|
+
*/
|
|
11
|
+
export function checkpointCommand() {
|
|
12
|
+
const cmd = new Command("checkpoint").description("working-tree checkpoints — the undo units behind `cruxy rollback`");
|
|
13
|
+
cmd
|
|
14
|
+
.command("list")
|
|
15
|
+
.description("list saved checkpoints, newest first")
|
|
16
|
+
.action(async () => {
|
|
17
|
+
const { config } = loadConfig();
|
|
18
|
+
const service = new CheckpointService({ root: process.cwd(), config });
|
|
19
|
+
const checkpoints = await service.list();
|
|
20
|
+
if (checkpoints.length === 0) {
|
|
21
|
+
logger.print(pc.dim("no checkpoints yet — one is created automatically before an agent run's first file change"));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const c of checkpoints) {
|
|
25
|
+
const files = `${c.files.length} file${c.files.length === 1 ? "" : "s"}`;
|
|
26
|
+
logger.print(`${pc.cyan(c.id)} ${pc.dim(c.createdAt)} ${pc.dim(`[${c.store}]`)} ${files} ${c.runSummary}`);
|
|
27
|
+
}
|
|
28
|
+
logger.print(pc.dim(`\nrestore one with \`cruxy rollback <id>\` (or \`cruxy rollback\` for the newest)`));
|
|
29
|
+
});
|
|
30
|
+
return cmd;
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy rollback [id]` (C.32) — restore the working tree to a checkpoint,
|
|
4
|
+
* undoing everything an agent run changed (creates, edits, deletes) in one
|
|
5
|
+
* operation. Destructive by definition, so it is preview-first and gated
|
|
6
|
+
* through U.3 at the destructive tier, ungrantable; non-TTY is refused with a
|
|
7
|
+
* coded error before anything is computed. Out of scope, stated in the
|
|
8
|
+
* preview: commits, pushes, and PRs made during the run are not undone.
|
|
9
|
+
*/
|
|
10
|
+
export declare function rollbackCommand(): Command;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { CheckpointService } from "../../checkpoint/index.js";
|
|
5
|
+
import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
|
|
6
|
+
import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
|
|
7
|
+
import { logger } from "../../utils/logger.js";
|
|
8
|
+
/**
|
|
9
|
+
* `cruxy rollback [id]` (C.32) — restore the working tree to a checkpoint,
|
|
10
|
+
* undoing everything an agent run changed (creates, edits, deletes) in one
|
|
11
|
+
* operation. Destructive by definition, so it is preview-first and gated
|
|
12
|
+
* through U.3 at the destructive tier, ungrantable; non-TTY is refused with a
|
|
13
|
+
* coded error before anything is computed. Out of scope, stated in the
|
|
14
|
+
* preview: commits, pushes, and PRs made during the run are not undone.
|
|
15
|
+
*/
|
|
16
|
+
export function rollbackCommand() {
|
|
17
|
+
return new Command("rollback")
|
|
18
|
+
.description("restore the working tree to a checkpoint, undoing an agent run's file changes")
|
|
19
|
+
.argument("[id]", "checkpoint id (defaults to the most recent)")
|
|
20
|
+
.action(async (id) => {
|
|
21
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
22
|
+
// Refuse before touching anything: rollback is a deliberate, interactive
|
|
23
|
+
// act. There is no flag to bypass this, by design.
|
|
24
|
+
if (!interactive)
|
|
25
|
+
throw rollbackApprovalRequired();
|
|
26
|
+
const { config } = loadConfig();
|
|
27
|
+
const root = process.cwd();
|
|
28
|
+
const service = new CheckpointService({ root, config });
|
|
29
|
+
const approval = new ApprovalService({
|
|
30
|
+
cwd: root,
|
|
31
|
+
interactive,
|
|
32
|
+
io: defaultPromptIO(shouldUseColor()),
|
|
33
|
+
});
|
|
34
|
+
const result = await service.rollback(id, {
|
|
35
|
+
requestApproval: (action) => approval.requestApproval(action),
|
|
36
|
+
interactive,
|
|
37
|
+
});
|
|
38
|
+
if (result.kind === "noop") {
|
|
39
|
+
logger.print(pc.dim(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (result.kind === "rejected") {
|
|
43
|
+
logger.print(pc.dim("rollback declined — nothing was changed"));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const { recreated, reverted, deleted } = result.applied;
|
|
47
|
+
logger.print(`${pc.green("✓")} restored checkpoint ${pc.cyan(result.checkpoint.id)} — ` +
|
|
48
|
+
`${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
|
|
49
|
+
logger.print(pc.dim("note: commits, pushes, and PRs made during the run are not undone"));
|
|
50
|
+
});
|
|
51
|
+
}
|