@cruxy/cli 0.22.0 → 0.23.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/dist/approval/classify.js +25 -3
- package/dist/approval/policy.d.ts +6 -0
- package/dist/approval/policy.js +15 -3
- package/dist/approval/prompt.js +11 -0
- package/dist/approval/types.d.ts +8 -1
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +3 -0
- package/dist/checkpoint/index.js +3 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/checkpoint/set.d.ts +44 -0
- package/dist/checkpoint/set.js +142 -0
- package/dist/checkpoint/types.d.ts +47 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +59 -10
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/session-factory.d.ts +4 -3
- package/dist/cli/session-factory.js +98 -12
- package/dist/errors/constructors.d.ts +65 -0
- package/dist/errors/constructors.js +168 -0
- package/dist/errors/types.d.ts +46 -0
- package/dist/errors/types.js +64 -0
- package/dist/indexing/retriever.d.ts +29 -0
- package/dist/indexing/retriever.js +26 -0
- package/dist/indexing/service.js +3 -1
- package/dist/indexing/types.d.ts +7 -0
- package/dist/lsp/tools/common.d.ts +34 -7
- package/dist/lsp/tools/common.js +33 -11
- package/dist/lsp/tools/find-definition.js +2 -2
- package/dist/lsp/tools/find-references.js +10 -4
- package/dist/lsp/tools/get-diagnostics.js +6 -4
- package/dist/render/diff.js +42 -5
- package/dist/sandbox/docker-runtime.js +4 -1
- package/dist/sandbox/policy.d.ts +12 -3
- package/dist/sandbox/policy.js +17 -3
- package/dist/sandbox/types.d.ts +10 -1
- package/dist/subagent/orchestrator.d.ts +15 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/testing/run-tests-tool.js +3 -0
- package/dist/tools/create-pull-request.d.ts +3 -0
- package/dist/tools/create-pull-request.js +50 -4
- package/dist/tools/file/apply-patch.js +2 -2
- package/dist/tools/file/edit-file.js +2 -2
- package/dist/tools/file/glob.d.ts +9 -2
- package/dist/tools/file/glob.js +73 -19
- package/dist/tools/file/grep-files.d.ts +12 -2
- package/dist/tools/file/grep-files.js +113 -38
- package/dist/tools/file/paths.d.ts +123 -17
- package/dist/tools/file/paths.js +158 -50
- package/dist/tools/file/read-file.js +2 -2
- package/dist/tools/file/write-file.js +2 -2
- package/dist/tools/git-status.d.ts +8 -1
- package/dist/tools/git-status.js +43 -11
- package/dist/tools/list-files.d.ts +9 -3
- package/dist/tools/list-files.js +48 -13
- package/dist/tools/search-codebase.d.ts +10 -0
- package/dist/tools/search-codebase.js +117 -14
- package/dist/tools/shell/exec.js +8 -1
- package/dist/tools/types.d.ts +63 -1
- package/dist/vcs/git.d.ts +8 -0
- package/dist/vcs/git.js +14 -0
- package/dist/vcs/github.d.ts +7 -1
- package/dist/vcs/github.js +10 -1
- package/dist/vcs/service.d.ts +8 -0
- package/dist/vcs/service.js +33 -1
- package/dist/vcs/types.d.ts +18 -2
- package/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/resolve.d.ts +54 -0
- package/dist/workspace/resolve.js +96 -0
- package/dist/workspace/select.d.ts +41 -0
- package/dist/workspace/select.js +44 -0
- package/dist/workspace/types.d.ts +30 -0
- package/dist/workspace/types.js +15 -0
- package/dist/workspace/workspace.d.ts +56 -0
- package/dist/workspace/workspace.js +180 -0
- package/package.json +1 -1
|
@@ -62,7 +62,7 @@ function shellRequest(action, root) {
|
|
|
62
62
|
// scope is the program token. Complex commands get `none` (approve-once only).
|
|
63
63
|
const tokens = commandTokens(command);
|
|
64
64
|
const scope = tokens
|
|
65
|
-
? { kind: "shell-prefix", token: tokens[0] }
|
|
65
|
+
? { kind: "shell-prefix", token: tokens[0], root }
|
|
66
66
|
: { kind: "none" };
|
|
67
67
|
return {
|
|
68
68
|
action,
|
|
@@ -86,7 +86,9 @@ function testRequest(action, root) {
|
|
|
86
86
|
return {
|
|
87
87
|
action,
|
|
88
88
|
tier: "destructive",
|
|
89
|
-
scope: command === ""
|
|
89
|
+
scope: command === ""
|
|
90
|
+
? { kind: "none" }
|
|
91
|
+
: { kind: "shell-exact", command, root },
|
|
90
92
|
summary: `run tests: ${command}`,
|
|
91
93
|
targets: [],
|
|
92
94
|
cwd: root,
|
|
@@ -119,6 +121,24 @@ function vcsRequest(action, root) {
|
|
|
119
121
|
* carries the full blast radius; the summary names the checkpoint.
|
|
120
122
|
*/
|
|
121
123
|
function rollbackRequest(action, root) {
|
|
124
|
+
// A multi-root set rollback (C.26): one destructive, ungrantable approval over
|
|
125
|
+
// every touched root of a run. Never session-grantable (scope `none`), and its
|
|
126
|
+
// targets span roots so they are not resolved here — the grouped preview carries
|
|
127
|
+
// the full per-root blast radius.
|
|
128
|
+
if (action.preview?.type === "rollback-set") {
|
|
129
|
+
const preview = action.preview;
|
|
130
|
+
const fileCount = preview.roots.reduce((n, r) => n + r.files.length, 0);
|
|
131
|
+
return {
|
|
132
|
+
action,
|
|
133
|
+
tier: "destructive",
|
|
134
|
+
scope: { kind: "none" },
|
|
135
|
+
summary: `rollback: restore run ${preview.runId} across ${preview.roots.length} ` +
|
|
136
|
+
`root${preview.roots.length === 1 ? "" : "s"} ` +
|
|
137
|
+
`(${fileCount} file${fileCount === 1 ? "" : "s"})`,
|
|
138
|
+
targets: [],
|
|
139
|
+
cwd: root,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
122
142
|
const preview = action.preview?.type === "rollback" ? action.preview : undefined;
|
|
123
143
|
const summary = preview
|
|
124
144
|
? `rollback: restore checkpoint ${preview.checkpointId} (${preview.files.length} file${preview.files.length === 1 ? "" : "s"})`
|
|
@@ -150,7 +170,9 @@ function mcpRequest(action, root) {
|
|
|
150
170
|
return {
|
|
151
171
|
action,
|
|
152
172
|
tier: "destructive",
|
|
153
|
-
scope: grantable
|
|
173
|
+
scope: grantable
|
|
174
|
+
? { kind: "mcp-tool", server, tool, root }
|
|
175
|
+
: { kind: "none" },
|
|
154
176
|
summary: `call MCP tool ${tool || "(unknown)"} on server ${server || "(unknown)"}`,
|
|
155
177
|
targets: [],
|
|
156
178
|
cwd: root,
|
|
@@ -20,6 +20,12 @@ export declare class SessionAllowlist {
|
|
|
20
20
|
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
21
21
|
* so a `git` grant never matches `git push && rm -rf /`. File: every target must
|
|
22
22
|
* resolve inside the granted subtree.
|
|
23
|
+
*
|
|
24
|
+
* Multi-repo (C.26): `shell-prefix`, `shell-exact`, and `mcp-tool` grants are
|
|
25
|
+
* additionally **bound to the root they were taken in** — the grant only covers a
|
|
26
|
+
* request whose `cwd` is the same root. So "allow `git` this session" in repo A
|
|
27
|
+
* never auto-approves `git` in repo B. `file-subtree` needs no such check: it is
|
|
28
|
+
* an absolute path, so a different root is already a different subtree.
|
|
23
29
|
*/
|
|
24
30
|
export declare function scopeCovers(scope: Exclude<Scope, {
|
|
25
31
|
kind: "none";
|
package/dist/approval/policy.js
CHANGED
|
@@ -30,28 +30,40 @@ export class SessionAllowlist {
|
|
|
30
30
|
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
31
31
|
* so a `git` grant never matches `git push && rm -rf /`. File: every target must
|
|
32
32
|
* resolve inside the granted subtree.
|
|
33
|
+
*
|
|
34
|
+
* Multi-repo (C.26): `shell-prefix`, `shell-exact`, and `mcp-tool` grants are
|
|
35
|
+
* additionally **bound to the root they were taken in** — the grant only covers a
|
|
36
|
+
* request whose `cwd` is the same root. So "allow `git` this session" in repo A
|
|
37
|
+
* never auto-approves `git` in repo B. `file-subtree` needs no such check: it is
|
|
38
|
+
* an absolute path, so a different root is already a different subtree.
|
|
33
39
|
*/
|
|
34
40
|
export function scopeCovers(scope, request) {
|
|
35
41
|
if (scope.kind === "shell-prefix") {
|
|
36
42
|
if (request.action.kind !== "shell")
|
|
37
43
|
return false;
|
|
44
|
+
if (scope.root !== request.cwd)
|
|
45
|
+
return false; // C.26: same root only
|
|
38
46
|
const tokens = commandTokens(request.action.command ?? "");
|
|
39
47
|
return tokens !== null && tokens[0] === scope.token;
|
|
40
48
|
}
|
|
41
49
|
if (scope.kind === "shell-exact") {
|
|
42
50
|
// Test grants (C.13): the exact command string, test actions only — a
|
|
43
|
-
// grant for `pnpm test` can never cover run_command or any other command
|
|
51
|
+
// grant for `pnpm test` can never cover run_command or any other command,
|
|
52
|
+
// and (C.26) never a test in a different root.
|
|
44
53
|
return (request.action.kind === "test" &&
|
|
54
|
+
scope.root === request.cwd &&
|
|
45
55
|
(request.action.command ?? "").trim() === scope.command);
|
|
46
56
|
}
|
|
47
57
|
if (scope.kind === "mcp-tool") {
|
|
48
58
|
// MCP grants (C.27): the exact server+tool pair, mcp actions only — a grant
|
|
49
|
-
// for one server's tool can never cover another tool
|
|
59
|
+
// for one server's tool can never cover another tool, another server, or
|
|
60
|
+
// (C.26) the same tool invoked from a different root.
|
|
50
61
|
return (request.action.kind === "mcp" &&
|
|
62
|
+
scope.root === request.cwd &&
|
|
51
63
|
request.action.server === scope.server &&
|
|
52
64
|
request.action.tool === scope.tool);
|
|
53
65
|
}
|
|
54
|
-
// file-subtree
|
|
66
|
+
// file-subtree — absolute path, inherently root-scoped.
|
|
55
67
|
return (request.targets.length > 0 &&
|
|
56
68
|
request.targets.every((t) => isInside(scope.root, t)));
|
|
57
69
|
}
|
package/dist/approval/prompt.js
CHANGED
|
@@ -74,6 +74,17 @@ function detail(request, t) {
|
|
|
74
74
|
// point of the call, not just at trust time.
|
|
75
75
|
return ` ${t.muted(`external MCP server "${request.action.server ?? ""}" — runs unsandboxed with your privileges`)}`;
|
|
76
76
|
}
|
|
77
|
+
if (request.action.kind === "vcs" && request.action.root) {
|
|
78
|
+
// C.26 Step 4 (⚖︎JC-4): name the acting root alongside the resolved owner/repo
|
|
79
|
+
// (rendered inside the preview), so the human sees BOTH which declared root the
|
|
80
|
+
// PR acts in and the real API destination before approving.
|
|
81
|
+
return [
|
|
82
|
+
` ${t.muted(`root ${request.action.root}`)}`,
|
|
83
|
+
renderActionPreview(request.action.preview, t),
|
|
84
|
+
]
|
|
85
|
+
.filter((l) => l !== "")
|
|
86
|
+
.join("\n");
|
|
87
|
+
}
|
|
77
88
|
return renderActionPreview(request.action.preview, t);
|
|
78
89
|
}
|
|
79
90
|
/** The choices line, including a short label of what an `a` grant would cover. */
|
package/dist/approval/types.d.ts
CHANGED
|
@@ -17,20 +17,26 @@ export type RiskTier = "read" | "mutate" | "destructive";
|
|
|
17
17
|
* The tight scope a session grant is keyed by. Never blanket.
|
|
18
18
|
* - `shell-prefix` — a command's leading program token (e.g. `git`); only ever
|
|
19
19
|
* matches commands we can *positively* prove are simple (no shell features).
|
|
20
|
+
* **Bound to `root`** (C.26): a `git` grant in repo A never covers `git` in B.
|
|
20
21
|
* - `shell-exact` — one exact command string, for `test` actions only (C.13):
|
|
21
22
|
* a grant covers re-runs of precisely that test command, nothing else.
|
|
23
|
+
* **Bound to `root`**: a `pnpm test` grant in A never covers B.
|
|
22
24
|
* - `file-subtree` — an absolute directory (or, under the root-cap, an exact
|
|
23
|
-
* file path); matches targets that resolve inside it.
|
|
25
|
+
* file path); matches targets that resolve inside it. Inherently root-safe —
|
|
26
|
+
* different roots are different absolute subtrees, so it needs no `root` field.
|
|
24
27
|
* - `mcp-tool` — one exact MCP server+tool pair (C.27): a grant covers re-calls
|
|
25
28
|
* of precisely that tool on that server, and never any other MCP tool.
|
|
29
|
+
* **Bound to `root`**: the same server+tool in another root still prompts.
|
|
26
30
|
* - `none` — nothing safe to grant (e.g. a multi-file patch spanning the root).
|
|
27
31
|
*/
|
|
28
32
|
export type Scope = {
|
|
29
33
|
readonly kind: "shell-prefix";
|
|
30
34
|
readonly token: string;
|
|
35
|
+
readonly root: string;
|
|
31
36
|
} | {
|
|
32
37
|
readonly kind: "shell-exact";
|
|
33
38
|
readonly command: string;
|
|
39
|
+
readonly root: string;
|
|
34
40
|
} | {
|
|
35
41
|
readonly kind: "file-subtree";
|
|
36
42
|
readonly root: string;
|
|
@@ -38,6 +44,7 @@ export type Scope = {
|
|
|
38
44
|
readonly kind: "mcp-tool";
|
|
39
45
|
readonly server: string;
|
|
40
46
|
readonly tool: string;
|
|
47
|
+
readonly root: string;
|
|
41
48
|
} | {
|
|
42
49
|
readonly kind: "none";
|
|
43
50
|
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import { CheckpointService } from "./service.js";
|
|
3
|
+
import type { CheckpointSet } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Per-root checkpoint gate (C.26 step 3). One run may mutate several declared
|
|
6
|
+
* roots; this owns one {@link CheckpointService} PER touched root (created lazily
|
|
7
|
+
* the first time a mutation is gated for that root) plus the run's
|
|
8
|
+
* {@link CheckpointSet} accumulator, so `cruxy rollback` can restore exactly the
|
|
9
|
+
* roots the run touched — no more, no less.
|
|
10
|
+
*
|
|
11
|
+
* Two invariants live here:
|
|
12
|
+
* • **exactly-touched** — {@link serviceFor} is the ONLY construction site for a
|
|
13
|
+
* per-root service, and it is called only when a mutation is actually gated for
|
|
14
|
+
* that root, so an untouched root's service is never constructed and never
|
|
15
|
+
* appears in the set.
|
|
16
|
+
* • **one set per run, shared across subagents** — the parent and every subagent
|
|
17
|
+
* share this one gate (C.14 wires the child gate over the same object), so a
|
|
18
|
+
* subagent's writes join the run's single set and are covered by the run's
|
|
19
|
+
* rollback (⚖︎JC-δ). The set manifest lives under the PRIMARY root (⚖︎#7).
|
|
20
|
+
*/
|
|
21
|
+
export type CreateCheckpointService = (root: string, config: CruxyConfig) => CheckpointService;
|
|
22
|
+
export interface CheckpointGateOptions {
|
|
23
|
+
config: CruxyConfig;
|
|
24
|
+
/** Absolute path of the primary root — home of the set manifest (⚖︎#7). */
|
|
25
|
+
primaryRoot: string;
|
|
26
|
+
/**
|
|
27
|
+
* Test seam: the sole factory for per-root services. A constructor spy passed
|
|
28
|
+
* here proves an untouched root's service is never built.
|
|
29
|
+
*/
|
|
30
|
+
createService?: CreateCheckpointService;
|
|
31
|
+
}
|
|
32
|
+
export declare class CheckpointGate {
|
|
33
|
+
private readonly config;
|
|
34
|
+
private readonly primaryRoot;
|
|
35
|
+
private readonly create;
|
|
36
|
+
private readonly services;
|
|
37
|
+
private runId;
|
|
38
|
+
private summary;
|
|
39
|
+
private set;
|
|
40
|
+
constructor(opts: CheckpointGateOptions);
|
|
41
|
+
/**
|
|
42
|
+
* Start a new undo unit for the WHOLE run (every root + the set). Resets each
|
|
43
|
+
* existing per-root service's once-per-run latch and clears the set so the next
|
|
44
|
+
* mutation begins a fresh run. The set is materialized lazily on the first member
|
|
45
|
+
* (so its `createdAt` marks the run's first mutation, and a no-mutation run
|
|
46
|
+
* writes no manifest).
|
|
47
|
+
*/
|
|
48
|
+
beginRun(summary: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Get-or-create the per-root service. The first time a root is touched this
|
|
51
|
+
* process, its service is constructed and joined to the current run; an untouched
|
|
52
|
+
* root's service is never built.
|
|
53
|
+
*/
|
|
54
|
+
serviceFor(rootName: string, rootAbsPath: string): CheckpointService;
|
|
55
|
+
/**
|
|
56
|
+
* Record that `rootName` was checkpointed this run: append its member to the set
|
|
57
|
+
* and persist the manifest under the primary root. Idempotent — later mutations
|
|
58
|
+
* to the same root this run are no-ops (the root already has exactly one member).
|
|
59
|
+
*/
|
|
60
|
+
recordMember(rootName: string, rootAbsPath: string, checkpointId: string): Promise<void>;
|
|
61
|
+
/** Root names that got a per-root service this process (inspection/tests). */
|
|
62
|
+
get touchedRoots(): readonly string[];
|
|
63
|
+
/** The current run's set, or null before its first mutation (inspection/tests). */
|
|
64
|
+
get currentSet(): CheckpointSet | null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { CheckpointService } from "./service.js";
|
|
2
|
+
import { newRunId, writeSet } from "./set.js";
|
|
3
|
+
/** Trim a run summary to one line ≤80 chars, matching CheckpointService.beginRun. */
|
|
4
|
+
function trimSummary(summary) {
|
|
5
|
+
const firstLine = summary.split("\n", 1)[0].trim();
|
|
6
|
+
return firstLine.length > 80
|
|
7
|
+
? `${firstLine.slice(0, 79)}…`
|
|
8
|
+
: firstLine || "agent run";
|
|
9
|
+
}
|
|
10
|
+
export class CheckpointGate {
|
|
11
|
+
config;
|
|
12
|
+
primaryRoot;
|
|
13
|
+
create;
|
|
14
|
+
services = new Map();
|
|
15
|
+
runId = null;
|
|
16
|
+
summary = "agent run";
|
|
17
|
+
set = null;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.config = opts.config;
|
|
20
|
+
this.primaryRoot = opts.primaryRoot;
|
|
21
|
+
this.create =
|
|
22
|
+
opts.createService ??
|
|
23
|
+
((root, config) => new CheckpointService({ root, config }));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Start a new undo unit for the WHOLE run (every root + the set). Resets each
|
|
27
|
+
* existing per-root service's once-per-run latch and clears the set so the next
|
|
28
|
+
* mutation begins a fresh run. The set is materialized lazily on the first member
|
|
29
|
+
* (so its `createdAt` marks the run's first mutation, and a no-mutation run
|
|
30
|
+
* writes no manifest).
|
|
31
|
+
*/
|
|
32
|
+
beginRun(summary) {
|
|
33
|
+
this.summary = summary;
|
|
34
|
+
this.runId = newRunId();
|
|
35
|
+
this.set = null;
|
|
36
|
+
for (const svc of this.services.values())
|
|
37
|
+
svc.beginRun(summary);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get-or-create the per-root service. The first time a root is touched this
|
|
41
|
+
* process, its service is constructed and joined to the current run; an untouched
|
|
42
|
+
* root's service is never built.
|
|
43
|
+
*/
|
|
44
|
+
serviceFor(rootName, rootAbsPath) {
|
|
45
|
+
let svc = this.services.get(rootName);
|
|
46
|
+
if (!svc) {
|
|
47
|
+
svc = this.create(rootAbsPath, this.config);
|
|
48
|
+
svc.beginRun(this.summary);
|
|
49
|
+
this.services.set(rootName, svc);
|
|
50
|
+
}
|
|
51
|
+
return svc;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Record that `rootName` was checkpointed this run: append its member to the set
|
|
55
|
+
* and persist the manifest under the primary root. Idempotent — later mutations
|
|
56
|
+
* to the same root this run are no-ops (the root already has exactly one member).
|
|
57
|
+
*/
|
|
58
|
+
async recordMember(rootName, rootAbsPath, checkpointId) {
|
|
59
|
+
if (!this.runId)
|
|
60
|
+
return; // no run in progress — defensive
|
|
61
|
+
if (this.set?.members.some((m) => m.rootName === rootName))
|
|
62
|
+
return;
|
|
63
|
+
if (!this.set) {
|
|
64
|
+
this.set = {
|
|
65
|
+
runId: this.runId,
|
|
66
|
+
createdAt: new Date().toISOString(),
|
|
67
|
+
runSummary: trimSummary(this.summary),
|
|
68
|
+
members: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
this.set.members.push({
|
|
72
|
+
rootName,
|
|
73
|
+
rootPath: rootAbsPath,
|
|
74
|
+
checkpointId,
|
|
75
|
+
});
|
|
76
|
+
await writeSet(this.primaryRoot, this.set);
|
|
77
|
+
}
|
|
78
|
+
/** Root names that got a per-root service this process (inspection/tests). */
|
|
79
|
+
get touchedRoots() {
|
|
80
|
+
return [...this.services.keys()];
|
|
81
|
+
}
|
|
82
|
+
/** The current run's set, or null before its first mutation (inspection/tests). */
|
|
83
|
+
get currentSet() {
|
|
84
|
+
return this.set;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/checkpoint/index.js
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
3
|
+
import { CheckpointService } from "./service.js";
|
|
4
|
+
import type { CheckpointSet, CheckpointStore, RollbackPlan, SetRollbackApplied } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Set-based rollback orchestration (C.26 step 3). Restore every member of a run's
|
|
7
|
+
* {@link CheckpointSet} as one gated operation, with the R3/⚖︎#8 guarantees:
|
|
8
|
+
* 1. **validate-all before any apply** — every member's checkpoint must load and
|
|
9
|
+
* plan cleanly first ({@link validateSet}); a missing/corrupt one throws
|
|
10
|
+
* `CRUXY_E_CHECKPOINT_SET_INCOMPLETE` and NOTHING is applied.
|
|
11
|
+
* 2. **one combined preview** grouped by root ({@link buildSetPreview}), each with
|
|
12
|
+
* its own external-change warnings, behind one U.3 approval (⚖︎JC-ι).
|
|
13
|
+
* 3. **sequential apply, stop on first failure** ({@link applySet}) →
|
|
14
|
+
* `CRUXY_E_CHECKPOINT_SET_PARTIAL` carrying restored-vs-not; the plan is
|
|
15
|
+
* recomputed from disk each run, so an idempotent re-run finishes the job.
|
|
16
|
+
*/
|
|
17
|
+
export type CreateService = (root: string, config: CruxyConfig) => CheckpointService;
|
|
18
|
+
/** A member whose rollback has been fully validated + planned, ready to apply. */
|
|
19
|
+
export interface ValidatedMember {
|
|
20
|
+
rootName: string;
|
|
21
|
+
rootPath: string;
|
|
22
|
+
checkpointId: string;
|
|
23
|
+
plan: RollbackPlan;
|
|
24
|
+
store: CheckpointStore;
|
|
25
|
+
preview: Extract<ActionPreview, {
|
|
26
|
+
type: "rollback";
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validate + plan EVERY member up front (R3 gate #1). Loads each root's checkpoint
|
|
31
|
+
* and computes its rollback plan/preview; the first member that cannot be loaded
|
|
32
|
+
* throws `CRUXY_E_CHECKPOINT_SET_INCOMPLETE` — before any filesystem apply — so a
|
|
33
|
+
* partial rollback can never masquerade as success.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validateSet(set: CheckpointSet, config: CruxyConfig, createService?: CreateService): Promise<ValidatedMember[]>;
|
|
36
|
+
/**
|
|
37
|
+
* One combined preview grouped by root (⚖︎JC-ι): each root keeps its own file diffs
|
|
38
|
+
* and external-change warnings, so a single U.3 approval covers the whole set.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildSetPreview(set: CheckpointSet, members: ValidatedMember[]): Extract<ActionPreview, {
|
|
41
|
+
type: "rollback-set";
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Apply the validated set sequentially, stopping on the first failure (R3/⚖︎#8).
|
|
45
|
+
* On any member's failure this throws `CRUXY_E_CHECKPOINT_SET_PARTIAL` with the
|
|
46
|
+
* restored-vs-not split; re-running (which recomputes each plan from disk) safely
|
|
47
|
+
* finishes the job.
|
|
48
|
+
*/
|
|
49
|
+
export declare function applySet(set: CheckpointSet, members: ValidatedMember[]): Promise<SetRollbackApplied>;
|
|
50
|
+
/** Is a validated set a no-op (every member's plan is empty)? */
|
|
51
|
+
export declare function setIsNoop(members: ValidatedMember[]): boolean;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { checkpointSetIncomplete } from "../errors/index.js";
|
|
2
|
+
import { applyRollback, buildRollbackPreview, computeRollbackPlan, } from "./restore.js";
|
|
3
|
+
import { CheckpointService, createCheckpointStore, isGitWorkTree, } from "./service.js";
|
|
4
|
+
import { applySetRollback } from "./set.js";
|
|
5
|
+
function defaultCreate(root, config) {
|
|
6
|
+
return new CheckpointService({ root, config });
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate + plan EVERY member up front (R3 gate #1). Loads each root's checkpoint
|
|
10
|
+
* and computes its rollback plan/preview; the first member that cannot be loaded
|
|
11
|
+
* throws `CRUXY_E_CHECKPOINT_SET_INCOMPLETE` — before any filesystem apply — so a
|
|
12
|
+
* partial rollback can never masquerade as success.
|
|
13
|
+
*/
|
|
14
|
+
export async function validateSet(set, config, createService = defaultCreate) {
|
|
15
|
+
const validated = [];
|
|
16
|
+
for (const member of set.members) {
|
|
17
|
+
const svc = createService(member.rootPath, config);
|
|
18
|
+
let checkpoint;
|
|
19
|
+
try {
|
|
20
|
+
checkpoint = await svc.read(member.checkpointId);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
throw checkpointSetIncomplete(set.runId, `root "${member.rootName}" checkpoint ${member.checkpointId} is missing or unreadable ` +
|
|
24
|
+
`(${err.message})`);
|
|
25
|
+
}
|
|
26
|
+
const store = createCheckpointStore(member.rootPath, checkpoint.store);
|
|
27
|
+
const plan = await computeRollbackPlan(member.rootPath, checkpoint, store, isGitWorkTree(member.rootPath));
|
|
28
|
+
const preview = await buildRollbackPreview(member.rootPath, plan, store);
|
|
29
|
+
validated.push({
|
|
30
|
+
rootName: member.rootName,
|
|
31
|
+
rootPath: member.rootPath,
|
|
32
|
+
checkpointId: member.checkpointId,
|
|
33
|
+
plan,
|
|
34
|
+
store,
|
|
35
|
+
preview,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return validated;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One combined preview grouped by root (⚖︎JC-ι): each root keeps its own file diffs
|
|
42
|
+
* and external-change warnings, so a single U.3 approval covers the whole set.
|
|
43
|
+
*/
|
|
44
|
+
export function buildSetPreview(set, members) {
|
|
45
|
+
return {
|
|
46
|
+
type: "rollback-set",
|
|
47
|
+
runId: set.runId,
|
|
48
|
+
createdAt: set.createdAt,
|
|
49
|
+
runSummary: set.runSummary,
|
|
50
|
+
roots: members.map((m) => ({
|
|
51
|
+
rootName: m.rootName,
|
|
52
|
+
checkpointId: m.checkpointId,
|
|
53
|
+
files: m.preview.files,
|
|
54
|
+
externalPaths: m.preview.externalPaths,
|
|
55
|
+
attributionUnknown: m.preview.attributionUnknown,
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Apply the validated set sequentially, stopping on the first failure (R3/⚖︎#8).
|
|
61
|
+
* On any member's failure this throws `CRUXY_E_CHECKPOINT_SET_PARTIAL` with the
|
|
62
|
+
* restored-vs-not split; re-running (which recomputes each plan from disk) safely
|
|
63
|
+
* finishes the job.
|
|
64
|
+
*/
|
|
65
|
+
export async function applySet(set, members) {
|
|
66
|
+
return applySetRollback(set.runId, members.map((m) => ({
|
|
67
|
+
rootName: m.rootName,
|
|
68
|
+
restore: () => applyRollback(m.rootPath, m.plan, m.store),
|
|
69
|
+
})));
|
|
70
|
+
}
|
|
71
|
+
/** Is a validated set a no-op (every member's plan is empty)? */
|
|
72
|
+
export function setIsNoop(members) {
|
|
73
|
+
return members.every((m) => m.plan.entries.length === 0);
|
|
74
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CheckpointSet, RollbackApplied, SetRollbackApplied } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Multi-root rollback sets (C.26). A run that mutates N repos produces one
|
|
4
|
+
* per-root checkpoint plus a {@link CheckpointSet} that ties them together, so
|
|
5
|
+
* `cruxy rollback` restores exactly the touched roots as one gated operation.
|
|
6
|
+
*
|
|
7
|
+
* Two guarantees live here:
|
|
8
|
+
* • **exactly the touched roots** — the applier iterates ONLY `set.members`, so
|
|
9
|
+
* an untouched root (absent from the set) is never opened;
|
|
10
|
+
* • **stop-and-report, never silent partial** (R3) — validate every member up
|
|
11
|
+
* front (missing/corrupt → `CHECKPOINT_SET_INCOMPLETE`), then apply
|
|
12
|
+
* sequentially and STOP on the first failure, throwing
|
|
13
|
+
* `CHECKPOINT_SET_PARTIAL` with the exact restored-vs-not split.
|
|
14
|
+
*/
|
|
15
|
+
/** `run-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
|
|
16
|
+
export declare function newRunId(): string;
|
|
17
|
+
/** The set-manifest directory under the PRIMARY root (⚖︎#7). */
|
|
18
|
+
export declare function setDir(primaryRoot: string): string;
|
|
19
|
+
/** Persist a set manifest (atomic temp-then-rename), self-ignoring from git. */
|
|
20
|
+
export declare function writeSet(primaryRoot: string, set: CheckpointSet): Promise<void>;
|
|
21
|
+
/** Read a set manifest, or throw `CHECKPOINT_SET_INCOMPLETE` if missing/corrupt. */
|
|
22
|
+
export declare function readSet(primaryRoot: string, runId: string): Promise<CheckpointSet>;
|
|
23
|
+
/** List all set manifests under the primary root, newest first. */
|
|
24
|
+
export declare function listSets(primaryRoot: string): Promise<CheckpointSet[]>;
|
|
25
|
+
/**
|
|
26
|
+
* One root's rollback, pre-validated (its plan already computed): `restore`
|
|
27
|
+
* actually applies it. The orchestrator builds one per member.
|
|
28
|
+
*/
|
|
29
|
+
export interface MemberRollback {
|
|
30
|
+
rootName: string;
|
|
31
|
+
restore: () => Promise<RollbackApplied>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Apply an all-roots rollback: run each member's `restore` in order and STOP on
|
|
35
|
+
* the first failure (R3). On success, returns the restored-vs-per-root summary.
|
|
36
|
+
* On failure, throws `CHECKPOINT_SET_PARTIAL` naming the roots restored (before
|
|
37
|
+
* the failure) and those not restored (the failing one + all not-yet-attempted)
|
|
38
|
+
* — so a partial rollback can never be reported as success, and re-running (which
|
|
39
|
+
* recomputes each root from disk) safely finishes the job.
|
|
40
|
+
*
|
|
41
|
+
* The applier only ever touches roots present in `rollbacks`; an untouched root
|
|
42
|
+
* (never added to the set) is structurally impossible to open here.
|
|
43
|
+
*/
|
|
44
|
+
export declare function applySetRollback(runId: string, rollbacks: readonly MemberRollback[]): Promise<SetRollbackApplied>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { checkpointSetIncomplete, checkpointSetPartial, } from "../errors/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Multi-root rollback sets (C.26). A run that mutates N repos produces one
|
|
7
|
+
* per-root checkpoint plus a {@link CheckpointSet} that ties them together, so
|
|
8
|
+
* `cruxy rollback` restores exactly the touched roots as one gated operation.
|
|
9
|
+
*
|
|
10
|
+
* Two guarantees live here:
|
|
11
|
+
* • **exactly the touched roots** — the applier iterates ONLY `set.members`, so
|
|
12
|
+
* an untouched root (absent from the set) is never opened;
|
|
13
|
+
* • **stop-and-report, never silent partial** (R3) — validate every member up
|
|
14
|
+
* front (missing/corrupt → `CHECKPOINT_SET_INCOMPLETE`), then apply
|
|
15
|
+
* sequentially and STOP on the first failure, throwing
|
|
16
|
+
* `CHECKPOINT_SET_PARTIAL` with the exact restored-vs-not split.
|
|
17
|
+
*/
|
|
18
|
+
/** `run-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
|
|
19
|
+
export function newRunId() {
|
|
20
|
+
const stamp = new Date()
|
|
21
|
+
.toISOString()
|
|
22
|
+
.replace(/[-:]/g, "")
|
|
23
|
+
.replace(/\..+$/, "");
|
|
24
|
+
return `run-${stamp}-${randomBytes(2).toString("hex")}`;
|
|
25
|
+
}
|
|
26
|
+
/** The set-manifest directory under the PRIMARY root (⚖︎#7). */
|
|
27
|
+
export function setDir(primaryRoot) {
|
|
28
|
+
return path.join(primaryRoot, ".cruxy", "checkpoints", "sets");
|
|
29
|
+
}
|
|
30
|
+
/** Persist a set manifest (atomic temp-then-rename), self-ignoring from git. */
|
|
31
|
+
export async function writeSet(primaryRoot, set) {
|
|
32
|
+
const dir = setDir(primaryRoot);
|
|
33
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
34
|
+
// The parent checkpoints/ dir already carries a `*` .gitignore; add one here
|
|
35
|
+
// too so a set manifest is never seen by git even if the layout changes.
|
|
36
|
+
const ignoreFile = path.join(dir, ".gitignore");
|
|
37
|
+
try {
|
|
38
|
+
await fsp.access(ignoreFile);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
await fsp.writeFile(ignoreFile, "*\n");
|
|
42
|
+
}
|
|
43
|
+
const file = path.join(dir, `${set.runId}.json`);
|
|
44
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
45
|
+
await fsp.writeFile(tmp, `${JSON.stringify(set, null, 2)}\n`);
|
|
46
|
+
await fsp.rename(tmp, file);
|
|
47
|
+
}
|
|
48
|
+
/** Read a set manifest, or throw `CHECKPOINT_SET_INCOMPLETE` if missing/corrupt. */
|
|
49
|
+
export async function readSet(primaryRoot, runId) {
|
|
50
|
+
const file = path.join(setDir(primaryRoot), `${runId}.json`);
|
|
51
|
+
let raw;
|
|
52
|
+
try {
|
|
53
|
+
raw = await fsp.readFile(file, "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw checkpointSetIncomplete(runId, `the set manifest is missing or unreadable (${file})`);
|
|
57
|
+
}
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(raw);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw checkpointSetIncomplete(runId, "the set manifest is not valid JSON");
|
|
64
|
+
}
|
|
65
|
+
if (!isSetShape(parsed)) {
|
|
66
|
+
throw checkpointSetIncomplete(runId, "the set manifest is malformed");
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
/** List all set manifests under the primary root, newest first. */
|
|
71
|
+
export async function listSets(primaryRoot) {
|
|
72
|
+
const dir = setDir(primaryRoot);
|
|
73
|
+
let names;
|
|
74
|
+
try {
|
|
75
|
+
names = await fsp.readdir(dir);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const sets = [];
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
if (!name.endsWith(".json"))
|
|
83
|
+
continue;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(await fsp.readFile(path.join(dir, name), "utf8"));
|
|
86
|
+
if (isSetShape(parsed))
|
|
87
|
+
sets.push(parsed);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A corrupt individual manifest is skipped in a listing (it fails loud
|
|
91
|
+
// only when that specific run is rolled back).
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return sets.sort((a, b) => b.runId.localeCompare(a.runId));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Apply an all-roots rollback: run each member's `restore` in order and STOP on
|
|
98
|
+
* the first failure (R3). On success, returns the restored-vs-per-root summary.
|
|
99
|
+
* On failure, throws `CHECKPOINT_SET_PARTIAL` naming the roots restored (before
|
|
100
|
+
* the failure) and those not restored (the failing one + all not-yet-attempted)
|
|
101
|
+
* — so a partial rollback can never be reported as success, and re-running (which
|
|
102
|
+
* recomputes each root from disk) safely finishes the job.
|
|
103
|
+
*
|
|
104
|
+
* The applier only ever touches roots present in `rollbacks`; an untouched root
|
|
105
|
+
* (never added to the set) is structurally impossible to open here.
|
|
106
|
+
*/
|
|
107
|
+
export async function applySetRollback(runId, rollbacks) {
|
|
108
|
+
const restored = [];
|
|
109
|
+
const perRoot = {};
|
|
110
|
+
for (let i = 0; i < rollbacks.length; i++) {
|
|
111
|
+
const { rootName, restore } = rollbacks[i];
|
|
112
|
+
try {
|
|
113
|
+
perRoot[rootName] = await restore();
|
|
114
|
+
restored.push(rootName);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const notRestored = rollbacks.slice(i).map((r) => r.rootName);
|
|
118
|
+
throw checkpointSetPartial(runId, restored, notRestored, err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { runId, restored, perRoot };
|
|
122
|
+
}
|
|
123
|
+
/** Structural check for a parsed set manifest — enough to fail loud on corruption. */
|
|
124
|
+
function isSetShape(value) {
|
|
125
|
+
if (typeof value !== "object" || value === null)
|
|
126
|
+
return false;
|
|
127
|
+
const v = value;
|
|
128
|
+
if (typeof v.runId !== "string" ||
|
|
129
|
+
typeof v.createdAt !== "string" ||
|
|
130
|
+
typeof v.runSummary !== "string" ||
|
|
131
|
+
!Array.isArray(v.members)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return v.members.every((m) => {
|
|
135
|
+
if (typeof m !== "object" || m === null)
|
|
136
|
+
return false;
|
|
137
|
+
const mm = m;
|
|
138
|
+
return (typeof mm.rootName === "string" &&
|
|
139
|
+
typeof mm.rootPath === "string" &&
|
|
140
|
+
typeof mm.checkpointId === "string");
|
|
141
|
+
});
|
|
142
|
+
}
|