@cruxy/cli 1.5.0 → 1.6.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/session.js +75 -2
- package/dist/budget/index.js +9 -0
- package/dist/budget/session-budget.js +223 -0
- package/dist/checkpoint/diff.js +130 -0
- package/dist/checkpoint/git-store.js +52 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/run-rollback.js +100 -0
- package/dist/cli/command-catalog.js +144 -0
- package/dist/cli/commands/hooks.js +1 -1
- package/dist/cli/commands/rollback.js +21 -57
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/test.js +28 -16
- package/dist/cli/session-commands.js +315 -69
- package/dist/cli/session-factory.js +13 -0
- package/dist/errors/constructors.js +22 -0
- package/dist/errors/types.js +15 -0
- package/dist/hooks/config.js +18 -0
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/router.js +1 -1
- package/dist/hooks/service.js +4 -4
- package/dist/hooks/slash.js +10 -26
- package/dist/memory/secrets.js +43 -0
- package/dist/render/context-view.js +2 -2
- package/dist/render/plan-view.js +1 -1
- package/dist/render/status-view.js +5 -5
- package/dist/render/units.js +22 -0
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +19 -0
- package/dist/session/redact.js +74 -0
- package/dist/session/replay.js +16 -0
- package/dist/session/resume.js +8 -0
- package/dist/session/types.js +38 -0
- package/dist/subagent/orchestrator.js +82 -5
- package/dist/theme/resolve.js +1 -0
- package/dist/tui/app.js +7 -4
- package/dist/tui/approval-overlay.js +7 -1
- package/dist/tui/layout.js +7 -2
- package/dist/tui/limits-panel.js +6 -14
- package/dist/tui/palette.js +11 -19
- package/dist/usage/weighted.js +14 -0
- package/dist/utils/disk.js +11 -3
- package/package.json +2 -2
package/dist/agent/session.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { UNRESOLVED_TIER } from "../budget/index.js";
|
|
2
3
|
import { loadProjectInstructions } from "../config/index.js";
|
|
4
|
+
import { sessionBudgetExhausted } from "../errors/index.js";
|
|
5
|
+
// The concrete module, not `session/index.js`: that barrel imports this file's
|
|
6
|
+
// package for its own types, and the transform is a leaf that needs none of it.
|
|
7
|
+
import { redactMessages } from "../session/redact.js";
|
|
3
8
|
import { resolveTaskModel, } from "../routing/index.js";
|
|
4
9
|
import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
|
|
5
10
|
import { Budget } from "./budget.js";
|
|
@@ -65,6 +70,25 @@ export class Session {
|
|
|
65
70
|
* previous iteration fully resolved its tool calls.
|
|
66
71
|
*/
|
|
67
72
|
recordedCount = 0;
|
|
73
|
+
/**
|
|
74
|
+
* The tier the next turn will draw on, in the three-way shape admission needs.
|
|
75
|
+
*
|
|
76
|
+
* A session with no {@link SessionModel} is a bring-your-own-provider session:
|
|
77
|
+
* its tokens never reach the weighted pool, so there is nothing to weigh and
|
|
78
|
+
* `undefined` says so. `auto` is a cruxy request whose tier the gateway picks,
|
|
79
|
+
* which is a different unknown entirely — it WILL draw on the pool — so it is
|
|
80
|
+
* passed through as {@link UNRESOLVED_TIER} and weighed at the worst case.
|
|
81
|
+
*/
|
|
82
|
+
turnTier() {
|
|
83
|
+
const choice = this.args.model?.current();
|
|
84
|
+
if (choice === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
return choice === "auto" ? UNRESOLVED_TIER : choice;
|
|
87
|
+
}
|
|
88
|
+
/** The session's weighted-token budget, when one is wired (`/budget` reads it). */
|
|
89
|
+
get budget() {
|
|
90
|
+
return this.args.budget;
|
|
91
|
+
}
|
|
68
92
|
constructor(args) {
|
|
69
93
|
this.args = args;
|
|
70
94
|
this.projectInstructions = args.projectInstructions ?? null;
|
|
@@ -213,11 +237,29 @@ export class Session {
|
|
|
213
237
|
// Unset/0 → no budget object at all, so the loop's budget check is a no-op
|
|
214
238
|
// and behavior is byte-identical to before. Rebuilt each `send`, so the cap
|
|
215
239
|
// is per-turn, never cumulative across the session (cost is C.22's concern).
|
|
240
|
+
//
|
|
241
|
+
// P10 track 3 narrows that cap further when a session `/budget` is set. The
|
|
242
|
+
// session budget is stated in WEIGHTED tokens (the unit the gateway meters)
|
|
243
|
+
// and this guard counts LOCAL ones, so the conversion is the budget's job,
|
|
244
|
+
// not this call site's — `admit` returns the local ceiling that fits.
|
|
245
|
+
// Refusal (nothing left) throws before the model is engaged: a turn that
|
|
246
|
+
// cannot run must not spend a request finding that out.
|
|
216
247
|
const maxTokensPerTurn = this.args.config.agent.maxTokensPerTurn;
|
|
217
|
-
const
|
|
248
|
+
const admission = this.args.budget?.admit({
|
|
249
|
+
count: 1,
|
|
250
|
+
perRunTokens: maxTokensPerTurn,
|
|
251
|
+
tier: this.turnTier(),
|
|
252
|
+
});
|
|
253
|
+
if (admission?.kind === "refused") {
|
|
254
|
+
throw sessionBudgetExhausted(admission.reason);
|
|
255
|
+
}
|
|
256
|
+
// `admit` only ever narrows: `maxTokens: 0` from an unbudgeted session means
|
|
257
|
+
// "no cap", exactly as the config value does.
|
|
258
|
+
const turnTokenCap = admission?.maxTokens ?? maxTokensPerTurn;
|
|
259
|
+
const budget = turnTokenCap > 0
|
|
218
260
|
? new Budget({
|
|
219
261
|
maxIterations: Number.POSITIVE_INFINITY,
|
|
220
|
-
maxTokens:
|
|
262
|
+
maxTokens: turnTokenCap,
|
|
221
263
|
})
|
|
222
264
|
: undefined;
|
|
223
265
|
// before-run (C.19): a blocking pre-run hook — or an untrusted project's
|
|
@@ -272,6 +314,9 @@ export class Session {
|
|
|
272
314
|
const record = collector.toRecord(randomUUID(), this.sessionId, startedAt);
|
|
273
315
|
this.lastRun = record;
|
|
274
316
|
this.args.onRunUsage?.(record);
|
|
317
|
+
// The budget draws down from the SAME record the usage store persists, so
|
|
318
|
+
// `/budget` and `/usage` can never report different spends for one turn.
|
|
319
|
+
this.args.budget?.record(record);
|
|
275
320
|
// after-run (C.19): advisory by default (a blocking after-run hook throws
|
|
276
321
|
// and surfaces at the boundary). The turn already completed and its history
|
|
277
322
|
// is adopted above — an advisory failure never rewrites it.
|
|
@@ -296,6 +341,34 @@ export class Session {
|
|
|
296
341
|
this.args.recorder?.clear();
|
|
297
342
|
this.recordedCount = 0;
|
|
298
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* `/redact` (P10 track 5) — mask every recognised secret in the history the
|
|
346
|
+
* model can still see, and record that it happened.
|
|
347
|
+
*
|
|
348
|
+
* THE LIVE ARRAY AND THE LOG'S FOLD RUN THE SAME TRANSFORM, so a resumed
|
|
349
|
+
* session sees exactly what this one sees from here on. The event carries no
|
|
350
|
+
* secret; replay re-derives the spans with the same detector.
|
|
351
|
+
*
|
|
352
|
+
* The recorder's watermark is deliberately NOT rewound. `recordedCount` says
|
|
353
|
+
* how many messages of the current history are already on disk, and every one
|
|
354
|
+
* of them still is — the redaction changed their CONTENT in memory, not how
|
|
355
|
+
* many there are. Rewinding would re-append the whole conversation as if it
|
|
356
|
+
* were new, doubling the file and (worse) writing the freshly-masked copies
|
|
357
|
+
* beside the originals, which is a second copy of nothing useful.
|
|
358
|
+
*
|
|
359
|
+
* Returns what it found, so the caller can say so — including zero, which is
|
|
360
|
+
* a real answer and not a no-op to swallow.
|
|
361
|
+
*/
|
|
362
|
+
redact() {
|
|
363
|
+
const result = redactMessages(this.messages);
|
|
364
|
+
if (result.count > 0)
|
|
365
|
+
this.messages = result.messages;
|
|
366
|
+
// Recorded even at zero: the fold is idempotent, and a log that records the
|
|
367
|
+
// request rather than only its hits keeps meaning the same thing when a
|
|
368
|
+
// later build's denylist recognises something this one did not.
|
|
369
|
+
this.args.recorder?.redact(result.kinds, result.count);
|
|
370
|
+
return { kinds: result.kinds, count: result.count };
|
|
371
|
+
}
|
|
299
372
|
/**
|
|
300
373
|
* Compact `this.messages` only when it has grown past threshold, adopting the
|
|
301
374
|
* result. On success logs a one-line notice and returns the number of older
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's weighted-token budget (P10 track 3 / cli#212).
|
|
3
|
+
*
|
|
4
|
+
* One object, two exposures: `/budget` (what the user sets and reads) and
|
|
5
|
+
* admission control at the fan-out seam (what stops a parallel batch draining a
|
|
6
|
+
* sliding window that refills by trickle). See `session-budget.ts` for why they
|
|
7
|
+
* are not two things.
|
|
8
|
+
*/
|
|
9
|
+
export { SessionBudget, UNRESOLVED_TIER, } from "./session-budget.js";
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { bindingWindow } from "../limits/index.js";
|
|
2
|
+
import { compactTokens } from "../render/units.js";
|
|
3
|
+
import { MAX_TIER_MULTIPLIER, multiplierForTier, weightedFor, } from "../usage/weighted.js";
|
|
4
|
+
/**
|
|
5
|
+
* The tier is not knowable before the request — routing is `auto` and the
|
|
6
|
+
* gateway resolves it. Weighed at the worst case, never skipped.
|
|
7
|
+
*/
|
|
8
|
+
export const UNRESOLVED_TIER = "auto";
|
|
9
|
+
export class SessionBudget {
|
|
10
|
+
opts;
|
|
11
|
+
limits;
|
|
12
|
+
limitWeighted = null;
|
|
13
|
+
spentWeighted = 0;
|
|
14
|
+
/** Requests whose weight could not be computed — surfaced, never assumed 0. */
|
|
15
|
+
unweighed = 0;
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.opts = opts;
|
|
18
|
+
this.limits = opts.limits;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Adopt the server denominator once it exists.
|
|
22
|
+
*
|
|
23
|
+
* A seam rather than a constructor argument because of the order the real
|
|
24
|
+
* wiring happens in: the limits cache needs the resolved provider and key,
|
|
25
|
+
* which the session factory has already consumed by the time it returns, and
|
|
26
|
+
* the TUI attaches the SAME cache a few lines later. Handing both the one
|
|
27
|
+
* instance is the point — a budget reading one cache while the rail panel
|
|
28
|
+
* draws another would be two answers to one question, and they would disagree
|
|
29
|
+
* exactly when the pool is moving.
|
|
30
|
+
*/
|
|
31
|
+
attachLimits(limits) {
|
|
32
|
+
this.limits = limits;
|
|
33
|
+
}
|
|
34
|
+
/** The session cap in weighted tokens, or `null` when the user has set none. */
|
|
35
|
+
get limit() {
|
|
36
|
+
return this.limitWeighted;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Set or clear the cap. SESSION STATE, never written to config — the same rule
|
|
40
|
+
* `/model` and `/mode` follow, and for the strongest version of the reason: a
|
|
41
|
+
* spending cap silently re-applied from a file to every future session would
|
|
42
|
+
* eventually refuse a turn with nothing on screen explaining why.
|
|
43
|
+
*/
|
|
44
|
+
setLimit(weighted) {
|
|
45
|
+
this.limitWeighted = weighted !== null && weighted > 0 ? weighted : null;
|
|
46
|
+
}
|
|
47
|
+
/** Weighted tokens this session has drawn, as far as they could be weighed. */
|
|
48
|
+
get spent() {
|
|
49
|
+
return this.spentWeighted;
|
|
50
|
+
}
|
|
51
|
+
/** Requests this session could not weigh (a non-cruxy provider, or an old gateway). */
|
|
52
|
+
get unweighable() {
|
|
53
|
+
return this.unweighed;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Fold one completed run's usage in, per REQUEST rather than from the run's
|
|
57
|
+
* summed totals — the same discipline `summarizeRuns` states: the multiplier
|
|
58
|
+
* is per-tier, and a run can mix tiers and mix weighable with unweighable
|
|
59
|
+
* requests, so a sum multiplied once would weigh tokens that were never
|
|
60
|
+
* eligible.
|
|
61
|
+
*/
|
|
62
|
+
record(run) {
|
|
63
|
+
for (const e of run.entries) {
|
|
64
|
+
const weighted = weightedFor(e.tier, e.billableInputTokens, e.outputTokens);
|
|
65
|
+
if (weighted === undefined) {
|
|
66
|
+
this.unweighed++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
this.spentWeighted += weighted;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Weighted tokens left on the SESSION cap, or `null` when none is set. */
|
|
73
|
+
remaining() {
|
|
74
|
+
if (this.limitWeighted === null)
|
|
75
|
+
return null;
|
|
76
|
+
return Math.max(0, this.limitWeighted - this.spentWeighted);
|
|
77
|
+
}
|
|
78
|
+
/** The server's denominator, resolved to a statement (see {@link ServerHeadroom}). */
|
|
79
|
+
serverHeadroom() {
|
|
80
|
+
const state = this.limits?.current();
|
|
81
|
+
if (!state)
|
|
82
|
+
return { kind: "unreadable", why: "no limits reading is wired" };
|
|
83
|
+
if (state.status === "pending") {
|
|
84
|
+
return {
|
|
85
|
+
kind: "unreadable",
|
|
86
|
+
why: "the limits reading has not arrived yet",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (state.status === "error") {
|
|
90
|
+
return {
|
|
91
|
+
kind: "unreadable",
|
|
92
|
+
why: `the limits reading is ${state.reason}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const budget = state.reading.budget;
|
|
96
|
+
if (budget.kind === "unenforced")
|
|
97
|
+
return { kind: "uncapped" };
|
|
98
|
+
if (budget.kind !== "pool") {
|
|
99
|
+
// metered / credits / unknown: no weighted pool to bound against. Only
|
|
100
|
+
// `unknown` is a failure, but none of the three yields a weighted window,
|
|
101
|
+
// and inventing one from a dollar balance would be a unit error.
|
|
102
|
+
return {
|
|
103
|
+
kind: "unreadable",
|
|
104
|
+
why: `this account is ${budget.kind}, which has no weighted pool`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const binding = bindingWindow(budget.monthly, budget.burst);
|
|
108
|
+
if (!binding) {
|
|
109
|
+
return {
|
|
110
|
+
kind: "unreadable",
|
|
111
|
+
why: "the pool reported no readable window",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
kind: "window",
|
|
116
|
+
name: binding.name,
|
|
117
|
+
remaining: binding.window.remaining,
|
|
118
|
+
cap: binding.window.cap,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* THE ONE DECISION. Both exposures land here.
|
|
123
|
+
*
|
|
124
|
+
* The arithmetic is deliberately an UPPER BOUND on the draw, not an estimate:
|
|
125
|
+
* `perRunTokens × count × multiplier`. The local ceiling counts input+output
|
|
126
|
+
* including re-sent prompt, while the meter counts `billable_input` (cache
|
|
127
|
+
* reads excluded), so the true draw is lower — often much lower. Erring high is
|
|
128
|
+
* the only safe direction for an admission check: erring low admits a batch
|
|
129
|
+
* that then trips a sliding window which refills by trickle over twelve hours.
|
|
130
|
+
*
|
|
131
|
+
* A tier we cannot weigh (a non-cruxy provider, or routing unresolved) yields
|
|
132
|
+
* NO weighted bound, because there is no honest multiplier to apply. The run
|
|
133
|
+
* proceeds — those tokens never touch the weighted pool.
|
|
134
|
+
*/
|
|
135
|
+
admit(req) {
|
|
136
|
+
const allow = (count, maxTokens) => ({
|
|
137
|
+
kind: "allow",
|
|
138
|
+
count,
|
|
139
|
+
maxTokens,
|
|
140
|
+
});
|
|
141
|
+
const configCap = this.opts.maxTokensPerTurn;
|
|
142
|
+
const perRun = req.perRunTokens > 0 ? req.perRunTokens : configCap > 0 ? configCap : 0;
|
|
143
|
+
const multiplier = req.tier === undefined
|
|
144
|
+
? undefined
|
|
145
|
+
: req.tier === UNRESOLVED_TIER
|
|
146
|
+
? MAX_TIER_MULTIPLIER
|
|
147
|
+
: multiplierForTier(req.tier);
|
|
148
|
+
const allowance = this.allowance();
|
|
149
|
+
// Nothing to enforce against, or nothing to enforce with.
|
|
150
|
+
if (allowance === null || multiplier === undefined || perRun === 0) {
|
|
151
|
+
return allow(req.count, req.perRunTokens);
|
|
152
|
+
}
|
|
153
|
+
if (allowance.weighted <= 0) {
|
|
154
|
+
return { kind: "refused", reason: exhaustedReason(allowance) };
|
|
155
|
+
}
|
|
156
|
+
// How many runs of `perRun` local tokens the allowance covers.
|
|
157
|
+
const perRunWeighted = perRun * multiplier;
|
|
158
|
+
const affordable = Math.floor(allowance.weighted / perRunWeighted);
|
|
159
|
+
if (affordable >= req.count)
|
|
160
|
+
return allow(req.count, req.perRunTokens);
|
|
161
|
+
if (affordable >= 1) {
|
|
162
|
+
return {
|
|
163
|
+
kind: "narrowed",
|
|
164
|
+
count: affordable,
|
|
165
|
+
maxTokens: req.perRunTokens,
|
|
166
|
+
reason: narrowedReason(req.count, affordable, req, allowance, multiplier),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Not even one run at the caller's ceiling. A single run is still admitted,
|
|
170
|
+
// with its token cap cut to what the allowance covers — refusing outright
|
|
171
|
+
// would leave a user with real headroom unable to ask anything at all. A
|
|
172
|
+
// fan-out of many, though, is narrowed to exactly one.
|
|
173
|
+
const cap = Math.max(1, Math.floor(allowance.weighted / multiplier));
|
|
174
|
+
if (req.count === 1) {
|
|
175
|
+
return {
|
|
176
|
+
kind: "narrowed",
|
|
177
|
+
count: 1,
|
|
178
|
+
maxTokens: cap,
|
|
179
|
+
reason: `capped this turn at ${compactTokens(cap)} tokens — ` +
|
|
180
|
+
`${allowanceLabel(allowance)} leaves room for about that much on ${req.tier}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
kind: "narrowed",
|
|
185
|
+
count: 1,
|
|
186
|
+
maxTokens: cap,
|
|
187
|
+
reason: narrowedReason(req.count, 1, req, allowance, multiplier),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The tighter of the two denominators, or `null` when neither bounds anything.
|
|
192
|
+
* Never their sum, and never one standing in for the other.
|
|
193
|
+
*/
|
|
194
|
+
allowance() {
|
|
195
|
+
const session = this.remaining();
|
|
196
|
+
const server = this.serverHeadroom();
|
|
197
|
+
const serverRemaining = server.kind === "window"
|
|
198
|
+
? { weighted: server.remaining, source: server.name }
|
|
199
|
+
: null;
|
|
200
|
+
if (session === null)
|
|
201
|
+
return serverRemaining;
|
|
202
|
+
const local = { weighted: session, source: "session" };
|
|
203
|
+
if (!serverRemaining)
|
|
204
|
+
return local;
|
|
205
|
+
return serverRemaining.weighted < local.weighted ? serverRemaining : local;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function allowanceLabel(a) {
|
|
209
|
+
return a.source === "session"
|
|
210
|
+
? "your /budget for this session"
|
|
211
|
+
: `your ${a.source === "burst" ? "12h burst" : "monthly"} window`;
|
|
212
|
+
}
|
|
213
|
+
function exhaustedReason(a) {
|
|
214
|
+
return a.source === "session"
|
|
215
|
+
? "this session's /budget is used up — raise it with `/budget <n>` or clear it with `/budget off`"
|
|
216
|
+
: `your ${a.source === "burst" ? "12h burst" : "monthly"} window has nothing left`;
|
|
217
|
+
}
|
|
218
|
+
function narrowedReason(asked, granted, req, a, multiplier) {
|
|
219
|
+
const wouldDraw = asked * req.perRunTokens * multiplier;
|
|
220
|
+
return (`narrowed ${asked} → ${granted}: ${asked} concurrent runs could draw up to ` +
|
|
221
|
+
`${compactTokens(wouldDraw)} weighted tokens on ${req.tier}, and ` +
|
|
222
|
+
`${allowanceLabel(a)} has ${compactTokens(a.weighted)} left`);
|
|
223
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { GitCheckpointStore } from "./git-store.js";
|
|
2
|
+
import { CheckpointService, isGitWorkTree } from "./service.js";
|
|
3
|
+
import { listSets, readSet } from "./set.js";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve `--since [id]` against the workspace.
|
|
6
|
+
*
|
|
7
|
+
* With no id: the most recent run, which is the same default `/undo-last` and
|
|
8
|
+
* `cruxy rollback` use — "since the last thing that happened" is the question
|
|
9
|
+
* being asked often enough that making it typed-out is friction for nothing.
|
|
10
|
+
*
|
|
11
|
+
* With an id: a set manifest first (so a background job's id works, C.28), then
|
|
12
|
+
* each root's own checkpoints. A checkpoint id is searched across EVERY declared
|
|
13
|
+
* root rather than only the primary, because a multi-root run writes one
|
|
14
|
+
* checkpoint per root and the id the user copied off a rollback preview may
|
|
15
|
+
* belong to any of them.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveCheckpointDiff(roots, id, config) {
|
|
18
|
+
const primary = roots.find((r) => r.primary) ?? roots[0];
|
|
19
|
+
if (id === undefined) {
|
|
20
|
+
const sets = await listSets(primary.absPath);
|
|
21
|
+
if (sets.length > 0) {
|
|
22
|
+
return {
|
|
23
|
+
kind: "run",
|
|
24
|
+
runId: sets[0].runId,
|
|
25
|
+
targets: await runTargets(sets[0].members, config),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// No set manifest — the pre-C.26 shape, and also a session whose only run
|
|
29
|
+
// touched nothing. Fall back to the primary root's newest checkpoint.
|
|
30
|
+
const newest = (await new CheckpointService({
|
|
31
|
+
root: primary.absPath,
|
|
32
|
+
config,
|
|
33
|
+
}).list())[0];
|
|
34
|
+
if (!newest)
|
|
35
|
+
return { kind: "none" };
|
|
36
|
+
return {
|
|
37
|
+
kind: "checkpoint",
|
|
38
|
+
targets: [await target(primary.name, primary.absPath, newest)],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// A run id first: `readSet` throws CHECKPOINT_SET_INCOMPLETE for a manifest
|
|
42
|
+
// that is missing OR corrupt, and only the second is worth reporting as an
|
|
43
|
+
// error — a plain miss just means the id was a checkpoint id.
|
|
44
|
+
const sets = await listSets(primary.absPath);
|
|
45
|
+
if (sets.some((s) => s.runId === id)) {
|
|
46
|
+
const set = await readSet(primary.absPath, id);
|
|
47
|
+
return {
|
|
48
|
+
kind: "run",
|
|
49
|
+
runId: set.runId,
|
|
50
|
+
targets: await runTargets(set.members, config),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
for (const root of roots) {
|
|
54
|
+
const service = new CheckpointService({ root: root.absPath, config });
|
|
55
|
+
let checkpoint;
|
|
56
|
+
try {
|
|
57
|
+
checkpoint = await service.read(id);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue; // not this root's — try the next
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
kind: "checkpoint",
|
|
64
|
+
targets: [await target(root.name, root.absPath, checkpoint)],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { kind: "not-found", id };
|
|
68
|
+
}
|
|
69
|
+
/** Build one target per set member, in the order the set records them. */
|
|
70
|
+
async function runTargets(members, config) {
|
|
71
|
+
const targets = [];
|
|
72
|
+
for (const member of members) {
|
|
73
|
+
const service = new CheckpointService({
|
|
74
|
+
root: member.rootPath,
|
|
75
|
+
config,
|
|
76
|
+
});
|
|
77
|
+
let checkpoint;
|
|
78
|
+
try {
|
|
79
|
+
checkpoint = await service.read(member.checkpointId);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// A missing member is fatal for a ROLLBACK (validate-all, R3) and merely
|
|
83
|
+
// a gap for a diff. Reporting it per root and showing the rest beats
|
|
84
|
+
// refusing to show anything — this command changes nothing.
|
|
85
|
+
targets.push({
|
|
86
|
+
rootName: member.rootName,
|
|
87
|
+
rootPath: member.rootPath,
|
|
88
|
+
checkpointId: member.checkpointId,
|
|
89
|
+
unavailable: `checkpoint ${member.checkpointId} is missing or unreadable (${err.message})`,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
targets.push(await target(member.rootName, member.rootPath, checkpoint));
|
|
94
|
+
}
|
|
95
|
+
return targets;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* One root's tree-ish, or the reason there isn't one.
|
|
99
|
+
*
|
|
100
|
+
* The two honest failures, both reported rather than papered over:
|
|
101
|
+
* • a SHADOW-store checkpoint — written when the root is not a git work tree,
|
|
102
|
+
* or when git plumbing failed mid-snapshot. Its content is a private blob
|
|
103
|
+
* pool git has never heard of, so there is no tree-ish to hand `git diff`.
|
|
104
|
+
* Rollback still works; only the preview does not.
|
|
105
|
+
* • pruned objects — `treeish` throws what `readContent` throws, and for the
|
|
106
|
+
* same reason.
|
|
107
|
+
*/
|
|
108
|
+
async function target(rootName, rootPath, checkpoint) {
|
|
109
|
+
const base = {
|
|
110
|
+
rootName,
|
|
111
|
+
rootPath,
|
|
112
|
+
checkpointId: checkpoint.id,
|
|
113
|
+
};
|
|
114
|
+
if (checkpoint.store !== "git" || !isGitWorkTree(rootPath)) {
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
unavailable: "this checkpoint was stored outside git (shadow copy), so there is no tree to diff against — " +
|
|
118
|
+
"`cruxy rollback` still restores it",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
treeish: await new GitCheckpointStore(rootPath).treeish(checkpoint.files),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return { ...base, unavailable: err.message };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -83,6 +83,58 @@ export class GitCheckpointStore {
|
|
|
83
83
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Rebuild a checkpoint's tree from its manifest entries and return the tree
|
|
88
|
+
* oid — a tree-ish that `git diff <treeish>` accepts (P10 track 2).
|
|
89
|
+
*
|
|
90
|
+
* WHY REBUILD RATHER THAN STORE. `snapshot` already calls `write-tree` and
|
|
91
|
+
* already throws the resulting oid away, on the grounds that per-file blob oids
|
|
92
|
+
* are what restore needs. Persisting it now would only help checkpoints written
|
|
93
|
+
* after this change and would leave every existing manifest undiffable, so the
|
|
94
|
+
* tree is reassembled instead — from the same manifest, into the same kind of
|
|
95
|
+
* temporary index, producing (by construction) the same oid.
|
|
96
|
+
*
|
|
97
|
+
* SAME FIVE-GIT-COMMAND DISCIPLINE as `snapshot`, and the same enforcement:
|
|
98
|
+
* `update-index --index-info` and `write-tree` both run against a
|
|
99
|
+
* `GIT_INDEX_FILE` in `os.tmpdir()`, no ref is created or moved, and the
|
|
100
|
+
* before/after fingerprint is asserted. This does not even write new objects —
|
|
101
|
+
* every blob it names is one the snapshot already wrote — so the only thing it
|
|
102
|
+
* can produce is a tree that was already there.
|
|
103
|
+
*
|
|
104
|
+
* Throws `CRUXY_E_CHECKPOINT_FAILED` when an object has been pruned out from
|
|
105
|
+
* under the manifest, which is the same failure `readContent` reports and for
|
|
106
|
+
* the same reason (`git gc --prune=now`).
|
|
107
|
+
*/
|
|
108
|
+
async treeish(entries) {
|
|
109
|
+
const before = this.fingerprint();
|
|
110
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "cruxy-ckdiff-"));
|
|
111
|
+
const indexFile = path.join(tmpDir, "index");
|
|
112
|
+
try {
|
|
113
|
+
const env = { GIT_INDEX_FILE: indexFile };
|
|
114
|
+
// `--index-info` with `-z`: "<mode> <oid>\t<path>" records, NUL-terminated,
|
|
115
|
+
// so a path containing a newline or a quote is carried verbatim.
|
|
116
|
+
const stdin = entries
|
|
117
|
+
.map((e) => `${e.mode} ${e.oid}\t${e.path}\0`)
|
|
118
|
+
.join("");
|
|
119
|
+
const add = this.git(["update-index", "-z", "--index-info"], {
|
|
120
|
+
env,
|
|
121
|
+
input: stdin,
|
|
122
|
+
});
|
|
123
|
+
if (!add.ok) {
|
|
124
|
+
throw checkpointFailed(`rebuilding the checkpoint tree failed — its objects may have been pruned ` +
|
|
125
|
+
`by \`git gc --prune\` (${add.stderr.trim()})`);
|
|
126
|
+
}
|
|
127
|
+
const tree = this.git(["write-tree"], { env });
|
|
128
|
+
if (!tree.ok) {
|
|
129
|
+
throw checkpointFailed(`writing the checkpoint tree failed: ${tree.stderr.trim()}`);
|
|
130
|
+
}
|
|
131
|
+
assertGitStateUnchanged(before, this.fingerprint());
|
|
132
|
+
return tree.stdout.toString("utf8").trim();
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
86
138
|
async readContent(entry) {
|
|
87
139
|
const res = this.git(["cat-file", "blob", entry.oid]);
|
|
88
140
|
if (!res.ok) {
|
package/dist/checkpoint/index.js
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { CheckpointService } from "./service.js";
|
|
3
|
+
import { listSets } from "./set.js";
|
|
4
|
+
import { applySet, buildSetPreview, setIsNoop, validateSet, } from "./set-rollback.js";
|
|
5
|
+
/**
|
|
6
|
+
* Restore every touched root of one run's {@link CheckpointSet} as a single gated
|
|
7
|
+
* operation (C.26): validate-all up front, ONE combined preview behind ONE
|
|
8
|
+
* approval, then a sequential apply that stops and reports on first failure.
|
|
9
|
+
*/
|
|
10
|
+
export async function rollbackSet(set, deps) {
|
|
11
|
+
const { report } = deps;
|
|
12
|
+
const t = report.theme;
|
|
13
|
+
// Validate-all BEFORE any apply — a missing/corrupt member throws here.
|
|
14
|
+
const members = await validateSet(set, deps.config);
|
|
15
|
+
if (setIsNoop(members)) {
|
|
16
|
+
report.print(t.muted(`working tree already matches run ${set.runId} — nothing to roll back`));
|
|
17
|
+
return { kind: "noop" };
|
|
18
|
+
}
|
|
19
|
+
const decision = await deps.requestApproval({
|
|
20
|
+
kind: "rollback",
|
|
21
|
+
preview: buildSetPreview(set, members),
|
|
22
|
+
});
|
|
23
|
+
if (!decision.allow) {
|
|
24
|
+
report.print(t.muted("rollback declined — nothing was changed"));
|
|
25
|
+
return { kind: "declined" };
|
|
26
|
+
}
|
|
27
|
+
const applied = await applySet(set, members);
|
|
28
|
+
const parts = applied.restored.map((name) => {
|
|
29
|
+
const counts = applied.perRoot[name];
|
|
30
|
+
return `${name} (${counts.reverted} reverted, ${counts.recreated} recreated, ${counts.deleted} deleted)`;
|
|
31
|
+
});
|
|
32
|
+
report.print(`${t.success(t.glyph.success)} restored run ${t.accent(set.runId)} across ` +
|
|
33
|
+
`${applied.restored.length} root${applied.restored.length === 1 ? "" : "s"} — ${parts.join("; ")}`);
|
|
34
|
+
printCaveat(report);
|
|
35
|
+
return { kind: "applied", roots: applied.restored.length };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Single-root rollback (C.32): restore ONE root to one checkpoint. Reached for an
|
|
39
|
+
* explicit checkpoint id (the per-member escape hatch — including when the
|
|
40
|
+
* primary root was removed mid-session and its set index is gone, ⚖︎#7) and as
|
|
41
|
+
* the JC-F back-compat fallback for a run that predates set manifests.
|
|
42
|
+
*
|
|
43
|
+
* `id` omitted → the newest checkpoint for that root.
|
|
44
|
+
*/
|
|
45
|
+
export async function rollbackCheckpoint(root, id, deps) {
|
|
46
|
+
const { report } = deps;
|
|
47
|
+
const t = report.theme;
|
|
48
|
+
const service = new CheckpointService({ root, config: deps.config });
|
|
49
|
+
const result = await service.rollback(id, {
|
|
50
|
+
requestApproval: deps.requestApproval,
|
|
51
|
+
interactive: deps.interactive,
|
|
52
|
+
});
|
|
53
|
+
if (result.kind === "noop") {
|
|
54
|
+
report.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
|
|
55
|
+
return { kind: "noop" };
|
|
56
|
+
}
|
|
57
|
+
if (result.kind === "rejected") {
|
|
58
|
+
report.print(t.muted("rollback declined — nothing was changed"));
|
|
59
|
+
return { kind: "declined" };
|
|
60
|
+
}
|
|
61
|
+
const { recreated, reverted, deleted } = result.applied;
|
|
62
|
+
report.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
|
|
63
|
+
`${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
|
|
64
|
+
printCaveat(report);
|
|
65
|
+
return { kind: "applied", roots: 1 };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Roll back the MOST RECENT run under `primaryRoot`, with no picker and no id to
|
|
69
|
+
* supply — the shape `/undo-last` needs, and the shape `cruxy rollback` already
|
|
70
|
+
* had for its no-argument case once its picker declines to appear.
|
|
71
|
+
*
|
|
72
|
+
* Prefers the set manifest, which is the only record that knows how many roots a
|
|
73
|
+
* run touched; falls back to the newest single-root checkpoint (logged through
|
|
74
|
+
* the reporter, never silently) for a run that predates set manifests.
|
|
75
|
+
*
|
|
76
|
+
* Returns `null` when there is nothing recorded at all — a fact the caller
|
|
77
|
+
* phrases, because "no checkpoints yet" means something different at a shell
|
|
78
|
+
* prompt than it does three turns into a session.
|
|
79
|
+
*/
|
|
80
|
+
export async function rollbackLatestRun(primaryRoot, deps) {
|
|
81
|
+
const sets = await listSets(primaryRoot); // newest first
|
|
82
|
+
if (sets.length > 0)
|
|
83
|
+
return rollbackSet(sets[0], deps);
|
|
84
|
+
const service = new CheckpointService({
|
|
85
|
+
root: primaryRoot,
|
|
86
|
+
config: deps.config,
|
|
87
|
+
});
|
|
88
|
+
if ((await service.list()).length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
deps.report.print(deps.report.theme.muted(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`));
|
|
91
|
+
return rollbackCheckpoint(primaryRoot, undefined, deps);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The one thing a successful rollback must always say. A user who has just
|
|
95
|
+
* watched a run be undone will reasonably assume it was undone; the parts that
|
|
96
|
+
* left the working tree were never in the checkpoint to begin with.
|
|
97
|
+
*/
|
|
98
|
+
function printCaveat(report) {
|
|
99
|
+
report.print(report.theme.muted("note: commits, pushes, and PRs made during the run are not undone"));
|
|
100
|
+
}
|