@cruxy/cli 0.23.0 → 0.25.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/agent/session.d.ts +13 -0
- package/dist/agent/session.js +6 -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/checkpoint/gate-hook.d.ts +28 -0
- package/dist/checkpoint/gate-hook.js +98 -0
- package/dist/checkpoint/gate.d.ts +7 -1
- package/dist/checkpoint/gate.js +8 -2
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/cli/commands/rollback.d.ts +4 -1
- package/dist/cli/commands/rollback.js +16 -9
- package/dist/cli/commands/run.js +62 -16
- package/dist/cli/onboard.js +2 -2
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +145 -0
- package/dist/cli/session-factory.d.ts +24 -10
- package/dist/cli/session-factory.js +179 -135
- package/dist/config/schema.d.ts +110 -0
- package/dist/config/schema.js +50 -0
- package/dist/errors/constructors.d.ts +41 -0
- package/dist/errors/constructors.js +87 -0
- package/dist/errors/types.d.ts +21 -0
- package/dist/errors/types.js +33 -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/jobs/approval-queue.d.ts +85 -0
- package/dist/jobs/approval-queue.js +96 -0
- package/dist/jobs/dispatch-tool.d.ts +34 -0
- package/dist/jobs/dispatch-tool.js +96 -0
- package/dist/jobs/index.d.ts +6 -0
- package/dist/jobs/index.js +6 -0
- package/dist/jobs/log-buffer.d.ts +31 -0
- package/dist/jobs/log-buffer.js +30 -0
- package/dist/jobs/log-renderer.d.ts +32 -0
- package/dist/jobs/log-renderer.js +70 -0
- package/dist/jobs/manager.d.ts +139 -0
- package/dist/jobs/manager.js +397 -0
- package/dist/jobs/types.d.ts +81 -0
- package/dist/jobs/types.js +10 -0
- 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/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 +76 -2
- package/dist/subagent/orchestrator.js +208 -18
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +56 -0
- package/dist/subagent/semaphore.js +53 -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 +1 -1
- package/dist/tools/file/paths.d.ts +5 -6
- package/dist/tools/file/paths.js +7 -8
- package/dist/tools/shell/exec.js +36 -4
- package/dist/tools/types.d.ts +16 -5
- 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) {
|
package/dist/agent/session.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { StreamRenderer } from "../render/index.js";
|
|
|
4
4
|
import { type Router } from "../routing/index.js";
|
|
5
5
|
import type { ToolContext } from "../tools/index.js";
|
|
6
6
|
import type { ToolRegistry } from "../tools/index.js";
|
|
7
|
+
import type { JobManager } from "../jobs/index.js";
|
|
7
8
|
import { type RequestUsage, type UsageRecord } from "../usage/index.js";
|
|
8
9
|
import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
|
|
9
10
|
/**
|
|
@@ -67,6 +68,14 @@ export interface SessionArgs {
|
|
|
67
68
|
* persists it locally — it never transmits. Omitted → no persistence.
|
|
68
69
|
*/
|
|
69
70
|
onRunUsage?: (record: UsageRecord) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Session-scoped background jobs (C.28): the manager the `run_in_background`
|
|
73
|
+
* tool dispatches onto. Exposed on the session so the REPL can service pending
|
|
74
|
+
* job approvals between turns and drive `/jobs`/`/logs`/`/cancel`, and so
|
|
75
|
+
* `cruxy run` can cancel every live job on session exit. Present only when
|
|
76
|
+
* `jobs.enabled`; omitted → no background jobs (unchanged behaviour).
|
|
77
|
+
*/
|
|
78
|
+
jobs?: JobManager;
|
|
70
79
|
}
|
|
71
80
|
/**
|
|
72
81
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -106,6 +115,10 @@ export declare class Session {
|
|
|
106
115
|
/** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
|
|
107
116
|
* shell-bound custom slash command (C.19) runs through the SAME gated path. */
|
|
108
117
|
get toolContext(): ToolContext;
|
|
118
|
+
/** The background-job manager (C.28), or undefined when jobs are disabled.
|
|
119
|
+
* The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
|
|
120
|
+
* run` uses it to cancel every live job on exit. */
|
|
121
|
+
get jobs(): JobManager | undefined;
|
|
109
122
|
/** Whether plan mode is currently on. */
|
|
110
123
|
getPlanMode(): boolean;
|
|
111
124
|
/**
|
package/dist/agent/session.js
CHANGED
|
@@ -72,6 +72,12 @@ export class Session {
|
|
|
72
72
|
get toolContext() {
|
|
73
73
|
return this.args.ctx;
|
|
74
74
|
}
|
|
75
|
+
/** The background-job manager (C.28), or undefined when jobs are disabled.
|
|
76
|
+
* The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
|
|
77
|
+
* run` uses it to cancel every live job on exit. */
|
|
78
|
+
get jobs() {
|
|
79
|
+
return this.args.jobs;
|
|
80
|
+
}
|
|
75
81
|
/** Whether plan mode is currently on. */
|
|
76
82
|
getPlanMode() {
|
|
77
83
|
return this.planMode;
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ApprovalDecision } from "../approval/types.js";
|
|
2
|
+
import type { ApproveAction } from "../tools/types.js";
|
|
3
|
+
import type { Workspace } from "../workspace/index.js";
|
|
4
|
+
import type { CheckpointGate } from "./gate.js";
|
|
5
|
+
/**
|
|
6
|
+
* Wrap an approval gate with the C.32 auto-checkpoint hook. Ordering is the whole
|
|
7
|
+
* point: a tool mutates only *after* `requestApproval` resolves, so snapshotting
|
|
8
|
+
* after an `allow` decision but before returning it means the checkpoint always
|
|
9
|
+
* lands before the run's first mutation — and a denied action never creates one.
|
|
10
|
+
* The same seam records which paths the run touched (file actions) or that
|
|
11
|
+
* attribution is lost (shell), for rollback's external-change detection.
|
|
12
|
+
*
|
|
13
|
+
* Lives in the checkpoint package (not the CLI wiring) so the SAME hook can be
|
|
14
|
+
* composed by the session factory, the subagent orchestrator, AND the C.28 job
|
|
15
|
+
* approval path without any of them importing the others.
|
|
16
|
+
*/
|
|
17
|
+
export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, gate: CheckpointGate | undefined, ws: Workspace): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
18
|
+
/**
|
|
19
|
+
* Group a file action's resolved absolute targets by the root that contains each
|
|
20
|
+
* (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
|
|
21
|
+
* relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
|
|
22
|
+
* we reconstruct the absolute path from the primary root rather than trusting
|
|
23
|
+
* classify's `targets` — which also correctly handles a patch spanning roots.
|
|
24
|
+
*/
|
|
25
|
+
export declare function attributeFileTargets(action: ApproveAction, ws: Workspace): Map<string, {
|
|
26
|
+
rootAbsPath: string;
|
|
27
|
+
paths: string[];
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { classify } from "../approval/classify.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wrap an approval gate with the C.32 auto-checkpoint hook. Ordering is the whole
|
|
5
|
+
* point: a tool mutates only *after* `requestApproval` resolves, so snapshotting
|
|
6
|
+
* after an `allow` decision but before returning it means the checkpoint always
|
|
7
|
+
* lands before the run's first mutation — and a denied action never creates one.
|
|
8
|
+
* The same seam records which paths the run touched (file actions) or that
|
|
9
|
+
* attribution is lost (shell), for rollback's external-change detection.
|
|
10
|
+
*
|
|
11
|
+
* Lives in the checkpoint package (not the CLI wiring) so the SAME hook can be
|
|
12
|
+
* composed by the session factory, the subagent orchestrator, AND the C.28 job
|
|
13
|
+
* approval path without any of them importing the others.
|
|
14
|
+
*/
|
|
15
|
+
export function withCheckpointGate(requestApproval, gate, ws) {
|
|
16
|
+
if (!gate)
|
|
17
|
+
return requestApproval;
|
|
18
|
+
return async (action) => {
|
|
19
|
+
const decision = await requestApproval(action);
|
|
20
|
+
if (!decision.allow)
|
|
21
|
+
return decision;
|
|
22
|
+
const request = classify(action, ws.primary().absPath);
|
|
23
|
+
if (request.tier === "read")
|
|
24
|
+
return decision;
|
|
25
|
+
if (action.kind === "shell" || action.kind === "test") {
|
|
26
|
+
// JC-β residual: non-primary shell/test are Step 5, so they are still
|
|
27
|
+
// hard-attributed to the primary root regardless of `action.root` (which
|
|
28
|
+
// those tools populate as the seam). They can mutate files we cannot
|
|
29
|
+
// attribute (scripts, snapshot writers) — record the lost attribution.
|
|
30
|
+
const root = ws.primary();
|
|
31
|
+
const svc = gate.serviceFor(root.name, root.absPath);
|
|
32
|
+
const checkpoint = await svc.ensureCheckpoint();
|
|
33
|
+
await svc.recordShellMutation();
|
|
34
|
+
if (checkpoint) {
|
|
35
|
+
await gate.recordMember(root.name, root.absPath, checkpoint.id);
|
|
36
|
+
}
|
|
37
|
+
return decision;
|
|
38
|
+
}
|
|
39
|
+
if (action.kind === "vcs") {
|
|
40
|
+
// C.26 Step 4: a PR now names its root (⚖︎#11), so the checkpoint is
|
|
41
|
+
// attributed to THAT selected root — its git commit stages/lands in that
|
|
42
|
+
// root's working tree, never the primary's. `recordShellMutation` because a
|
|
43
|
+
// `git add -A` + commit mutates the tree opaquely (no per-file attribution).
|
|
44
|
+
// Fall back to the primary only if a root name is somehow absent (defensive).
|
|
45
|
+
const root = (action.root ? ws.tryRootByName(action.root) : undefined) ??
|
|
46
|
+
ws.primary();
|
|
47
|
+
const svc = gate.serviceFor(root.name, root.absPath);
|
|
48
|
+
const checkpoint = await svc.ensureCheckpoint();
|
|
49
|
+
await svc.recordShellMutation();
|
|
50
|
+
if (checkpoint) {
|
|
51
|
+
await gate.recordMember(root.name, root.absPath, checkpoint.id);
|
|
52
|
+
}
|
|
53
|
+
return decision;
|
|
54
|
+
}
|
|
55
|
+
// File actions (write/edit/patch): attribute each RESOLVED target to its root
|
|
56
|
+
// (JC-G — post-confinement truth) and checkpoint every touched root. A patch
|
|
57
|
+
// may span roots; each root gets its own checkpoint + set member.
|
|
58
|
+
for (const [rootName, group] of attributeFileTargets(action, ws)) {
|
|
59
|
+
const svc = gate.serviceFor(rootName, group.rootAbsPath);
|
|
60
|
+
const checkpoint = await svc.ensureCheckpoint();
|
|
61
|
+
await svc.recordTouched(group.paths);
|
|
62
|
+
if (checkpoint) {
|
|
63
|
+
await gate.recordMember(rootName, group.rootAbsPath, checkpoint.id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return decision;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Group a file action's resolved absolute targets by the root that contains each
|
|
71
|
+
* (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
|
|
72
|
+
* relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
|
|
73
|
+
* we reconstruct the absolute path from the primary root rather than trusting
|
|
74
|
+
* classify's `targets` — which also correctly handles a patch spanning roots.
|
|
75
|
+
*/
|
|
76
|
+
export function attributeFileTargets(action, ws) {
|
|
77
|
+
const abs = [];
|
|
78
|
+
if (action.kind === "write" || action.kind === "edit") {
|
|
79
|
+
if (action.path)
|
|
80
|
+
abs.push(action.path);
|
|
81
|
+
}
|
|
82
|
+
else if (action.kind === "patch" && action.preview?.type === "patch") {
|
|
83
|
+
for (const file of action.preview.files) {
|
|
84
|
+
abs.push(path.resolve(ws.primary().absPath, file.path));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const byRoot = new Map();
|
|
88
|
+
for (const target of abs) {
|
|
89
|
+
const root = ws.rootContaining(target);
|
|
90
|
+
const group = byRoot.get(root.name) ?? {
|
|
91
|
+
rootAbsPath: root.absPath,
|
|
92
|
+
paths: [],
|
|
93
|
+
};
|
|
94
|
+
group.paths.push(target);
|
|
95
|
+
byRoot.set(root.name, group);
|
|
96
|
+
}
|
|
97
|
+
return byRoot;
|
|
98
|
+
}
|
|
@@ -44,8 +44,14 @@ export declare class CheckpointGate {
|
|
|
44
44
|
* mutation begins a fresh run. The set is materialized lazily on the first member
|
|
45
45
|
* (so its `createdAt` marks the run's first mutation, and a no-mutation run
|
|
46
46
|
* writes no manifest).
|
|
47
|
+
*
|
|
48
|
+
* `runId` may be supplied to key the set manifest by a caller-chosen id (C.28: a
|
|
49
|
+
* background job passes its OWN id, so `cruxy rollback <jobId>` maps straight to
|
|
50
|
+
* that job's set and reverts exactly the job — pre- AND post-pause mutations,
|
|
51
|
+
* because a job calls this ONCE at start and never again on resume). Omitted → a
|
|
52
|
+
* fresh generated `run-…` id, unchanged from C.26.
|
|
47
53
|
*/
|
|
48
|
-
beginRun(summary: string): void;
|
|
54
|
+
beginRun(summary: string, runId?: string): void;
|
|
49
55
|
/**
|
|
50
56
|
* Get-or-create the per-root service. The first time a root is touched this
|
|
51
57
|
* process, its service is constructed and joined to the current run; an untouched
|
package/dist/checkpoint/gate.js
CHANGED
|
@@ -28,10 +28,16 @@ export class CheckpointGate {
|
|
|
28
28
|
* mutation begins a fresh run. The set is materialized lazily on the first member
|
|
29
29
|
* (so its `createdAt` marks the run's first mutation, and a no-mutation run
|
|
30
30
|
* writes no manifest).
|
|
31
|
+
*
|
|
32
|
+
* `runId` may be supplied to key the set manifest by a caller-chosen id (C.28: a
|
|
33
|
+
* background job passes its OWN id, so `cruxy rollback <jobId>` maps straight to
|
|
34
|
+
* that job's set and reverts exactly the job — pre- AND post-pause mutations,
|
|
35
|
+
* because a job calls this ONCE at start and never again on resume). Omitted → a
|
|
36
|
+
* fresh generated `run-…` id, unchanged from C.26.
|
|
31
37
|
*/
|
|
32
|
-
beginRun(summary) {
|
|
38
|
+
beginRun(summary, runId) {
|
|
33
39
|
this.summary = summary;
|
|
34
|
-
this.runId = newRunId();
|
|
40
|
+
this.runId = runId ?? newRunId();
|
|
35
41
|
this.set = null;
|
|
36
42
|
for (const svc of this.services.values())
|
|
37
43
|
svc.beginRun(summary);
|
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);
|
|
@@ -8,7 +8,10 @@ import { Command } from "commander";
|
|
|
8
8
|
* PRs made during the run are not undone.
|
|
9
9
|
*
|
|
10
10
|
* Routing:
|
|
11
|
-
* • an explicit `<id>`
|
|
11
|
+
* • an explicit `<id>` that names a run/JOB set (C.28: a background job's id) →
|
|
12
|
+
* set-based rollback of exactly that run, restoring every root it touched;
|
|
13
|
+
* • an explicit `<id>` that is a checkpoint id → single-root rollback (escape
|
|
14
|
+
* hatch; also the recovery route if the primary root's set index was removed);
|
|
12
15
|
* • no id, a set manifest exists → set-based rollback of the latest run;
|
|
13
16
|
* • no id, no set manifest → JC-F fallback to legacy single-root, logged.
|
|
14
17
|
*/
|
|
@@ -81,9 +81,7 @@ async function legacyRollback(root, config, approval, interactive, id, t) {
|
|
|
81
81
|
* one U.3 approval, then a sequential apply that stops on first failure
|
|
82
82
|
* (`CHECKPOINT_SET_PARTIAL`, R3). Both coded errors propagate to the boundary.
|
|
83
83
|
*/
|
|
84
|
-
async function setRollback(
|
|
85
|
-
const sets = await listSets(primaryRoot); // newest first
|
|
86
|
-
const set = sets[0];
|
|
84
|
+
async function setRollback(config, approval, t, set) {
|
|
87
85
|
// Validate-all BEFORE any apply — a missing/corrupt member throws here.
|
|
88
86
|
const members = await validateSet(set, config);
|
|
89
87
|
if (setIsNoop(members)) {
|
|
@@ -116,14 +114,17 @@ async function setRollback(primaryRoot, config, approval, t) {
|
|
|
116
114
|
* PRs made during the run are not undone.
|
|
117
115
|
*
|
|
118
116
|
* Routing:
|
|
119
|
-
* • an explicit `<id>`
|
|
117
|
+
* • an explicit `<id>` that names a run/JOB set (C.28: a background job's id) →
|
|
118
|
+
* set-based rollback of exactly that run, restoring every root it touched;
|
|
119
|
+
* • an explicit `<id>` that is a checkpoint id → single-root rollback (escape
|
|
120
|
+
* hatch; also the recovery route if the primary root's set index was removed);
|
|
120
121
|
* • no id, a set manifest exists → set-based rollback of the latest run;
|
|
121
122
|
* • no id, no set manifest → JC-F fallback to legacy single-root, logged.
|
|
122
123
|
*/
|
|
123
124
|
export function rollbackCommand() {
|
|
124
125
|
return new Command("rollback")
|
|
125
126
|
.description("restore the working tree to a checkpoint, undoing a run's file changes")
|
|
126
|
-
.argument("[id]", "checkpoint id (defaults to the most recent run)")
|
|
127
|
+
.argument("[id]", "run/job id or checkpoint id (defaults to the most recent run)")
|
|
127
128
|
.action(async (id) => {
|
|
128
129
|
const interactive = Boolean(process.stdin.isTTY);
|
|
129
130
|
// Refuse before touching anything: rollback is a deliberate, interactive
|
|
@@ -138,20 +139,26 @@ export function rollbackCommand() {
|
|
|
138
139
|
interactive,
|
|
139
140
|
io: defaultPromptIO(shouldUseColor()),
|
|
140
141
|
});
|
|
141
|
-
|
|
142
|
-
// recovery route if the primary root — and its set index — was removed).
|
|
142
|
+
const sets = await listSets(primaryRoot); // newest first
|
|
143
143
|
if (id !== undefined) {
|
|
144
|
+
// A job/run id first: if `<id>` names a set manifest (a background job's
|
|
145
|
+
// id IS its set's runId, C.28), roll back that whole run. Otherwise treat
|
|
146
|
+
// it as a single checkpoint id (the per-member escape hatch).
|
|
147
|
+
const jobSet = sets.find((s) => s.runId === id);
|
|
148
|
+
if (jobSet) {
|
|
149
|
+
await setRollback(config, approval, t, jobSet);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
144
152
|
await legacyRollback(primaryRoot, config, approval, interactive, id, t);
|
|
145
153
|
return;
|
|
146
154
|
}
|
|
147
155
|
// No set manifest → JC-F: fall back to legacy single-root, logged (never
|
|
148
156
|
// silent). The primary root name matches single-root workspace naming.
|
|
149
|
-
const sets = await listSets(primaryRoot);
|
|
150
157
|
if (sets.length === 0) {
|
|
151
158
|
logger.info(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`);
|
|
152
159
|
await legacyRollback(primaryRoot, config, approval, interactive, undefined, t);
|
|
153
160
|
return;
|
|
154
161
|
}
|
|
155
|
-
await setRollback(
|
|
162
|
+
await setRollback(config, approval, t, sets[0]);
|
|
156
163
|
});
|
|
157
164
|
}
|