@cruxy/cli 0.22.1 → 0.24.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/agent/loop.d.ts +21 -2
- package/dist/agent/loop.js +21 -5
- package/dist/approval/classify.js +18 -0
- package/dist/approval/index.d.ts +1 -0
- package/dist/approval/index.js +1 -0
- package/dist/approval/mutex.d.ts +45 -0
- package/dist/approval/mutex.js +57 -0
- package/dist/approval/prompt.js +11 -0
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +2 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +104 -21
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/repl.js +39 -0
- package/dist/cli/session-factory.d.ts +27 -4
- package/dist/cli/session-factory.js +234 -58
- package/dist/config/schema.d.ts +24 -0
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.d.ts +51 -0
- package/dist/errors/constructors.js +97 -0
- package/dist/errors/types.d.ts +28 -0
- package/dist/errors/types.js +38 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/router.d.ts +58 -0
- package/dist/hooks/router.js +136 -0
- package/dist/hooks/runner.d.ts +12 -0
- package/dist/hooks/runner.js +23 -1
- 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/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/sibling-banner.d.ts +25 -0
- package/dist/mcp/sibling-banner.js +34 -0
- package/dist/memory/recall.d.ts +24 -0
- package/dist/memory/recall.js +54 -0
- package/dist/memory/remember-tool.d.ts +3 -0
- package/dist/memory/remember-tool.js +11 -1
- package/dist/render/diff.js +42 -5
- package/dist/sandbox/policy.js +14 -5
- package/dist/sandbox/service.d.ts +8 -1
- package/dist/sandbox/service.js +4 -1
- package/dist/subagent/index.d.ts +1 -0
- package/dist/subagent/index.js +1 -0
- package/dist/subagent/orchestrator.d.ts +81 -1
- package/dist/subagent/orchestrator.js +204 -17
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +27 -0
- package/dist/subagent/semaphore.js +56 -0
- package/dist/subagent/spawn-tool.d.ts +57 -0
- package/dist/subagent/spawn-tool.js +104 -9
- package/dist/subagent/types.d.ts +17 -2
- 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 +121 -9
- package/dist/tools/file/paths.js +164 -10
- 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 +43 -4
- package/dist/tools/types.d.ts +74 -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/add-root.d.ts +27 -0
- package/dist/workspace/add-root.js +16 -0
- package/dist/workspace/index.d.ts +2 -1
- package/dist/workspace/index.js +2 -1
- package/dist/workspace/workspace.d.ts +9 -4
- package/dist/workspace/workspace.js +9 -4
- package/package.json +1 -1
package/dist/agent/loop.d.ts
CHANGED
|
@@ -5,11 +5,19 @@ import type { StreamRenderer } from "../render/index.js";
|
|
|
5
5
|
import { type Router, type TaskClass } from "../routing/index.js";
|
|
6
6
|
import type { ToolContext } from "../tools/index.js";
|
|
7
7
|
import { ToolRegistry } from "../tools/index.js";
|
|
8
|
+
/** Optional per-fire context (C.26 step 5). For tool-scoped events the loop
|
|
9
|
+
* passes the raw tool-call `input` so a multi-root {@link HookRouter} can resolve
|
|
10
|
+
* the ONE acting root and fire only that root's hooks. Session lifecycle events
|
|
11
|
+
* (`before-run`/`after-run`) carry no hint — they have no acting root and fan
|
|
12
|
+
* every trusted root. Ignored by the single-root runner. */
|
|
13
|
+
export interface HookFireHint {
|
|
14
|
+
input?: unknown;
|
|
15
|
+
}
|
|
8
16
|
/** The lifecycle-hook firing seam (C.19). Structural so the loop stays
|
|
9
17
|
* decoupled from the concrete `HookRunner`. `fire` resolves when hooks pass (or
|
|
10
18
|
* advisory ones fail) and throws `CRUXY_E_HOOK_FAILED` on a blocking failure. */
|
|
11
19
|
export interface LifecycleHookRunner {
|
|
12
|
-
fire(event: HookEvent, ctx: ToolContext): Promise<void>;
|
|
20
|
+
fire(event: HookEvent, ctx: ToolContext, hint?: HookFireHint): Promise<void>;
|
|
13
21
|
}
|
|
14
22
|
export interface RunAgentArgs {
|
|
15
23
|
/**
|
|
@@ -84,6 +92,17 @@ export interface RunAgentArgs {
|
|
|
84
92
|
tier?: string;
|
|
85
93
|
usage?: Usage;
|
|
86
94
|
}) => void;
|
|
95
|
+
/**
|
|
96
|
+
* Cooperative cancellation (C.33). When the signal aborts, the loop stops at
|
|
97
|
+
* the NEXT turn boundary and returns `stop: "aborted"` with the coherent
|
|
98
|
+
* partial history — the in-flight turn (model call + its tool executions)
|
|
99
|
+
* always completes first, exactly like a tripped {@link budget}, so overshoot
|
|
100
|
+
* is bounded by one turn. Used by the parallel orchestrator to cancel sibling
|
|
101
|
+
* subagents on a fatal failure or Ctrl-C; omitted → no cancellation (unchanged).
|
|
102
|
+
* The same signal reaches tools via `ctx.signal`, so an in-flight shell child
|
|
103
|
+
* is kill-tree'd rather than orphaned.
|
|
104
|
+
*/
|
|
105
|
+
signal?: AbortSignal;
|
|
87
106
|
}
|
|
88
107
|
/**
|
|
89
108
|
* The budget seam for {@link runAgent}: implementations track their own caps
|
|
@@ -107,7 +126,7 @@ export interface AgentResult {
|
|
|
107
126
|
/** Number of model turns consumed. */
|
|
108
127
|
iterations: number;
|
|
109
128
|
/** Why the loop ended. */
|
|
110
|
-
stop: "completed" | "max_iterations" | "budget";
|
|
129
|
+
stop: "completed" | "max_iterations" | "budget" | "aborted";
|
|
111
130
|
/** Which cap tripped, when `stop === "budget"` (from {@link LoopBudget}). */
|
|
112
131
|
stopReason?: string;
|
|
113
132
|
/** Accumulated token usage (stashed for cost tracking in C.22). */
|
package/dist/agent/loop.js
CHANGED
|
@@ -62,6 +62,19 @@ async function driveLoop(args, renderer, routed) {
|
|
|
62
62
|
});
|
|
63
63
|
let iterations = 0;
|
|
64
64
|
for (let i = 0; i < maxIterations; i++) {
|
|
65
|
+
// Cancellation check (C.33), before committing to another model turn: a
|
|
66
|
+
// signalled abort returns the history as it stands, at a clean turn boundary
|
|
67
|
+
// (the prior iteration fully resolved its tool calls). Checked ahead of the
|
|
68
|
+
// budget so a cancelled fan-out never spends one more turn's tokens.
|
|
69
|
+
if (args.signal?.aborted) {
|
|
70
|
+
return {
|
|
71
|
+
messages,
|
|
72
|
+
iterations,
|
|
73
|
+
stop: "aborted",
|
|
74
|
+
stopReason: "cancelled",
|
|
75
|
+
usage,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
65
78
|
// Budget check before committing to another model turn (C.14): a tripped
|
|
66
79
|
// cap returns the history as it stands — always at a clean turn boundary,
|
|
67
80
|
// because the previous iteration fully resolved its tool calls.
|
|
@@ -184,7 +197,10 @@ async function driveLoop(args, renderer, routed) {
|
|
|
184
197
|
// fail-closed — the tool never runs; the model is told via an error
|
|
185
198
|
// result. The hook command itself went through the U.3 gate + C.16 sandbox
|
|
186
199
|
// (same path as run_command), so a hook is never an approval bypass.
|
|
187
|
-
|
|
200
|
+
// The raw input lets a multi-root HookRouter resolve the ONE acting root
|
|
201
|
+
// (same precedence the tool uses) and fire only that root's hooks.
|
|
202
|
+
const hint = { input: call.input };
|
|
203
|
+
const blocked = await fireBeforeTool(args.hooks, ctx, call.id, hint);
|
|
188
204
|
if (blocked) {
|
|
189
205
|
renderer?.toolLifecycle({ event: "end", label, ok: false });
|
|
190
206
|
toolResults.push(blocked);
|
|
@@ -196,9 +212,9 @@ async function driveLoop(args, renderer, routed) {
|
|
|
196
212
|
// after-tool + on-file-change (C.19): fire once the action is done.
|
|
197
213
|
// Advisory by default (report, don't rewrite history); a hook explicitly
|
|
198
214
|
// marked blocking here throws and aborts the run.
|
|
199
|
-
await args.hooks?.fire("after-tool", ctx);
|
|
215
|
+
await args.hooks?.fire("after-tool", ctx, hint);
|
|
200
216
|
if (!result.is_error && FILE_MUTATING_TOOLS.has(call.name)) {
|
|
201
|
-
await args.hooks?.fire("on-file-change", ctx);
|
|
217
|
+
await args.hooks?.fire("on-file-change", ctx, hint);
|
|
202
218
|
}
|
|
203
219
|
}
|
|
204
220
|
messages.push({ role: "user", content: toolResults });
|
|
@@ -250,11 +266,11 @@ function describeToolCall(call) {
|
|
|
250
266
|
* the failure is greppable. A non-blocking (advisory) hook failure never reaches
|
|
251
267
|
* here — the runner reports it and resolves normally.
|
|
252
268
|
*/
|
|
253
|
-
async function fireBeforeTool(hooks, ctx, toolUseId) {
|
|
269
|
+
async function fireBeforeTool(hooks, ctx, toolUseId, hint) {
|
|
254
270
|
if (!hooks)
|
|
255
271
|
return null;
|
|
256
272
|
try {
|
|
257
|
-
await hooks.fire("before-tool", ctx);
|
|
273
|
+
await hooks.fire("before-tool", ctx, hint);
|
|
258
274
|
return null;
|
|
259
275
|
}
|
|
260
276
|
catch (err) {
|
|
@@ -121,6 +121,24 @@ function vcsRequest(action, root) {
|
|
|
121
121
|
* carries the full blast radius; the summary names the checkpoint.
|
|
122
122
|
*/
|
|
123
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
|
+
}
|
|
124
142
|
const preview = action.preview?.type === "rollback" ? action.preview : undefined;
|
|
125
143
|
const summary = preview
|
|
126
144
|
? `rollback: restore checkpoint ${preview.checkpointId} (${preview.files.length} file${preview.files.length === 1 ? "" : "s"})`
|
package/dist/approval/index.d.ts
CHANGED
package/dist/approval/index.js
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ApprovalDecision } from "./types.js";
|
|
2
|
+
import type { ApproveAction } from "../tools/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The approval mutex (C.33, JC-C) — the spine of the concurrent-subagent safety
|
|
5
|
+
* model. Node is single-threaded, so the only hazard between parallel subagents
|
|
6
|
+
* is interleaving at `await` boundaries; the one resource they genuinely contend
|
|
7
|
+
* for is the interactive terminal (one prompt at a time) and the run's shared
|
|
8
|
+
* checkpoint state (one snapshot/set-write at a time). Serializing every
|
|
9
|
+
* *gated write* through this one lock resolves BOTH with a single mechanism:
|
|
10
|
+
*
|
|
11
|
+
* • **one prompt at a time** — a child blocked awaiting the user's keypress
|
|
12
|
+
* holds the lock, so no sibling can paint a second prompt over it (the
|
|
13
|
+
* keypress is always attributable to the one displayed prompt); and
|
|
14
|
+
* • **serialized gated writes** — the checkpoint hook (snapshot + per-root set
|
|
15
|
+
* member write) runs inside the same critical section, so two concurrent
|
|
16
|
+
* writes to disjoint roots can never race on `ensureCheckpoint`'s latch or
|
|
17
|
+
* the set manifest.
|
|
18
|
+
*
|
|
19
|
+
* It is a plain promise-chain serializer: `runExclusive(fn)` runs `fn` only
|
|
20
|
+
* after every previously-enqueued `fn` has settled. It is a LEAF lock — nothing
|
|
21
|
+
* is acquired while holding it except the terminal and the filesystem, neither
|
|
22
|
+
* of which waits on a subagent resource — so it cannot take part in a cycle
|
|
23
|
+
* (see the deadlock argument in the C.33 design doc).
|
|
24
|
+
*/
|
|
25
|
+
export declare class ApprovalMutex {
|
|
26
|
+
/** The settled-marker chain: always resolves (never rejects), so a rejecting
|
|
27
|
+
* critical section never wedges the queue for the next waiter. */
|
|
28
|
+
private tail;
|
|
29
|
+
/** Run `fn` in mutual exclusion with every other `runExclusive` on this mutex. */
|
|
30
|
+
runExclusive<T>(fn: () => Promise<T>): Promise<T>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Wrap a fully-built gate (`ApprovalService.requestApproval` behind the C.32
|
|
34
|
+
* checkpoint hook) so that every gated action serializes through `mutex`.
|
|
35
|
+
*
|
|
36
|
+
* Every action that actually reaches the gate is a *mutation* — read-only tools
|
|
37
|
+
* never call `requestApproval` at all (see the ToolContext contract), so a
|
|
38
|
+
* parallel READ fan-out is already free of the lock and never stalls behind a
|
|
39
|
+
* sibling's pending prompt. The `read`-tier short-circuit below is therefore
|
|
40
|
+
* defensive belt-and-suspenders (mirroring `ApprovalService`'s own read check):
|
|
41
|
+
* if a read-classified action ever did flow here, it would bypass the spine
|
|
42
|
+
* rather than needlessly hold it. What the mutex serializes in practice is the
|
|
43
|
+
* mutating set — exactly the prompt + checkpoint work that must be one-at-a-time.
|
|
44
|
+
*/
|
|
45
|
+
export declare function serializeGate(gate: (action: ApproveAction) => Promise<ApprovalDecision>, mutex: ApprovalMutex, cwd: string): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { classify } from "./classify.js";
|
|
2
|
+
/**
|
|
3
|
+
* The approval mutex (C.33, JC-C) — the spine of the concurrent-subagent safety
|
|
4
|
+
* model. Node is single-threaded, so the only hazard between parallel subagents
|
|
5
|
+
* is interleaving at `await` boundaries; the one resource they genuinely contend
|
|
6
|
+
* for is the interactive terminal (one prompt at a time) and the run's shared
|
|
7
|
+
* checkpoint state (one snapshot/set-write at a time). Serializing every
|
|
8
|
+
* *gated write* through this one lock resolves BOTH with a single mechanism:
|
|
9
|
+
*
|
|
10
|
+
* • **one prompt at a time** — a child blocked awaiting the user's keypress
|
|
11
|
+
* holds the lock, so no sibling can paint a second prompt over it (the
|
|
12
|
+
* keypress is always attributable to the one displayed prompt); and
|
|
13
|
+
* • **serialized gated writes** — the checkpoint hook (snapshot + per-root set
|
|
14
|
+
* member write) runs inside the same critical section, so two concurrent
|
|
15
|
+
* writes to disjoint roots can never race on `ensureCheckpoint`'s latch or
|
|
16
|
+
* the set manifest.
|
|
17
|
+
*
|
|
18
|
+
* It is a plain promise-chain serializer: `runExclusive(fn)` runs `fn` only
|
|
19
|
+
* after every previously-enqueued `fn` has settled. It is a LEAF lock — nothing
|
|
20
|
+
* is acquired while holding it except the terminal and the filesystem, neither
|
|
21
|
+
* of which waits on a subagent resource — so it cannot take part in a cycle
|
|
22
|
+
* (see the deadlock argument in the C.33 design doc).
|
|
23
|
+
*/
|
|
24
|
+
export class ApprovalMutex {
|
|
25
|
+
/** The settled-marker chain: always resolves (never rejects), so a rejecting
|
|
26
|
+
* critical section never wedges the queue for the next waiter. */
|
|
27
|
+
tail = Promise.resolve();
|
|
28
|
+
/** Run `fn` in mutual exclusion with every other `runExclusive` on this mutex. */
|
|
29
|
+
runExclusive(fn) {
|
|
30
|
+
const result = this.tail.then(fn);
|
|
31
|
+
// Advance the chain on a branch that swallows the outcome, so the caller
|
|
32
|
+
// still observes `fn`'s rejection while the next waiter is not poisoned.
|
|
33
|
+
this.tail = result.then(() => undefined, () => undefined);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Wrap a fully-built gate (`ApprovalService.requestApproval` behind the C.32
|
|
39
|
+
* checkpoint hook) so that every gated action serializes through `mutex`.
|
|
40
|
+
*
|
|
41
|
+
* Every action that actually reaches the gate is a *mutation* — read-only tools
|
|
42
|
+
* never call `requestApproval` at all (see the ToolContext contract), so a
|
|
43
|
+
* parallel READ fan-out is already free of the lock and never stalls behind a
|
|
44
|
+
* sibling's pending prompt. The `read`-tier short-circuit below is therefore
|
|
45
|
+
* defensive belt-and-suspenders (mirroring `ApprovalService`'s own read check):
|
|
46
|
+
* if a read-classified action ever did flow here, it would bypass the spine
|
|
47
|
+
* rather than needlessly hold it. What the mutex serializes in practice is the
|
|
48
|
+
* mutating set — exactly the prompt + checkpoint work that must be one-at-a-time.
|
|
49
|
+
*/
|
|
50
|
+
export function serializeGate(gate, mutex, cwd) {
|
|
51
|
+
return (action) => {
|
|
52
|
+
// Same classifier the gate uses; read tier contends for nothing → no lock.
|
|
53
|
+
if (classify(action, cwd).tier === "read")
|
|
54
|
+
return gate(action);
|
|
55
|
+
return mutex.runExclusive(() => gate(action));
|
|
56
|
+
};
|
|
57
|
+
}
|
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. */
|
|
@@ -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
|
@@ -48,6 +48,14 @@ export declare class CheckpointService {
|
|
|
48
48
|
private readonly pinnedStore?;
|
|
49
49
|
private runSummary;
|
|
50
50
|
private active;
|
|
51
|
+
/** In-flight `ensureCheckpoint` construction (C.33). PROMISE-latched, not
|
|
52
|
+
* value-latched: two concurrent gated writes to this root await the SAME
|
|
53
|
+
* snapshot instead of each taking one (the once-per-run latch `this.active`
|
|
54
|
+
* is only set AFTER several awaits, so a boolean/value latch would let a
|
|
55
|
+
* second caller slip through and double-snapshot). The approval mutex already
|
|
56
|
+
* serializes gated writes, so this is defense-in-depth — but it makes the
|
|
57
|
+
* service correct on its own, independent of the caller's discipline. */
|
|
58
|
+
private pending;
|
|
51
59
|
constructor(opts: CheckpointServiceOptions);
|
|
52
60
|
/** Start a new undo unit: reset the once-per-run latch and name the run. */
|
|
53
61
|
beginRun(summary: string): void;
|
|
@@ -59,6 +67,7 @@ export declare class CheckpointService {
|
|
|
59
67
|
* either substrate, the run must not mutate without its undo protection.
|
|
60
68
|
*/
|
|
61
69
|
ensureCheckpoint(): Promise<Checkpoint | null>;
|
|
70
|
+
private buildCheckpoint;
|
|
62
71
|
/** Attribute mutated paths to the current run (persisted for later rollback). */
|
|
63
72
|
recordTouched(absPaths: string[]): Promise<void>;
|
|
64
73
|
/** The run ran a shell command: per-path attribution is no longer possible. */
|
|
@@ -36,6 +36,14 @@ export class CheckpointService {
|
|
|
36
36
|
pinnedStore;
|
|
37
37
|
runSummary = "agent run";
|
|
38
38
|
active = null;
|
|
39
|
+
/** In-flight `ensureCheckpoint` construction (C.33). PROMISE-latched, not
|
|
40
|
+
* value-latched: two concurrent gated writes to this root await the SAME
|
|
41
|
+
* snapshot instead of each taking one (the once-per-run latch `this.active`
|
|
42
|
+
* is only set AFTER several awaits, so a boolean/value latch would let a
|
|
43
|
+
* second caller slip through and double-snapshot). The approval mutex already
|
|
44
|
+
* serializes gated writes, so this is defense-in-depth — but it makes the
|
|
45
|
+
* service correct on its own, independent of the caller's discipline. */
|
|
46
|
+
pending = null;
|
|
39
47
|
constructor(opts) {
|
|
40
48
|
this.root = path.resolve(opts.root);
|
|
41
49
|
this.config = opts.config;
|
|
@@ -44,6 +52,7 @@ export class CheckpointService {
|
|
|
44
52
|
/** Start a new undo unit: reset the once-per-run latch and name the run. */
|
|
45
53
|
beginRun(summary) {
|
|
46
54
|
this.active = null;
|
|
55
|
+
this.pending = null;
|
|
47
56
|
const firstLine = summary.split("\n", 1)[0].trim();
|
|
48
57
|
this.runSummary =
|
|
49
58
|
firstLine.length > 80
|
|
@@ -62,6 +71,17 @@ export class CheckpointService {
|
|
|
62
71
|
return null;
|
|
63
72
|
if (this.active)
|
|
64
73
|
return this.active;
|
|
74
|
+
// Coalesce concurrent first-use: a second caller awaits the first's snapshot
|
|
75
|
+
// rather than starting a second one. Cleared on settle so a FAILED attempt
|
|
76
|
+
// (which leaves `this.active` null) lets the next call retry.
|
|
77
|
+
if (this.pending)
|
|
78
|
+
return this.pending;
|
|
79
|
+
this.pending = this.buildCheckpoint().finally(() => {
|
|
80
|
+
this.pending = null;
|
|
81
|
+
});
|
|
82
|
+
return this.pending;
|
|
83
|
+
}
|
|
84
|
+
async buildCheckpoint() {
|
|
65
85
|
const gitWorkTree = this.pinnedStore
|
|
66
86
|
? this.pinnedStore.kind === "git"
|
|
67
87
|
: isGitWorkTree(this.root);
|
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
/**
|
|
3
|
-
* `cruxy rollback [id]` (C.32) — restore the working tree to a
|
|
4
|
-
* undoing everything an agent run changed
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
3
|
+
* `cruxy rollback [id]` (C.32/C.26) — restore the working tree to a run's
|
|
4
|
+
* checkpoint(s), undoing everything an agent run changed in one operation.
|
|
5
|
+
* Destructive by definition, so it is preview-first and gated through U.3 at the
|
|
6
|
+
* destructive tier, ungrantable; non-TTY is refused with a coded error before
|
|
7
|
+
* anything is computed. Out of scope, stated in the preview: commits, pushes, and
|
|
8
|
+
* PRs made during the run are not undone.
|
|
9
|
+
*
|
|
10
|
+
* Routing:
|
|
11
|
+
* • an explicit `<id>` → single-root rollback of that checkpoint (escape hatch);
|
|
12
|
+
* • no id, a set manifest exists → set-based rollback of the latest run;
|
|
13
|
+
* • no id, no set manifest → JC-F fallback to legacy single-root, logged.
|
|
9
14
|
*/
|
|
10
15
|
export declare function rollbackCommand(): Command;
|