@basein/runner 0.2.11 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +540 -350
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -10
- package/dist/bin/bir-scenario.js +704 -135
- package/dist/bin/bir.d.ts +14 -8
- package/dist/bin/bir.js +2937 -488
- package/dist/bin/investigate.js +41 -16
- package/dist/bin/scenario-edit.d.ts +43 -10
- package/dist/bin/scenario-edit.js +281 -80
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2803 -306
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +371 -20
- package/dist/replay/controller.js +1185 -111
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1244
- package/docs/calculatedReplayGuide.md +1212 -879
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -271
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
|
|
8
8
|
* [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
|
|
9
9
|
* bir scenario edits <runId|scnId>
|
|
10
|
-
* bir scenario undo <runId|scnId> [--edit <sedit_id>]
|
|
11
|
-
* bir scenario calc <runId> [--force [--discard-edits]]
|
|
10
|
+
* bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
|
|
11
|
+
* bir scenario calc <runId|scnId> [--force [--discard-edits]]
|
|
12
12
|
* bir scenario editing on|off|status
|
|
13
13
|
*
|
|
14
14
|
* WHY THIS IS A CLIENT AND NOTHING MORE. Whether a change may be saved is
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { normalizePath } from "../control/paths.js";
|
|
32
32
|
import { SCENARIO_SERVER_KEY, editingEnabled, projectRecord, readSidecar, setEditing, writeSidecar, } from "../config/generate.js";
|
|
33
33
|
import { errText } from "../util/log.js";
|
|
34
|
+
import { ReadClock, jsonText, readWithin, serviceProblem } from "./service-read.js";
|
|
34
35
|
/** The `bir scenario` words this module answers; `list` and `replay` stay in bir.ts. */
|
|
35
36
|
export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
|
|
36
37
|
"show",
|
|
@@ -41,7 +42,13 @@ export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
|
|
|
41
42
|
"calc",
|
|
42
43
|
"editing",
|
|
43
44
|
]);
|
|
44
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The `bir` MCP server's tools that only read. Offered with scenario editing
|
|
47
|
+
* on, or where the server's environment has `BIR_SCENARIO_TOOLS=read` — no
|
|
48
|
+
* longer in every session: their definitions ride along with every request a
|
|
49
|
+
* session makes, and only somebody working on a scenario ever calls them (the
|
|
50
|
+
* same answers are `bir scenario show` and `bir investigate` in a terminal).
|
|
51
|
+
*/
|
|
45
52
|
export const READ_TOOLS = ["scenario_show", "scenario_edits", "investigate"];
|
|
46
53
|
/** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
|
|
47
54
|
export const EDIT_TOOLS = ["scenario_check", "scenario_edit", "scenario_undo"];
|
|
@@ -51,8 +58,8 @@ const USAGE = {
|
|
|
51
58
|
edit: "usage: bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]\n" +
|
|
52
59
|
' [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>] [--json]',
|
|
53
60
|
edits: "usage: bir scenario edits <runId|scnId> [--json]",
|
|
54
|
-
undo:
|
|
55
|
-
calc: "usage: bir scenario calc <runId> [--force [--discard-edits]]",
|
|
61
|
+
undo: 'usage: bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"] [--json]',
|
|
62
|
+
calc: "usage: bir scenario calc <runId|scnId> [--force [--discard-edits]]",
|
|
56
63
|
editing: "usage: bir scenario editing on|off|status",
|
|
57
64
|
};
|
|
58
65
|
export async function scenarioEditCommand(args, deps) {
|
|
@@ -79,7 +86,11 @@ export async function scenarioEditCommand(args, deps) {
|
|
|
79
86
|
/**
|
|
80
87
|
* The raw JSON, as it always was — scripts and the guide read it. A `scn_` id
|
|
81
88
|
* goes to `GET /scenarios/:id`, which also serves sub-task scenarios (they have
|
|
82
|
-
* no run of their own); `--step` narrows the print to that one step object
|
|
89
|
+
* no run of their own); `--step` narrows the print to that one step object,
|
|
90
|
+
* with the scenario's id and `chainRevision` put in front of it. The revision
|
|
91
|
+
* is a scenario's, not a step's, and it is what `edit --revision` (and the MCP
|
|
92
|
+
* `scenario_edit`'s `revision`) takes: a step printed without it sent the model
|
|
93
|
+
* that had just read the step off to guess one (C20).
|
|
83
94
|
*/
|
|
84
95
|
async function show(args, deps) {
|
|
85
96
|
const { out } = deps;
|
|
@@ -87,7 +98,7 @@ async function show(args, deps) {
|
|
|
87
98
|
if (!target)
|
|
88
99
|
return usageError(deps, "show");
|
|
89
100
|
const byScenario = target.startsWith("scn_");
|
|
90
|
-
const reply = await deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`);
|
|
101
|
+
const reply = await readWithin(deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`));
|
|
91
102
|
if ("error" in reply || reply.status !== 200) {
|
|
92
103
|
return explain(reply, args, deps, {
|
|
93
104
|
verb: "Could not read the scenario",
|
|
@@ -98,14 +109,13 @@ async function show(args, deps) {
|
|
|
98
109
|
});
|
|
99
110
|
}
|
|
100
111
|
if (args.step === undefined) {
|
|
101
|
-
out(
|
|
112
|
+
out(jsonText(reply.body));
|
|
102
113
|
return 0;
|
|
103
114
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
}
|
|
115
|
+
const served = reply.body;
|
|
116
|
+
const scenario = served?.scenario;
|
|
117
|
+
if (!scenario)
|
|
118
|
+
return noWholeRunScenario(target, served?.segments, args, deps);
|
|
109
119
|
if (!Array.isArray(scenario.steps)) {
|
|
110
120
|
out(`${scenario.id ?? target} is ${scenario.state ?? "not ready"}, so it has no steps to show yet.`);
|
|
111
121
|
return 1;
|
|
@@ -116,7 +126,7 @@ async function show(args, deps) {
|
|
|
116
126
|
out(`${scenario.id ?? target} has no step ${args.step}${indexes.length ? ` (its steps are ${Math.min(...indexes)}–${Math.max(...indexes)})` : " (it has no steps)"}.`);
|
|
117
127
|
return 1;
|
|
118
128
|
}
|
|
119
|
-
out(
|
|
129
|
+
out(jsonText({ scenarioId: scenario.id ?? null, chainRevision: scenario.chainRevision ?? null, ...step }));
|
|
120
130
|
return 0;
|
|
121
131
|
}
|
|
122
132
|
/**
|
|
@@ -181,19 +191,28 @@ async function checkOrEdit(args, deps, save) {
|
|
|
181
191
|
return explain(reply, args, deps, { verb: "Not checked", ...explained });
|
|
182
192
|
const report = reply.body?.report;
|
|
183
193
|
if (args.json) {
|
|
184
|
-
out(
|
|
185
|
-
return report?.ok ? 0 : 1;
|
|
194
|
+
out(jsonText(reply.body));
|
|
195
|
+
return report?.ok || report?.noChange ? 0 : 1;
|
|
186
196
|
}
|
|
187
197
|
if (!report) {
|
|
188
198
|
out("The service answered the check without a report.");
|
|
189
199
|
return 1;
|
|
190
200
|
}
|
|
191
201
|
renderReport(out, report);
|
|
202
|
+
// Before `ok`: a change that is already the step's would pass the check and
|
|
203
|
+
// then be answered `nothing_to_change` by `edit` — so `check` says so
|
|
204
|
+
// itself, instead of printing an `edit` command that cannot save (C15).
|
|
205
|
+
if (report.noChange) {
|
|
206
|
+
out("Nothing would change: the step already has this code and mark.");
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
192
209
|
if (report.ok) {
|
|
193
210
|
out(`Nothing saved. To save: ${editCommand(target, args)}`);
|
|
194
211
|
return 0;
|
|
195
212
|
}
|
|
196
|
-
refusal(out, report
|
|
213
|
+
refusal(out, report, "Nothing saved, and `bir scenario edit` would refuse it", report.budgetExceeded
|
|
214
|
+
? ' Make the code cheaper to run and check again. If the recording itself was wrong, edit can save it on purpose: add --force --note "why".'
|
|
215
|
+
: ' Fix the code and check again. If the recording itself was wrong, edit can save it on purpose: add --force --note "why".');
|
|
197
216
|
return 1;
|
|
198
217
|
}
|
|
199
218
|
if (args.freeze)
|
|
@@ -213,13 +232,15 @@ async function checkOrEdit(args, deps, save) {
|
|
|
213
232
|
return explain(reply, args, deps, { verb: "Not saved", ...explained });
|
|
214
233
|
}
|
|
215
234
|
if (args.json) {
|
|
216
|
-
out(
|
|
235
|
+
out(jsonText(reply.body));
|
|
217
236
|
return refused ? 1 : 0;
|
|
218
237
|
}
|
|
219
238
|
if (answer?.report)
|
|
220
|
-
renderReport(out, answer.report);
|
|
221
|
-
if (refused) {
|
|
222
|
-
refusal(out, answer
|
|
239
|
+
renderReport(out, answer.report, { brief: true });
|
|
240
|
+
if (refused && answer?.report) {
|
|
241
|
+
refusal(out, answer.report, "Not saved", answer.report.budgetExceeded
|
|
242
|
+
? ' Make the code cheaper to run and try again, or save it on purpose: add --force --note "why".'
|
|
243
|
+
: ' Fix the code and try again, or save it on purpose: add --force --note "why".');
|
|
223
244
|
return 1;
|
|
224
245
|
}
|
|
225
246
|
const edit = answer?.edit;
|
|
@@ -283,12 +304,28 @@ const label = (name, text) => ` ${name.padEnd(9)}${text}`;
|
|
|
283
304
|
* The report in the lines editSteps.md "What a person sees" shows:
|
|
284
305
|
* `input`, `output` (when the output logic changed), `later`, `answer` (when
|
|
285
306
|
* the final answer read anything before) and `mark`.
|
|
307
|
+
*
|
|
308
|
+
* `check` prints every line: it is where a person explores a change, and "step
|
|
309
|
+
* 5 still reproduces" is an answer there. `edit` and `undo` are acts, and print
|
|
310
|
+
* `brief`ly — what changed and what went wrong: `input` always, `output` when
|
|
311
|
+
* it changed, `later` only for a step that broke, `answer` only when it lost its
|
|
312
|
+
* values, `mark` only when it moves. The report is the same one either way (D11);
|
|
313
|
+
* only the lines that say "nothing to see" are left out.
|
|
314
|
+
*
|
|
315
|
+
* An `undo` does not judge the step itself — its code and mark go back exactly
|
|
316
|
+
* as they were (editSteps.md, "Undo") — so its report's input status is
|
|
317
|
+
* `not_checked` by design, and printing the usual reason for that ("the
|
|
318
|
+
* recorded call is plain text") would be false. It says what happened instead.
|
|
286
319
|
*/
|
|
287
|
-
export function renderReport(out, r) {
|
|
320
|
+
export function renderReport(out, r, opts = {}) {
|
|
321
|
+
const brief = opts.brief === true;
|
|
288
322
|
out(`Step ${r.stepIndex} (${r.toolName ?? "?"}) — checked against ${r.sourceRunId}`);
|
|
289
323
|
const i = r.input ?? { changed: false, before: "not_checked", after: "not_checked" };
|
|
290
324
|
const unchanged = i.changed ? "" : " (input logic unchanged)";
|
|
291
|
-
if (
|
|
325
|
+
if (opts.undo) {
|
|
326
|
+
out(label("input", i.changed ? "back as it was before that edit" : "unchanged"));
|
|
327
|
+
}
|
|
328
|
+
else if (i.after === "differs") {
|
|
292
329
|
const d = i.firstDifference ?? firstDifference(i.computed, i.recorded);
|
|
293
330
|
out(label("input", `differs from the recorded call${d ? ` at character ${d.at}` : ""}${unchanged}`));
|
|
294
331
|
if (d) {
|
|
@@ -314,8 +351,18 @@ export function renderReport(out, r) {
|
|
|
314
351
|
}
|
|
315
352
|
const later = r.later ?? [];
|
|
316
353
|
const regressed = later.filter((l) => l.regressed);
|
|
317
|
-
if (
|
|
318
|
-
|
|
354
|
+
if (brief && regressed.length === 0) {
|
|
355
|
+
// Nothing broke: an act has nothing to say about later steps.
|
|
356
|
+
}
|
|
357
|
+
else if (later.length === 0) {
|
|
358
|
+
// The service judges later steps only when what they read can change: an
|
|
359
|
+
// input-only change (or a mark) leaves them alone, and marked or
|
|
360
|
+
// unverifiable later steps are never judged (editSteps.md rule 6).
|
|
361
|
+
out(label("later", r.output?.changed
|
|
362
|
+
? "none to check"
|
|
363
|
+
: r.input?.changed
|
|
364
|
+
? "not affected: only the input logic changed"
|
|
365
|
+
: "not affected: the code did not change"));
|
|
319
366
|
}
|
|
320
367
|
else if (regressed.length === 0) {
|
|
321
368
|
const still = later.filter((l) => l.after === "reproduces").map((l) => l.stepIndex);
|
|
@@ -330,18 +377,21 @@ export function renderReport(out, r) {
|
|
|
330
377
|
out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) reproduced before; now it ${laterPhrase(l.after)}`));
|
|
331
378
|
}
|
|
332
379
|
}
|
|
333
|
-
if (r.answer?.informativeBefore) {
|
|
380
|
+
if (r.answer?.informativeBefore && !(brief && r.answer.informativeAfter)) {
|
|
334
381
|
out(label("answer", r.answer.informativeAfter
|
|
335
382
|
? "still gets its values"
|
|
336
383
|
: "no longer gets its values: the final answer's logic stops finding what it reads"));
|
|
337
384
|
}
|
|
338
385
|
if (r.mark?.before && r.mark.after) {
|
|
339
386
|
const { before, after } = r.mark;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
387
|
+
const moves = before.marked !== after.marked || before.why !== after.why;
|
|
388
|
+
if (moves || !brief) {
|
|
389
|
+
out(label("mark", moves
|
|
390
|
+
? `${markWord(before)} → ${markWord(after)}`
|
|
391
|
+
: before.marked
|
|
392
|
+
? `${markWord(before)} → stays marked`
|
|
393
|
+
: "runs by itself"));
|
|
394
|
+
}
|
|
345
395
|
}
|
|
346
396
|
}
|
|
347
397
|
function inputPhrase(status, verifiable) {
|
|
@@ -413,8 +463,27 @@ function sentence(problem) {
|
|
|
413
463
|
const lowered = /^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text;
|
|
414
464
|
return /[.!?]$/.test(lowered) ? lowered : `${lowered}.`;
|
|
415
465
|
}
|
|
416
|
-
|
|
417
|
-
|
|
466
|
+
/**
|
|
467
|
+
* The verdict under a refused report. When the one thing wrong is that the
|
|
468
|
+
* changed input logic does not compute the recorded call, the `input` line
|
|
469
|
+
* above has already said where it parts, and the verdict says it in the words
|
|
470
|
+
* editSteps.md shows: "the change does not reproduce the recording". Anything
|
|
471
|
+
* else — a copy, a later step, the answer, the mark, *too costly to check* —
|
|
472
|
+
* is the service's own sentence, one per problem.
|
|
473
|
+
*/
|
|
474
|
+
function refusal(out, report, opening, hint) {
|
|
475
|
+
const problems = report.problems ?? [];
|
|
476
|
+
const inputOnly = problems.length === 1 &&
|
|
477
|
+
report.input?.changed === true &&
|
|
478
|
+
(report.input.after === "differs" || report.input.after === "throws") &&
|
|
479
|
+
!report.budgetExceeded &&
|
|
480
|
+
!(report.later ?? []).some((l) => l.regressed) &&
|
|
481
|
+
!(report.answer?.informativeBefore && !report.answer.informativeAfter);
|
|
482
|
+
const list = inputOnly
|
|
483
|
+
? ["the change does not reproduce the recording"]
|
|
484
|
+
: problems.length
|
|
485
|
+
? problems
|
|
486
|
+
: ["the check did not pass"];
|
|
418
487
|
if (list.length === 1) {
|
|
419
488
|
out(`${opening}: ${sentence(list[0])}`);
|
|
420
489
|
}
|
|
@@ -427,7 +496,7 @@ function refusal(out, problems, opening, hint) {
|
|
|
427
496
|
}
|
|
428
497
|
// ── edits / undo ──────────────────────────────────────────────────────────────
|
|
429
498
|
async function fetchEdits(scenarioId, deps) {
|
|
430
|
-
const reply = await deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`);
|
|
499
|
+
const reply = await readWithin(deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`));
|
|
431
500
|
if ("error" in reply || reply.status !== 200)
|
|
432
501
|
return { reply };
|
|
433
502
|
const edits = reply.body?.edits;
|
|
@@ -438,15 +507,18 @@ async function listEdits(args, deps) {
|
|
|
438
507
|
const target = args.positionals[1];
|
|
439
508
|
if (!target)
|
|
440
509
|
return usageError(deps, "edits");
|
|
441
|
-
|
|
510
|
+
// A `run_` id is two reads — which scenario, then its history — inside one
|
|
511
|
+
// bound: the `bir` MCP server gives a reading tool one read's worth of time.
|
|
512
|
+
const clock = new ReadClock();
|
|
513
|
+
const resolved = await resolveTarget(target, args, deps, clock);
|
|
442
514
|
if ("code" in resolved)
|
|
443
515
|
return resolved.code;
|
|
444
|
-
const reply = await deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`);
|
|
516
|
+
const reply = await clock.within(deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`));
|
|
445
517
|
if ("error" in reply || reply.status !== 200) {
|
|
446
518
|
return explain(reply, args, deps, { verb: "Could not read the history", target });
|
|
447
519
|
}
|
|
448
520
|
if (args.json) {
|
|
449
|
-
out(
|
|
521
|
+
out(jsonText(reply.body));
|
|
450
522
|
return 0;
|
|
451
523
|
}
|
|
452
524
|
const edits = reply.body?.edits ?? [];
|
|
@@ -489,17 +561,28 @@ function editSummary(e) {
|
|
|
489
561
|
return parts.length ? parts.join("; ") : "no change to the code or the mark";
|
|
490
562
|
}
|
|
491
563
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
564
|
+
* What `undo` without `--edit` takes back (D9): the newest **edit** — never an
|
|
565
|
+
* undo entry — that nothing has put back and no recalculation replaced; of
|
|
566
|
+
* step `step` when it is given.
|
|
567
|
+
*
|
|
568
|
+
* Undo entries are skipped on purpose. They used to be picked like any other,
|
|
569
|
+
* so a second `undo` redid the first one, and a step edited twice toggled
|
|
570
|
+
* between its two edits for ever and never got back to the calculation's code
|
|
571
|
+
* (C12). Now a second `undo` goes one further back; a redo is asked for by
|
|
572
|
+
* name, `--edit <the undo's id>`, which the undo prints.
|
|
573
|
+
*
|
|
574
|
+
* Whether the step is still exactly as that edit left it is the service's
|
|
575
|
+
* question, not this list's: it answers `not_latest_edit` when it is not.
|
|
496
576
|
*/
|
|
497
|
-
export function newestUndoable(edits) {
|
|
577
|
+
export function newestUndoable(edits, step) {
|
|
498
578
|
// Newest first, as the service lists them; sorted again so a reordering
|
|
499
579
|
// there cannot make this undo the wrong one.
|
|
500
580
|
return [...edits]
|
|
501
581
|
.sort((x, y) => (x.createdAt < y.createdAt ? 1 : x.createdAt > y.createdAt ? -1 : 0))
|
|
502
|
-
.find((e) =>
|
|
582
|
+
.find((e) => e.kind !== "revert" &&
|
|
583
|
+
!e.revertedAt &&
|
|
584
|
+
!e.replacedAt &&
|
|
585
|
+
(step === undefined || e.stepIndex === step));
|
|
503
586
|
}
|
|
504
587
|
async function undo(args, deps) {
|
|
505
588
|
const { out } = deps;
|
|
@@ -509,6 +592,15 @@ async function undo(args, deps) {
|
|
|
509
592
|
if (args.edit !== undefined && !/^sedit_[\w-]+$/.test(args.edit)) {
|
|
510
593
|
return usageError(deps, "undo", `--edit takes a sedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
|
|
511
594
|
}
|
|
595
|
+
if (args.edit !== undefined && args.step !== undefined) {
|
|
596
|
+
return usageError(deps, "undo", "--edit names one entry and --step picks one; give one of them.");
|
|
597
|
+
}
|
|
598
|
+
const note = args.note?.trim() ? args.note : undefined;
|
|
599
|
+
if (args.force && !note) {
|
|
600
|
+
// As for `edit`: an undo the check refused is taken back only on purpose,
|
|
601
|
+
// and the note is the only record of why.
|
|
602
|
+
return usageError(deps, "undo", '--force needs --note "why" (note_required): an undo made on purpose says why, and the history keeps it.');
|
|
603
|
+
}
|
|
512
604
|
const resolved = await resolveTarget(target, args, deps);
|
|
513
605
|
if ("code" in resolved)
|
|
514
606
|
return resolved.code;
|
|
@@ -517,34 +609,61 @@ async function undo(args, deps) {
|
|
|
517
609
|
const listed = await fetchEdits(resolved.id, deps);
|
|
518
610
|
if ("reply" in listed)
|
|
519
611
|
return explain(listed.reply, args, deps, { verb: "Could not read the history", target });
|
|
520
|
-
const pick = newestUndoable(listed.edits);
|
|
612
|
+
const pick = newestUndoable(listed.edits, args.step);
|
|
521
613
|
if (!pick) {
|
|
522
|
-
return localFailure(args, deps, "nothing_to_undo",
|
|
614
|
+
return localFailure(args, deps, "nothing_to_undo", args.step === undefined
|
|
615
|
+
? `Nothing to undo: ${target} has no hand edit that can be undone.`
|
|
616
|
+
: `Nothing to undo: step ${args.step} of ${target} has no hand edit that can be undone.`);
|
|
523
617
|
}
|
|
524
618
|
editId = pick.id;
|
|
525
619
|
}
|
|
526
620
|
const body = {};
|
|
527
|
-
if (
|
|
528
|
-
body.note =
|
|
621
|
+
if (note)
|
|
622
|
+
body.note = note;
|
|
623
|
+
if (args.force)
|
|
624
|
+
body.force = true;
|
|
529
625
|
if (args.revision !== undefined)
|
|
530
626
|
body.expectedRevision = args.revision;
|
|
531
627
|
const reply = await deps.service("POST", `/scenarios/${seg(resolved.id)}/edits/${seg(editId)}/revert`, body);
|
|
532
|
-
|
|
628
|
+
const answer = ("error" in reply ? undefined : reply.body);
|
|
629
|
+
const refused = !("error" in reply) && reply.status === 422 && Boolean(answer?.report);
|
|
630
|
+
if ("error" in reply || (reply.status !== 200 && !refused)) {
|
|
533
631
|
return explain(reply, args, deps, {
|
|
534
632
|
verb: "Not undone",
|
|
535
633
|
target,
|
|
634
|
+
editId,
|
|
536
635
|
notFound: `Not undone (not_found): ${target} has no edit ${editId}, or it is not yours.`,
|
|
537
636
|
});
|
|
538
637
|
}
|
|
539
638
|
if (args.json) {
|
|
540
|
-
out(
|
|
541
|
-
return 0;
|
|
639
|
+
out(jsonText(reply.body));
|
|
640
|
+
return refused ? 1 : 0;
|
|
542
641
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
642
|
+
if (answer?.report)
|
|
643
|
+
renderReport(out, answer.report, { brief: true, undo: true });
|
|
644
|
+
if (refused && answer?.report) {
|
|
645
|
+
// The undo would break a later step or the answer (D9): the same check a
|
|
646
|
+
// save runs, and the same way past it.
|
|
647
|
+
refusal(out, answer.report, "Not undone", ' To undo it anyway, on purpose: add --force --note "why".');
|
|
648
|
+
return 1;
|
|
649
|
+
}
|
|
650
|
+
const entry = answer?.edit;
|
|
651
|
+
const revision = answer?.chainRevision ?? entry?.chainRevisionAfter;
|
|
546
652
|
out(`Step ${entry?.stepIndex ?? "?"} is back as it was before ${entry?.revertsEditId ?? editId} (revision ${revision ?? "?"}).` +
|
|
547
653
|
(entry?.id ? ` Redo: bir scenario undo ${target} --edit ${entry.id}` : ""));
|
|
654
|
+
// Undo walks back (D9): the same command again goes one further. An undo
|
|
655
|
+
// asked for by id names its step, so "further back" stays on that step.
|
|
656
|
+
const further = args.step !== undefined
|
|
657
|
+
? ` --step ${args.step}`
|
|
658
|
+
: args.edit !== undefined && typeof entry?.stepIndex === "number"
|
|
659
|
+
? ` --step ${entry.stepIndex}`
|
|
660
|
+
: "";
|
|
661
|
+
out(` One further back: bir scenario undo ${target}${further}`);
|
|
662
|
+
if (entry?.forced) {
|
|
663
|
+
out(" Undone on purpose (--force), although the check refused it.");
|
|
664
|
+
for (const p of answer?.report?.problems ?? [])
|
|
665
|
+
out(` The check said: ${sentence(p)}`);
|
|
666
|
+
}
|
|
548
667
|
return 0;
|
|
549
668
|
}
|
|
550
669
|
// ── calc ──────────────────────────────────────────────────────────────────────
|
|
@@ -552,6 +671,13 @@ async function undo(args, deps) {
|
|
|
552
671
|
* `calc` lives here because of one answer: a recalculation rebuilds every
|
|
553
672
|
* step from the recording, so the service refuses `--force` on a plan with
|
|
554
673
|
* hand edits (D4) unless `--discard-edits` says to throw them away.
|
|
674
|
+
*
|
|
675
|
+
* A `run_` id posts `/recordings/runs/:id/calculate`, which reaches only the
|
|
676
|
+
* run's **whole-run** scenario. A `scn_` id posts `/scenarios/:id/recalculate`
|
|
677
|
+
* — the only way to recalculate a sub-task (segment) scenario from here, and
|
|
678
|
+
* so the only way to take a segment's hand edits back once undo cannot (C12).
|
|
679
|
+
* A scenario id always names one that exists, so it is always a
|
|
680
|
+
* recalculation in place, and asks for `--force` as the run form does.
|
|
555
681
|
*/
|
|
556
682
|
async function calc(args, deps) {
|
|
557
683
|
const { out } = deps;
|
|
@@ -561,20 +687,30 @@ async function calc(args, deps) {
|
|
|
561
687
|
if (args.discardEdits && !args.force) {
|
|
562
688
|
return usageError(deps, "calc", "--discard-edits only means something with --force (a recalculation in place).");
|
|
563
689
|
}
|
|
564
|
-
const
|
|
690
|
+
const byScenario = target.startsWith("scn_");
|
|
691
|
+
if (byScenario && !args.force) {
|
|
692
|
+
return usageError(deps, "calc", `${target} is a scenario already, so calculating it means recalculating it in place: add --force (and --discard-edits to replace steps edited by hand).`);
|
|
693
|
+
}
|
|
694
|
+
const reply = byScenario
|
|
695
|
+
? await deps.service("POST", `/scenarios/${seg(target)}/recalculate`, args.discardEdits ? { discardEdits: true } : {})
|
|
696
|
+
: await deps.service("POST", `/recordings/runs/${seg(target)}/calculate`, args.force ? { force: true, ...(args.discardEdits ? { discardEdits: true } : {}) } : {});
|
|
565
697
|
if ("error" in reply)
|
|
566
698
|
return explain(reply, args, deps, { verb: "Could not start calculation", target });
|
|
567
699
|
if (args.json) {
|
|
568
|
-
out(
|
|
700
|
+
out(jsonText(reply.body ?? null));
|
|
569
701
|
return reply.status === 202 ? 0 : 1;
|
|
570
702
|
}
|
|
571
703
|
const { status, body } = reply;
|
|
572
704
|
const code = body?.error;
|
|
573
705
|
if (status === 202) {
|
|
574
706
|
const b = body;
|
|
575
|
-
out(
|
|
707
|
+
out(`${byScenario ? "Recalculating" : "Calculating"} ${b.scenarioId ?? target} — poll with \`bir scenario show ${target}\`.`);
|
|
576
708
|
return 0;
|
|
577
709
|
}
|
|
710
|
+
if (status === 404) {
|
|
711
|
+
out(byScenario ? `No such scenario, or it is not yours: ${target}.` : `No such run, or it is not yours: ${target}.`);
|
|
712
|
+
return 1;
|
|
713
|
+
}
|
|
578
714
|
if (status === 409 && code === "scenario_has_edits") {
|
|
579
715
|
const steps = body.editedSteps;
|
|
580
716
|
const list = Array.isArray(steps) ? steps.map(String) : [];
|
|
@@ -588,15 +724,26 @@ async function calc(args, deps) {
|
|
|
588
724
|
out("Another calculation of yours is running, and the service calculates one at a time. Try again when it is ready.");
|
|
589
725
|
return 1;
|
|
590
726
|
}
|
|
591
|
-
|
|
727
|
+
// With --force the run route answers `scenario_exists` for one reason only:
|
|
728
|
+
// that scenario is being calculated right now. `scenario_calculating` is the
|
|
729
|
+
// scenario route's word for the same thing (C16).
|
|
730
|
+
if (status === 409 && (code === "scenario_calculating" || (code === "scenario_exists" && args.force))) {
|
|
731
|
+
out(`A calculation of ${target} is already running. Wait for it to finish (\`bir scenario show ${target}\`).`);
|
|
732
|
+
return 1;
|
|
733
|
+
}
|
|
734
|
+
if (status === 409 && code === "scenario_exists") {
|
|
592
735
|
out("That run already has a scenario. Re-derive it in place with --force.");
|
|
593
736
|
return 1;
|
|
594
737
|
}
|
|
595
|
-
if (status === 503) {
|
|
738
|
+
if (status === 503 && code === "calculation_paused_today") {
|
|
739
|
+
out("Calculation is paused for today: the service has reached its daily calculation budget. Try again tomorrow.");
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
if (status === 503 && code === "anthropic_not_configured") {
|
|
596
743
|
out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
|
|
597
744
|
return 1;
|
|
598
745
|
}
|
|
599
|
-
out(`Could not start calculation (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
746
|
+
out(`Could not start calculation (HTTP ${status}${code ? `, ${code}` : ""}): ${JSON.stringify(body)}`);
|
|
600
747
|
return 1;
|
|
601
748
|
}
|
|
602
749
|
// ── editing on|off|status ─────────────────────────────────────────────────────
|
|
@@ -634,11 +781,19 @@ function describeEditing(out, sidecar, cwd) {
|
|
|
634
781
|
if (!hasScenarioServer(sidecar, cwd)) {
|
|
635
782
|
out(" (no `bir` MCP server is installed here, so no tool is offered at all — `bir install --replay` adds it)");
|
|
636
783
|
}
|
|
637
|
-
|
|
638
|
-
|
|
784
|
+
// The reading tools come with the switch too: they are for whoever is
|
|
785
|
+
// working on a scenario, and every other session would pay for their
|
|
786
|
+
// definitions on every request without ever calling them.
|
|
787
|
+
if (on) {
|
|
788
|
+
out(` the bir MCP server offers run_scenario, ${READ_TOOLS.join(", ")} (they read)`);
|
|
639
789
|
out(` and ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
|
|
640
|
-
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
out(" the bir MCP server offers run_scenario");
|
|
641
793
|
out(` \`bir scenario editing on\` adds ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
|
|
794
|
+
out(` and ${READ_TOOLS.join(", ")} (they read)`);
|
|
795
|
+
out(" (BIR_SCENARIO_TOOLS=read in the server's environment offers the reading ones alone)");
|
|
796
|
+
}
|
|
642
797
|
out(" The `bir scenario` commands work either way: this switch decides only the MCP tools.");
|
|
643
798
|
}
|
|
644
799
|
function hasScenarioServer(sidecar, cwd) {
|
|
@@ -654,20 +809,41 @@ function usageError(deps, sub, why) {
|
|
|
654
809
|
deps.out(USAGE[sub] ?? "usage: bir scenario …");
|
|
655
810
|
return 2;
|
|
656
811
|
}
|
|
657
|
-
/**
|
|
658
|
-
|
|
659
|
-
|
|
812
|
+
/**
|
|
813
|
+
* A failure this side decided on; `--json` still gets JSON, in the service's
|
|
814
|
+
* `{error, details}` shape (with `extra` beside them when there is more to say).
|
|
815
|
+
*/
|
|
816
|
+
function localFailure(args, deps, code, text, extra = {}) {
|
|
817
|
+
deps.out(args.json ? jsonText({ error: code, details: text, ...extra }) : text);
|
|
660
818
|
return 1;
|
|
661
819
|
}
|
|
820
|
+
/**
|
|
821
|
+
* A run with no whole-run scenario. When sub-task (segment) scenarios were
|
|
822
|
+
* calculated from it, they are listed with their `scn_` ids — the only ids
|
|
823
|
+
* they have — instead of suggesting a calculation, which would start a new
|
|
824
|
+
* whole-run plan beside them rather than reach the one the person meant (C21).
|
|
825
|
+
*/
|
|
826
|
+
function noWholeRunScenario(target, segments, args, deps) {
|
|
827
|
+
const listed = (Array.isArray(segments) ? segments : []).filter((s) => typeof s?.id === "string");
|
|
828
|
+
if (listed.length === 0) {
|
|
829
|
+
return localFailure(args, deps, "no_scenario", `${target} has no calculated scenario yet — \`bir scenario calc ${target}\` first.`);
|
|
830
|
+
}
|
|
831
|
+
const range = (s) => typeof s.stepFrom === "number" && typeof s.stepTo === "number" ? ` steps ${s.stepFrom}–${s.stepTo} of the recording` : "";
|
|
832
|
+
const text = [
|
|
833
|
+
`${target} has no whole-run scenario, only sub-task scenarios — and a sub-task scenario is addressed by its own scn_ id:`,
|
|
834
|
+
...listed.map((s) => ` ${s.id}${range(s)}`),
|
|
835
|
+
].join("\n");
|
|
836
|
+
return localFailure(args, deps, "no_whole_run_scenario", text, { segments: listed.map((s) => s.id) });
|
|
837
|
+
}
|
|
662
838
|
/**
|
|
663
839
|
* A `run_` id is the recording's; the routes that change a scenario take its
|
|
664
|
-
* `scn_` id (D7). A `
|
|
665
|
-
* nothing else.
|
|
840
|
+
* `scn_` id (D7). A `run_` id reaches only the run's **whole-run** scenario; a
|
|
841
|
+
* `scn_` id is used as it is — sub-task scenarios have nothing else.
|
|
666
842
|
*/
|
|
667
|
-
async function resolveTarget(target, args, deps) {
|
|
843
|
+
async function resolveTarget(target, args, deps, clock = new ReadClock()) {
|
|
668
844
|
if (target.startsWith("scn_"))
|
|
669
845
|
return { id: target };
|
|
670
|
-
const reply = await deps.service("GET", `/recordings/runs/${seg(target)}/scenario`);
|
|
846
|
+
const reply = await clock.within(deps.service("GET", `/recordings/runs/${seg(target)}/scenario`));
|
|
671
847
|
if ("error" in reply || reply.status !== 200) {
|
|
672
848
|
return {
|
|
673
849
|
code: explain(reply, args, deps, {
|
|
@@ -677,12 +853,10 @@ async function resolveTarget(target, args, deps) {
|
|
|
677
853
|
}),
|
|
678
854
|
};
|
|
679
855
|
}
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
};
|
|
685
|
-
}
|
|
856
|
+
const served = reply.body;
|
|
857
|
+
const id = served?.scenario?.id;
|
|
858
|
+
if (typeof id !== "string")
|
|
859
|
+
return { code: noWholeRunScenario(target, served?.segments, args, deps) };
|
|
686
860
|
return { id };
|
|
687
861
|
}
|
|
688
862
|
/**
|
|
@@ -693,14 +867,18 @@ async function resolveTarget(target, args, deps) {
|
|
|
693
867
|
function explain(reply, args, deps, e) {
|
|
694
868
|
const { out } = deps;
|
|
695
869
|
if ("error" in reply) {
|
|
870
|
+
// In words, not the transport's ("fetch failed"): this is what reaches a
|
|
871
|
+
// model through the `bir` MCP server. A person at a terminal also gets the
|
|
872
|
+
// raw reason in brackets, for a bug report.
|
|
873
|
+
const words = serviceProblem(reply.error);
|
|
696
874
|
if (args.json)
|
|
697
|
-
out(
|
|
875
|
+
out(jsonText({ error: "service_unreachable", details: words }));
|
|
698
876
|
else
|
|
699
|
-
out(`Could not reach the service: ${reply.error}`);
|
|
877
|
+
out(`Could not reach the service: ${words}${words === reply.error ? "" : ` (${reply.error})`}`);
|
|
700
878
|
return 1;
|
|
701
879
|
}
|
|
702
880
|
if (args.json) {
|
|
703
|
-
out(
|
|
881
|
+
out(jsonText(reply.body ?? null));
|
|
704
882
|
return 1;
|
|
705
883
|
}
|
|
706
884
|
const body = (reply.body && typeof reply.body === "object" ? reply.body : {});
|
|
@@ -716,6 +894,12 @@ function explain(reply, args, deps, e) {
|
|
|
716
894
|
out(`${e.verb}: the service did not accept this machine's session (HTTP 401) — run \`bir login\`.`);
|
|
717
895
|
return 1;
|
|
718
896
|
}
|
|
897
|
+
if (reply.status === 413) {
|
|
898
|
+
// By status, not code: a proxy in front of the service answers 413 with a
|
|
899
|
+
// page of its own, and the cause is the same.
|
|
900
|
+
out(`${e.verb} (payload_too_large): the request is larger than the service accepts. A logic body may be at most 20 000 characters, and a note 500.`);
|
|
901
|
+
return 1;
|
|
902
|
+
}
|
|
719
903
|
switch (code) {
|
|
720
904
|
case "invalid_input": {
|
|
721
905
|
const details = brief(body.details);
|
|
@@ -726,7 +910,14 @@ function explain(reply, args, deps, e) {
|
|
|
726
910
|
out(`${e.verb} (not_a_tool_step): step ${e.step ?? "?"} is a sub-task call row, not a tool step. Call rows belong to the service; only tool steps can be edited.`);
|
|
727
911
|
return 1;
|
|
728
912
|
case "nothing_to_change":
|
|
729
|
-
|
|
913
|
+
// The empty command is refused on this side before any request, so a
|
|
914
|
+
// service `nothing_to_change` always means the change is already the
|
|
915
|
+
// step's — naming the flags again would tell the person to repeat
|
|
916
|
+
// themselves (C15, C27).
|
|
917
|
+
out("Nothing to change: the step already has this code and this mark.");
|
|
918
|
+
return 1;
|
|
919
|
+
case "logic_sandbox_unavailable":
|
|
920
|
+
out(`${e.verb} (logic_sandbox_unavailable): the service could not run step code just now, so nothing was checked, saved or undone. Try again in a minute.`);
|
|
730
921
|
return 1;
|
|
731
922
|
case "note_required":
|
|
732
923
|
out(`${e.verb} (note_required): --force needs --note "why".`);
|
|
@@ -741,9 +932,19 @@ function explain(reply, args, deps, e) {
|
|
|
741
932
|
return 1;
|
|
742
933
|
}
|
|
743
934
|
case "not_latest_edit": {
|
|
935
|
+
// Undo walks back (D9), so this is a real way through now: undoing the
|
|
936
|
+
// newer entry puts the step back as this one left it, and then this one
|
|
937
|
+
// can be undone. It used to loop, when undo only toggled (C23).
|
|
744
938
|
const latest = typeof body.latestEditId === "string" ? body.latestEditId : "<the newer edit>";
|
|
745
|
-
out(`${e.verb} (not_latest_edit):
|
|
939
|
+
out(`${e.verb} (not_latest_edit): the step has changed since that edit — ${latest} changed it after. Undo the newer one first, then this one:`);
|
|
746
940
|
out(` bir scenario undo ${target} --edit ${latest}`);
|
|
941
|
+
out(` bir scenario undo ${target} --edit ${e.editId ?? "<this edit>"}`);
|
|
942
|
+
return 1;
|
|
943
|
+
}
|
|
944
|
+
case "already_undone": {
|
|
945
|
+
const by = typeof body.revertedById === "string" ? body.revertedById : undefined;
|
|
946
|
+
out(`${e.verb} (already_undone): ${e.editId ?? "that edit"} was undone already${by ? `, by ${by}` : ""}. To redo it, undo that undo:`);
|
|
947
|
+
out(` bir scenario undo ${target} --edit ${by ?? "<the undo's sedit_ id, from bir scenario edits>"}`);
|
|
747
948
|
return 1;
|
|
748
949
|
}
|
|
749
950
|
case "edit_replaced":
|