@basein/runner 0.2.10 → 0.2.12
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 +41 -1
- package/dist/bin/bir-scenario.d.ts +29 -2
- package/dist/bin/bir-scenario.js +484 -16
- package/dist/bin/bir.d.ts +10 -0
- package/dist/bin/bir.js +143 -43
- package/dist/bin/investigate.js +2 -2
- package/dist/bin/scenario-edit.d.ts +200 -0
- package/dist/bin/scenario-edit.js +950 -0
- package/dist/config/generate.d.ts +21 -0
- package/dist/config/generate.js +16 -0
- package/dist/control/server.js +18 -5
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/replay/controller.d.ts +3 -1
- package/dist/replay/controller.js +17 -12
- package/docs/calculatedReplay.md +115 -4
- package/docs/calculatedReplayGuide.md +528 -10
- package/docs/quickstart.md +44 -2
- package/package.json +1 -1
|
@@ -0,0 +1,950 @@
|
|
|
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> | --step <n>] [--force --note "why"]
|
|
11
|
+
* bir scenario calc <runId|scnId> [--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> | --step <n>] [--force --note "why"] [--json]',
|
|
55
|
+
calc: "usage: bir scenario calc <runId|scnId> [--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
|
+
* with the scenario's id and `chainRevision` put in front of it. The revision
|
|
84
|
+
* is a scenario's, not a step's, and it is what `edit --revision` (and the MCP
|
|
85
|
+
* `scenario_edit`'s `revision`) takes: a step printed without it sent the model
|
|
86
|
+
* that had just read the step off to guess one (C20).
|
|
87
|
+
*/
|
|
88
|
+
async function show(args, deps) {
|
|
89
|
+
const { out } = deps;
|
|
90
|
+
const target = args.positionals[1];
|
|
91
|
+
if (!target)
|
|
92
|
+
return usageError(deps, "show");
|
|
93
|
+
const byScenario = target.startsWith("scn_");
|
|
94
|
+
const reply = await deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`);
|
|
95
|
+
if ("error" in reply || reply.status !== 200) {
|
|
96
|
+
return explain(reply, args, deps, {
|
|
97
|
+
verb: "Could not read the scenario",
|
|
98
|
+
target,
|
|
99
|
+
notFound: byScenario
|
|
100
|
+
? `No such scenario, or it is not yours: ${target}.`
|
|
101
|
+
: "No scenario for that run yet — `bir scenario calc <runId>` first.",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (args.step === undefined) {
|
|
105
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
const served = reply.body;
|
|
109
|
+
const scenario = served?.scenario;
|
|
110
|
+
if (!scenario)
|
|
111
|
+
return noWholeRunScenario(target, served?.segments, args, deps);
|
|
112
|
+
if (!Array.isArray(scenario.steps)) {
|
|
113
|
+
out(`${scenario.id ?? target} is ${scenario.state ?? "not ready"}, so it has no steps to show yet.`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
const step = scenario.steps.find((s) => s.stepIndex === args.step);
|
|
117
|
+
if (!step) {
|
|
118
|
+
const indexes = scenario.steps.map((s) => s.stepIndex).filter((n) => typeof n === "number");
|
|
119
|
+
out(`${scenario.id ?? target} has no step ${args.step}${indexes.length ? ` (its steps are ${Math.min(...indexes)}–${Math.max(...indexes)})` : " (it has no steps)"}.`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
out(JSON.stringify({ scenarioId: scenario.id ?? null, chainRevision: scenario.chainRevision ?? null, ...step }, null, 2));
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `check` and `edit` share everything up to the request, on purpose: the
|
|
127
|
+
* service runs the same function for both (D11), so the same flags must build
|
|
128
|
+
* the same body. `check` asks and writes nothing; `edit` writes only what the
|
|
129
|
+
* check approves — or, with `--force` and a note, what it did not.
|
|
130
|
+
*/
|
|
131
|
+
async function checkOrEdit(args, deps, save) {
|
|
132
|
+
const sub = save ? "edit" : "check";
|
|
133
|
+
const { out } = deps;
|
|
134
|
+
const target = args.positionals[1];
|
|
135
|
+
if (!target)
|
|
136
|
+
return usageError(deps, sub);
|
|
137
|
+
if (args.step === undefined) {
|
|
138
|
+
return usageError(deps, sub, "Which step? Give --step <n>, the stepIndex `bir scenario show` prints.");
|
|
139
|
+
}
|
|
140
|
+
if (!save && (args.freeze || args.force)) {
|
|
141
|
+
return usageError(deps, sub, "`check` never saves, so --freeze and --force belong to `bir scenario edit`.");
|
|
142
|
+
}
|
|
143
|
+
if (args.freeze && args.unfreeze) {
|
|
144
|
+
return usageError(deps, sub, "--freeze and --unfreeze ask for opposite things; give one of them.");
|
|
145
|
+
}
|
|
146
|
+
const note = args.note?.trim() ? args.note : undefined;
|
|
147
|
+
if (save && args.force && !note) {
|
|
148
|
+
// Before any request: a forced save is the one kind the check did not
|
|
149
|
+
// approve, and the note is the only record of why somebody wanted it.
|
|
150
|
+
return usageError(deps, sub, '--force needs --note "why" (note_required): a change saved on purpose says why, and the history keeps it.');
|
|
151
|
+
}
|
|
152
|
+
if (!args.inputLogic && !args.outputLogic && !args.unfreeze && !(save && args.freeze)) {
|
|
153
|
+
return usageError(deps, sub, save
|
|
154
|
+
? "Nothing to change: give --input-logic <file>, --output-logic <file>, --freeze or --unfreeze."
|
|
155
|
+
: "Nothing to check: give --input-logic <file>, --output-logic <file> or --unfreeze.");
|
|
156
|
+
}
|
|
157
|
+
if (args.inputLogic === "-" && args.outputLogic === "-") {
|
|
158
|
+
return usageError(deps, sub, "Only one of --input-logic and --output-logic can read stdin (-); put the other in a file.");
|
|
159
|
+
}
|
|
160
|
+
// Read the files before any request, so a typo in a path costs nothing.
|
|
161
|
+
const body = {};
|
|
162
|
+
const sources = [
|
|
163
|
+
[args.inputLogic, "input logic", "toolInputLogic"],
|
|
164
|
+
[args.outputLogic, "output logic", "toolOutputLogic"],
|
|
165
|
+
];
|
|
166
|
+
for (const [spec, what, key] of sources) {
|
|
167
|
+
if (spec === undefined)
|
|
168
|
+
continue;
|
|
169
|
+
const read = await readLogic(spec, what, deps);
|
|
170
|
+
if ("problem" in read)
|
|
171
|
+
return usageError(deps, sub, read.problem);
|
|
172
|
+
body[key] = read.text;
|
|
173
|
+
}
|
|
174
|
+
const resolved = await resolveTarget(target, args, deps);
|
|
175
|
+
if ("code" in resolved)
|
|
176
|
+
return resolved.code;
|
|
177
|
+
const path = `/scenarios/${seg(resolved.id)}/steps/${args.step}`;
|
|
178
|
+
const explained = { target, step: args.step };
|
|
179
|
+
if (!save) {
|
|
180
|
+
if (args.unfreeze)
|
|
181
|
+
body.unfreeze = true;
|
|
182
|
+
const reply = await deps.service("POST", `${path}/check`, body);
|
|
183
|
+
if ("error" in reply || reply.status !== 200)
|
|
184
|
+
return explain(reply, args, deps, { verb: "Not checked", ...explained });
|
|
185
|
+
const report = reply.body?.report;
|
|
186
|
+
if (args.json) {
|
|
187
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
188
|
+
return report?.ok || report?.noChange ? 0 : 1;
|
|
189
|
+
}
|
|
190
|
+
if (!report) {
|
|
191
|
+
out("The service answered the check without a report.");
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
renderReport(out, report);
|
|
195
|
+
// Before `ok`: a change that is already the step's would pass the check and
|
|
196
|
+
// then be answered `nothing_to_change` by `edit` — so `check` says so
|
|
197
|
+
// itself, instead of printing an `edit` command that cannot save (C15).
|
|
198
|
+
if (report.noChange) {
|
|
199
|
+
out("Nothing would change: the step already has this code and mark.");
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (report.ok) {
|
|
203
|
+
out(`Nothing saved. To save: ${editCommand(target, args)}`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
refusal(out, report, "Nothing saved, and `bir scenario edit` would refuse it", report.budgetExceeded
|
|
207
|
+
? ' 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".'
|
|
208
|
+
: ' Fix the code and check again. If the recording itself was wrong, edit can save it on purpose: add --force --note "why".');
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
if (args.freeze)
|
|
212
|
+
body.freeze = true;
|
|
213
|
+
else if (args.unfreeze)
|
|
214
|
+
body.freeze = false;
|
|
215
|
+
if (args.force)
|
|
216
|
+
body.force = true;
|
|
217
|
+
if (note)
|
|
218
|
+
body.note = note;
|
|
219
|
+
if (args.revision !== undefined)
|
|
220
|
+
body.expectedRevision = args.revision;
|
|
221
|
+
const reply = await deps.service("PATCH", path, body);
|
|
222
|
+
const answer = ("error" in reply ? undefined : reply.body);
|
|
223
|
+
const refused = !("error" in reply) && reply.status === 422 && Boolean(answer?.report);
|
|
224
|
+
if ("error" in reply || (reply.status !== 200 && !refused)) {
|
|
225
|
+
return explain(reply, args, deps, { verb: "Not saved", ...explained });
|
|
226
|
+
}
|
|
227
|
+
if (args.json) {
|
|
228
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
229
|
+
return refused ? 1 : 0;
|
|
230
|
+
}
|
|
231
|
+
if (answer?.report)
|
|
232
|
+
renderReport(out, answer.report, { brief: true });
|
|
233
|
+
if (refused && answer?.report) {
|
|
234
|
+
refusal(out, answer.report, "Not saved", answer.report.budgetExceeded
|
|
235
|
+
? ' Make the code cheaper to run and try again, or save it on purpose: add --force --note "why".'
|
|
236
|
+
: ' Fix the code and try again, or save it on purpose: add --force --note "why".');
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
const edit = answer?.edit;
|
|
240
|
+
const revision = answer?.chainRevision ?? edit?.chainRevisionAfter;
|
|
241
|
+
out(`Saved as ${edit?.id ?? "(no id)"} (revision ${revision ?? "?"}).` +
|
|
242
|
+
(edit?.id ? ` Undo: bir scenario undo ${target} --edit ${edit.id}` : ""));
|
|
243
|
+
if (edit?.forced) {
|
|
244
|
+
out(" Saved on purpose (--force): `bir investigate` keeps reporting it as forced.");
|
|
245
|
+
for (const p of answer?.report?.problems ?? [])
|
|
246
|
+
out(` The check said: ${sentence(p)}`);
|
|
247
|
+
}
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
/** What `check` printed, turned into the `edit` that would save it. */
|
|
251
|
+
function editCommand(target, args) {
|
|
252
|
+
const parts = ["bir scenario edit", target, "--step", String(args.step)];
|
|
253
|
+
if (args.inputLogic)
|
|
254
|
+
parts.push("--input-logic", quoteArg(args.inputLogic));
|
|
255
|
+
if (args.outputLogic)
|
|
256
|
+
parts.push("--output-logic", quoteArg(args.outputLogic));
|
|
257
|
+
if (args.unfreeze)
|
|
258
|
+
parts.push("--unfreeze");
|
|
259
|
+
return parts.join(" ");
|
|
260
|
+
}
|
|
261
|
+
function quoteArg(value) {
|
|
262
|
+
return /\s/.test(value) ? `"${value}"` : value;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A logic file's text. UTF-8, with a byte-order mark dropped — and UTF-16 with
|
|
266
|
+
* its mark decoded, because that is what `"…" > step.js` writes in Windows
|
|
267
|
+
* PowerShell 5.1, and a service answering "SyntaxError" to a file that looks
|
|
268
|
+
* fine in every editor is a riddle nobody should have to solve.
|
|
269
|
+
*/
|
|
270
|
+
export function decodeLogic(bytes) {
|
|
271
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
|
|
272
|
+
return bytes.subarray(2).toString("utf16le");
|
|
273
|
+
const text = bytes.toString("utf8");
|
|
274
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
275
|
+
}
|
|
276
|
+
async function readLogic(spec, what, deps) {
|
|
277
|
+
const from = spec === "-" ? "stdin" : spec;
|
|
278
|
+
let text;
|
|
279
|
+
try {
|
|
280
|
+
text = decodeLogic(spec === "-" ? await deps.readStdin() : deps.readFile(spec));
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
return { problem: `Could not read the ${what} from ${from}: ${errText(err)}` };
|
|
284
|
+
}
|
|
285
|
+
// Trailing whitespace is a file's last newline, not code; the history reads
|
|
286
|
+
// better without it, and `check` and `edit` trim it the same way.
|
|
287
|
+
text = text.replace(/\s+$/, "");
|
|
288
|
+
if (!text)
|
|
289
|
+
return { problem: `The ${what} from ${from} is empty.` };
|
|
290
|
+
return { text };
|
|
291
|
+
}
|
|
292
|
+
// ── rendering the report ──────────────────────────────────────────────────────
|
|
293
|
+
/** Characters of context shown on either side of the first difference. */
|
|
294
|
+
const CONTEXT = 40;
|
|
295
|
+
const label = (name, text) => ` ${name.padEnd(9)}${text}`;
|
|
296
|
+
/**
|
|
297
|
+
* The report in the lines editSteps.md "What a person sees" shows:
|
|
298
|
+
* `input`, `output` (when the output logic changed), `later`, `answer` (when
|
|
299
|
+
* the final answer read anything before) and `mark`.
|
|
300
|
+
*
|
|
301
|
+
* `check` prints every line: it is where a person explores a change, and "step
|
|
302
|
+
* 5 still reproduces" is an answer there. `edit` and `undo` are acts, and print
|
|
303
|
+
* `brief`ly — what changed and what went wrong: `input` always, `output` when
|
|
304
|
+
* it changed, `later` only for a step that broke, `answer` only when it lost its
|
|
305
|
+
* values, `mark` only when it moves. The report is the same one either way (D11);
|
|
306
|
+
* only the lines that say "nothing to see" are left out.
|
|
307
|
+
*
|
|
308
|
+
* An `undo` does not judge the step itself — its code and mark go back exactly
|
|
309
|
+
* as they were (editSteps.md, "Undo") — so its report's input status is
|
|
310
|
+
* `not_checked` by design, and printing the usual reason for that ("the
|
|
311
|
+
* recorded call is plain text") would be false. It says what happened instead.
|
|
312
|
+
*/
|
|
313
|
+
export function renderReport(out, r, opts = {}) {
|
|
314
|
+
const brief = opts.brief === true;
|
|
315
|
+
out(`Step ${r.stepIndex} (${r.toolName ?? "?"}) — checked against ${r.sourceRunId}`);
|
|
316
|
+
const i = r.input ?? { changed: false, before: "not_checked", after: "not_checked" };
|
|
317
|
+
const unchanged = i.changed ? "" : " (input logic unchanged)";
|
|
318
|
+
if (opts.undo) {
|
|
319
|
+
out(label("input", i.changed ? "back as it was before that edit" : "unchanged"));
|
|
320
|
+
}
|
|
321
|
+
else if (i.after === "differs") {
|
|
322
|
+
const d = i.firstDifference ?? firstDifference(i.computed, i.recorded);
|
|
323
|
+
out(label("input", `differs from the recorded call${d ? ` at character ${d.at}` : ""}${unchanged}`));
|
|
324
|
+
if (d) {
|
|
325
|
+
out(` computed ${around(d.computed, d.at)}`);
|
|
326
|
+
out(` recorded ${around(d.recorded, d.at)}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else if (i.after === "throws") {
|
|
330
|
+
out(label("input", `throws: ${i.error ?? "(no message)"}${unchanged}`));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
out(label("input", `${inputPhrase(i.after, r.verifiable)}${unchanged}`));
|
|
334
|
+
}
|
|
335
|
+
if (r.output?.changed) {
|
|
336
|
+
const o = r.output;
|
|
337
|
+
out(label("output", o.status === "runs"
|
|
338
|
+
? `runs; keeps ${o.emittedKeys?.length ? o.emittedKeys.join(", ") : "nothing"}`
|
|
339
|
+
: o.status === "throws"
|
|
340
|
+
? `throws: ${o.error ?? "(no message)"}`
|
|
341
|
+
: o.status === "not_object"
|
|
342
|
+
? "does not return an object"
|
|
343
|
+
: "unchanged"));
|
|
344
|
+
}
|
|
345
|
+
const later = r.later ?? [];
|
|
346
|
+
const regressed = later.filter((l) => l.regressed);
|
|
347
|
+
if (brief && regressed.length === 0) {
|
|
348
|
+
// Nothing broke: an act has nothing to say about later steps.
|
|
349
|
+
}
|
|
350
|
+
else if (later.length === 0) {
|
|
351
|
+
// The service judges later steps only when what they read can change: an
|
|
352
|
+
// input-only change (or a mark) leaves them alone, and marked or
|
|
353
|
+
// unverifiable later steps are never judged (editSteps.md rule 6).
|
|
354
|
+
out(label("later", r.output?.changed
|
|
355
|
+
? "none to check"
|
|
356
|
+
: r.input?.changed
|
|
357
|
+
? "not affected: only the input logic changed"
|
|
358
|
+
: "not affected: the code did not change"));
|
|
359
|
+
}
|
|
360
|
+
else if (regressed.length === 0) {
|
|
361
|
+
const still = later.filter((l) => l.after === "reproduces").map((l) => l.stepIndex);
|
|
362
|
+
out(label("later", still.length === 0
|
|
363
|
+
? "no later step reproduced before, so none can break"
|
|
364
|
+
: still.length === 1
|
|
365
|
+
? `step ${still[0]} still reproduces`
|
|
366
|
+
: `steps ${still.join(", ")} still reproduce`));
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
for (const l of regressed) {
|
|
370
|
+
out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) reproduced before; now it ${laterPhrase(l.after)}`));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (r.answer?.informativeBefore && !(brief && r.answer.informativeAfter)) {
|
|
374
|
+
out(label("answer", r.answer.informativeAfter
|
|
375
|
+
? "still gets its values"
|
|
376
|
+
: "no longer gets its values: the final answer's logic stops finding what it reads"));
|
|
377
|
+
}
|
|
378
|
+
if (r.mark?.before && r.mark.after) {
|
|
379
|
+
const { before, after } = r.mark;
|
|
380
|
+
const moves = before.marked !== after.marked || before.why !== after.why;
|
|
381
|
+
if (moves || !brief) {
|
|
382
|
+
out(label("mark", moves
|
|
383
|
+
? `${markWord(before)} → ${markWord(after)}`
|
|
384
|
+
: before.marked
|
|
385
|
+
? `${markWord(before)} → stays marked`
|
|
386
|
+
: "runs by itself"));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function inputPhrase(status, verifiable) {
|
|
391
|
+
switch (status) {
|
|
392
|
+
case "reproduces":
|
|
393
|
+
return "reproduces the recorded call";
|
|
394
|
+
case "copy":
|
|
395
|
+
return "is a copy: it returns the recorded call whatever the parameters say";
|
|
396
|
+
case "not_checked":
|
|
397
|
+
return verifiable
|
|
398
|
+
? "not checked: the recorded call is plain text, not an object"
|
|
399
|
+
: "not checked: the step comes after a sub-task call, whose outputs the check cannot rebuild";
|
|
400
|
+
case "differs":
|
|
401
|
+
return "differs from the recorded call";
|
|
402
|
+
case "throws":
|
|
403
|
+
return "throws";
|
|
404
|
+
default:
|
|
405
|
+
return String(status);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function laterPhrase(status) {
|
|
409
|
+
switch (status) {
|
|
410
|
+
case "differs":
|
|
411
|
+
return "differs from its recorded call";
|
|
412
|
+
case "throws":
|
|
413
|
+
return "throws";
|
|
414
|
+
case "copy":
|
|
415
|
+
return "is a copy of its recorded call";
|
|
416
|
+
case "not_checked":
|
|
417
|
+
return "cannot be checked";
|
|
418
|
+
default:
|
|
419
|
+
return status;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** A report's `MarkState` or a history entry's `StepState`, in the words the report uses. */
|
|
423
|
+
const markWord = (m) => (m.marked ?? m.nondeterministic)
|
|
424
|
+
? `needs a judgement (${m.why ?? m.nondeterministicWhy ?? "?"})`
|
|
425
|
+
: "runs by itself";
|
|
426
|
+
/**
|
|
427
|
+
* The report's `firstDifference`, or the same computed here from `computed` and
|
|
428
|
+
* `recorded` when the service sent only those: whitespace-squashed JSON text,
|
|
429
|
+
* as the service compares it.
|
|
430
|
+
*/
|
|
431
|
+
function firstDifference(computed, recorded) {
|
|
432
|
+
if (computed === undefined || recorded === undefined)
|
|
433
|
+
return undefined;
|
|
434
|
+
const squash = (v) => (JSON.stringify(v) ?? String(v)).replace(/\s+/g, " ");
|
|
435
|
+
const a = squash(computed);
|
|
436
|
+
const b = squash(recorded);
|
|
437
|
+
let at = 0;
|
|
438
|
+
while (at < a.length && at < b.length && a[at] === b[at])
|
|
439
|
+
at += 1;
|
|
440
|
+
return { at, computed: a, recorded: b };
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* About {@link CONTEXT} characters either side of `at`. A text already that
|
|
444
|
+
* short — a window the service cut itself, or a small call — is printed whole.
|
|
445
|
+
*/
|
|
446
|
+
export function around(text, at) {
|
|
447
|
+
if (text.length <= 2 * CONTEXT + 2)
|
|
448
|
+
return text;
|
|
449
|
+
const from = Math.max(0, Math.min(at, text.length) - CONTEXT);
|
|
450
|
+
const to = Math.min(text.length, from + 2 * CONTEXT);
|
|
451
|
+
return `${from > 0 ? "…" : ""}${text.slice(from, to)}${to < text.length ? "…" : ""}`;
|
|
452
|
+
}
|
|
453
|
+
/** One problem sentence, continuing a line that already opened with "Not saved:". */
|
|
454
|
+
function sentence(problem) {
|
|
455
|
+
const text = problem.trim();
|
|
456
|
+
const lowered = /^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text;
|
|
457
|
+
return /[.!?]$/.test(lowered) ? lowered : `${lowered}.`;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The verdict under a refused report. When the one thing wrong is that the
|
|
461
|
+
* changed input logic does not compute the recorded call, the `input` line
|
|
462
|
+
* above has already said where it parts, and the verdict says it in the words
|
|
463
|
+
* editSteps.md shows: "the change does not reproduce the recording". Anything
|
|
464
|
+
* else — a copy, a later step, the answer, the mark, *too costly to check* —
|
|
465
|
+
* is the service's own sentence, one per problem.
|
|
466
|
+
*/
|
|
467
|
+
function refusal(out, report, opening, hint) {
|
|
468
|
+
const problems = report.problems ?? [];
|
|
469
|
+
const inputOnly = problems.length === 1 &&
|
|
470
|
+
report.input?.changed === true &&
|
|
471
|
+
(report.input.after === "differs" || report.input.after === "throws") &&
|
|
472
|
+
!report.budgetExceeded &&
|
|
473
|
+
!(report.later ?? []).some((l) => l.regressed) &&
|
|
474
|
+
!(report.answer?.informativeBefore && !report.answer.informativeAfter);
|
|
475
|
+
const list = inputOnly
|
|
476
|
+
? ["the change does not reproduce the recording"]
|
|
477
|
+
: problems.length
|
|
478
|
+
? problems
|
|
479
|
+
: ["the check did not pass"];
|
|
480
|
+
if (list.length === 1) {
|
|
481
|
+
out(`${opening}: ${sentence(list[0])}`);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
out(`${opening}:`);
|
|
485
|
+
for (const p of list)
|
|
486
|
+
out(` - ${sentence(p)}`);
|
|
487
|
+
}
|
|
488
|
+
out(hint);
|
|
489
|
+
}
|
|
490
|
+
// ── edits / undo ──────────────────────────────────────────────────────────────
|
|
491
|
+
async function fetchEdits(scenarioId, deps) {
|
|
492
|
+
const reply = await deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`);
|
|
493
|
+
if ("error" in reply || reply.status !== 200)
|
|
494
|
+
return { reply };
|
|
495
|
+
const edits = reply.body?.edits;
|
|
496
|
+
return { edits: Array.isArray(edits) ? edits : [] };
|
|
497
|
+
}
|
|
498
|
+
async function listEdits(args, deps) {
|
|
499
|
+
const { out } = deps;
|
|
500
|
+
const target = args.positionals[1];
|
|
501
|
+
if (!target)
|
|
502
|
+
return usageError(deps, "edits");
|
|
503
|
+
const resolved = await resolveTarget(target, args, deps);
|
|
504
|
+
if ("code" in resolved)
|
|
505
|
+
return resolved.code;
|
|
506
|
+
const reply = await deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`);
|
|
507
|
+
if ("error" in reply || reply.status !== 200) {
|
|
508
|
+
return explain(reply, args, deps, { verb: "Could not read the history", target });
|
|
509
|
+
}
|
|
510
|
+
if (args.json) {
|
|
511
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
const edits = reply.body?.edits ?? [];
|
|
515
|
+
if (edits.length === 0) {
|
|
516
|
+
out(`No hand edits of ${target}.`);
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
// Ids are printed whole: they are what `undo --edit` takes. The detail line
|
|
520
|
+
// is indented rather than aligned under the date, which a 42-character id
|
|
521
|
+
// would push past most terminals' width.
|
|
522
|
+
for (const e of edits) {
|
|
523
|
+
const note = e.note ? ` "${e.note.replace(/\s+/g, " ")}"` : "";
|
|
524
|
+
out(`${e.id} ${when(e.createdAt)} step ${e.stepIndex} ${String(e.kind).padEnd(7)} revision ${e.chainRevisionBefore} → ${e.chainRevisionAfter}${note}`);
|
|
525
|
+
out(` ${editSummary(e)}`);
|
|
526
|
+
}
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
/** The second line of an `edits` entry: what the edit changed, and what became of it. */
|
|
530
|
+
function editSummary(e) {
|
|
531
|
+
const parts = [];
|
|
532
|
+
if (e.kind === "revert" && e.revertsEditId)
|
|
533
|
+
parts.push(`undoes ${e.revertsEditId}`);
|
|
534
|
+
const b = e.before;
|
|
535
|
+
const a = e.after;
|
|
536
|
+
if (b && a) {
|
|
537
|
+
if ((b.toolInputLogic ?? null) !== (a.toolInputLogic ?? null))
|
|
538
|
+
parts.push("input logic changed");
|
|
539
|
+
if ((b.toolOutputLogic ?? null) !== (a.toolOutputLogic ?? null))
|
|
540
|
+
parts.push("output logic changed");
|
|
541
|
+
if (Boolean(b.nondeterministic) !== Boolean(a.nondeterministic) || (b.nondeterministicWhy ?? null) !== (a.nondeterministicWhy ?? null)) {
|
|
542
|
+
parts.push(`${markWord(b)} → ${markWord(a)}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (e.forced)
|
|
546
|
+
parts.push("saved on purpose (--force)");
|
|
547
|
+
if (e.revertedAt)
|
|
548
|
+
parts.push(`undone ${when(e.revertedAt)}`);
|
|
549
|
+
if (e.replacedAt)
|
|
550
|
+
parts.push(`replaced by a recalculation ${when(e.replacedAt)}`);
|
|
551
|
+
return parts.length ? parts.join("; ") : "no change to the code or the mark";
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* What `undo` without `--edit` takes back (D9): the newest **edit** — never an
|
|
555
|
+
* undo entry — that nothing has put back and no recalculation replaced; of
|
|
556
|
+
* step `step` when it is given.
|
|
557
|
+
*
|
|
558
|
+
* Undo entries are skipped on purpose. They used to be picked like any other,
|
|
559
|
+
* so a second `undo` redid the first one, and a step edited twice toggled
|
|
560
|
+
* between its two edits for ever and never got back to the calculation's code
|
|
561
|
+
* (C12). Now a second `undo` goes one further back; a redo is asked for by
|
|
562
|
+
* name, `--edit <the undo's id>`, which the undo prints.
|
|
563
|
+
*
|
|
564
|
+
* Whether the step is still exactly as that edit left it is the service's
|
|
565
|
+
* question, not this list's: it answers `not_latest_edit` when it is not.
|
|
566
|
+
*/
|
|
567
|
+
export function newestUndoable(edits, step) {
|
|
568
|
+
// Newest first, as the service lists them; sorted again so a reordering
|
|
569
|
+
// there cannot make this undo the wrong one.
|
|
570
|
+
return [...edits]
|
|
571
|
+
.sort((x, y) => (x.createdAt < y.createdAt ? 1 : x.createdAt > y.createdAt ? -1 : 0))
|
|
572
|
+
.find((e) => e.kind !== "revert" &&
|
|
573
|
+
!e.revertedAt &&
|
|
574
|
+
!e.replacedAt &&
|
|
575
|
+
(step === undefined || e.stepIndex === step));
|
|
576
|
+
}
|
|
577
|
+
async function undo(args, deps) {
|
|
578
|
+
const { out } = deps;
|
|
579
|
+
const target = args.positionals[1];
|
|
580
|
+
if (!target)
|
|
581
|
+
return usageError(deps, "undo");
|
|
582
|
+
if (args.edit !== undefined && !/^sedit_[\w-]+$/.test(args.edit)) {
|
|
583
|
+
return usageError(deps, "undo", `--edit takes a sedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
|
|
584
|
+
}
|
|
585
|
+
if (args.edit !== undefined && args.step !== undefined) {
|
|
586
|
+
return usageError(deps, "undo", "--edit names one entry and --step picks one; give one of them.");
|
|
587
|
+
}
|
|
588
|
+
const note = args.note?.trim() ? args.note : undefined;
|
|
589
|
+
if (args.force && !note) {
|
|
590
|
+
// As for `edit`: an undo the check refused is taken back only on purpose,
|
|
591
|
+
// and the note is the only record of why.
|
|
592
|
+
return usageError(deps, "undo", '--force needs --note "why" (note_required): an undo made on purpose says why, and the history keeps it.');
|
|
593
|
+
}
|
|
594
|
+
const resolved = await resolveTarget(target, args, deps);
|
|
595
|
+
if ("code" in resolved)
|
|
596
|
+
return resolved.code;
|
|
597
|
+
let editId = args.edit;
|
|
598
|
+
if (!editId) {
|
|
599
|
+
const listed = await fetchEdits(resolved.id, deps);
|
|
600
|
+
if ("reply" in listed)
|
|
601
|
+
return explain(listed.reply, args, deps, { verb: "Could not read the history", target });
|
|
602
|
+
const pick = newestUndoable(listed.edits, args.step);
|
|
603
|
+
if (!pick) {
|
|
604
|
+
return localFailure(args, deps, "nothing_to_undo", args.step === undefined
|
|
605
|
+
? `Nothing to undo: ${target} has no hand edit that can be undone.`
|
|
606
|
+
: `Nothing to undo: step ${args.step} of ${target} has no hand edit that can be undone.`);
|
|
607
|
+
}
|
|
608
|
+
editId = pick.id;
|
|
609
|
+
}
|
|
610
|
+
const body = {};
|
|
611
|
+
if (note)
|
|
612
|
+
body.note = note;
|
|
613
|
+
if (args.force)
|
|
614
|
+
body.force = true;
|
|
615
|
+
if (args.revision !== undefined)
|
|
616
|
+
body.expectedRevision = args.revision;
|
|
617
|
+
const reply = await deps.service("POST", `/scenarios/${seg(resolved.id)}/edits/${seg(editId)}/revert`, body);
|
|
618
|
+
const answer = ("error" in reply ? undefined : reply.body);
|
|
619
|
+
const refused = !("error" in reply) && reply.status === 422 && Boolean(answer?.report);
|
|
620
|
+
if ("error" in reply || (reply.status !== 200 && !refused)) {
|
|
621
|
+
return explain(reply, args, deps, {
|
|
622
|
+
verb: "Not undone",
|
|
623
|
+
target,
|
|
624
|
+
editId,
|
|
625
|
+
notFound: `Not undone (not_found): ${target} has no edit ${editId}, or it is not yours.`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (args.json) {
|
|
629
|
+
out(JSON.stringify(reply.body, null, 2));
|
|
630
|
+
return refused ? 1 : 0;
|
|
631
|
+
}
|
|
632
|
+
if (answer?.report)
|
|
633
|
+
renderReport(out, answer.report, { brief: true, undo: true });
|
|
634
|
+
if (refused && answer?.report) {
|
|
635
|
+
// The undo would break a later step or the answer (D9): the same check a
|
|
636
|
+
// save runs, and the same way past it.
|
|
637
|
+
refusal(out, answer.report, "Not undone", ' To undo it anyway, on purpose: add --force --note "why".');
|
|
638
|
+
return 1;
|
|
639
|
+
}
|
|
640
|
+
const entry = answer?.edit;
|
|
641
|
+
const revision = answer?.chainRevision ?? entry?.chainRevisionAfter;
|
|
642
|
+
out(`Step ${entry?.stepIndex ?? "?"} is back as it was before ${entry?.revertsEditId ?? editId} (revision ${revision ?? "?"}).` +
|
|
643
|
+
(entry?.id ? ` Redo: bir scenario undo ${target} --edit ${entry.id}` : ""));
|
|
644
|
+
// Undo walks back (D9): the same command again goes one further. An undo
|
|
645
|
+
// asked for by id names its step, so "further back" stays on that step.
|
|
646
|
+
const further = args.step !== undefined
|
|
647
|
+
? ` --step ${args.step}`
|
|
648
|
+
: args.edit !== undefined && typeof entry?.stepIndex === "number"
|
|
649
|
+
? ` --step ${entry.stepIndex}`
|
|
650
|
+
: "";
|
|
651
|
+
out(` One further back: bir scenario undo ${target}${further}`);
|
|
652
|
+
if (entry?.forced) {
|
|
653
|
+
out(" Undone on purpose (--force), although the check refused it.");
|
|
654
|
+
for (const p of answer?.report?.problems ?? [])
|
|
655
|
+
out(` The check said: ${sentence(p)}`);
|
|
656
|
+
}
|
|
657
|
+
return 0;
|
|
658
|
+
}
|
|
659
|
+
// ── calc ──────────────────────────────────────────────────────────────────────
|
|
660
|
+
/**
|
|
661
|
+
* `calc` lives here because of one answer: a recalculation rebuilds every
|
|
662
|
+
* step from the recording, so the service refuses `--force` on a plan with
|
|
663
|
+
* hand edits (D4) unless `--discard-edits` says to throw them away.
|
|
664
|
+
*
|
|
665
|
+
* A `run_` id posts `/recordings/runs/:id/calculate`, which reaches only the
|
|
666
|
+
* run's **whole-run** scenario. A `scn_` id posts `/scenarios/:id/recalculate`
|
|
667
|
+
* — the only way to recalculate a sub-task (segment) scenario from here, and
|
|
668
|
+
* so the only way to take a segment's hand edits back once undo cannot (C12).
|
|
669
|
+
* A scenario id always names one that exists, so it is always a
|
|
670
|
+
* recalculation in place, and asks for `--force` as the run form does.
|
|
671
|
+
*/
|
|
672
|
+
async function calc(args, deps) {
|
|
673
|
+
const { out } = deps;
|
|
674
|
+
const target = args.positionals[1];
|
|
675
|
+
if (!target)
|
|
676
|
+
return usageError(deps, "calc");
|
|
677
|
+
if (args.discardEdits && !args.force) {
|
|
678
|
+
return usageError(deps, "calc", "--discard-edits only means something with --force (a recalculation in place).");
|
|
679
|
+
}
|
|
680
|
+
const byScenario = target.startsWith("scn_");
|
|
681
|
+
if (byScenario && !args.force) {
|
|
682
|
+
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).`);
|
|
683
|
+
}
|
|
684
|
+
const reply = byScenario
|
|
685
|
+
? await deps.service("POST", `/scenarios/${seg(target)}/recalculate`, args.discardEdits ? { discardEdits: true } : {})
|
|
686
|
+
: await deps.service("POST", `/recordings/runs/${seg(target)}/calculate`, args.force ? { force: true, ...(args.discardEdits ? { discardEdits: true } : {}) } : {});
|
|
687
|
+
if ("error" in reply)
|
|
688
|
+
return explain(reply, args, deps, { verb: "Could not start calculation", target });
|
|
689
|
+
if (args.json) {
|
|
690
|
+
out(JSON.stringify(reply.body ?? null, null, 2));
|
|
691
|
+
return reply.status === 202 ? 0 : 1;
|
|
692
|
+
}
|
|
693
|
+
const { status, body } = reply;
|
|
694
|
+
const code = body?.error;
|
|
695
|
+
if (status === 202) {
|
|
696
|
+
const b = body;
|
|
697
|
+
out(`${byScenario ? "Recalculating" : "Calculating"} ${b.scenarioId ?? target} — poll with \`bir scenario show ${target}\`.`);
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
if (status === 404) {
|
|
701
|
+
out(byScenario ? `No such scenario, or it is not yours: ${target}.` : `No such run, or it is not yours: ${target}.`);
|
|
702
|
+
return 1;
|
|
703
|
+
}
|
|
704
|
+
if (status === 409 && code === "scenario_has_edits") {
|
|
705
|
+
const steps = body.editedSteps;
|
|
706
|
+
const list = Array.isArray(steps) ? steps.map(String) : [];
|
|
707
|
+
const which = list.length === 0 ? "some steps were" : list.length === 1 ? `step ${list[0]} was` : `steps ${list.join(", ")} were`;
|
|
708
|
+
out(`Not recalculated: ${which} edited by hand, and a recalculation rebuilds every step.`);
|
|
709
|
+
out(` To recalculate anyway and discard the edits: bir scenario calc ${target} --force --discard-edits`);
|
|
710
|
+
out(" (the edits stay in `bir scenario edits`, marked replaced)");
|
|
711
|
+
return 1;
|
|
712
|
+
}
|
|
713
|
+
if (status === 409 && code === "calculation_in_flight") {
|
|
714
|
+
out("Another calculation of yours is running, and the service calculates one at a time. Try again when it is ready.");
|
|
715
|
+
return 1;
|
|
716
|
+
}
|
|
717
|
+
// With --force the run route answers `scenario_exists` for one reason only:
|
|
718
|
+
// that scenario is being calculated right now. `scenario_calculating` is the
|
|
719
|
+
// scenario route's word for the same thing (C16).
|
|
720
|
+
if (status === 409 && (code === "scenario_calculating" || (code === "scenario_exists" && args.force))) {
|
|
721
|
+
out(`A calculation of ${target} is already running. Wait for it to finish (\`bir scenario show ${target}\`).`);
|
|
722
|
+
return 1;
|
|
723
|
+
}
|
|
724
|
+
if (status === 409 && code === "scenario_exists") {
|
|
725
|
+
out("That run already has a scenario. Re-derive it in place with --force.");
|
|
726
|
+
return 1;
|
|
727
|
+
}
|
|
728
|
+
if (status === 503 && code === "calculation_paused_today") {
|
|
729
|
+
out("Calculation is paused for today: the service has reached its daily calculation budget. Try again tomorrow.");
|
|
730
|
+
return 1;
|
|
731
|
+
}
|
|
732
|
+
if (status === 503 && code === "anthropic_not_configured") {
|
|
733
|
+
out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
|
|
734
|
+
return 1;
|
|
735
|
+
}
|
|
736
|
+
out(`Could not start calculation (HTTP ${status}${code ? `, ${code}` : ""}): ${JSON.stringify(body)}`);
|
|
737
|
+
return 1;
|
|
738
|
+
}
|
|
739
|
+
// ── editing on|off|status ─────────────────────────────────────────────────────
|
|
740
|
+
/**
|
|
741
|
+
* Which tools this project's `bir` MCP server offers (D2). Stored in the
|
|
742
|
+
* project's record in `installed.json`, next to the replay switches, so a
|
|
743
|
+
* server Claude Code starts with its own environment reads the same answer.
|
|
744
|
+
* The server reads it when the session lists its tools, hence the restart.
|
|
745
|
+
*/
|
|
746
|
+
function editing(args, deps) {
|
|
747
|
+
const { out, cwd } = deps;
|
|
748
|
+
const word = args.positionals[1];
|
|
749
|
+
if (word !== "on" && word !== "off" && word !== "status")
|
|
750
|
+
return usageError(deps, "editing");
|
|
751
|
+
const sidecar = readSidecar();
|
|
752
|
+
if (word !== "status") {
|
|
753
|
+
if (!setEditing(sidecar, cwd, word === "on")) {
|
|
754
|
+
out("This directory has not been installed — run `bir setup` (or `bir install --replay`) here first.");
|
|
755
|
+
return 1;
|
|
756
|
+
}
|
|
757
|
+
writeSidecar(sidecar);
|
|
758
|
+
}
|
|
759
|
+
describeEditing(out, sidecar, cwd);
|
|
760
|
+
if (word !== "status")
|
|
761
|
+
out("Restart Claude Code in this project: a running session does not see the change.");
|
|
762
|
+
return 0;
|
|
763
|
+
}
|
|
764
|
+
function describeEditing(out, sidecar, cwd) {
|
|
765
|
+
const on = editingEnabled(sidecar, cwd);
|
|
766
|
+
out(`Scenario editing for ${cwd}: ${on ? "on" : "off"}`);
|
|
767
|
+
if (!projectRecord(sidecar, cwd)) {
|
|
768
|
+
out(" (this directory has not been installed — `bir setup` here first)");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (!hasScenarioServer(sidecar, cwd)) {
|
|
772
|
+
out(" (no `bir` MCP server is installed here, so no tool is offered at all — `bir install --replay` adds it)");
|
|
773
|
+
}
|
|
774
|
+
out(` the bir MCP server offers ${READ_TOOLS.join(", ")} (they read)`);
|
|
775
|
+
if (on)
|
|
776
|
+
out(` and ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
|
|
777
|
+
else
|
|
778
|
+
out(` \`bir scenario editing on\` adds ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
|
|
779
|
+
out(" The `bir scenario` commands work either way: this switch decides only the MCP tools.");
|
|
780
|
+
}
|
|
781
|
+
function hasScenarioServer(sidecar, cwd) {
|
|
782
|
+
const here = normalizePath(cwd);
|
|
783
|
+
return Object.entries(sidecar.servers ?? {}).some(([key, record]) => key.endsWith(`::${SCENARIO_SERVER_KEY}`) && normalizePath(record.cwd) === here);
|
|
784
|
+
}
|
|
785
|
+
// ── shared ────────────────────────────────────────────────────────────────────
|
|
786
|
+
const seg = (id) => encodeURIComponent(id);
|
|
787
|
+
const when = (iso) => iso ? iso.replace("T", " ").replace(/\.\d+Z$/, "Z") : "—";
|
|
788
|
+
function usageError(deps, sub, why) {
|
|
789
|
+
if (why)
|
|
790
|
+
deps.out(why);
|
|
791
|
+
deps.out(USAGE[sub] ?? "usage: bir scenario …");
|
|
792
|
+
return 2;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* A failure this side decided on; `--json` still gets JSON, in the service's
|
|
796
|
+
* `{error, details}` shape (with `extra` beside them when there is more to say).
|
|
797
|
+
*/
|
|
798
|
+
function localFailure(args, deps, code, text, extra = {}) {
|
|
799
|
+
deps.out(args.json ? JSON.stringify({ error: code, details: text, ...extra }, null, 2) : text);
|
|
800
|
+
return 1;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* A run with no whole-run scenario. When sub-task (segment) scenarios were
|
|
804
|
+
* calculated from it, they are listed with their `scn_` ids — the only ids
|
|
805
|
+
* they have — instead of suggesting a calculation, which would start a new
|
|
806
|
+
* whole-run plan beside them rather than reach the one the person meant (C21).
|
|
807
|
+
*/
|
|
808
|
+
function noWholeRunScenario(target, segments, args, deps) {
|
|
809
|
+
const listed = (Array.isArray(segments) ? segments : []).filter((s) => typeof s?.id === "string");
|
|
810
|
+
if (listed.length === 0) {
|
|
811
|
+
return localFailure(args, deps, "no_scenario", `${target} has no calculated scenario yet — \`bir scenario calc ${target}\` first.`);
|
|
812
|
+
}
|
|
813
|
+
const range = (s) => typeof s.stepFrom === "number" && typeof s.stepTo === "number" ? ` steps ${s.stepFrom}–${s.stepTo} of the recording` : "";
|
|
814
|
+
const text = [
|
|
815
|
+
`${target} has no whole-run scenario, only sub-task scenarios — and a sub-task scenario is addressed by its own scn_ id:`,
|
|
816
|
+
...listed.map((s) => ` ${s.id}${range(s)}`),
|
|
817
|
+
].join("\n");
|
|
818
|
+
return localFailure(args, deps, "no_whole_run_scenario", text, { segments: listed.map((s) => s.id) });
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* A `run_` id is the recording's; the routes that change a scenario take its
|
|
822
|
+
* `scn_` id (D7). A `run_` id reaches only the run's **whole-run** scenario; a
|
|
823
|
+
* `scn_` id is used as it is — sub-task scenarios have nothing else.
|
|
824
|
+
*/
|
|
825
|
+
async function resolveTarget(target, args, deps) {
|
|
826
|
+
if (target.startsWith("scn_"))
|
|
827
|
+
return { id: target };
|
|
828
|
+
const reply = await deps.service("GET", `/recordings/runs/${seg(target)}/scenario`);
|
|
829
|
+
if ("error" in reply || reply.status !== 200) {
|
|
830
|
+
return {
|
|
831
|
+
code: explain(reply, args, deps, {
|
|
832
|
+
verb: "Could not find the scenario",
|
|
833
|
+
target,
|
|
834
|
+
notFound: `No scenario for ${target}: it is not a run of yours, or nothing was calculated from it yet (\`bir scenario calc ${target}\`).`,
|
|
835
|
+
}),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
const served = reply.body;
|
|
839
|
+
const id = served?.scenario?.id;
|
|
840
|
+
if (typeof id !== "string")
|
|
841
|
+
return { code: noWholeRunScenario(target, served?.segments, args, deps) };
|
|
842
|
+
return { id };
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Every answer that is not the one asked for, in words — with the service's
|
|
846
|
+
* error code in brackets, because that is what the guide's troubleshooting
|
|
847
|
+
* table is keyed on. `--json` gets the service's body unchanged.
|
|
848
|
+
*/
|
|
849
|
+
function explain(reply, args, deps, e) {
|
|
850
|
+
const { out } = deps;
|
|
851
|
+
if ("error" in reply) {
|
|
852
|
+
if (args.json)
|
|
853
|
+
out(JSON.stringify({ error: "service_unreachable", details: reply.error }, null, 2));
|
|
854
|
+
else
|
|
855
|
+
out(`Could not reach the service: ${reply.error}`);
|
|
856
|
+
return 1;
|
|
857
|
+
}
|
|
858
|
+
if (args.json) {
|
|
859
|
+
out(JSON.stringify(reply.body ?? null, null, 2));
|
|
860
|
+
return 1;
|
|
861
|
+
}
|
|
862
|
+
const body = (reply.body && typeof reply.body === "object" ? reply.body : {});
|
|
863
|
+
const code = typeof body.error === "string" ? body.error : "";
|
|
864
|
+
const target = e.target ?? "<id>";
|
|
865
|
+
const stepFlag = e.step !== undefined ? ` --step ${e.step}` : "";
|
|
866
|
+
if (reply.status === 404) {
|
|
867
|
+
out(e.notFound ??
|
|
868
|
+
`${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.`);
|
|
869
|
+
return 1;
|
|
870
|
+
}
|
|
871
|
+
if (reply.status === 401) {
|
|
872
|
+
out(`${e.verb}: the service did not accept this machine's session (HTTP 401) — run \`bir login\`.`);
|
|
873
|
+
return 1;
|
|
874
|
+
}
|
|
875
|
+
if (reply.status === 413) {
|
|
876
|
+
// By status, not code: a proxy in front of the service answers 413 with a
|
|
877
|
+
// page of its own, and the cause is the same.
|
|
878
|
+
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.`);
|
|
879
|
+
return 1;
|
|
880
|
+
}
|
|
881
|
+
switch (code) {
|
|
882
|
+
case "invalid_input": {
|
|
883
|
+
const details = brief(body.details);
|
|
884
|
+
out(`${e.verb} (invalid_input): the service found the request invalid${details ? ` — ${details}` : ""}.`);
|
|
885
|
+
return 1;
|
|
886
|
+
}
|
|
887
|
+
case "not_a_tool_step":
|
|
888
|
+
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.`);
|
|
889
|
+
return 1;
|
|
890
|
+
case "nothing_to_change":
|
|
891
|
+
// The empty command is refused on this side before any request, so a
|
|
892
|
+
// service `nothing_to_change` always means the change is already the
|
|
893
|
+
// step's — naming the flags again would tell the person to repeat
|
|
894
|
+
// themselves (C15, C27).
|
|
895
|
+
out("Nothing to change: the step already has this code and this mark.");
|
|
896
|
+
return 1;
|
|
897
|
+
case "logic_sandbox_unavailable":
|
|
898
|
+
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.`);
|
|
899
|
+
return 1;
|
|
900
|
+
case "note_required":
|
|
901
|
+
out(`${e.verb} (note_required): --force needs --note "why".`);
|
|
902
|
+
return 1;
|
|
903
|
+
case "not_ready":
|
|
904
|
+
case "scenario_calculating":
|
|
905
|
+
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.`);
|
|
906
|
+
return 1;
|
|
907
|
+
case "revision_changed": {
|
|
908
|
+
const now = typeof body.chainRevision === "number" ? ` — it is at revision ${body.chainRevision} now` : "";
|
|
909
|
+
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.`);
|
|
910
|
+
return 1;
|
|
911
|
+
}
|
|
912
|
+
case "not_latest_edit": {
|
|
913
|
+
// Undo walks back (D9), so this is a real way through now: undoing the
|
|
914
|
+
// newer entry puts the step back as this one left it, and then this one
|
|
915
|
+
// can be undone. It used to loop, when undo only toggled (C23).
|
|
916
|
+
const latest = typeof body.latestEditId === "string" ? body.latestEditId : "<the newer edit>";
|
|
917
|
+
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:`);
|
|
918
|
+
out(` bir scenario undo ${target} --edit ${latest}`);
|
|
919
|
+
out(` bir scenario undo ${target} --edit ${e.editId ?? "<this edit>"}`);
|
|
920
|
+
return 1;
|
|
921
|
+
}
|
|
922
|
+
case "already_undone": {
|
|
923
|
+
const by = typeof body.revertedById === "string" ? body.revertedById : undefined;
|
|
924
|
+
out(`${e.verb} (already_undone): ${e.editId ?? "that edit"} was undone already${by ? `, by ${by}` : ""}. To redo it, undo that undo:`);
|
|
925
|
+
out(` bir scenario undo ${target} --edit ${by ?? "<the undo's sedit_ id, from bir scenario edits>"}`);
|
|
926
|
+
return 1;
|
|
927
|
+
}
|
|
928
|
+
case "edit_replaced":
|
|
929
|
+
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.`);
|
|
930
|
+
return 1;
|
|
931
|
+
case "rate_limited": {
|
|
932
|
+
const ms = typeof body.retryAfterMs === "number" ? body.retryAfterMs : undefined;
|
|
933
|
+
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"}.`);
|
|
934
|
+
return 1;
|
|
935
|
+
}
|
|
936
|
+
default: {
|
|
937
|
+
const details = brief(body.details);
|
|
938
|
+
out(`${e.verb} (HTTP ${reply.status}${code ? `, ${code}` : ""})${details ? `: ${details}` : ""}.`);
|
|
939
|
+
return 1;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
/** A service's `details`, short enough for one line. */
|
|
944
|
+
function brief(details) {
|
|
945
|
+
if (details === undefined || details === null)
|
|
946
|
+
return "";
|
|
947
|
+
const text = typeof details === "string" ? details : JSON.stringify(details);
|
|
948
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
949
|
+
}
|
|
950
|
+
//# sourceMappingURL=scenario-edit.js.map
|