/** * The step scrubber: a run read one step at a time. Selecting a step shows the * call that was made, the whole of what went into it and came back as * formatted JSON, and the handles in scope by that point — the same reading * for a run that finished an hour ago and one that is still going. */ import { html, LitElement, nothing, type TemplateResult } from "lit"; import { isObjectOrArray } from "@commonfabric/utils/types"; import { type ConsoleArgumentRef, type ConsoleHandle, type ConsoleStep, consoleStepArguments, } from "../steps.ts"; const json = (value: unknown): string => { try { return JSON.stringify(value, null, 2); } catch { // A payload holding a cycle is not one the run produced, but showing // something beats an empty pane. return String(value); } }; /** The one way a cell is drawn, wherever this view draws one. */ import "./cell-chip.ts"; /** * How long a run of numbers has to be before the page calls it out. Numbers * are never sealed, so a long array of them is a channel wide enough to carry * arbitrary content — well past what an inert scalar result looks like. */ const WIDE_NUMERIC_RUN = 32; /** * The short names of a label's clauses. An atom's `type` is a CFC atom URL * whose last segment identifies it — `ExternalIngest` for the atom marking * a value that arrived from outside the fabric. A clause is * arbitrary CFC JSON, so one that is not a typed atom is reported by its shape * rather than dropped: a clause the page cannot name is still one the label * carries. */ const atomNames = (clauses: readonly unknown[] = []): string[] => clauses.map((clause) => { const type = isObjectOrArray(clause) ? (clause as { type?: unknown }).type : undefined; return typeof type === "string" ? type.split("/").pop() ?? type : JSON.stringify(clause) ?? "clause"; }); /** * Where the scrubber sits when `count` steps are what it is scrubbing. A run * shorter than the one that was open cannot hold the step it was scrubbed to, * so the selection follows the steps down rather than pointing past their end, * where the rail highlights nothing and an arrow key moves from a step that is * not there. */ export const clampSelection = (selected: number, count: number): number => Math.min(Math.max(selected, 0), Math.max(count - 1, 0)); /** Elides a value's rendering so one long literal cannot fill the pane. */ const truncate = (text: string, limit: number): string => text.length > limit ? `${text.slice(0, limit - 1)}…` : text; /** A step's one-line label in the rail. */ const stepLabel = (step: ConsoleStep): string => step.kind === "tool" ? step.toolName ?? "tool" : step.kind; const omissionLabels = { "artifact-only": "kept on the artifact, not sent to the model", "observation-denied": "withheld by policy", "bare-fabric-identifier-scrub": "bare-fabric-identifier-scrub", "model-context-truncation": "model-context-truncation", "superseded-run-pattern-diagnostic-collapse": "superseded-run-pattern-diagnostic-collapse", }; /** The expandable omission block's label for one tool result. */ export const withheldSummary = (step: ConsoleStep): string => step.withheld.status === "unrecorded" ? "model omissions · no record" : step.withheld.status === "record-unreadable" ? "model omissions · record unreadable" : step.withheld.status === "record-entry-missing" ? "model omissions · entry missing" : `${ [ ...new Set( step.withheld.locations.map((location) => omissionLabels[location.rule] ), ), ].join("; ") || "model omissions" } · ${step.withheld.locations.length}`; /** * What the full tool artifact records beside the model-facing result, under * `summary`, which says by default what omission rules applied. */ export const withheldView = ( step: ConsoleStep, summary = withheldSummary(step), ): TemplateResult => { if (step.withheld.status === "unrecorded") { return html`
${summary}

No omission record exists for this tool result. Legacy runs cannot be reconstructed honestly from the model-facing transcript alone.

`; } if (step.withheld.status === "record-unreadable") { return html`
${summary}

The omission record exists but is unreadable or does not match the current contract. This result cannot be reconstructed honestly.

`; } if (step.withheld.status === "record-entry-missing") { return html`
${summary}

The omission record exists but has no entry for this tool result. Which omission rules applied cannot be determined.

`; } if (step.withheld.locations.length === 0) { return html`
${summary}

No omission rule applied to this result.

`; } const modelResult = step.output !== undefined ? json(step.output) : step.outputText; return html`
${summary} ${modelResult === undefined ? nothing : html`

Model received: ${truncate(modelResult, 200)}

`} ${step.withheld.locations.map((location) => html`
${omissionLabels[location.rule]}
${location.artifactPath}${location.jsonPointer}
${location.available ? html`
${location.redaction ??
                  json(location.value)}
` : html`

The recorded artifact position is unavailable.

`}
` )}
`; }; /** * The labels CFC computed for one call's inputs. Taint and the prompt slot's * influence are separate views of the same inputs; both are listed, one row * per entry, each on its own axis. */ const stepLabelEntries = (step: ConsoleStep) => [ ...step.invocation?.cfcInputLabels?.entries ?? [], ...step.invocation?.promptSlotInfluenceLabels?.entries ?? [], ]; /** * Whether CFC recorded anything about one call: a decision, an event, or * labels on its inputs. */ export const stepCfcRecorded = (step: ConsoleStep): boolean => step.policy !== undefined || step.policyEvents.length > 0 || stepLabelEntries(step).length > 0; /** What CFC decided about one call, and any event it raised. */ export const stepPolicyView = ( step: ConsoleStep, ): TemplateResult | typeof nothing => { const labelEntries = stepLabelEntries(step); if (!stepCfcRecorded(step)) { return nothing; } return html`
cfc
${step.policy === undefined ? nothing : html`
${step.policy.decision} ${step.policy.effectClass === undefined ? nothing : html` ${step.policy.effectClass} `} ${step.policy.reasonCodes.join(", ")} ${step.policy.decision === "withheld" ? html` withheld by policy ` : nothing}
`} ${step.policyEvents.map((event) => html`
${event.severity} ${event.detail ?? ""}
` )} ${labelEntries.length === 0 ? nothing : html` ${labelEntries.map((entry) => html` ` )}
${entry.path.length === 0 ? "(whole input)" : entry.path.join(".")} ${atomNames(entry.label?.confidentiality).length === 0 && atomNames(entry.label?.integrity).length === 0 ? html` no confidentiality atom ` : atomNames(entry.label?.confidentiality).map((name) => html` ${name} ` )} ${atomNames(entry.label?.integrity).map((name) => html` ${name} ` )}
`}
`; }; export class ConsoleSteps extends LitElement { static override properties = { steps: { attribute: false }, handles: { attribute: false }, selected: { attribute: false }, }; declare steps: readonly ConsoleStep[]; declare handles: readonly ConsoleHandle[]; declare selected: number; constructor() { super(); this.steps = []; this.handles = []; this.selected = 0; } protected override createRenderRoot(): HTMLElement { return this; } protected override willUpdate(changed: Map): void { if (changed.has("steps")) { this.selected = clampSelection(this.selected, this.steps.length); } } override connectedCallback(): void { super.connectedCallback(); globalThis.addEventListener("keydown", this.#onKey); } override disconnectedCallback(): void { super.disconnectedCallback(); globalThis.removeEventListener("keydown", this.#onKey); } /** * Arrow keys scrub. A key pressed while the task box has focus is that box's * to handle, so the shortcut yields to any editable element. */ readonly #onKey = (event: KeyboardEvent): void => { const target = event.target as HTMLElement | null; if ( target !== null && (target.isContentEditable || target.tagName === "TEXTAREA" || target.tagName === "INPUT") ) { return; } if (event.key === "ArrowLeft") { this.#select(this.selected - 1); } else if (event.key === "ArrowRight") { this.#select(this.selected + 1); } }; #select(index: number): void { if (this.steps.length === 0) { return; } this.selected = clampSelection(index, this.steps.length); // The flow aside marks where the reader is, so it is told rather than // left to guess from a property it does not own. this.dispatchEvent( new CustomEvent("step-selected", { detail: this.selected, bubbles: true, }), ); } /** The handles in scope at a step, the ones it introduced first. */ #scopeAt(step: ConsoleStep): readonly ConsoleHandle[] { const introduced = new Set(step.handlesIntroduced); return this.handles .filter((handle) => step.handlesInScope.includes(handle.token)) .sort((left, right) => (introduced.has(right.token) ? 1 : 0) - (introduced.has(left.token) ? 1 : 0) ); } #rail(): TemplateResult { return html`
${this.steps.map((step) => html` ` )}
`; } #handles(step: ConsoleStep): TemplateResult { const scope = this.#scopeAt(step); if (scope.length === 0) { return html`
handles in scope

None yet.

`; } return html`
handles in scope (${scope.length})
${scope.map((handle) => html` ` )}
${handle.ref ?? "—"} ${step.handlesIntroduced.includes(handle.token) ? "new here" : `step ${handle.introducedAtStep}`}
`; } /** * What this call was given, argument by argument. A reference is a chip * rather than a string: it carries the name the cell has, the handle behind * it, the shape it declared and the labels riding on it, and it leads back * to the step that produced it — which is the whole of tracing a value * through a run. */ #arguments(step: ConsoleStep): TemplateResult | typeof nothing { const args = consoleStepArguments(step, this.handles); if (args.length === 0) { return nothing; } const references = args.filter((argument) => argument.isReference); return html`
arguments ${references.length} ${references.length === 1 ? "reference" : "references"}
${args.map((argument) => argument.isReference ? this.#reference(argument) : html`
${argument.key} ${truncate(json(argument.value), 120)} value ${argument.confidentiality.map((name) => html` ${name} ` )} ${argument.integrity.map((name) => html` ${name} ` )}
` )}
`; } #reference(argument: ConsoleArgumentRef): TemplateResult { const origin = argument.producedByStep; return html`
${argument.key} ${origin === undefined ? html` from an earlier turn ` : html` `}
`; } /** * What the result let across as a value. The harness seals a string the * schema does not pin to an enum or a const, but never a number — so an * array of them is a channel as wide as its author cared to make it, and * the size of one is stated here rather than left to be counted out of the * JSON below. */ #disclosure(step: ConsoleStep): TemplateResult | typeof nothing { const disclosure = step.disclosure; if (disclosure === undefined) { return nothing; } const wide = disclosure.longestNumericRun >= WIDE_NUMERIC_RUN; return html`
disclosure
${disclosure.valueBytes} B as value ${disclosure .sealedPositions} sealed behind a reference${disclosure .longestNumericRun === 0 ? "" : ` · longest numeric run ${disclosure.longestNumericRun}`}
${wide ? html`
A run of ${disclosure .longestNumericRun} numbers crossed as value. Numbers are never sealed, so an array of them carries whatever its author chose to encode.
` : nothing}
`; } /** What the model was thinking before the step, when the provider said. */ #reasoning(step: ConsoleStep): unknown { return step.reasoning === undefined ? nothing : html`
thinking
${step.reasoning}
`; } #detail(step: ConsoleStep): TemplateResult { if (step.kind !== "tool") { return html` ${this.#reasoning(step)}
${step.kind}
${step.text ?? ""}
${this.#handles(step)} `; } return html` ${this.#reasoning(step)}
${step.toolName} ${step.status}
${this.#handles(step)} ${this.#arguments(step)} ${stepPolicyView( step, )} ${this.#disclosure(step)}
${step.toolName} input
${step.input !== undefined
          ? json(step.input)
          : step.inputText ?? "—"}
${step.sourceReplacedByLaterAttempt !== true ? nothing : html`

Source replaced by a later attempt; see the run-pattern-source sidecar named by the marker.

`}
${step .toolName} output ${step.resultRef?.outputId === undefined ? nothing : html` ${step.resultRef.outputId} `}
${step.output !== undefined
            ? json(step.output)
            : step.outputText ?? "—"}
${step.childRunId === undefined ? nothing : html` `}
${withheldView(step)}
`; } protected override render(): TemplateResult { if (this.steps.length === 0) { return html`

This run recorded no transcript.

`; } const step = this.steps[clampSelection(this.selected, this.steps.length)]; return html`
step ${step.index} of ${this.steps.length - 1}
${this.#rail()} ${this.#detail(step)} `; } } customElements.define("console-steps", ConsoleSteps);