@basein/runner 0.2.11 → 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 +33 -14
- package/dist/bin/bir-scenario.d.ts +11 -0
- package/dist/bin/bir-scenario.js +132 -34
- package/dist/bin/bir.d.ts +2 -2
- package/dist/bin/bir.js +11 -8
- package/dist/bin/investigate.js +1 -1
- package/dist/bin/scenario-edit.d.ts +36 -9
- package/dist/bin/scenario-edit.js +240 -61
- 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 +89 -29
- package/docs/calculatedReplayGuide.md +294 -86
- package/docs/quickstart.md +42 -2
- package/package.json +1 -1
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
|
|
8
8
|
* [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
|
|
9
9
|
* bir scenario edits <runId|scnId>
|
|
10
|
-
* bir scenario undo <runId|scnId> [--edit <sedit_id>]
|
|
11
|
-
* bir scenario calc <runId> [--force [--discard-edits]]
|
|
10
|
+
* bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
|
|
11
|
+
* bir scenario calc <runId|scnId> [--force [--discard-edits]]
|
|
12
12
|
* bir scenario editing on|off|status
|
|
13
13
|
*
|
|
14
14
|
* WHY THIS IS A CLIENT AND NOTHING MORE. Whether a change may be saved is
|
|
@@ -51,8 +51,8 @@ const USAGE = {
|
|
|
51
51
|
edit: "usage: bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]\n" +
|
|
52
52
|
' [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>] [--json]',
|
|
53
53
|
edits: "usage: bir scenario edits <runId|scnId> [--json]",
|
|
54
|
-
undo:
|
|
55
|
-
calc: "usage: bir scenario calc <runId> [--force [--discard-edits]]",
|
|
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
56
|
editing: "usage: bir scenario editing on|off|status",
|
|
57
57
|
};
|
|
58
58
|
export async function scenarioEditCommand(args, deps) {
|
|
@@ -79,7 +79,11 @@ export async function scenarioEditCommand(args, deps) {
|
|
|
79
79
|
/**
|
|
80
80
|
* The raw JSON, as it always was — scripts and the guide read it. A `scn_` id
|
|
81
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
|
|
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).
|
|
83
87
|
*/
|
|
84
88
|
async function show(args, deps) {
|
|
85
89
|
const { out } = deps;
|
|
@@ -101,11 +105,10 @@ async function show(args, deps) {
|
|
|
101
105
|
out(JSON.stringify(reply.body, null, 2));
|
|
102
106
|
return 0;
|
|
103
107
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
}
|
|
108
|
+
const served = reply.body;
|
|
109
|
+
const scenario = served?.scenario;
|
|
110
|
+
if (!scenario)
|
|
111
|
+
return noWholeRunScenario(target, served?.segments, args, deps);
|
|
109
112
|
if (!Array.isArray(scenario.steps)) {
|
|
110
113
|
out(`${scenario.id ?? target} is ${scenario.state ?? "not ready"}, so it has no steps to show yet.`);
|
|
111
114
|
return 1;
|
|
@@ -116,7 +119,7 @@ async function show(args, deps) {
|
|
|
116
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)"}.`);
|
|
117
120
|
return 1;
|
|
118
121
|
}
|
|
119
|
-
out(JSON.stringify(step, null, 2));
|
|
122
|
+
out(JSON.stringify({ scenarioId: scenario.id ?? null, chainRevision: scenario.chainRevision ?? null, ...step }, null, 2));
|
|
120
123
|
return 0;
|
|
121
124
|
}
|
|
122
125
|
/**
|
|
@@ -182,18 +185,27 @@ async function checkOrEdit(args, deps, save) {
|
|
|
182
185
|
const report = reply.body?.report;
|
|
183
186
|
if (args.json) {
|
|
184
187
|
out(JSON.stringify(reply.body, null, 2));
|
|
185
|
-
return report?.ok ? 0 : 1;
|
|
188
|
+
return report?.ok || report?.noChange ? 0 : 1;
|
|
186
189
|
}
|
|
187
190
|
if (!report) {
|
|
188
191
|
out("The service answered the check without a report.");
|
|
189
192
|
return 1;
|
|
190
193
|
}
|
|
191
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
|
+
}
|
|
192
202
|
if (report.ok) {
|
|
193
203
|
out(`Nothing saved. To save: ${editCommand(target, args)}`);
|
|
194
204
|
return 0;
|
|
195
205
|
}
|
|
196
|
-
refusal(out, report
|
|
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".');
|
|
197
209
|
return 1;
|
|
198
210
|
}
|
|
199
211
|
if (args.freeze)
|
|
@@ -217,9 +229,11 @@ async function checkOrEdit(args, deps, save) {
|
|
|
217
229
|
return refused ? 1 : 0;
|
|
218
230
|
}
|
|
219
231
|
if (answer?.report)
|
|
220
|
-
renderReport(out, answer.report);
|
|
221
|
-
if (refused) {
|
|
222
|
-
refusal(out, answer
|
|
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".');
|
|
223
237
|
return 1;
|
|
224
238
|
}
|
|
225
239
|
const edit = answer?.edit;
|
|
@@ -283,12 +297,28 @@ const label = (name, text) => ` ${name.padEnd(9)}${text}`;
|
|
|
283
297
|
* The report in the lines editSteps.md "What a person sees" shows:
|
|
284
298
|
* `input`, `output` (when the output logic changed), `later`, `answer` (when
|
|
285
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.
|
|
286
312
|
*/
|
|
287
|
-
export function renderReport(out, r) {
|
|
313
|
+
export function renderReport(out, r, opts = {}) {
|
|
314
|
+
const brief = opts.brief === true;
|
|
288
315
|
out(`Step ${r.stepIndex} (${r.toolName ?? "?"}) — checked against ${r.sourceRunId}`);
|
|
289
316
|
const i = r.input ?? { changed: false, before: "not_checked", after: "not_checked" };
|
|
290
317
|
const unchanged = i.changed ? "" : " (input logic unchanged)";
|
|
291
|
-
if (
|
|
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") {
|
|
292
322
|
const d = i.firstDifference ?? firstDifference(i.computed, i.recorded);
|
|
293
323
|
out(label("input", `differs from the recorded call${d ? ` at character ${d.at}` : ""}${unchanged}`));
|
|
294
324
|
if (d) {
|
|
@@ -314,8 +344,18 @@ export function renderReport(out, r) {
|
|
|
314
344
|
}
|
|
315
345
|
const later = r.later ?? [];
|
|
316
346
|
const regressed = later.filter((l) => l.regressed);
|
|
317
|
-
if (
|
|
318
|
-
|
|
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"));
|
|
319
359
|
}
|
|
320
360
|
else if (regressed.length === 0) {
|
|
321
361
|
const still = later.filter((l) => l.after === "reproduces").map((l) => l.stepIndex);
|
|
@@ -330,18 +370,21 @@ export function renderReport(out, r) {
|
|
|
330
370
|
out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) reproduced before; now it ${laterPhrase(l.after)}`));
|
|
331
371
|
}
|
|
332
372
|
}
|
|
333
|
-
if (r.answer?.informativeBefore) {
|
|
373
|
+
if (r.answer?.informativeBefore && !(brief && r.answer.informativeAfter)) {
|
|
334
374
|
out(label("answer", r.answer.informativeAfter
|
|
335
375
|
? "still gets its values"
|
|
336
376
|
: "no longer gets its values: the final answer's logic stops finding what it reads"));
|
|
337
377
|
}
|
|
338
378
|
if (r.mark?.before && r.mark.after) {
|
|
339
379
|
const { before, after } = r.mark;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
}
|
|
345
388
|
}
|
|
346
389
|
}
|
|
347
390
|
function inputPhrase(status, verifiable) {
|
|
@@ -413,8 +456,27 @@ function sentence(problem) {
|
|
|
413
456
|
const lowered = /^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text;
|
|
414
457
|
return /[.!?]$/.test(lowered) ? lowered : `${lowered}.`;
|
|
415
458
|
}
|
|
416
|
-
|
|
417
|
-
|
|
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"];
|
|
418
480
|
if (list.length === 1) {
|
|
419
481
|
out(`${opening}: ${sentence(list[0])}`);
|
|
420
482
|
}
|
|
@@ -489,17 +551,28 @@ function editSummary(e) {
|
|
|
489
551
|
return parts.length ? parts.join("; ") : "no change to the code or the mark";
|
|
490
552
|
}
|
|
491
553
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
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.
|
|
496
566
|
*/
|
|
497
|
-
export function newestUndoable(edits) {
|
|
567
|
+
export function newestUndoable(edits, step) {
|
|
498
568
|
// Newest first, as the service lists them; sorted again so a reordering
|
|
499
569
|
// there cannot make this undo the wrong one.
|
|
500
570
|
return [...edits]
|
|
501
571
|
.sort((x, y) => (x.createdAt < y.createdAt ? 1 : x.createdAt > y.createdAt ? -1 : 0))
|
|
502
|
-
.find((e) =>
|
|
572
|
+
.find((e) => e.kind !== "revert" &&
|
|
573
|
+
!e.revertedAt &&
|
|
574
|
+
!e.replacedAt &&
|
|
575
|
+
(step === undefined || e.stepIndex === step));
|
|
503
576
|
}
|
|
504
577
|
async function undo(args, deps) {
|
|
505
578
|
const { out } = deps;
|
|
@@ -509,6 +582,15 @@ async function undo(args, deps) {
|
|
|
509
582
|
if (args.edit !== undefined && !/^sedit_[\w-]+$/.test(args.edit)) {
|
|
510
583
|
return usageError(deps, "undo", `--edit takes a sedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
|
|
511
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
|
+
}
|
|
512
594
|
const resolved = await resolveTarget(target, args, deps);
|
|
513
595
|
if ("code" in resolved)
|
|
514
596
|
return resolved.code;
|
|
@@ -517,34 +599,61 @@ async function undo(args, deps) {
|
|
|
517
599
|
const listed = await fetchEdits(resolved.id, deps);
|
|
518
600
|
if ("reply" in listed)
|
|
519
601
|
return explain(listed.reply, args, deps, { verb: "Could not read the history", target });
|
|
520
|
-
const pick = newestUndoable(listed.edits);
|
|
602
|
+
const pick = newestUndoable(listed.edits, args.step);
|
|
521
603
|
if (!pick) {
|
|
522
|
-
return localFailure(args, deps, "nothing_to_undo",
|
|
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.`);
|
|
523
607
|
}
|
|
524
608
|
editId = pick.id;
|
|
525
609
|
}
|
|
526
610
|
const body = {};
|
|
527
|
-
if (
|
|
528
|
-
body.note =
|
|
611
|
+
if (note)
|
|
612
|
+
body.note = note;
|
|
613
|
+
if (args.force)
|
|
614
|
+
body.force = true;
|
|
529
615
|
if (args.revision !== undefined)
|
|
530
616
|
body.expectedRevision = args.revision;
|
|
531
617
|
const reply = await deps.service("POST", `/scenarios/${seg(resolved.id)}/edits/${seg(editId)}/revert`, body);
|
|
532
|
-
|
|
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)) {
|
|
533
621
|
return explain(reply, args, deps, {
|
|
534
622
|
verb: "Not undone",
|
|
535
623
|
target,
|
|
624
|
+
editId,
|
|
536
625
|
notFound: `Not undone (not_found): ${target} has no edit ${editId}, or it is not yours.`,
|
|
537
626
|
});
|
|
538
627
|
}
|
|
539
628
|
if (args.json) {
|
|
540
629
|
out(JSON.stringify(reply.body, null, 2));
|
|
541
|
-
return 0;
|
|
630
|
+
return refused ? 1 : 0;
|
|
542
631
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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;
|
|
546
642
|
out(`Step ${entry?.stepIndex ?? "?"} is back as it was before ${entry?.revertsEditId ?? editId} (revision ${revision ?? "?"}).` +
|
|
547
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
|
+
}
|
|
548
657
|
return 0;
|
|
549
658
|
}
|
|
550
659
|
// ── calc ──────────────────────────────────────────────────────────────────────
|
|
@@ -552,6 +661,13 @@ async function undo(args, deps) {
|
|
|
552
661
|
* `calc` lives here because of one answer: a recalculation rebuilds every
|
|
553
662
|
* step from the recording, so the service refuses `--force` on a plan with
|
|
554
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.
|
|
555
671
|
*/
|
|
556
672
|
async function calc(args, deps) {
|
|
557
673
|
const { out } = deps;
|
|
@@ -561,7 +677,13 @@ async function calc(args, deps) {
|
|
|
561
677
|
if (args.discardEdits && !args.force) {
|
|
562
678
|
return usageError(deps, "calc", "--discard-edits only means something with --force (a recalculation in place).");
|
|
563
679
|
}
|
|
564
|
-
const
|
|
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 } : {}) } : {});
|
|
565
687
|
if ("error" in reply)
|
|
566
688
|
return explain(reply, args, deps, { verb: "Could not start calculation", target });
|
|
567
689
|
if (args.json) {
|
|
@@ -572,9 +694,13 @@ async function calc(args, deps) {
|
|
|
572
694
|
const code = body?.error;
|
|
573
695
|
if (status === 202) {
|
|
574
696
|
const b = body;
|
|
575
|
-
out(
|
|
697
|
+
out(`${byScenario ? "Recalculating" : "Calculating"} ${b.scenarioId ?? target} — poll with \`bir scenario show ${target}\`.`);
|
|
576
698
|
return 0;
|
|
577
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
|
+
}
|
|
578
704
|
if (status === 409 && code === "scenario_has_edits") {
|
|
579
705
|
const steps = body.editedSteps;
|
|
580
706
|
const list = Array.isArray(steps) ? steps.map(String) : [];
|
|
@@ -588,15 +714,26 @@ async function calc(args, deps) {
|
|
|
588
714
|
out("Another calculation of yours is running, and the service calculates one at a time. Try again when it is ready.");
|
|
589
715
|
return 1;
|
|
590
716
|
}
|
|
591
|
-
|
|
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") {
|
|
592
725
|
out("That run already has a scenario. Re-derive it in place with --force.");
|
|
593
726
|
return 1;
|
|
594
727
|
}
|
|
595
|
-
if (status === 503) {
|
|
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") {
|
|
596
733
|
out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
|
|
597
734
|
return 1;
|
|
598
735
|
}
|
|
599
|
-
out(`Could not start calculation (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
736
|
+
out(`Could not start calculation (HTTP ${status}${code ? `, ${code}` : ""}): ${JSON.stringify(body)}`);
|
|
600
737
|
return 1;
|
|
601
738
|
}
|
|
602
739
|
// ── editing on|off|status ─────────────────────────────────────────────────────
|
|
@@ -654,15 +791,36 @@ function usageError(deps, sub, why) {
|
|
|
654
791
|
deps.out(USAGE[sub] ?? "usage: bir scenario …");
|
|
655
792
|
return 2;
|
|
656
793
|
}
|
|
657
|
-
/**
|
|
658
|
-
|
|
659
|
-
|
|
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);
|
|
660
800
|
return 1;
|
|
661
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
|
+
}
|
|
662
820
|
/**
|
|
663
821
|
* A `run_` id is the recording's; the routes that change a scenario take its
|
|
664
|
-
* `scn_` id (D7). A `
|
|
665
|
-
* nothing else.
|
|
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.
|
|
666
824
|
*/
|
|
667
825
|
async function resolveTarget(target, args, deps) {
|
|
668
826
|
if (target.startsWith("scn_"))
|
|
@@ -677,12 +835,10 @@ async function resolveTarget(target, args, deps) {
|
|
|
677
835
|
}),
|
|
678
836
|
};
|
|
679
837
|
}
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
};
|
|
685
|
-
}
|
|
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) };
|
|
686
842
|
return { id };
|
|
687
843
|
}
|
|
688
844
|
/**
|
|
@@ -716,6 +872,12 @@ function explain(reply, args, deps, e) {
|
|
|
716
872
|
out(`${e.verb}: the service did not accept this machine's session (HTTP 401) — run \`bir login\`.`);
|
|
717
873
|
return 1;
|
|
718
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
|
+
}
|
|
719
881
|
switch (code) {
|
|
720
882
|
case "invalid_input": {
|
|
721
883
|
const details = brief(body.details);
|
|
@@ -726,7 +888,14 @@ function explain(reply, args, deps, e) {
|
|
|
726
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.`);
|
|
727
889
|
return 1;
|
|
728
890
|
case "nothing_to_change":
|
|
729
|
-
|
|
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.`);
|
|
730
899
|
return 1;
|
|
731
900
|
case "note_required":
|
|
732
901
|
out(`${e.verb} (note_required): --force needs --note "why".`);
|
|
@@ -741,9 +910,19 @@ function explain(reply, args, deps, e) {
|
|
|
741
910
|
return 1;
|
|
742
911
|
}
|
|
743
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).
|
|
744
916
|
const latest = typeof body.latestEditId === "string" ? body.latestEditId : "<the newer edit>";
|
|
745
|
-
out(`${e.verb} (not_latest_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:`);
|
|
746
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>"}`);
|
|
747
926
|
return 1;
|
|
748
927
|
}
|
|
749
928
|
case "edit_replaced":
|
package/dist/control/server.js
CHANGED
|
@@ -28,7 +28,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
28
28
|
import { FINGERPRINT_WINDOW_MS, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
|
|
29
29
|
import { StepIndexAllocator } from "./ordering.js";
|
|
30
30
|
import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, usageSince, } from "./transcript.js";
|
|
31
|
-
import {
|
|
31
|
+
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
32
32
|
import { redact } from "../record/redact.js";
|
|
33
33
|
import { serializeCapped } from "../record/truncate.js";
|
|
34
34
|
import { StepQueue } from "../record/queue.js";
|
|
@@ -732,7 +732,13 @@ export class ControlServer {
|
|
|
732
732
|
// calculated scenario: its generated reasoning is injected into every
|
|
733
733
|
// matched turn's steering directive, and because it is not an MCP tool it
|
|
734
734
|
// drags the whole scenario out of `direct` mode. Observed in production.
|
|
735
|
-
|
|
735
|
+
// The runner's own `bir` — its MCP tools and a Bash command that only runs
|
|
736
|
+
// it — is housekeeping too (D13): a session that fixes a plan must never
|
|
737
|
+
// become a scenario that edits plans when it is replayed. Returning here
|
|
738
|
+
// also keeps it out of intent matching, the fragment and the plan.
|
|
739
|
+
if (isHousekeepingCall(toolName, modelArgs)) {
|
|
740
|
+
if (toolUseId)
|
|
741
|
+
(run.housekeepingUses ??= new Set()).add(toolUseId);
|
|
736
742
|
logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
|
|
737
743
|
return {};
|
|
738
744
|
}
|
|
@@ -761,7 +767,7 @@ export class ControlServer {
|
|
|
761
767
|
// Runs before anything else, because two of its four answers end the call.
|
|
762
768
|
let pinned;
|
|
763
769
|
if (run.replay?.plan && !run.replay.retired) {
|
|
764
|
-
const action = await this.replay.preTool(run.replay, toolName, toolUseId);
|
|
770
|
+
const action = await this.replay.preTool(run.replay, toolName, toolUseId, modelArgs);
|
|
765
771
|
// A hand-over inside `preTool` (an input logic that threw) must still
|
|
766
772
|
// schedule the fragment, even though this call ends here.
|
|
767
773
|
this.noteHandover(run);
|
|
@@ -1266,8 +1272,15 @@ export class ControlServer {
|
|
|
1266
1272
|
const toolUseId = payload.tool_use_id ?? "";
|
|
1267
1273
|
const toolName = payload.tool_name ?? "unknown";
|
|
1268
1274
|
// `onToolPre` opened no step for these, so closing one here would record a
|
|
1269
|
-
// `tool_response` with no `tool_selected` before it.
|
|
1270
|
-
|
|
1275
|
+
// `tool_response` with no `tool_selected` before it. The id it remembered
|
|
1276
|
+
// decides first; the call itself is judged again only when no step was
|
|
1277
|
+
// opened for it — a post whose pre never reached this server. A step the
|
|
1278
|
+
// pre did open (a plan pinned the model's call to a command that happens
|
|
1279
|
+
// to be `bir`) is closed as usual: its output is what the plan threads.
|
|
1280
|
+
const skippedAtPre = toolUseId !== "" && run.housekeepingUses?.delete(toolUseId) === true;
|
|
1281
|
+
if (skippedAtPre)
|
|
1282
|
+
return {};
|
|
1283
|
+
if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input))
|
|
1271
1284
|
return {};
|
|
1272
1285
|
// Nor for an unarmed `run_scenario`. The plan may have retired since the
|
|
1273
1286
|
// pre hook, so the test is whether that hook opened a step, not the plan.
|
|
@@ -27,8 +27,79 @@
|
|
|
27
27
|
* possibly be the user's task. `Read`, `Bash` and `Grep` are NOT here: they do
|
|
28
28
|
* real work, they belong in a recording, and reaching for one mid-replay
|
|
29
29
|
* usually *is* the model doing the task another way.
|
|
30
|
+
*
|
|
31
|
+
* THE RUNNER'S OWN `bir` IS HOUSEKEEPING TOO (editSteps.md D13), and that is
|
|
32
|
+
* the one reason this file now looks at a call's input and not only its name.
|
|
33
|
+
* A session that fixes a plan — `bir investigate`, then `bir scenario edit`, or
|
|
34
|
+
* the same through the `bir` MCP server's `scenario_*` tools — is exactly the
|
|
35
|
+
* kind of session that gets recorded and calculated. Recorded, it becomes a
|
|
36
|
+
* scenario whose steps *edit plans*, and a steered or direct replay of it would
|
|
37
|
+
* then change a plan unattended, on a prompt nobody meant as "change the plan":
|
|
38
|
+
* the very thing D2 keeps the editing tools switched off in a fleet to prevent.
|
|
39
|
+
* And mid-replay, a model that stops to read `bir investigate` has not left the
|
|
40
|
+
* plan; it is reading about it.
|
|
41
|
+
*
|
|
42
|
+
* So two more kinds of call are housekeeping, and both are still narrow:
|
|
43
|
+
*
|
|
44
|
+
* - every tool of the `bir` MCP server **except `run_scenario`**, which is
|
|
45
|
+
* the direct plan's delivery vehicle and keeps its own handling (it is
|
|
46
|
+
* recorded, tagged `pinnedBy`, while its plan is live);
|
|
47
|
+
* - a `Bash` call whose command is **nothing but** `bir` invocations
|
|
48
|
+
* ({@link isBirOnlyCommand}), and the same for a `PowerShell` call
|
|
49
|
+
* ({@link isBirOnlyPowerShell}) — Claude Code on Windows reaches for its
|
|
50
|
+
* PowerShell tool as readily as for Bash, and a `bir scenario edit` run
|
|
51
|
+
* there is the same act. `npm test && bir investigate` is not: the
|
|
52
|
+
* `npm test` half is real work, and hiding it from the recording would be
|
|
53
|
+
* hiding the task. When in doubt the answer is "not housekeeping", because
|
|
54
|
+
* a `bir` call recorded by mistake costs a noisy step, while real work
|
|
55
|
+
* skipped by mistake is a plan with a hole in it.
|
|
30
56
|
*/
|
|
31
57
|
/** Host tools that are never recorded as steps and never count as divergence. */
|
|
32
58
|
export declare const HOUSEKEEPING_TOOLS: ReadonlySet<string>;
|
|
59
|
+
/** By name alone — the host's own bookkeeping tools. See {@link isHousekeepingCall} for the full rule. */
|
|
33
60
|
export declare function isHousekeeping(toolName: string): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* The direct plan's delivery vehicle. Must equal `DIRECT_TOOL_NAME` in the
|
|
63
|
+
* replay controller (a test holds them together); spelled out here because
|
|
64
|
+
* the controller imports this file.
|
|
65
|
+
*/
|
|
66
|
+
export declare const RUN_SCENARIO_TOOL = "mcp__bir__run_scenario";
|
|
67
|
+
/**
|
|
68
|
+
* Whether one call is host housekeeping: never recorded as a step, never a
|
|
69
|
+
* divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
|
|
70
|
+
* a `Bash` call's `command` is read from it.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isHousekeepingCall(toolName: string, toolInput?: unknown): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* {@link isBirOnlyCommand} for PowerShell, whose rules differ enough that the
|
|
75
|
+
* Bash reader would get it wrong: a backslash is a path separator, not an
|
|
76
|
+
* escape; the escape is the backtick; `$(…)` runs code even inside double
|
|
77
|
+
* quotes; `&` at the start of a part is the call operator. Deliberately
|
|
78
|
+
* narrower than the Bash reader: parts split on `;`, `&&`, `||` and newlines
|
|
79
|
+
* (outside quotes); each is `bir …` (as {@link isBirInvocation} reads it, also
|
|
80
|
+
* after a leading `&`) or a plain `cd`/`Set-Location`/`Push-Location <dir>`.
|
|
81
|
+
* A pipe, a backtick, `$(` or `@(` anywhere, a brace or parenthesis outside
|
|
82
|
+
* quotes, an `@` starting a word, or a quote left open makes the answer false — for the same
|
|
83
|
+
* reason as there: when unsure, record it.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isBirOnlyPowerShell(command: string): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* True when a shell command runs `bir` and nothing else: split on `&&`, `||`,
|
|
88
|
+
* `;`, `|`, `&` and newlines (outside quotes), every part is a `bir`
|
|
89
|
+
* invocation or a plain `cd <dir>`, and at least one is `bir`.
|
|
90
|
+
*
|
|
91
|
+
* A `bir` invocation is `bir` / `bir.cmd` / a path ending in `bir`, `bir.cmd`,
|
|
92
|
+
* `bir.js` or `bir.ps1`; `npx [-y] [-p @basein/runner…] [@basein/runner…] bir …`;
|
|
93
|
+
* or `node <path>/bir.js …` — each optionally after `NAME=value` assignments,
|
|
94
|
+
* which only set `bir`'s own environment. A heredoc is read as the data it is,
|
|
95
|
+
* because `bir scenario check … --input-logic - <<'EOF'` is the natural way to
|
|
96
|
+
* hand `bir` a logic body, and its lines are JavaScript, not commands.
|
|
97
|
+
*
|
|
98
|
+
* Anything that could run other code while looking like `bir` makes the answer
|
|
99
|
+
* false: a command substitution (`$(…)`, backticks) outside single quotes and
|
|
100
|
+
* quoted heredocs, and a subshell, group or process substitution (`(`, `{`).
|
|
101
|
+
* So does a quote or heredoc left open — a parse this side is not sure of is
|
|
102
|
+
* not one to hide a step on.
|
|
103
|
+
*/
|
|
104
|
+
export declare function isBirOnlyCommand(command: string): boolean;
|
|
34
105
|
//# sourceMappingURL=housekeeping.d.ts.map
|