import { capBytes, capModelVisibleText } from "./sanitize.ts"; import { formatFlowError, type FlowDetails, type FlowError, type FlowMode, type FlowRunResult, type ModeOutput } from "./types.ts"; /** * The registered refusal footer's own byte allowance. A recovery pointer is a * line or two; bounding it here keeps {@link Settle.refuse}'s bound structural * — the full refusal never exceeds the model-visible cap plus this — instead * of resting on the registering mode's discipline. */ const REGISTERED_FOOTER_CAP = 2 * 1024; /** * Per-field byte allowances for a refusal's model-visible text. Message, * cause, and fix are the variable-length fields a FlowError carries — git * stderr and failed-child reports reach the cause, and user-supplied params * (a worktree baseRef, say) reach interpolated messages — and capping the * assembled text alone truncates from the tail, which is exactly where * formatFlowError puts the Retryable/Fix/Code lines a reader recovers by. * Bounding each field before formatting keeps their sum (37 KiB plus fixed * labels and the code enum) under the 50 KiB model-visible cap, so the outer * cap can only ever truncate a per-call footer and the structured suffix * survives no matter what a caller interpolates. Model-visible only: the * details and trace evidence keep the error object uncut. */ const REFUSAL_MESSAGE_CAP = 8 * 1024; const REFUSAL_CAUSE_CAP = 25 * 1024; const REFUSAL_FIX_CAP = 4 * 1024; /** * The curried details builder a settle is constructed over — the shape * `ModeDeps.makeDetails(mode)` returns, already bound to one mode. */ export type SettleDetailsBuilder = (results: FlowRunResult[], error?: FlowError) => FlowDetails; /** * The settle object for one mode invocation: the mode identity, the runs * accumulated so far, and the invariant that every output — refusal or * completion — carries them. * * The invariant used to live in a prose comment on the details constructor * ("error paths pass what already ran") and be re-satisfied by hand at every * return site, which is exactly where it broke: an error output built with an * empty results array makes a run that spent real tokens vanish from the flow * card, the ledger, and the lesson gate. Here the accumulated runs move only * through {@link track}, and both output shapes read them from the same place, * so an output that drops a tracked run is not constructible. * * Mode identity is fixed at construction. The one production constructor is * the mode registry (modes/registry.ts), which builds each handler's settle * from that handler's own table entry — so the mode a settled output reports * is the registry's, never a literal re-typed inside a handler body. * Constructed by {@link makeSettle}; tests build theirs through the same * factory so fakes cannot drift. */ export class Settle { readonly #tracked: FlowRunResult[] = []; readonly #buildDetails: SettleDetailsBuilder; #decorate: (details: FlowDetails) => FlowDetails = (details) => details; #footer: () => string = () => ""; /** The mode this invocation settles under, fixed at construction. */ readonly mode: FlowMode; constructor(mode: FlowMode, buildDetails: SettleDetailsBuilder) { this.mode = mode; this.#buildDetails = buildDetails; } /** * The runs tracked so far, in step order. Read-only by type: handlers read * prior results to build later tasks, but the accumulation itself moves only * through {@link track} — a push site outside this object is the drift this * module exists to end. */ get results(): readonly FlowRunResult[] { return this.#tracked; } /** The 1-based step the next dispatched run will hold — the one home of step arithmetic, so no dispatch site hand-computes `results.length + 1`. */ get nextStep(): number { return this.#tracked.length + 1; } /** * Append settled runs, returning the 1-based step of the last one. Track a * run before any return path that could carry an output: a tracked run is * what keeps its spend visible. */ track(...results: FlowRunResult[]): number { this.#tracked.push(...results); return this.#tracked.length; } /** * The refusal shape every error return reduces to: the formatted error (plus * an optional footer, e.g. worktree's recovery locations) over details that * carry every tracked run and the error itself. The model-visible cap is * applied here, over the formatted error and the per-call footer together — * with every variable-length field bounded first (the per-field allowances * above), so a refusal built over megabytes of git stderr or an unbounded * interpolated param still shows its Retryable/Fix/Code lines — not at * return sites, which is how two modes ended up hand-assembling the * capped message and slicing the formatted prefix back off. The registered * footer ({@link decorateFooter}) lands after the cap: it is the short * recovery pointer a truncated refusal needs most, so truncation must not * be able to swallow it — and it is bounded by its own small allowance, so * the whole refusal stays capped without trusting the registering mode. */ refuse(error: FlowError, options: { footer?: string } = {}): ModeOutput { const bounded = { ...error, message: capBytes(error.message, REFUSAL_MESSAGE_CAP, "Message"), cause: capBytes(error.cause, REFUSAL_CAUSE_CAP, "Cause"), fix: capBytes(error.fix, REFUSAL_FIX_CAP, "Fix"), }; const body = capModelVisibleText(`${formatFlowError(bounded)}${options.footer ?? ""}`); return { content: [{ type: "text", text: `${body}${capBytes(this.#footer(), REGISTERED_FOOTER_CAP, "Recovery pointer")}` }], details: this.details(error), }; } /** * A custom-prose output over the tracked runs, with no error in details. * Covers both the success text and the failure paths that deliberately * return sanitized prose without an error object (a failed run's text is the * mode's own to word; the run itself stays visible through the results). */ complete(text: string): ModeOutput { return { content: [{ type: "text", text }], details: this.details(), }; } /** * Register a decoration applied to every subsequent output's details, * refuse and complete alike — workflow's approval-receipt decoration, once, * instead of a parallel details helper per return site. The latest * registration wins; a decorator reads live state through its closure. */ decorateDetails(decorator: (details: FlowDetails) => FlowDetails): void { this.#decorate = decorator; } /** * Register a footer appended to every subsequent refusal — worktree's * integration-branch recovery pointer, once, after the branch exists — * instead of a string literal re-written at every return site, which is how * two refusals shipped telling users to inspect a branch they never named. * Refusal vocabulary only: {@link complete}'s text is the mode's own. The * latest registration wins; the footer reads live state through its closure. */ decorateFooter(footer: () => string): void { this.#footer = footer; } /** Details over a snapshot of the tracked runs, so later tracking cannot rewrite an output already returned. */ private details(error?: FlowError): FlowDetails { return this.#decorate(this.#buildDetails([...this.#tracked], error)); } } /** * The one construction path. The registry calls it per dispatch with the * contract entry's own mode (`makeSettle(contract.mode, * deps.makeDetails(contract.mode))`); test deps construct theirs the same way. */ export function makeSettle(mode: FlowMode, buildDetails: SettleDetailsBuilder): Settle { return new Settle(mode, buildDetails); } /** * The checked accessor for a handler's settle. Every dispatched handler has * one — the registry (modes/registry.ts) binds it before the handler runs — * and the field is optional on `ModeDeps` only because the aggregate supplies * deps ahead of that binding. Absence is therefore a wiring bug in a caller * that skipped the registry, and it fails loud here instead of surfacing as a * non-null assertion in every handler. */ export function modeSettle(deps: { settle?: Settle }): Settle { if (!deps.settle) { throw new Error("ModeDeps.settle is unset: handlers receive their settle from the mode registry (modes/registry.ts); test deps must construct one with makeSettle."); } return deps.settle; }