Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions packages/runner/src/runtime/jev-executor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { renderJourneySteps } from "@shipbash/contracts/runtime/journey-steps.mj
import { splitWaiting } from "./action-requests.mjs";
import { chooseJevAction, closingOptions, pageOptions } from "./jev-actions.mjs";
import { judge, movedBeforeHandBack, record, settle, stepRemains } from "./jev-checklist.mjs";
import { readSteps, whichStep } from "./jev-step-questions.mjs";
import { attribute, refuse, stepLedger, walkInputs } from "./jev-steps.mjs";
import { readStepText, readSteps, TEXT_LIFTED_BELOW, whichStep } from "./jev-step-questions.mjs";
import { attribute, liftTextWall, recheckable, refuse, stepLedger, walkInputs } from "./jev-steps.mjs";
import { checkInterruption, movedFrom, readPage, waitOut } from "./jev-page-reading.mjs";
import { withRoute } from "./jev-route.mjs";
import { interruption, JEV_REASONS } from "./jev-stops.mjs";
Expand Down Expand Up @@ -182,15 +182,36 @@ async function stillOnPage(run, state, action) {
return url.trim() === state.url && pageState(page) === pageState(state.page) && namesLiveElement(page, selector);
}

/**
* The ledger's refusal of `action` (jev-steps.mjs refuse), unless the step
* that stops it is a text wall whose own command has run and ended: that step
* is read once more, on the terminal (readStepText), and below
* TEXT_LIFTED_BELOW it stops the walk no longer — a step that types text only
* on a condition its command did not print. Recorded either way.
*/
async function refusal(run, state, step, action) {
const refused = refuse(run.ledger, step, action);
if (refused?.code !== "step_needs_text" || !recheckable(refused.step, state.terminal)) return refused;
refused.step.rechecked = true;
const read = { steps: run.instructions, history: run.history, terminal: state.terminal };
const { needsText } = await run.readStepText(read, refused.step.number - 1, run.request);
run.requests += 1;
const lifted = needsText !== null && needsText < TEXT_LIFTED_BELOW;
await record(run, "recheck", { choice: lifted ? "lifted" : "kept", step: refused.step.number, needsText });
if (!lifted) return refused;
liftTextWall(run.ledger, refused.step);
return refuse(run.ledger, step, action);
}

/**
* Takes the chosen action. One read off the page is checked against the page
* first: it may have moved on while Jev chose, or remounted the element the
* choice names. A terminal action names nothing on the page, so a page that
* moved does not discard it, and the screen is not read again: a spinner
* redraws it between any two readings. Beside that read, the step the action
* performs is named (jev-walk-steps.mjs stepFor), and the ledger may refuse it
* (jev-steps.mjs refuse): the walk then hands that step back before the
* action reaches the page.
* (refusal): the walk then hands that step back before the action reaches the
* page.
* @param {boolean} fromPage whether the action was read off the page
*/
async function act(run, state, action, fromPage) {
Expand All @@ -206,7 +227,7 @@ async function act(run, state, action, fromPage) {
run.guidance = "Page changed during selection; previous choice was NOT executed.";
return outcome(null);
}
const refused = refuse(run.ledger, step, action);
const refused = await refusal(run, state, step, action);
await record(run, "step", { choice: refused?.code ?? step ?? "current", step: refused?.step.number ?? step });
if (refused) return outcome(refused.code);
await perform(run, action);
Expand Down Expand Up @@ -288,6 +309,7 @@ function newRun(input, options) {
terminalSteps,
choose: options.choose ?? chooseJevAction,
readSteps: options.readSteps ?? readSteps,
readStepText: options.readStepText ?? readStepText,
whichStep: options.whichStep ?? whichStep,
onDecision: options.onDecision ?? (() => {}),
onCheckpoint: options.onCheckpoint ?? (() => {}),
Expand Down
44 changes: 44 additions & 0 deletions packages/runner/src/runtime/jev-step-questions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ import { acceptedNoul, JEV_MODEL } from "./jev-target.mjs";
*/
export const NEEDS_TEXT_AT = 0.23;
export const NO_ACTION_BELOW = 0.13;
/**
* A step read again once its command has ended (readStepText) stops nothing
* below TEXT_LIFTED_BELOW. On the 25 production walls of a conditional setup
* step ("if its first JSON line has need_user_action, … run the
* resume_command it prints") whose condition never showed, it read 0.05–0.06
* (75 readings); with that condition's line on the screen, 0.81–0.89 (75). A
* step that types a value it gives in backticks read 0.41–0.46 and stays a wall.
*/
export const TEXT_LIFTED_BELOW = 0.2;

const needsText = (step) => ({
type: "noul",
Expand Down Expand Up @@ -68,6 +77,27 @@ const needsAction = (step) => ({
},
});

const stillNeedsText = (step) => ({
type: "noul",
instructions: `\`${step}\` was read as typing text it does not give. Its command has run: does it still need that text typed, as \`terminal\` shows the command now?`,
criteria: {
true: {
what: "The step types text it does not give whatever the command printed; or it types that text only if a condition it names is shown — a status, a prompt or a message — and `terminal.screen` shows that condition; or the command is still running (`terminal.running` is true); or `terminal.screen` does not show the command's result.",
examples: [
'step "Run `acme setup --agent`. If its first JSON line has "status":"need_user_action", open the url it prints, then run the resume_command it prints"; `terminal.screen` shows {"step":"auth","status":"need_user_action","url":"https://acme.test/cli/auth?ticket=8f2","resume_command":"acme setup --agent --login-ticket 8f2"}',
'step "Run `acme login` and enter the one-time code it prints"; `terminal.screen` shows "Enter code: "',
],
},
false: {
what: "The step types its unquoted text only if a condition it names is shown, the command has ended (`terminal.running` is false), and `terminal.screen` shows another result where that condition would be.",
examples: [
'step "Run `acme setup --agent`. If its first JSON line has "status":"need_user_action", open the url it prints, then run the resume_command it prints"; `terminal.screen` shows {"step":"auth","status":"ok"} then {"step":"done","status":"ok"} and the shell prompt',
'step "Run `acme init`; if it asks for a project name, type the name the owner gave"; `terminal.screen` shows "Initialized project acme-web" and the shell prompt',
],
},
},
});

/** The answers to `questions` about `state`, and how long they took. */
async function ask(state, questions, { jev, signal = undefined, redact = (text) => text, fetchImpl = fetch }) {
const started = Date.now();
Expand Down Expand Up @@ -148,3 +178,17 @@ export async function stepsNeedingText(instructions, jev) {
return null;
}
}

/**
* Whether a step read as typing text it does not give (readSteps) still needs
* that text once its command has run, asked on what the terminal shows: a
* step that types it only on a condition the command did not print is no wall
* to the steps after it. `state` carries `steps`, `history` and `terminal`.
* An unanswered reading is null.
* @param {{ steps: ReadonlyArray<string>, history: unknown[], terminal: unknown }} state
* @param {number} index the step's 0-based index in `state.steps`
*/
export async function readStepText(state, index, request) {
const { result, durationMs } = await ask(state, { text: stillNeedsText(`steps[${String(index)}]`) }, request);
return { durationMs, needsText: acceptedNoul(result, "text") };
}
22 changes: 19 additions & 3 deletions packages/runner/src/runtime/jev-steps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { JEV_REASONS } from "./jev-stops.mjs";
import { terminalSteps, withoutCodeSpans } from "./jev-terminal.mjs";

/**
* @typedef {{ number: number, id: string, instruction: string, values: string[], commands: string[], inputs: string[], keys: string[], needsText: number | null, needsAction: number | null, actions: string[], pressed: string[], remembered?: boolean }} LedgerStep
* @typedef {{ number: number, id: string, instruction: string, values: string[], commands: string[], inputs: string[], keys: string[], needsText: number | null, needsAction: number | null, actions: string[], pressed: string[], remembered?: boolean, rechecked?: boolean, lifted?: boolean }} LedgerStep
* @typedef {{ code: "step_needs_text" | "step_skipped" | "steps_not_taken", step: LedgerStep }} HandBack
* @typedef {{ steps: LedgerStep[], handedBack: HandBack | null, completed: boolean }} Ledger
*/
Expand Down Expand Up @@ -67,8 +67,8 @@ export function readInto(ledger, { needsText, needsAction }) {

/** Whether a step types text it does not give; unanswered counts as yes. */
// A step that types text it does not quote, unless the walk typed for it what the Journey's newest passing walk
// typed there (jev-route.mjs: a remembered value).
const typesUnquoted = (step) => !step.remembered && (step.needsText === null || step.needsText >= NEEDS_TEXT_AT);
// typed there (jev-route.mjs: a remembered value), or its own command showed it no longer needs it (liftTextWall).
const typesUnquoted = (step) => !step.remembered && !step.lifted && (step.needsText === null || step.needsText >= NEEDS_TEXT_AT);
/** Whether a step needs an action of its own; unanswered or unsure counts as yes. */
const needsAction = (step) => step.needsAction === null || step.needsAction >= NO_ACTION_BELOW;

Expand Down Expand Up @@ -104,6 +104,22 @@ export function refuse(ledger, number, action) {
return handBack;
}

/**
* Whether the step a text wall stopped at may be read again on the terminal
* (the executor's recheck): once, after the walk ran the step's own command
* and that command has ended.
* @param {{ running?: boolean } | null | undefined} terminal the round's terminal reading
*/
export function recheckable(step, terminal) {
return !step.rechecked && step.actions.length > 0 && terminal?.running === false;
}

/** A step whose command showed it no longer needs its unquoted text stops nothing from now on. */
export function liftTextWall(ledger, step) {
step.lifted = true;
ledger.handedBack = null;
}

/**
* The step an action belongs to when code knows it: the one step whose own
* text the action types or runs. Undefined when the action types nothing, or
Expand Down
Loading