@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,61 @@
|
|
|
1
|
+
import type { CaptureFile, CheckpointStore, FileEntry } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Git-object-backed checkpoint content store (C.32 primary substrate).
|
|
4
|
+
*
|
|
5
|
+
* How a snapshot works — and exactly why it can never disturb the user's git
|
|
6
|
+
* state. Every git invocation is one of these five, and nothing else:
|
|
7
|
+
*
|
|
8
|
+
* 1. `git rev-parse --absolute-git-dir` (read-only)
|
|
9
|
+
* 2. `git update-index --add -z --stdin`, with `GIT_INDEX_FILE` pointing at a
|
|
10
|
+
* **temporary index file in os.tmpdir()**. Git hashes each captured file
|
|
11
|
+
* into `.git/objects` as loose blobs and stages them *in the temp index*.
|
|
12
|
+
* The user's `.git/index` is never opened for writing.
|
|
13
|
+
* 3. `git write-tree`, same `GIT_INDEX_FILE` — writes tree objects, returns
|
|
14
|
+
* the root tree oid.
|
|
15
|
+
* 4. `git ls-files --stage -z`, same `GIT_INDEX_FILE` — reads back
|
|
16
|
+
* `mode oid stage\tpath` per file for the manifest. The temp index is then
|
|
17
|
+
* deleted.
|
|
18
|
+
* 5. `git cat-file blob <oid>` on restore/preview (read-only)
|
|
19
|
+
*
|
|
20
|
+
* No `commit-tree`, no `update-ref`, no `stash`, no branch: **no ref is ever
|
|
21
|
+
* created or moved, and HEAD / the index / the stash are never written.** The
|
|
22
|
+
* blobs and trees are deliberately *dangling* — referenced only by the manifest
|
|
23
|
+
* JSON under `.cruxy/checkpoints/`.
|
|
24
|
+
*
|
|
25
|
+
* Enforced, not assumed: {@link snapshot} fingerprints the user-visible state
|
|
26
|
+
* (raw `.git/HEAD` bytes, a hash of `.git/index`, `git for-each-ref`, and
|
|
27
|
+
* `git stash list`) before and after, and throws CRUXY_E_CHECKPOINT_FAILED
|
|
28
|
+
* naming what moved if anything drifted.
|
|
29
|
+
*
|
|
30
|
+
* Accepted trade-off: dangling objects are subject to `git gc --prune`. The
|
|
31
|
+
* default two-week grace plus retention pruning makes that a non-issue, but a
|
|
32
|
+
* user running `git gc --prune=now` can orphan a manifest — then rollback fails
|
|
33
|
+
* loudly (never restores partial state), and the shadow store remains the
|
|
34
|
+
* fallback substrate for new checkpoints.
|
|
35
|
+
*/
|
|
36
|
+
export declare class GitCheckpointStore implements CheckpointStore {
|
|
37
|
+
private readonly root;
|
|
38
|
+
readonly kind: "git";
|
|
39
|
+
constructor(root: string);
|
|
40
|
+
/** Git blob sha-1: `sha1("blob <len>\0" + content)` — matches `git hash-object`. */
|
|
41
|
+
hashContent(content: Buffer): string;
|
|
42
|
+
snapshot(files: CaptureFile[]): Promise<FileEntry[]>;
|
|
43
|
+
readContent(entry: FileEntry): Promise<Buffer>;
|
|
44
|
+
/** Dangling objects belong to git's own gc; nothing for us to sweep. */
|
|
45
|
+
collect(): Promise<void>;
|
|
46
|
+
/** Parse `git ls-files --stage -z` from the temp index into manifest entries. */
|
|
47
|
+
private readStagedEntries;
|
|
48
|
+
/**
|
|
49
|
+
* Run git with binary-safe stdout (restore must round-trip arbitrary bytes),
|
|
50
|
+
* optional stdin, and optional extra env. Local to this store on purpose —
|
|
51
|
+
* `runGitCapture` is text-mode and env-less, and the rest of the CLI should
|
|
52
|
+
* stay that way.
|
|
53
|
+
*/
|
|
54
|
+
private git;
|
|
55
|
+
/**
|
|
56
|
+
* Everything a user can observe of their git state, byte-for-byte: HEAD file,
|
|
57
|
+
* index contents, every ref (branches, tags, remotes, stash tip — packed or
|
|
58
|
+
* loose, via for-each-ref), and the full stash reflog (stash list).
|
|
59
|
+
*/
|
|
60
|
+
private fingerprint;
|
|
61
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { promises as fsp, readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { checkpointFailed } from "../errors/index.js";
|
|
7
|
+
import { runGitCapture } from "../vcs/git.js";
|
|
8
|
+
/**
|
|
9
|
+
* Git-object-backed checkpoint content store (C.32 primary substrate).
|
|
10
|
+
*
|
|
11
|
+
* How a snapshot works — and exactly why it can never disturb the user's git
|
|
12
|
+
* state. Every git invocation is one of these five, and nothing else:
|
|
13
|
+
*
|
|
14
|
+
* 1. `git rev-parse --absolute-git-dir` (read-only)
|
|
15
|
+
* 2. `git update-index --add -z --stdin`, with `GIT_INDEX_FILE` pointing at a
|
|
16
|
+
* **temporary index file in os.tmpdir()**. Git hashes each captured file
|
|
17
|
+
* into `.git/objects` as loose blobs and stages them *in the temp index*.
|
|
18
|
+
* The user's `.git/index` is never opened for writing.
|
|
19
|
+
* 3. `git write-tree`, same `GIT_INDEX_FILE` — writes tree objects, returns
|
|
20
|
+
* the root tree oid.
|
|
21
|
+
* 4. `git ls-files --stage -z`, same `GIT_INDEX_FILE` — reads back
|
|
22
|
+
* `mode oid stage\tpath` per file for the manifest. The temp index is then
|
|
23
|
+
* deleted.
|
|
24
|
+
* 5. `git cat-file blob <oid>` on restore/preview (read-only)
|
|
25
|
+
*
|
|
26
|
+
* No `commit-tree`, no `update-ref`, no `stash`, no branch: **no ref is ever
|
|
27
|
+
* created or moved, and HEAD / the index / the stash are never written.** The
|
|
28
|
+
* blobs and trees are deliberately *dangling* — referenced only by the manifest
|
|
29
|
+
* JSON under `.cruxy/checkpoints/`.
|
|
30
|
+
*
|
|
31
|
+
* Enforced, not assumed: {@link snapshot} fingerprints the user-visible state
|
|
32
|
+
* (raw `.git/HEAD` bytes, a hash of `.git/index`, `git for-each-ref`, and
|
|
33
|
+
* `git stash list`) before and after, and throws CRUXY_E_CHECKPOINT_FAILED
|
|
34
|
+
* naming what moved if anything drifted.
|
|
35
|
+
*
|
|
36
|
+
* Accepted trade-off: dangling objects are subject to `git gc --prune`. The
|
|
37
|
+
* default two-week grace plus retention pruning makes that a non-issue, but a
|
|
38
|
+
* user running `git gc --prune=now` can orphan a manifest — then rollback fails
|
|
39
|
+
* loudly (never restores partial state), and the shadow store remains the
|
|
40
|
+
* fallback substrate for new checkpoints.
|
|
41
|
+
*/
|
|
42
|
+
export class GitCheckpointStore {
|
|
43
|
+
root;
|
|
44
|
+
kind = "git";
|
|
45
|
+
constructor(root) {
|
|
46
|
+
this.root = root;
|
|
47
|
+
}
|
|
48
|
+
/** Git blob sha-1: `sha1("blob <len>\0" + content)` — matches `git hash-object`. */
|
|
49
|
+
hashContent(content) {
|
|
50
|
+
return createHash("sha1")
|
|
51
|
+
.update(`blob ${content.length}\0`)
|
|
52
|
+
.update(content)
|
|
53
|
+
.digest("hex");
|
|
54
|
+
}
|
|
55
|
+
async snapshot(files) {
|
|
56
|
+
const before = this.fingerprint();
|
|
57
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "cruxy-checkpoint-"));
|
|
58
|
+
const indexFile = path.join(tmpDir, "index");
|
|
59
|
+
try {
|
|
60
|
+
const env = { GIT_INDEX_FILE: indexFile };
|
|
61
|
+
// Stage every captured path into the temp index (writes blobs).
|
|
62
|
+
const stdin = files.map((f) => `${f.path}\0`).join("");
|
|
63
|
+
const add = this.git(["update-index", "--add", "-z", "--stdin"], {
|
|
64
|
+
env,
|
|
65
|
+
input: stdin,
|
|
66
|
+
});
|
|
67
|
+
if (!add.ok) {
|
|
68
|
+
throw checkpointFailed(`staging files into the temporary index failed: ${add.stderr.trim()}`);
|
|
69
|
+
}
|
|
70
|
+
// Persist the tree objects. The oid itself isn't stored — per-file blob
|
|
71
|
+
// oids from ls-files --stage are what restore needs — but write-tree is
|
|
72
|
+
// what makes the whole snapshot a single connected object graph.
|
|
73
|
+
const tree = this.git(["write-tree"], { env });
|
|
74
|
+
if (!tree.ok) {
|
|
75
|
+
throw checkpointFailed(`writing the snapshot tree failed: ${tree.stderr.trim()}`);
|
|
76
|
+
}
|
|
77
|
+
const entries = this.readStagedEntries(env);
|
|
78
|
+
const after = this.fingerprint();
|
|
79
|
+
assertGitStateUnchanged(before, after);
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async readContent(entry) {
|
|
87
|
+
const res = this.git(["cat-file", "blob", entry.oid]);
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
throw checkpointFailed(`checkpoint content for ${entry.path} is gone from the git object database ` +
|
|
90
|
+
`(blob ${entry.oid}) — most likely pruned by \`git gc --prune\``);
|
|
91
|
+
}
|
|
92
|
+
return res.stdout;
|
|
93
|
+
}
|
|
94
|
+
/** Dangling objects belong to git's own gc; nothing for us to sweep. */
|
|
95
|
+
async collect() {
|
|
96
|
+
/* no-op by design */
|
|
97
|
+
}
|
|
98
|
+
// ── internals ───────────────────────────────────────────────────────────────
|
|
99
|
+
/** Parse `git ls-files --stage -z` from the temp index into manifest entries. */
|
|
100
|
+
readStagedEntries(env) {
|
|
101
|
+
const res = this.git(["ls-files", "--stage", "-z"], { env });
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
throw checkpointFailed(`reading back the staged snapshot failed: ${res.stderr.trim()}`);
|
|
104
|
+
}
|
|
105
|
+
const entries = [];
|
|
106
|
+
for (const record of res.stdout.toString("utf8").split("\0")) {
|
|
107
|
+
if (record === "")
|
|
108
|
+
continue;
|
|
109
|
+
// "<mode> <oid> <stage>\t<path>"
|
|
110
|
+
const tab = record.indexOf("\t");
|
|
111
|
+
const [mode, oid] = record.slice(0, tab).split(" ");
|
|
112
|
+
const relPath = record.slice(tab + 1);
|
|
113
|
+
if (mode !== "100644" && mode !== "100755") {
|
|
114
|
+
// Capture filters to regular files, so anything else staging here is a
|
|
115
|
+
// bug or a race — refuse to write a manifest we can't faithfully restore.
|
|
116
|
+
throw checkpointFailed(`unsupported file mode ${mode} for ${relPath} in the snapshot`);
|
|
117
|
+
}
|
|
118
|
+
entries.push({ path: relPath, mode, oid });
|
|
119
|
+
}
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Run git with binary-safe stdout (restore must round-trip arbitrary bytes),
|
|
124
|
+
* optional stdin, and optional extra env. Local to this store on purpose —
|
|
125
|
+
* `runGitCapture` is text-mode and env-less, and the rest of the CLI should
|
|
126
|
+
* stay that way.
|
|
127
|
+
*/
|
|
128
|
+
git(args, opts = {}) {
|
|
129
|
+
const res = spawnSync("git", args, {
|
|
130
|
+
cwd: this.root,
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
maxBuffer: 1024 * 1024 * 1024,
|
|
133
|
+
input: opts.input,
|
|
134
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
135
|
+
});
|
|
136
|
+
const stdout = Buffer.isBuffer(res.stdout) ? res.stdout : Buffer.alloc(0);
|
|
137
|
+
const stderr = (res.stderr?.toString("utf8") ?? "") ||
|
|
138
|
+
(res.error ? res.error.message : "");
|
|
139
|
+
return { ok: res.status === 0 && !res.error, stdout, stderr };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Everything a user can observe of their git state, byte-for-byte: HEAD file,
|
|
143
|
+
* index contents, every ref (branches, tags, remotes, stash tip — packed or
|
|
144
|
+
* loose, via for-each-ref), and the full stash reflog (stash list).
|
|
145
|
+
*/
|
|
146
|
+
fingerprint() {
|
|
147
|
+
const gitDirRes = runGitCapture(["rev-parse", "--absolute-git-dir"], this.root);
|
|
148
|
+
const gitDir = gitDirRes.ok ? gitDirRes.stdout.trim() : "";
|
|
149
|
+
return {
|
|
150
|
+
head: readFileOr(path.join(gitDir, "HEAD")),
|
|
151
|
+
index: hashFileOr(path.join(gitDir, "index")),
|
|
152
|
+
refs: runGitCapture(["for-each-ref", "--format=%(refname) %(objectname)"], this.root).stdout,
|
|
153
|
+
stash: runGitCapture(["stash", "list"], this.root).stdout,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function readFileOr(file, fallback = "<absent>") {
|
|
158
|
+
return existsSync(file) ? readFileSync(file, "utf8") : fallback;
|
|
159
|
+
}
|
|
160
|
+
function hashFileOr(file, fallback = "<absent>") {
|
|
161
|
+
if (!existsSync(file))
|
|
162
|
+
return fallback;
|
|
163
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
164
|
+
}
|
|
165
|
+
/** The runtime self-check: any drift in user-visible git state is a hard failure. */
|
|
166
|
+
function assertGitStateUnchanged(before, after) {
|
|
167
|
+
const drifted = Object.keys(before).filter((k) => before[k] !== after[k]);
|
|
168
|
+
if (drifted.length > 0) {
|
|
169
|
+
throw checkpointFailed(`snapshot invariant violated: user-visible git state changed (${drifted.join(", ")}) — refusing to continue`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -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
|
+
}
|