@basein/runner 0.2.8 → 0.2.11
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 +86 -22
- package/dist/auth/client.d.ts +40 -1
- package/dist/auth/client.js +77 -9
- package/dist/bin/bir-hooks.d.ts +18 -3
- package/dist/bin/bir-hooks.js +124 -38
- package/dist/bin/bir-scenario.d.ts +18 -2
- package/dist/bin/bir-scenario.js +374 -4
- package/dist/bin/bir.d.ts +12 -0
- package/dist/bin/bir.js +501 -81
- package/dist/bin/investigate.js +1 -1
- package/dist/bin/scenario-edit.d.ts +173 -0
- package/dist/bin/scenario-edit.js +771 -0
- package/dist/bin/setup.d.ts +72 -0
- package/dist/bin/setup.js +286 -0
- package/dist/config/adapters/claude-code.d.ts +90 -4
- package/dist/config/adapters/claude-code.js +164 -16
- package/dist/config/generate.d.ts +114 -1
- package/dist/config/generate.js +106 -3
- package/dist/control/client.d.ts +5 -0
- package/dist/control/client.js +8 -0
- package/dist/control/daemon.d.ts +116 -0
- package/dist/control/daemon.js +339 -0
- package/dist/control/discovery.d.ts +26 -0
- package/dist/control/discovery.js +41 -9
- package/dist/control/ensure-hook.d.ts +39 -0
- package/dist/control/ensure-hook.js +98 -0
- package/dist/control/paths.d.ts +14 -0
- package/dist/control/paths.js +20 -0
- package/dist/control/server.d.ts +28 -0
- package/dist/control/server.js +15 -2
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +28 -6
- package/docs/calculatedReplay.md +51 -0
- package/docs/calculatedReplayGuide.md +471 -74
- package/docs/installRun.md +457 -111
- package/docs/loginWeb.md +1 -1
- package/docs/quickstart.md +195 -158
- package/package.json +2 -1
- package/scripts/install.ps1 +669 -0
- package/scripts/install.sh +586 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scenario-edit — read, check, change and undo one step of a calculated
|
|
3
|
+
* scenario (editSteps.md in the BaseIn repository; docs/calculatedReplayGuide.md §9.2).
|
|
4
|
+
*
|
|
5
|
+
* bir scenario show <runId|scnId> [--step <n>]
|
|
6
|
+
* bir scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
|
|
7
|
+
* bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
|
|
8
|
+
* [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
|
|
9
|
+
* bir scenario edits <runId|scnId>
|
|
10
|
+
* bir scenario undo <runId|scnId> [--edit <sedit_id>]
|
|
11
|
+
* bir scenario calc <runId> [--force [--discard-edits]]
|
|
12
|
+
* bir scenario editing on|off|status
|
|
13
|
+
*
|
|
14
|
+
* WHY THIS IS A CLIENT AND NOTHING MORE. Whether a change may be saved is
|
|
15
|
+
* decided on the service, against the recording, by the calculation's own
|
|
16
|
+
* check (D3) — and `check` and `edit` are the same function there (D11). A
|
|
17
|
+
* runner-side pre-check would be a second opinion that can disagree with the
|
|
18
|
+
* one that counts, so this module reads logic from files, sends it, and says
|
|
19
|
+
* in words what the service answered. `--json` prints the answer unchanged,
|
|
20
|
+
* which is what the `bir` MCP server hands the model.
|
|
21
|
+
*
|
|
22
|
+
* Logic never comes from the command line itself. A JavaScript body is full
|
|
23
|
+
* of quotes, braces and `$`, and every shell mangles a different subset of
|
|
24
|
+
* them; a file, or stdin with `-`, arrives byte for byte.
|
|
25
|
+
*
|
|
26
|
+
* Everything the commands touch is injected ({@link ScenarioEditDeps}), as in
|
|
27
|
+
* `investigate.ts`, so all of it is tested in-process against a fake service.
|
|
28
|
+
* The exception is `editing`, which writes the install sidecar — tests point
|
|
29
|
+
* `BIR_HOME` at a temporary directory for that.
|
|
30
|
+
*/
|
|
31
|
+
import { normalizePath } from "../control/paths.js";
|
|
32
|
+
import { SCENARIO_SERVER_KEY, editingEnabled, projectRecord, readSidecar, setEditing, writeSidecar, } from "../config/generate.js";
|
|
33
|
+
import { errText } from "../util/log.js";
|
|
34
|
+
/** The `bir scenario` words this module answers; `list` and `replay` stay in bir.ts. */
|
|
35
|
+
export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
|
|
36
|
+
"show",
|
|
37
|
+
"check",
|
|
38
|
+
"edit",
|
|
39
|
+
"edits",
|
|
40
|
+
"undo",
|
|
41
|
+
"calc",
|
|
42
|
+
"editing",
|
|
43
|
+
]);
|
|
44
|
+
/** The `bir` MCP server's tools that only read: always offered (D2). */
|
|
45
|
+
export const READ_TOOLS = ["scenario_show", "scenario_edits", "investigate"];
|
|
46
|
+
/** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
|
|
47
|
+
export const EDIT_TOOLS = ["scenario_check", "scenario_edit", "scenario_undo"];
|
|
48
|
+
const USAGE = {
|
|
49
|
+
show: "usage: bir scenario show <runId|scnId> [--step <n>]",
|
|
50
|
+
check: "usage: bir scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze] [--json]",
|
|
51
|
+
edit: "usage: bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]\n" +
|
|
52
|
+
' [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>] [--json]',
|
|
53
|
+
edits: "usage: bir scenario edits <runId|scnId> [--json]",
|
|
54
|
+
undo: "usage: bir scenario undo <runId|scnId> [--edit <sedit_id>] [--json]",
|
|
55
|
+
calc: "usage: bir scenario calc <runId> [--force [--discard-edits]]",
|
|
56
|
+
editing: "usage: bir scenario editing on|off|status",
|
|
57
|
+
};
|
|
58
|
+
export async function scenarioEditCommand(args, deps) {
|
|
59
|
+
switch (args.positionals[0] ?? "") {
|
|
60
|
+
case "show":
|
|
61
|
+
return show(args, deps);
|
|
62
|
+
case "check":
|
|
63
|
+
return checkOrEdit(args, deps, false);
|
|
64
|
+
case "edit":
|
|
65
|
+
return checkOrEdit(args, deps, true);
|
|
66
|
+
case "edits":
|
|
67
|
+
return listEdits(args, deps);
|
|
68
|
+
case "undo":
|
|
69
|
+
return undo(args, deps);
|
|
70
|
+
case "calc":
|
|
71
|
+
return calc(args, deps);
|
|
72
|
+
case "editing":
|
|
73
|
+
return editing(args, deps);
|
|
74
|
+
default:
|
|
75
|
+
deps.out("usage: bir scenario <list|show|calc|check|edit|edits|undo|editing|replay> …");
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The raw JSON, as it always was — scripts and the guide read it. A `scn_` id
|
|
81
|
+
* 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.
|
|
83
|
+
*/
|
|
84
|
+
async function show(args, deps) {
|
|
85
|
+
const { out } = deps;
|
|
86
|
+
const target = args.positionals[1];
|
|
87
|
+
if (!target)
|
|
88
|
+
return usageError(deps, "show");
|
|
89
|
+
const byScenario = target.startsWith("scn_");
|
|
90
|
+
const reply = await deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`);
|
|
91
|
+
if ("error" in reply || reply.status !== 200) {
|
|
92
|
+
return explain(reply, args, deps, {
|
|
93
|
+
verb: "Could not read the scenario",
|
|
94
|
+
target,
|
|
95
|
+
notFound: byScenario
|
|
96
|
+
? `No such scenario, or it is not yours: ${target}.`
|
|
97
|
+
: "No scenario for that run yet — `bir scenario calc <runId>` first.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (args.step === undefined) {
|
|
101
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
const scenario = reply.body?.scenario;
|
|
105
|
+
if (!scenario) {
|
|
106
|
+
out(`${target} has no calculated scenario yet — \`bir scenario calc ${target}\` first.`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(scenario.steps)) {
|
|
110
|
+
out(`${scenario.id ?? target} is ${scenario.state ?? "not ready"}, so it has no steps to show yet.`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
const step = scenario.steps.find((s) => s.stepIndex === args.step);
|
|
114
|
+
if (!step) {
|
|
115
|
+
const indexes = scenario.steps.map((s) => s.stepIndex).filter((n) => typeof n === "number");
|
|
116
|
+
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
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
out(JSON.stringify(step, null, 2));
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* `check` and `edit` share everything up to the request, on purpose: the
|
|
124
|
+
* service runs the same function for both (D11), so the same flags must build
|
|
125
|
+
* the same body. `check` asks and writes nothing; `edit` writes only what the
|
|
126
|
+
* check approves — or, with `--force` and a note, what it did not.
|
|
127
|
+
*/
|
|
128
|
+
async function checkOrEdit(args, deps, save) {
|
|
129
|
+
const sub = save ? "edit" : "check";
|
|
130
|
+
const { out } = deps;
|
|
131
|
+
const target = args.positionals[1];
|
|
132
|
+
if (!target)
|
|
133
|
+
return usageError(deps, sub);
|
|
134
|
+
if (args.step === undefined) {
|
|
135
|
+
return usageError(deps, sub, "Which step? Give --step <n>, the stepIndex `bir scenario show` prints.");
|
|
136
|
+
}
|
|
137
|
+
if (!save && (args.freeze || args.force)) {
|
|
138
|
+
return usageError(deps, sub, "`check` never saves, so --freeze and --force belong to `bir scenario edit`.");
|
|
139
|
+
}
|
|
140
|
+
if (args.freeze && args.unfreeze) {
|
|
141
|
+
return usageError(deps, sub, "--freeze and --unfreeze ask for opposite things; give one of them.");
|
|
142
|
+
}
|
|
143
|
+
const note = args.note?.trim() ? args.note : undefined;
|
|
144
|
+
if (save && args.force && !note) {
|
|
145
|
+
// Before any request: a forced save is the one kind the check did not
|
|
146
|
+
// approve, and the note is the only record of why somebody wanted it.
|
|
147
|
+
return usageError(deps, sub, '--force needs --note "why" (note_required): a change saved on purpose says why, and the history keeps it.');
|
|
148
|
+
}
|
|
149
|
+
if (!args.inputLogic && !args.outputLogic && !args.unfreeze && !(save && args.freeze)) {
|
|
150
|
+
return usageError(deps, sub, save
|
|
151
|
+
? "Nothing to change: give --input-logic <file>, --output-logic <file>, --freeze or --unfreeze."
|
|
152
|
+
: "Nothing to check: give --input-logic <file>, --output-logic <file> or --unfreeze.");
|
|
153
|
+
}
|
|
154
|
+
if (args.inputLogic === "-" && args.outputLogic === "-") {
|
|
155
|
+
return usageError(deps, sub, "Only one of --input-logic and --output-logic can read stdin (-); put the other in a file.");
|
|
156
|
+
}
|
|
157
|
+
// Read the files before any request, so a typo in a path costs nothing.
|
|
158
|
+
const body = {};
|
|
159
|
+
const sources = [
|
|
160
|
+
[args.inputLogic, "input logic", "toolInputLogic"],
|
|
161
|
+
[args.outputLogic, "output logic", "toolOutputLogic"],
|
|
162
|
+
];
|
|
163
|
+
for (const [spec, what, key] of sources) {
|
|
164
|
+
if (spec === undefined)
|
|
165
|
+
continue;
|
|
166
|
+
const read = await readLogic(spec, what, deps);
|
|
167
|
+
if ("problem" in read)
|
|
168
|
+
return usageError(deps, sub, read.problem);
|
|
169
|
+
body[key] = read.text;
|
|
170
|
+
}
|
|
171
|
+
const resolved = await resolveTarget(target, args, deps);
|
|
172
|
+
if ("code" in resolved)
|
|
173
|
+
return resolved.code;
|
|
174
|
+
const path = `/scenarios/${seg(resolved.id)}/steps/${args.step}`;
|
|
175
|
+
const explained = { target, step: args.step };
|
|
176
|
+
if (!save) {
|
|
177
|
+
if (args.unfreeze)
|
|
178
|
+
body.unfreeze = true;
|
|
179
|
+
const reply = await deps.service("POST", `${path}/check`, body);
|
|
180
|
+
if ("error" in reply || reply.status !== 200)
|
|
181
|
+
return explain(reply, args, deps, { verb: "Not checked", ...explained });
|
|
182
|
+
const report = reply.body?.report;
|
|
183
|
+
if (args.json) {
|
|
184
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
185
|
+
return report?.ok ? 0 : 1;
|
|
186
|
+
}
|
|
187
|
+
if (!report) {
|
|
188
|
+
out("The service answered the check without a report.");
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
renderReport(out, report);
|
|
192
|
+
if (report.ok) {
|
|
193
|
+
out(`Nothing saved. To save: ${editCommand(target, args)}`);
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
refusal(out, report.problems ?? [], "Nothing saved, and `bir scenario edit` would refuse it", ' Fix the code and check again. If the recording itself was wrong, edit can save it on purpose: add --force --note "why".');
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
if (args.freeze)
|
|
200
|
+
body.freeze = true;
|
|
201
|
+
else if (args.unfreeze)
|
|
202
|
+
body.freeze = false;
|
|
203
|
+
if (args.force)
|
|
204
|
+
body.force = true;
|
|
205
|
+
if (note)
|
|
206
|
+
body.note = note;
|
|
207
|
+
if (args.revision !== undefined)
|
|
208
|
+
body.expectedRevision = args.revision;
|
|
209
|
+
const reply = await deps.service("PATCH", path, body);
|
|
210
|
+
const answer = ("error" in reply ? undefined : reply.body);
|
|
211
|
+
const refused = !("error" in reply) && reply.status === 422 && Boolean(answer?.report);
|
|
212
|
+
if ("error" in reply || (reply.status !== 200 && !refused)) {
|
|
213
|
+
return explain(reply, args, deps, { verb: "Not saved", ...explained });
|
|
214
|
+
}
|
|
215
|
+
if (args.json) {
|
|
216
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
217
|
+
return refused ? 1 : 0;
|
|
218
|
+
}
|
|
219
|
+
if (answer?.report)
|
|
220
|
+
renderReport(out, answer.report);
|
|
221
|
+
if (refused) {
|
|
222
|
+
refusal(out, answer?.report?.problems ?? [], "Not saved", ' Fix the code and try again, or save it on purpose: add --force --note "why".');
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
const edit = answer?.edit;
|
|
226
|
+
const revision = answer?.chainRevision ?? edit?.chainRevisionAfter;
|
|
227
|
+
out(`Saved as ${edit?.id ?? "(no id)"} (revision ${revision ?? "?"}).` +
|
|
228
|
+
(edit?.id ? ` Undo: bir scenario undo ${target} --edit ${edit.id}` : ""));
|
|
229
|
+
if (edit?.forced) {
|
|
230
|
+
out(" Saved on purpose (--force): `bir investigate` keeps reporting it as forced.");
|
|
231
|
+
for (const p of answer?.report?.problems ?? [])
|
|
232
|
+
out(` The check said: ${sentence(p)}`);
|
|
233
|
+
}
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
/** What `check` printed, turned into the `edit` that would save it. */
|
|
237
|
+
function editCommand(target, args) {
|
|
238
|
+
const parts = ["bir scenario edit", target, "--step", String(args.step)];
|
|
239
|
+
if (args.inputLogic)
|
|
240
|
+
parts.push("--input-logic", quoteArg(args.inputLogic));
|
|
241
|
+
if (args.outputLogic)
|
|
242
|
+
parts.push("--output-logic", quoteArg(args.outputLogic));
|
|
243
|
+
if (args.unfreeze)
|
|
244
|
+
parts.push("--unfreeze");
|
|
245
|
+
return parts.join(" ");
|
|
246
|
+
}
|
|
247
|
+
function quoteArg(value) {
|
|
248
|
+
return /\s/.test(value) ? `"${value}"` : value;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A logic file's text. UTF-8, with a byte-order mark dropped — and UTF-16 with
|
|
252
|
+
* its mark decoded, because that is what `"…" > step.js` writes in Windows
|
|
253
|
+
* PowerShell 5.1, and a service answering "SyntaxError" to a file that looks
|
|
254
|
+
* fine in every editor is a riddle nobody should have to solve.
|
|
255
|
+
*/
|
|
256
|
+
export function decodeLogic(bytes) {
|
|
257
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
|
|
258
|
+
return bytes.subarray(2).toString("utf16le");
|
|
259
|
+
const text = bytes.toString("utf8");
|
|
260
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
261
|
+
}
|
|
262
|
+
async function readLogic(spec, what, deps) {
|
|
263
|
+
const from = spec === "-" ? "stdin" : spec;
|
|
264
|
+
let text;
|
|
265
|
+
try {
|
|
266
|
+
text = decodeLogic(spec === "-" ? await deps.readStdin() : deps.readFile(spec));
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
return { problem: `Could not read the ${what} from ${from}: ${errText(err)}` };
|
|
270
|
+
}
|
|
271
|
+
// Trailing whitespace is a file's last newline, not code; the history reads
|
|
272
|
+
// better without it, and `check` and `edit` trim it the same way.
|
|
273
|
+
text = text.replace(/\s+$/, "");
|
|
274
|
+
if (!text)
|
|
275
|
+
return { problem: `The ${what} from ${from} is empty.` };
|
|
276
|
+
return { text };
|
|
277
|
+
}
|
|
278
|
+
// ── rendering the report ──────────────────────────────────────────────────────
|
|
279
|
+
/** Characters of context shown on either side of the first difference. */
|
|
280
|
+
const CONTEXT = 40;
|
|
281
|
+
const label = (name, text) => ` ${name.padEnd(9)}${text}`;
|
|
282
|
+
/**
|
|
283
|
+
* The report in the lines editSteps.md "What a person sees" shows:
|
|
284
|
+
* `input`, `output` (when the output logic changed), `later`, `answer` (when
|
|
285
|
+
* the final answer read anything before) and `mark`.
|
|
286
|
+
*/
|
|
287
|
+
export function renderReport(out, r) {
|
|
288
|
+
out(`Step ${r.stepIndex} (${r.toolName ?? "?"}) — checked against ${r.sourceRunId}`);
|
|
289
|
+
const i = r.input ?? { changed: false, before: "not_checked", after: "not_checked" };
|
|
290
|
+
const unchanged = i.changed ? "" : " (input logic unchanged)";
|
|
291
|
+
if (i.after === "differs") {
|
|
292
|
+
const d = i.firstDifference ?? firstDifference(i.computed, i.recorded);
|
|
293
|
+
out(label("input", `differs from the recorded call${d ? ` at character ${d.at}` : ""}${unchanged}`));
|
|
294
|
+
if (d) {
|
|
295
|
+
out(` computed ${around(d.computed, d.at)}`);
|
|
296
|
+
out(` recorded ${around(d.recorded, d.at)}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else if (i.after === "throws") {
|
|
300
|
+
out(label("input", `throws: ${i.error ?? "(no message)"}${unchanged}`));
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
out(label("input", `${inputPhrase(i.after, r.verifiable)}${unchanged}`));
|
|
304
|
+
}
|
|
305
|
+
if (r.output?.changed) {
|
|
306
|
+
const o = r.output;
|
|
307
|
+
out(label("output", o.status === "runs"
|
|
308
|
+
? `runs; keeps ${o.emittedKeys?.length ? o.emittedKeys.join(", ") : "nothing"}`
|
|
309
|
+
: o.status === "throws"
|
|
310
|
+
? `throws: ${o.error ?? "(no message)"}`
|
|
311
|
+
: o.status === "not_object"
|
|
312
|
+
? "does not return an object"
|
|
313
|
+
: "unchanged"));
|
|
314
|
+
}
|
|
315
|
+
const later = r.later ?? [];
|
|
316
|
+
const regressed = later.filter((l) => l.regressed);
|
|
317
|
+
if (later.length === 0) {
|
|
318
|
+
out(label("later", "no later steps"));
|
|
319
|
+
}
|
|
320
|
+
else if (regressed.length === 0) {
|
|
321
|
+
const still = later.filter((l) => l.after === "reproduces").map((l) => l.stepIndex);
|
|
322
|
+
out(label("later", still.length === 0
|
|
323
|
+
? "no later step reproduced before, so none can break"
|
|
324
|
+
: still.length === 1
|
|
325
|
+
? `step ${still[0]} still reproduces`
|
|
326
|
+
: `steps ${still.join(", ")} still reproduce`));
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
for (const l of regressed) {
|
|
330
|
+
out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) reproduced before; now it ${laterPhrase(l.after)}`));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (r.answer?.informativeBefore) {
|
|
334
|
+
out(label("answer", r.answer.informativeAfter
|
|
335
|
+
? "still gets its values"
|
|
336
|
+
: "no longer gets its values: the final answer's logic stops finding what it reads"));
|
|
337
|
+
}
|
|
338
|
+
if (r.mark?.before && r.mark.after) {
|
|
339
|
+
const { before, after } = r.mark;
|
|
340
|
+
out(label("mark", before.marked === after.marked && before.why === after.why
|
|
341
|
+
? before.marked
|
|
342
|
+
? `${markWord(before)} → stays marked`
|
|
343
|
+
: "runs by itself"
|
|
344
|
+
: `${markWord(before)} → ${markWord(after)}`));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function inputPhrase(status, verifiable) {
|
|
348
|
+
switch (status) {
|
|
349
|
+
case "reproduces":
|
|
350
|
+
return "reproduces the recorded call";
|
|
351
|
+
case "copy":
|
|
352
|
+
return "is a copy: it returns the recorded call whatever the parameters say";
|
|
353
|
+
case "not_checked":
|
|
354
|
+
return verifiable
|
|
355
|
+
? "not checked: the recorded call is plain text, not an object"
|
|
356
|
+
: "not checked: the step comes after a sub-task call, whose outputs the check cannot rebuild";
|
|
357
|
+
case "differs":
|
|
358
|
+
return "differs from the recorded call";
|
|
359
|
+
case "throws":
|
|
360
|
+
return "throws";
|
|
361
|
+
default:
|
|
362
|
+
return String(status);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function laterPhrase(status) {
|
|
366
|
+
switch (status) {
|
|
367
|
+
case "differs":
|
|
368
|
+
return "differs from its recorded call";
|
|
369
|
+
case "throws":
|
|
370
|
+
return "throws";
|
|
371
|
+
case "copy":
|
|
372
|
+
return "is a copy of its recorded call";
|
|
373
|
+
case "not_checked":
|
|
374
|
+
return "cannot be checked";
|
|
375
|
+
default:
|
|
376
|
+
return status;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** A report's `MarkState` or a history entry's `StepState`, in the words the report uses. */
|
|
380
|
+
const markWord = (m) => (m.marked ?? m.nondeterministic)
|
|
381
|
+
? `needs a judgement (${m.why ?? m.nondeterministicWhy ?? "?"})`
|
|
382
|
+
: "runs by itself";
|
|
383
|
+
/**
|
|
384
|
+
* The report's `firstDifference`, or the same computed here from `computed` and
|
|
385
|
+
* `recorded` when the service sent only those: whitespace-squashed JSON text,
|
|
386
|
+
* as the service compares it.
|
|
387
|
+
*/
|
|
388
|
+
function firstDifference(computed, recorded) {
|
|
389
|
+
if (computed === undefined || recorded === undefined)
|
|
390
|
+
return undefined;
|
|
391
|
+
const squash = (v) => (JSON.stringify(v) ?? String(v)).replace(/\s+/g, " ");
|
|
392
|
+
const a = squash(computed);
|
|
393
|
+
const b = squash(recorded);
|
|
394
|
+
let at = 0;
|
|
395
|
+
while (at < a.length && at < b.length && a[at] === b[at])
|
|
396
|
+
at += 1;
|
|
397
|
+
return { at, computed: a, recorded: b };
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* About {@link CONTEXT} characters either side of `at`. A text already that
|
|
401
|
+
* short — a window the service cut itself, or a small call — is printed whole.
|
|
402
|
+
*/
|
|
403
|
+
export function around(text, at) {
|
|
404
|
+
if (text.length <= 2 * CONTEXT + 2)
|
|
405
|
+
return text;
|
|
406
|
+
const from = Math.max(0, Math.min(at, text.length) - CONTEXT);
|
|
407
|
+
const to = Math.min(text.length, from + 2 * CONTEXT);
|
|
408
|
+
return `${from > 0 ? "…" : ""}${text.slice(from, to)}${to < text.length ? "…" : ""}`;
|
|
409
|
+
}
|
|
410
|
+
/** One problem sentence, continuing a line that already opened with "Not saved:". */
|
|
411
|
+
function sentence(problem) {
|
|
412
|
+
const text = problem.trim();
|
|
413
|
+
const lowered = /^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text;
|
|
414
|
+
return /[.!?]$/.test(lowered) ? lowered : `${lowered}.`;
|
|
415
|
+
}
|
|
416
|
+
function refusal(out, problems, opening, hint) {
|
|
417
|
+
const list = problems.length ? problems : ["the check did not pass"];
|
|
418
|
+
if (list.length === 1) {
|
|
419
|
+
out(`${opening}: ${sentence(list[0])}`);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
out(`${opening}:`);
|
|
423
|
+
for (const p of list)
|
|
424
|
+
out(` - ${sentence(p)}`);
|
|
425
|
+
}
|
|
426
|
+
out(hint);
|
|
427
|
+
}
|
|
428
|
+
// ── edits / undo ──────────────────────────────────────────────────────────────
|
|
429
|
+
async function fetchEdits(scenarioId, deps) {
|
|
430
|
+
const reply = await deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`);
|
|
431
|
+
if ("error" in reply || reply.status !== 200)
|
|
432
|
+
return { reply };
|
|
433
|
+
const edits = reply.body?.edits;
|
|
434
|
+
return { edits: Array.isArray(edits) ? edits : [] };
|
|
435
|
+
}
|
|
436
|
+
async function listEdits(args, deps) {
|
|
437
|
+
const { out } = deps;
|
|
438
|
+
const target = args.positionals[1];
|
|
439
|
+
if (!target)
|
|
440
|
+
return usageError(deps, "edits");
|
|
441
|
+
const resolved = await resolveTarget(target, args, deps);
|
|
442
|
+
if ("code" in resolved)
|
|
443
|
+
return resolved.code;
|
|
444
|
+
const reply = await deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`);
|
|
445
|
+
if ("error" in reply || reply.status !== 200) {
|
|
446
|
+
return explain(reply, args, deps, { verb: "Could not read the history", target });
|
|
447
|
+
}
|
|
448
|
+
if (args.json) {
|
|
449
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
const edits = reply.body?.edits ?? [];
|
|
453
|
+
if (edits.length === 0) {
|
|
454
|
+
out(`No hand edits of ${target}.`);
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
// Ids are printed whole: they are what `undo --edit` takes. The detail line
|
|
458
|
+
// is indented rather than aligned under the date, which a 42-character id
|
|
459
|
+
// would push past most terminals' width.
|
|
460
|
+
for (const e of edits) {
|
|
461
|
+
const note = e.note ? ` "${e.note.replace(/\s+/g, " ")}"` : "";
|
|
462
|
+
out(`${e.id} ${when(e.createdAt)} step ${e.stepIndex} ${String(e.kind).padEnd(7)} revision ${e.chainRevisionBefore} → ${e.chainRevisionAfter}${note}`);
|
|
463
|
+
out(` ${editSummary(e)}`);
|
|
464
|
+
}
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
/** The second line of an `edits` entry: what the edit changed, and what became of it. */
|
|
468
|
+
function editSummary(e) {
|
|
469
|
+
const parts = [];
|
|
470
|
+
if (e.kind === "revert" && e.revertsEditId)
|
|
471
|
+
parts.push(`undoes ${e.revertsEditId}`);
|
|
472
|
+
const b = e.before;
|
|
473
|
+
const a = e.after;
|
|
474
|
+
if (b && a) {
|
|
475
|
+
if ((b.toolInputLogic ?? null) !== (a.toolInputLogic ?? null))
|
|
476
|
+
parts.push("input logic changed");
|
|
477
|
+
if ((b.toolOutputLogic ?? null) !== (a.toolOutputLogic ?? null))
|
|
478
|
+
parts.push("output logic changed");
|
|
479
|
+
if (Boolean(b.nondeterministic) !== Boolean(a.nondeterministic) || (b.nondeterministicWhy ?? null) !== (a.nondeterministicWhy ?? null)) {
|
|
480
|
+
parts.push(`${markWord(b)} → ${markWord(a)}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (e.forced)
|
|
484
|
+
parts.push("saved on purpose (--force)");
|
|
485
|
+
if (e.revertedAt)
|
|
486
|
+
parts.push(`undone ${when(e.revertedAt)}`);
|
|
487
|
+
if (e.replacedAt)
|
|
488
|
+
parts.push(`replaced by a recalculation ${when(e.replacedAt)}`);
|
|
489
|
+
return parts.length ? parts.join("; ") : "no change to the code or the mark";
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Undo puts one step back as it was before its newest edit (D9). Without
|
|
493
|
+
* `--edit`, that is the newest entry nothing has put back and no
|
|
494
|
+
* recalculation replaced — which is by construction the newest of its step.
|
|
495
|
+
* An undo is itself such an entry, so a second `undo` redoes.
|
|
496
|
+
*/
|
|
497
|
+
export function newestUndoable(edits) {
|
|
498
|
+
// Newest first, as the service lists them; sorted again so a reordering
|
|
499
|
+
// there cannot make this undo the wrong one.
|
|
500
|
+
return [...edits]
|
|
501
|
+
.sort((x, y) => (x.createdAt < y.createdAt ? 1 : x.createdAt > y.createdAt ? -1 : 0))
|
|
502
|
+
.find((e) => !e.revertedAt && !e.replacedAt);
|
|
503
|
+
}
|
|
504
|
+
async function undo(args, deps) {
|
|
505
|
+
const { out } = deps;
|
|
506
|
+
const target = args.positionals[1];
|
|
507
|
+
if (!target)
|
|
508
|
+
return usageError(deps, "undo");
|
|
509
|
+
if (args.edit !== undefined && !/^sedit_[\w-]+$/.test(args.edit)) {
|
|
510
|
+
return usageError(deps, "undo", `--edit takes a sedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
|
|
511
|
+
}
|
|
512
|
+
const resolved = await resolveTarget(target, args, deps);
|
|
513
|
+
if ("code" in resolved)
|
|
514
|
+
return resolved.code;
|
|
515
|
+
let editId = args.edit;
|
|
516
|
+
if (!editId) {
|
|
517
|
+
const listed = await fetchEdits(resolved.id, deps);
|
|
518
|
+
if ("reply" in listed)
|
|
519
|
+
return explain(listed.reply, args, deps, { verb: "Could not read the history", target });
|
|
520
|
+
const pick = newestUndoable(listed.edits);
|
|
521
|
+
if (!pick) {
|
|
522
|
+
return localFailure(args, deps, "nothing_to_undo", `Nothing to undo: ${target} has no hand edit that can be undone.`);
|
|
523
|
+
}
|
|
524
|
+
editId = pick.id;
|
|
525
|
+
}
|
|
526
|
+
const body = {};
|
|
527
|
+
if (args.note?.trim())
|
|
528
|
+
body.note = args.note;
|
|
529
|
+
if (args.revision !== undefined)
|
|
530
|
+
body.expectedRevision = args.revision;
|
|
531
|
+
const reply = await deps.service("POST", `/scenarios/${seg(resolved.id)}/edits/${seg(editId)}/revert`, body);
|
|
532
|
+
if ("error" in reply || reply.status !== 200) {
|
|
533
|
+
return explain(reply, args, deps, {
|
|
534
|
+
verb: "Not undone",
|
|
535
|
+
target,
|
|
536
|
+
notFound: `Not undone (not_found): ${target} has no edit ${editId}, or it is not yours.`,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if (args.json) {
|
|
540
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
const b = reply.body;
|
|
544
|
+
const entry = b?.edit;
|
|
545
|
+
const revision = b?.chainRevision ?? entry?.chainRevisionAfter;
|
|
546
|
+
out(`Step ${entry?.stepIndex ?? "?"} is back as it was before ${entry?.revertsEditId ?? editId} (revision ${revision ?? "?"}).` +
|
|
547
|
+
(entry?.id ? ` Redo: bir scenario undo ${target} --edit ${entry.id}` : ""));
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
// ── calc ──────────────────────────────────────────────────────────────────────
|
|
551
|
+
/**
|
|
552
|
+
* `calc` lives here because of one answer: a recalculation rebuilds every
|
|
553
|
+
* step from the recording, so the service refuses `--force` on a plan with
|
|
554
|
+
* hand edits (D4) unless `--discard-edits` says to throw them away.
|
|
555
|
+
*/
|
|
556
|
+
async function calc(args, deps) {
|
|
557
|
+
const { out } = deps;
|
|
558
|
+
const target = args.positionals[1];
|
|
559
|
+
if (!target)
|
|
560
|
+
return usageError(deps, "calc");
|
|
561
|
+
if (args.discardEdits && !args.force) {
|
|
562
|
+
return usageError(deps, "calc", "--discard-edits only means something with --force (a recalculation in place).");
|
|
563
|
+
}
|
|
564
|
+
const reply = await deps.service("POST", `/recordings/runs/${seg(target)}/calculate`, args.force ? { force: true, ...(args.discardEdits ? { discardEdits: true } : {}) } : {});
|
|
565
|
+
if ("error" in reply)
|
|
566
|
+
return explain(reply, args, deps, { verb: "Could not start calculation", target });
|
|
567
|
+
if (args.json) {
|
|
568
|
+
out(JSON.stringify(reply.body ?? null, null, 2));
|
|
569
|
+
return reply.status === 202 ? 0 : 1;
|
|
570
|
+
}
|
|
571
|
+
const { status, body } = reply;
|
|
572
|
+
const code = body?.error;
|
|
573
|
+
if (status === 202) {
|
|
574
|
+
const b = body;
|
|
575
|
+
out(`Calculating ${b.scenarioId ?? ""} — poll with \`bir scenario show ${target}\`.`);
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
if (status === 409 && code === "scenario_has_edits") {
|
|
579
|
+
const steps = body.editedSteps;
|
|
580
|
+
const list = Array.isArray(steps) ? steps.map(String) : [];
|
|
581
|
+
const which = list.length === 0 ? "some steps were" : list.length === 1 ? `step ${list[0]} was` : `steps ${list.join(", ")} were`;
|
|
582
|
+
out(`Not recalculated: ${which} edited by hand, and a recalculation rebuilds every step.`);
|
|
583
|
+
out(` To recalculate anyway and discard the edits: bir scenario calc ${target} --force --discard-edits`);
|
|
584
|
+
out(" (the edits stay in `bir scenario edits`, marked replaced)");
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
if (status === 409 && code === "calculation_in_flight") {
|
|
588
|
+
out("Another calculation of yours is running, and the service calculates one at a time. Try again when it is ready.");
|
|
589
|
+
return 1;
|
|
590
|
+
}
|
|
591
|
+
if (status === 409) {
|
|
592
|
+
out("That run already has a scenario. Re-derive it in place with --force.");
|
|
593
|
+
return 1;
|
|
594
|
+
}
|
|
595
|
+
if (status === 503) {
|
|
596
|
+
out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
|
|
597
|
+
return 1;
|
|
598
|
+
}
|
|
599
|
+
out(`Could not start calculation (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
600
|
+
return 1;
|
|
601
|
+
}
|
|
602
|
+
// ── editing on|off|status ─────────────────────────────────────────────────────
|
|
603
|
+
/**
|
|
604
|
+
* Which tools this project's `bir` MCP server offers (D2). Stored in the
|
|
605
|
+
* project's record in `installed.json`, next to the replay switches, so a
|
|
606
|
+
* server Claude Code starts with its own environment reads the same answer.
|
|
607
|
+
* The server reads it when the session lists its tools, hence the restart.
|
|
608
|
+
*/
|
|
609
|
+
function editing(args, deps) {
|
|
610
|
+
const { out, cwd } = deps;
|
|
611
|
+
const word = args.positionals[1];
|
|
612
|
+
if (word !== "on" && word !== "off" && word !== "status")
|
|
613
|
+
return usageError(deps, "editing");
|
|
614
|
+
const sidecar = readSidecar();
|
|
615
|
+
if (word !== "status") {
|
|
616
|
+
if (!setEditing(sidecar, cwd, word === "on")) {
|
|
617
|
+
out("This directory has not been installed — run `bir setup` (or `bir install --replay`) here first.");
|
|
618
|
+
return 1;
|
|
619
|
+
}
|
|
620
|
+
writeSidecar(sidecar);
|
|
621
|
+
}
|
|
622
|
+
describeEditing(out, sidecar, cwd);
|
|
623
|
+
if (word !== "status")
|
|
624
|
+
out("Restart Claude Code in this project: a running session does not see the change.");
|
|
625
|
+
return 0;
|
|
626
|
+
}
|
|
627
|
+
function describeEditing(out, sidecar, cwd) {
|
|
628
|
+
const on = editingEnabled(sidecar, cwd);
|
|
629
|
+
out(`Scenario editing for ${cwd}: ${on ? "on" : "off"}`);
|
|
630
|
+
if (!projectRecord(sidecar, cwd)) {
|
|
631
|
+
out(" (this directory has not been installed — `bir setup` here first)");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (!hasScenarioServer(sidecar, cwd)) {
|
|
635
|
+
out(" (no `bir` MCP server is installed here, so no tool is offered at all — `bir install --replay` adds it)");
|
|
636
|
+
}
|
|
637
|
+
out(` the bir MCP server offers ${READ_TOOLS.join(", ")} (they read)`);
|
|
638
|
+
if (on)
|
|
639
|
+
out(` and ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
|
|
640
|
+
else
|
|
641
|
+
out(` \`bir scenario editing on\` adds ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
|
|
642
|
+
out(" The `bir scenario` commands work either way: this switch decides only the MCP tools.");
|
|
643
|
+
}
|
|
644
|
+
function hasScenarioServer(sidecar, cwd) {
|
|
645
|
+
const here = normalizePath(cwd);
|
|
646
|
+
return Object.entries(sidecar.servers ?? {}).some(([key, record]) => key.endsWith(`::${SCENARIO_SERVER_KEY}`) && normalizePath(record.cwd) === here);
|
|
647
|
+
}
|
|
648
|
+
// ── shared ────────────────────────────────────────────────────────────────────
|
|
649
|
+
const seg = (id) => encodeURIComponent(id);
|
|
650
|
+
const when = (iso) => iso ? iso.replace("T", " ").replace(/\.\d+Z$/, "Z") : "—";
|
|
651
|
+
function usageError(deps, sub, why) {
|
|
652
|
+
if (why)
|
|
653
|
+
deps.out(why);
|
|
654
|
+
deps.out(USAGE[sub] ?? "usage: bir scenario …");
|
|
655
|
+
return 2;
|
|
656
|
+
}
|
|
657
|
+
/** A failure this side decided on; `--json` still gets JSON, in the service's `{error, details}` shape. */
|
|
658
|
+
function localFailure(args, deps, code, text) {
|
|
659
|
+
deps.out(args.json ? JSON.stringify({ error: code, details: text }, null, 2) : text);
|
|
660
|
+
return 1;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* A `run_` id is the recording's; the routes that change a scenario take its
|
|
664
|
+
* `scn_` id (D7). A `scn_` id is used as it is — sub-task scenarios have
|
|
665
|
+
* nothing else.
|
|
666
|
+
*/
|
|
667
|
+
async function resolveTarget(target, args, deps) {
|
|
668
|
+
if (target.startsWith("scn_"))
|
|
669
|
+
return { id: target };
|
|
670
|
+
const reply = await deps.service("GET", `/recordings/runs/${seg(target)}/scenario`);
|
|
671
|
+
if ("error" in reply || reply.status !== 200) {
|
|
672
|
+
return {
|
|
673
|
+
code: explain(reply, args, deps, {
|
|
674
|
+
verb: "Could not find the scenario",
|
|
675
|
+
target,
|
|
676
|
+
notFound: `No scenario for ${target}: it is not a run of yours, or nothing was calculated from it yet (\`bir scenario calc ${target}\`).`,
|
|
677
|
+
}),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const id = reply.body?.scenario?.id;
|
|
681
|
+
if (typeof id !== "string") {
|
|
682
|
+
return {
|
|
683
|
+
code: localFailure(args, deps, "no_scenario", `${target} has no calculated scenario yet — \`bir scenario calc ${target}\` first.`),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
return { id };
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Every answer that is not the one asked for, in words — with the service's
|
|
690
|
+
* error code in brackets, because that is what the guide's troubleshooting
|
|
691
|
+
* table is keyed on. `--json` gets the service's body unchanged.
|
|
692
|
+
*/
|
|
693
|
+
function explain(reply, args, deps, e) {
|
|
694
|
+
const { out } = deps;
|
|
695
|
+
if ("error" in reply) {
|
|
696
|
+
if (args.json)
|
|
697
|
+
out(JSON.stringify({ error: "service_unreachable", details: reply.error }, null, 2));
|
|
698
|
+
else
|
|
699
|
+
out(`Could not reach the service: ${reply.error}`);
|
|
700
|
+
return 1;
|
|
701
|
+
}
|
|
702
|
+
if (args.json) {
|
|
703
|
+
out(JSON.stringify(reply.body ?? null, null, 2));
|
|
704
|
+
return 1;
|
|
705
|
+
}
|
|
706
|
+
const body = (reply.body && typeof reply.body === "object" ? reply.body : {});
|
|
707
|
+
const code = typeof body.error === "string" ? body.error : "";
|
|
708
|
+
const target = e.target ?? "<id>";
|
|
709
|
+
const stepFlag = e.step !== undefined ? ` --step ${e.step}` : "";
|
|
710
|
+
if (reply.status === 404) {
|
|
711
|
+
out(e.notFound ??
|
|
712
|
+
`${e.verb} (not_found): no such scenario, step or edit — or it is not yours. Only a scenario's owner can see or change it.`);
|
|
713
|
+
return 1;
|
|
714
|
+
}
|
|
715
|
+
if (reply.status === 401) {
|
|
716
|
+
out(`${e.verb}: the service did not accept this machine's session (HTTP 401) — run \`bir login\`.`);
|
|
717
|
+
return 1;
|
|
718
|
+
}
|
|
719
|
+
switch (code) {
|
|
720
|
+
case "invalid_input": {
|
|
721
|
+
const details = brief(body.details);
|
|
722
|
+
out(`${e.verb} (invalid_input): the service found the request invalid${details ? ` — ${details}` : ""}.`);
|
|
723
|
+
return 1;
|
|
724
|
+
}
|
|
725
|
+
case "not_a_tool_step":
|
|
726
|
+
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
|
+
return 1;
|
|
728
|
+
case "nothing_to_change":
|
|
729
|
+
out(`${e.verb} (nothing_to_change): give --input-logic, --output-logic, --freeze or --unfreeze.`);
|
|
730
|
+
return 1;
|
|
731
|
+
case "note_required":
|
|
732
|
+
out(`${e.verb} (note_required): --force needs --note "why".`);
|
|
733
|
+
return 1;
|
|
734
|
+
case "not_ready":
|
|
735
|
+
case "scenario_calculating":
|
|
736
|
+
out(`${e.verb} (${code}): the scenario is not ready, or a recalculation of it is running. Wait for it to finish (\`bir scenario show ${target}\`), then try again.`);
|
|
737
|
+
return 1;
|
|
738
|
+
case "revision_changed": {
|
|
739
|
+
const now = typeof body.chainRevision === "number" ? ` — it is at revision ${body.chainRevision} now` : "";
|
|
740
|
+
out(`${e.verb} (revision_changed): the plan changed since it was read${now}. Show it again (\`bir scenario show ${target}${stepFlag}\`), check again, then save again.`);
|
|
741
|
+
return 1;
|
|
742
|
+
}
|
|
743
|
+
case "not_latest_edit": {
|
|
744
|
+
const latest = typeof body.latestEditId === "string" ? body.latestEditId : "<the newer edit>";
|
|
745
|
+
out(`${e.verb} (not_latest_edit): a newer edit of the same step follows it. Undo that one first:`);
|
|
746
|
+
out(` bir scenario undo ${target} --edit ${latest}`);
|
|
747
|
+
return 1;
|
|
748
|
+
}
|
|
749
|
+
case "edit_replaced":
|
|
750
|
+
out(`${e.verb} (edit_replaced): a recalculation replaced the plan after that edit. It stays in \`bir scenario edits\`, but there is nothing left to undo.`);
|
|
751
|
+
return 1;
|
|
752
|
+
case "rate_limited": {
|
|
753
|
+
const ms = typeof body.retryAfterMs === "number" ? body.retryAfterMs : undefined;
|
|
754
|
+
out(`${e.verb} (rate_limited): too many checks, edits and undos in a minute. Try again${ms !== undefined ? ` in ${Math.ceil(ms / 1000)} s` : " in a minute"}.`);
|
|
755
|
+
return 1;
|
|
756
|
+
}
|
|
757
|
+
default: {
|
|
758
|
+
const details = brief(body.details);
|
|
759
|
+
out(`${e.verb} (HTTP ${reply.status}${code ? `, ${code}` : ""})${details ? `: ${details}` : ""}.`);
|
|
760
|
+
return 1;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/** A service's `details`, short enough for one line. */
|
|
765
|
+
function brief(details) {
|
|
766
|
+
if (details === undefined || details === null)
|
|
767
|
+
return "";
|
|
768
|
+
const text = typeof details === "string" ? details : JSON.stringify(details);
|
|
769
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
770
|
+
}
|
|
771
|
+
//# sourceMappingURL=scenario-edit.js.map
|