Decisions

ADR-070: Playbook UI contract

We ship a **single Svelte component — `<PlaybookRunner>` — as the canonical frontend binding to the ADR-068 playbook runtime**. The component accepts a playbook id plus optional initial inputs, renders the playbook's…

#070

ADR-070: Playbook UI contract

Status: Accepted Date: 2026-04-23 Related: ADR-068 (playbook runtime architecture), ADR-067 (playbooks unified primitive), ADR-066 (microvisualisations — sparklines + timeline strips), ADR-062 (design system + component strategy), ADR-065 (persona-aware IA + niceties), ADR-059 (project-first IA), ADR-053 (frontend observability)

Decision (one paragraph)

We ship a single Svelte component — <PlaybookRunner> — as the canonical frontend binding to the ADR-068 playbook runtime. The component accepts a playbook id plus optional initial inputs, renders the playbook's input form from its JSON-Schema, POSTs /api/v1/playbook-runs, subscribes to the SSE events stream, renders each step's output in an append-only terminal pane (ANSI coloured via anser, not xterm.js), handles awaiting_input events in a modal (Escape cancels the whole run, prompt_secret masks, approval is Yes/No), and renders a final report at run completion that embeds ADR-066 microvisualisations. The component ships first in ai-frontend at src/lib/components/playbooks/PlaybookRunner.svelte and graduates to @gyrum-labs/svelte as <gy-playbook-runner> once it has two independent consumers inside ai-frontend and one outside (per ADR-062's "prove then lift" pattern). The biggest migration win is the existing /projects/import wizard — its four hand-coded steps (Discover → Triage → Review → Execute, with polling + transcript) collapse into a kind: onboarding, id: import-projects playbook rendered by a <PlaybookRunner> instance.

Context

ADR-068 put the runtime in place: YAML-declared playbooks, a Postgres-backed queue, step executors, an SSE events stream, and a POST /input endpoint for interactive replies. The runtime streams events; it does not render. The rendering contract is deliberately deferred to this ADR so the runtime PRs can ship without blocking on frontend work.

The frontend problem is narrower than the runtime's but harder than it looks. Three constraints collide:

  • Every playbook run looks the same on screen. The operator's framing (ADR-068, quoted above) is explicit: "i like when you deploy you show the termianl output". Install-grafana, exp-315-rerun, gyrum-complete-pr, and a future /projects/import all present the same four phases: fill in inputs, watch steps execute, answer mid-run prompts, read the final report. Today each of those has a bespoke UI (or, more often, no UI at all — a laptop terminal). A fifth bespoke UI would be the drift ADR-062 exists to prevent.

  • The runtime is authoritative. ADR-068 §3 nails the executor contract: events flow server → client, replies flow client → server via a separate POST. The frontend does not execute anything. It does not re-run a failed step. It does not know what a step "is" beyond the event stream. Every UI decision must survive a deploy of the runtime that adds a new step type without touching the frontend.

  • The existing /projects/import wizard is the canary. The four-step Discover → Triage → Review → Execute flow at ai-frontend/src/routes/projects/import/+page.svelte is exactly the shape this ADR subsumes. It has localStorage draft persistence, a step breadcrumb, a 1-second poll loop against /api/v1/projects/import, conflict-handling retry paths, and ~350 lines of Svelte plus a StepExecute.svelte transcript pane. All of it — minus the project-specific triage heuristics — is what <PlaybookRunner> provides for free once its runner is an ADR-068 playbook. The migration is the proof.

Four rendering decisions force the issue now: (1) a terminal pane that append-renders ANSI-coloured lines without the weight of a full VT100 emulator; (2) an awaiting-input UX that stops the pane, presents the question unambiguously, and round-trips a reply; (3) a generic form renderer over the InputSpec union from ADR-068 §9; (4) a uniform report shape, using ADR-066's sparkline + timeline-strip primitives that already named this the "report-rendering role".

This ADR is the frontend mirror of ADR-068: same decision weight, different side of the wire. Without it, Phase 2 lands with a throwaway Svelte page that /projects/import's migration then rewrites from scratch — paying for a bespoke runner twice.

Decision

1. <PlaybookRunner> — one component, six responsibilities

A single Svelte component binds the runtime. It is the only component in the fleet that talks to /api/v1/playbook-runs directly.

1.1 Public API

<script lang="ts">
  import PlaybookRunner from '$lib/components/playbooks/PlaybookRunner.svelte';
  import { goto } from '$app/navigation';
</script>

<PlaybookRunner
  playbookId="service_runbooks:install-grafana"
  initialInputs={{ server: 'buzzy-web-01' }}
  idempotencyKey={crypto.randomUUID()}
  on:started={(e) => console.log('run started:', e.detail.runId)}
  on:complete={(e) => goto(`/runs/${e.detail.runId}`)}
  on:cancelled={(e) => goto('/runs')}
  on:failed={(e) => goto(`/runs/${e.detail.runId}`)}
/>

Props (TypeScript):

export interface PlaybookRunnerProps {
  /** kind:id per ADR-067 — e.g. "service_runbooks:install-grafana". */
  playbookId: string;

  /**
   * Initial values for the inputs form. Keys match InputSpec.name.
   * Unknown keys are silently dropped; required inputs absent here
   * render as empty form fields with the author's `default` if any.
   */
  initialInputs?: Record<string, unknown>;

  /**
   * Optional client-chosen UUID for idempotent POSTs (ADR-068 §6).
   * When omitted, PlaybookRunner generates one on mount and holds it
   * for the lifetime of the component — refreshing the page with the
   * same render produces a new key (by design: a page refresh is a
   * new intent).
   */
  idempotencyKey?: string;

  /**
   * If true, skip the inputs form and start immediately with
   * `initialInputs`. Only valid when every required input has a
   * value in initialInputs; otherwise the form renders anyway.
   * Defaults to false.
   */
  autoStart?: boolean;

  /**
   * Controls the terminal pane's max line buffer. Defaults to 10,000
   * lines per step; a chatty `rsync` step can blow past this. The
   * runtime's `output_mode:` (ADR-068 §3) is the authoritative cap;
   * this prop is a belt-and-braces guard against the browser tab
   * running out of DOM nodes.
   */
  maxLinesPerStep?: number;
}

Events (Svelte 5 createEventDispatcher):

Event Detail Fired when
started { runId: string } POST /playbook-runs succeeded; SSE is opening
complete { runId: string, outputs: Record<string, unknown> } run_completed event with state: completed
failed { runId: string, error: string } run_completed event with state: failed or SSE fatal error
cancelled { runId: string } run_completed with state: cancelled or Escape-cancelled modal
awaitingInput { runId: string, stepId: string, question: string } SSE awaiting_input event (advisory; the component already handles it)

No other public API. The component is deliberately a black box; its internals (the store, the SSE subscription, the modal wiring) are private implementation.

1.2 Four-phase lifecycle

Every run moves through the same four phases. The component's root renders exactly one of them at a time.

┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│ 1. Form  │ ──▶ │ 2. Run   │ ──▶ │ 3. Pause │ ──▶ │ 4. Report│
│ (inputs) │     │ (stream) │ ⇄   │ (modal)  │     │ (final)  │
└──────────┘     └──────────┘     └──────────┘     └──────────┘
                       │
                       └─▶ (cancelled / failed) ──▶ 4. Report (with error)
  1. Form — renders the playbook's inputs: spec; submit POSTs /playbook-runs and transitions to Run.
  2. Run — terminal pane + cancel button; SSE streams events; awaiting_input transitions to Pause.
  3. Pause — modal over Run; submit POSTs /input; success transitions back to Run.
  4. Report — final state; renders header, inputs, outputs, timeline strip, sparklines, and retro editor.

Transitions between phases are pure functions of the SSE event stream plus operator actions. The component keeps no derived state that is not reproducible from replaying the stream. This is load-bearing: an SSE reconnect (ADR-068 §6 Last-Event-ID) restores the correct phase by replay, not by a separate "current-phase" message.

1.3 File layout in ai-frontend

ai-frontend/src/lib/components/playbooks/
├── PlaybookRunner.svelte              # top-level orchestrator
├── PlaybookForm.svelte                # phase 1
├── PlaybookTerminalPane.svelte        # phase 2 + 3 background
├── PlaybookStep.svelte                # one <details>/<summary> per step
├── PlaybookAwaitingInputModal.svelte  # phase 3 modal
├── PlaybookReport.svelte              # phase 4
├── inputs/                            # one component per InputType
│   └── Input{String,Int,Bool,Secret,Hostname,Enum,Multiline}.svelte
└── runner/
    ├── sse-client.ts                  # EventSource wrapper, reconnect, Last-Event-ID
    ├── runner-store.svelte.ts         # Svelte 5 $state store; reducer over events
    ├── input-spec.ts                  # InputSpec TS types + validators
    └── report-model.ts                # run-record → report props

Pure logic (reducer, input validators, report model) lives under runner/*.ts so Vitest can exercise it without mounting Svelte — the same testing discipline src/routes/projects/import/+page.svelte uses today against $lib/projects/import-wizard.ts.

2. Form rendering from the inputs: spec

The playbook's YAML front-matter (ADR-068 §9) declares inputs: as an ordered list of input specs:

inputs:
  - name: server
    type: hostname
    required: true
    description: "Host to install Grafana on"
  - name: admin_user
    type: string
    required: true
    default: "admin"
    validate: "^[a-z][a-z0-9_-]{2,31}$"
  - name: admin_password
    type: secret
    required: true
    description: "Initial admin password; stored in the vault"
  - name: port
    type: int
    required: false
    default: 3000
  - name: enable_anonymous
    type: bool
    default: false
  - name: theme
    type: enum
    values: [light, dark, system]
    default: system
  - name: motd
    type: multiline
    required: false
    description: "Banner shown on Grafana login"

2.1 InputSpec TypeScript

The Go struct on the server (ADR-068 §9) codegens a JSON-Schema; ai-frontend consumes that schema via a typed InputSpec union:

// src/lib/components/playbooks/runner/input-spec.ts

export type InputType =
  | 'string'
  | 'int'
  | 'bool'
  | 'secret'
  | 'hostname'
  | 'enum'
  | 'multiline';

interface InputSpecBase {
  name: string;                    // must be [a-z_][a-z0-9_]*
  type: InputType;
  required?: boolean;
  description?: string;
  /** Author-provided regex. Applied after type coercion, before POST. */
  validate?: string;
}

export interface InputSpecString  extends InputSpecBase { type: 'string';    default?: string; }
export interface InputSpecInt     extends InputSpecBase { type: 'int';       default?: number; min?: number; max?: number; }
export interface InputSpecBool    extends InputSpecBase { type: 'bool';      default?: boolean; }
export interface InputSpecSecret  extends InputSpecBase { type: 'secret';    /* no default — secrets must be entered */ }
export interface InputSpecHost    extends InputSpecBase { type: 'hostname';  default?: string; }
export interface InputSpecEnum    extends InputSpecBase { type: 'enum';      values: string[]; default?: string; }
export interface InputSpecMulti   extends InputSpecBase { type: 'multiline'; default?: string; rows?: number; }

export type InputSpec =
  | InputSpecString
  | InputSpecInt
  | InputSpecBool
  | InputSpecSecret
  | InputSpecHost
  | InputSpecEnum
  | InputSpecMulti;

2.2 The generic form renderer

<!-- PlaybookForm.svelte (abridged) -->
<script lang="ts">
  import { validateValue } from './runner/input-spec';
  import type { InputSpec } from './runner/input-spec';
  import { INPUT_COMPONENTS } from './inputs';

  let { specs, initial, onSubmit, onCancel }: {
    specs: InputSpec[];
    initial: Record<string, unknown>;
    onSubmit: (values: Record<string, unknown>) => void;
    onCancel: () => void;
  } = $props();

  const values = $state<Record<string, unknown>>({ ...seedFromSpec(specs), ...initial });
  const errors = $state<Record<string, string>>({});

  function submit(e: SubmitEvent) {
    e.preventDefault();
    const next: Record<string, string> = {};
    for (const spec of specs) {
      const err = validateValue(spec, values[spec.name]);
      if (err) next[spec.name] = err;
    }
    Object.assign(errors, next);
    if (Object.keys(next).length === 0) onSubmit($state.snapshot(values));
  }
</script>

<form class="playbook-form" onsubmit={submit}>
  {#each specs as spec (spec.name)}
    {@const Input = INPUT_COMPONENTS[spec.type]}
    <div class="field" class:field--error={errors[spec.name]}>
      <label for={`in-${spec.name}`}>
        {spec.description ?? spec.name}
        {#if spec.required} <span class="req" aria-label="required">*</span>{/if}
      </label>
      <Input bind:value={values[spec.name]} id={`in-${spec.name}`} {spec} />
      {#if errors[spec.name]}
        <p class="err" role="alert">{errors[spec.name]}</p>
      {/if}
    </div>
  {/each}
  <div class="actions">
    <button type="button" class="ghost" onclick={onCancel}>Cancel</button>
    <button type="submit" class="primary">Run playbook</button>
  </div>
</form>

2.3 Input kind contracts

Every input component is ~30 lines. The contract is tight:

  • string<input type="text">. validate: regex applied client-side before submit; runtime re-validates server-side (client validation is courtesy, not security).
  • int<input type="number" inputmode="numeric">. Coerced to number; min: / max: as HTML attrs and validated.
  • bool<input type="checkbox">. No tri-state.
  • secret<input type="password" autocomplete="new-password">. default: is ignored. Value travels in the POST body over HTTPS; the runtime routes it to the vault (ADR-069) and never echoes it back.
  • hostname<input type="text" list="known-hosts"> + a <datalist> populated from /api/v1/catalog/hosts (ADR-064). The operator can still type an unknown host; the runtime errors at run time (a fresh host should not require a catalog edit first).
  • enum<select> with options from values:; default selected if present, else first.
  • multiline<textarea rows={spec.rows ?? 4}>; grows on content up to 12 * line-height.

2.4 Validation rules

A value is valid if:

  1. It is not empty when required: true.
  2. Its coerced type matches (int is a finite number, bool is a boolean, enum is in values:).
  3. min: / max: (int) are honoured.
  4. The validate: regex (if any) matches the stringified value.

Validation errors render inline beneath the field (role="alert"). The form cannot submit until every error clears. There is no "submit anyway" escape hatch.

3. Terminal pane

The monospace pane is where the operator watches a run happen. It renders step_started / stdout / stderr / step_completed / step_failed events in arrival order. It is append-only. Once a line is painted, it never re-renders; once a step is completed, its block is immutable.

3.1 Visual grammar

▸ install-grafana · shell · host:buzzy-web-01                 ── 12:04:23
  12:04:23.041  ➜ apt-get update
  12:04:23.812  Hit:1 http://deb.debian.org/debian bookworm InRelease
  12:04:24.103  Get:2 http://deb.debian.org/debian bookworm/main amd64 Packages
  12:04:25.987  Fetched 42.1 MB in 2s (21.0 MB/s)
✔ exit 0 · 4.2s                                               [ copy ⎘ ]
─────────────────────────────────────────────────────────────────────────
▸ configure-grafana · ssh · host:buzzy-web-01                 ── 12:04:27
  12:04:27.114  ➜ systemctl restart grafana-server
  12:04:27.902  ● grafana-server.service - Grafana instance
  12:04:27.905      Active: active (running) since Thu 2026-04-23 12:04:27
✔ exit 0 · 0.8s                                               [ copy ⎘ ]
─────────────────────────────────────────────────────────────────────────
▸ ask-admin-password · prompt_secret · local                  ── 12:04:28
  (waiting for input — see modal)

Rules:

  • Step header: ▸ <step_id> · <type> · <target> ── <HH:MM:SS>. The glyph is a U+25B8; completed steps render (U+2714) or (U+2718) in their trailer.
  • Line format: HH:MM:SS.mmm <line>. Millisecond precision; left-padded with two spaces; one space between timestamp and body. Stdout is neutral; stderr is tinted with the warning token at opacity: 0.85.
  • ANSI rendering: anser parses the line server-side is not an option (the runtime forwards raw bytes per ADR-068 §8); the frontend calls anser.ansiToHtml(line, { use_classes: true }) per line on receipt and inserts the resulting HTML. anser's class-based output lets mode tokens (ADR-062) drive the colour palette, not hardcoded hex.
  • Step trailer: ✔ exit <code> · <duration> on success; ✘ exit <code> · <duration> · <error> on failure; a per-step [ copy ⎘ ] button on the right.
  • Separator: a 1px currentColor rule at opacity: 0.2 between steps.

3.2 Auto-scroll, fold, and copy

  • The current step auto-scrolls its own body to the bottom on every stdout / stderr event. The pane itself also auto-scrolls to keep the current step's trailer in view.
  • Completed steps default to expanded for the first two steps after the current one and collapsed for anything older than that. A <details>/<summary> pair owns the fold state; the operator can override. Collapsed steps show only the header and trailer.
  • Failed steps never auto-collapse — if something broke, the operator is going to want to read the output without clicking.
  • Copy: [ copy ⎘ ] writes the full step block to the clipboard as plain text — header, lines (timestamped), trailer. A 2-second "copied" toast acknowledges.

3.3 Auto-scroll-pause on scroll-up

A classic terminal UX trap: the operator scrolls up to re-read a line, new events arrive, auto-scroll yanks them back down. We fix it the same way tail -f + less does:

  • Auto-scroll is on by default.
  • If the operator scrolls up (scrollTop decreases or a manual wheel/keyboard event), auto-scroll pauses.
  • A floating ↓ jump to live button appears at the bottom-right.
  • Clicking it (or scrolling the pane back to scrollTop == scrollHeight - clientHeight) re-enables auto-scroll.

3.4 Buffer limits

  • Per-step lines are capped at maxLinesPerStep (default 10,000). When exceeded, the oldest lines are replaced in the DOM with a single ⋯ N earlier lines dropped marker. The full stream is still on the server (ADR-068 §5 events table); the cap is a DOM-budget guard.
  • Per-run completed steps older than the last 20 have their bodies unmounted from the DOM when collapsed. Re-expanding re-mounts from the run-store state.

3.5 Why not xterm.js

xterm.js (~300kb) is a full VT100 emulator — cursor movement, alt-screen, resize semantics, selection — rendered to a canvas or an absolutely-positioned grid. Its theming is baked per theme API, not CSS-custom-property-driven, so our mode-tinting (ADR-062) does not pass through. anser (~15kb) takes ANSI bytes and produces styled <span>s that inherit currentColor. We are rendering shell logs, not hosting vim. xterm.js is the correct tool for a web SSH client; it is the wrong tool for a scrolling log.

4. Awaiting-input modal flow

When the runner receives an awaiting_input event (ADR-068 §8), the terminal pane dims to opacity: 0.4 and a centered modal takes focus. The modal's job is narrow: present the question, collect one reply, POST it, close. It does not replace the run UI; it suspends it.

4.1 Payload and shape

The event's body:

{
  "step_id": "ask-admin-password",
  "question": "Paste the initial admin password.",
  "schema": { "type": "string", "pattern": "^.{12,}$" },
  "default": null,
  "is_secret": true
}

The modal renders a single input whose control is chosen from schema.type + is_secret:

schema.type is_secret Control
string false <input type="text">
string true <input type="password" autocomplete="off">
enum any <select> with schema.values
bool (as used by approval steps) any Two buttons: "Yes, proceed" / "No, reject"

For approval, the modal has no text input. It has two buttons, plainly labelled, with the "No" button styled as the destructive action. Submit posts {"value": true|false}.

4.2 Submit + validate

Submit POSTs /api/v1/playbook-runs/{id}/input with {step_id, value} (same-origin credentials, CSRF header per ADR-036). 204 closes the modal; 400 surfaces the runtime's validation error inline without closing — the run is still awaiting_input with the same step_id (ADR-068 §6), so the operator retypes and re-submits without a new SSE event firing.

4.3 Cancel semantics

Escape cancels the whole run, not the prompt. This is counter-intuitive and so we make it loud:

  • The modal's top-right close button reads "Cancel run" (not "×").
  • Escape shows a confirm sub-dialog: "Cancelling will abort the current step and end the run. Continue?" with Cancel run as primary and Keep waiting as secondary.
  • A confirmed cancel POSTs /playbook-runs/{id}/cancel; the SSE stream will emit run_completed with state: cancelled and the component transitions to the Report phase with the cancellation cause rendered.

Rationale: an awaiting_input prompt has no "skip" option by design (ADR-068 §4 — approval reject is a fatal error the playbook author opts into via on_error: continue, not a runtime affordance). Dismissing the modal silently would leave the run stuck and the SSE stream open. Escape is either "answer this" or "end the run"; we make that explicit.

4.4 prompt_secret rendering

The input is type="password". autocomplete="off" and autocorrect="off" are set to keep the browser from volunteering anything. The value is never written to component state that outlives the POST — once submitInput returns, the modal's local ref is overwritten with '' in a finally block. The pane scrubs any stdout line containing the exact secret value for the remainder of the run (a cheap textual pass, belt-and-braces alongside the server-side scrub in ADR-068 §4 prompt_secret).

4.5 Why modal, not inline

Inline prompts (question + input beneath the pane) cause pane-height jitter as they appear (completed steps visually shuffle; the live step's auto-scroll fights the height change), create focus ambiguity between the pane's copy/fold controls and the prompt's input, and bury secret fields inside a log pane so they read as debug ephemera, not load-bearing form controls. Modal is heavier; we accept it. One modal per run is the ceiling — awaiting_input steps are infrequent by construction.

5. Report rendering at run completion

Every run ends with a report. The report is the operator's durable artifact — the thing they link to in a ticket, paste in a Slack thread, or re-read at the 90-day review.

5.1 Layout

┌─ service_runbooks:install-grafana @ v3.2.1 ─── success · 42s ─┐
│ Invoked by @jon · 2026-04-23 12:04:23 → 12:05:05 · 8 steps    │
│                                                               │
│ Inputs  server=buzzy-web-01  admin=admin  password=••••••••   │
│         port=3000  theme=dark  motd=(2 lines)                 │
│                                                               │
│ Timeline                                                      │
│  12:04:23 ●────●──●─────●──●──────●──●   12:05:05  ← strip    │
│           install configure seed restart verify ping          │
│                                                               │
│ Outputs grafana_url    https://buzzy-web-01:3000              │
│         password_vault vlt_01J3F…                             │
│         version        10.4.2                                 │
│                                                               │
│ Metrics step durations ━━━╱━╲━╱╲━━━━━  ← sparkline            │
│                                                               │
│ Retro (editable)  [markdown textarea: what I learned]         │
│                                                               │
│ [ View full log ]  [ Copy link ]  [ Re-run with same inputs ] │
└───────────────────────────────────────────────────────────────┘

5.2 Section rules

  • Header<playbook_kind>:<playbook_id> @ v<playbook_version> — <status> · <duration>. Status is a <gy-status-pill> (ADR-062) coloured from the run's final state (completed / failed / cancelled). The playbook_version is the content hash from ADR-068 §5.
  • Inputs table — two-column monospace. Secrets render as ••••••••••••, always 12 bullets, never the length of the secret. Multiline values show (N lines) with a click-to-expand disclosure.
  • Timeline<gy-timeline-strip> (ADR-066) across the run's wall-clock window. Each step is one pin; severity is status-up for success, status-down for failure, status-warning for a continue-tolerated error, status-info for awaiting_input pauses. A caption beneath lists step labels left-to-right. Cites the journey window in a call-site comment per ADR-066 §5: <!-- window: this run's duration -->.
  • Outputs — two-column, same shape as Inputs. The playbook's declared outputs: (ADR-068 §9) drives the order. Values that are URLs render as links; values that look like ids render with a monospace chip.
  • Metrics<gy-sparkline> (ADR-066) for each step whose output included a time_series: block. Rare — most steps don't produce one — but when they do the sparkline replaces a full chart. A step with no time series contributes nothing to this section; an empty section is hidden.
  • Retro — a <textarea> bound to the run's retro_markdown field. Edits PATCH /playbook-runs/{id} with {retro_markdown}. The textarea debounces 800ms and a small "saved" indicator renders beside it. This is the only part of the report that is mutable after the run ends.

5.3 Report data contract

The report consumes a single ReportModel built by runner/report-model.ts from the final run record (GET /playbook-runs/{id}) plus the event stream the component has buffered:

export interface ReportModel {
  runId: string;
  playbook: { kind: string; id: string; title: string; version: string };
  state: 'completed' | 'failed' | 'cancelled';
  actor: string;
  startedAt: string;               // ISO
  finishedAt: string;
  durationMs: number;
  steps: Array<{
    stepId: string;
    stepIndex: number;
    type: string;
    target: string;
    state: 'completed' | 'failed' | 'cancelled' | 'skipped';
    startedAt: string;
    finishedAt: string;
    severity: 'status-up' | 'status-down' | 'status-warning' | 'status-info';
  }>;
  inputs: Array<{ spec: InputSpec; value: unknown; redacted: boolean }>;
  outputs: Array<{ key: string; value: unknown }>;
  timeSeries: Array<{ stepId: string; label: string; values: number[] }>;
  retroMarkdown: string;
  errors: Array<{ stepId: string; message: string }>;
}

report-model.ts is pure; Vitest fixtures cover the common shapes (all-success, one-failure-continued, one-failure-fatal, cancelled, awaiting-input-then-cancel).

6. Where this lives (library placement)

The runner ships in three stages, each a separate PR.

Stage Location Consumer Gate
A. Prove ai-frontend/src/lib/components/playbooks/ ai-frontend only ADR-068 Phase 2 runtime live
B. Lift @gyrum-labs/sveltePlaybookRunner.svelte (Svelte adapter) ai-frontend + second consumer Two independent ai-frontend consumers (e.g. /projects/import + /runs/new)
C. Graduate gyrum-ui<gy-playbook-runner> (Web Component) + Svelte adapter re-exports Cross-stack (Go templ, plain HTML, React) One non-ai-frontend consumer (ai-research-dev or a product UI)

This is ADR-062's "prove then lift" pattern applied exactly. The Web Component graduation is explicitly gated on cross-stack demand, not wishful thinking — in our experience (ADR-062 §Alternatives) a component that never leaves its first framework never needs to.

Until the Web Component lands, non-ai-frontend consumers that need the runner fall back to the raw SSE contract (ADR-068 §6). That is also fine; the JSON event shapes are the real contract.

6.1 The ANSI dependency

anser (MIT, ~15kb gzipped, zero runtime deps) is added to ai-frontend's package.json in Stage A. It stays a direct dependency in Stage B. In Stage C the Web Component imports it from its own package.json and the Svelte adapter re-declares it as a peer — the bundler dedupes via npm hoisting.

There is no abstraction layer around anser. If we ever need to replace it, replace the single ansiToHtml call site in PlaybookTerminalPane.svelte. Premature abstraction over a tiny dependency is a cost, not a benefit.

Consequences

Easier.

  • One component replaces every bespoke wizard UI. /projects/import's four-step flow collapses into a playbook + a <PlaybookRunner> instance (see Migration). Future wizards (onboarding a new developer, rotating a credential, triggering a release) are zero-frontend work.
  • Terminal output is a first-class UI element. Operators get the tail -f experience the framing asked for (ADR-068 quote), not a "job running…" spinner.
  • Forms inherit the playbook's schema. Adding a new input to an install-grafana playbook is a YAML change; the form updates on deploy. No frontend PR.
  • Reports are uniform. An operator who has read one playbook report can read any playbook report. The KPI tiles, timeline strip, and sparklines all use ADR-066 primitives so mode-tinting and accessibility come for free.
  • Cancel is a button, not a kubectl. The cancel path is UI-native; the SSE stream + POST /cancel endpoint are already ADR-068 contracts.
  • Migration is mostly deletion. The /projects/import migration removes ~350 lines of Svelte state management, a polling loop, and four custom step components (see §Migration).

Harder.

  • Bundle weight. anser (~15kb) plus the runner's own code (~25kb estimated) lands in the ai-frontend bundle for every page that might show a run. We code-split PlaybookRunner as a dynamic import so pages that never render one pay nothing; only routes that mount the runner pay the full weight.
  • Modal interrupts flow. awaiting_input takes focus; a power-user who "knew what was coming" still has to click/type through the modal. This is by design — see §4.5 — but we acknowledge the friction.
  • Cancel-via-Escape is a double-edge. An operator who hits Escape expecting "dismiss this dialog" gets "end the run". We mitigate with the confirm sub-dialog (§4.3); we still expect one or two post-launch reports of surprise cancels.
  • Form renderer is 7 input types strong. Adding an InputFile or an InputDate is a new component + a new InputSpec variant + server-side validation in the runtime. We budget adding one new input type per quarter; more than that suggests the playbook schema is growing something chartable that should be its own decision.
  • Report sections are opinionated. An author cannot "add a custom section" to the report without an ADR amendment. That is on purpose — the report's legibility across playbooks is the point — but it will be asked for.

What we sign up to maintain.

  • The Svelte component surface area. Six Svelte files (runner, form, pane, step, modal, report) plus seven input components. Each ≤ 200 lines per CLAUDE.md rules; helper logic in .ts.
  • The SSE client's reconnect logic (Last-Event-ID). Flaky connections are the expected failure mode; reconnect is the expected recovery.
  • The copy-to-clipboard UX. Every browser formats clipboard data differently; we commit to plain text only (no HTML) to keep the matrix small.
  • The report model transformer. Its fixtures are the canonical "what does a run look like" snapshots for visual regression.
  • The anser dependency. If it goes unmaintained, we vendor it (it is ~400 lines of JS).

Alternatives considered

  • xterm.js instead of anser. Rejected. See §3.5 — ~300kb bundle weight, brings a full VT100 emulator, and its canvas/grid DOM shape fights Svelte's reactivity. We are rendering shell logs, not hosting vim.

  • A separate component per step type (e.g. <ShellStepView>, <ClaudeStepView>, <ApprovalView>). Rejected. The runtime's event stream is uniform (ADR-068 §8) — every step produces step_started / stdout / stderr / step_completed. A per-type view explodes component count from ~10 to ~30 (one per step type × one per phase) and forces the frontend to know things the runtime already abstracted. The terminal pane is the uniform renderer; the runtime decides which events to emit.

  • Runtime renders HTML directly (ship stdout as HTML via server-side anser). Rejected. The runtime is Go and does not speak ANSI colour (it forwards bytes). Pushing HTML generation server-side couples the runtime to a specific frontend rendering choice; the SSE contract stays cleaner as raw lines. anser is cheap enough to run per-line in the browser.

  • Inline prompt beneath the terminal pane (no modal). Rejected. See §4.5 — layout jitter, focus ambiguity, weak secret UX. A modal is disruptive but unambiguous.

  • WebSocket instead of SSE. Out of scope — ADR-068 §6 settled it server-side. This ADR inherits. For completeness: a WebSocket client here would let us send /input over the same channel instead of a separate POST, saving one round-trip of TCP setup for an interaction that happens a handful of times per run. Not worth the framing / reconnect / proxy complexity.

  • Host the runner as a Svelte route, not a component. Rejected. A route is too coarse — the import wizard, the release page, and the runbook view each want to embed a runner on a page that has other chrome. A component is embeddable anywhere; a route is not.

  • A skip option in the awaiting_input modal. Rejected. A UI-only "skip" creates a state the runtime cannot express; the author controls skippability via required: false on the input spec, not the UI.

  • Multiple simultaneous runners on one page. Rejected for v1. Technically possible but the UX of two concurrently streaming terminals on one page is poor. A dashboard of running playbooks uses a compact <PlaybookRunList> (status + sparkline per row), not N embedded runners.

  • Custom report sections per playbook. Rejected for v1. A playbook that wants a bespoke chart emits a time_series: block and gets the sparkline for free; anything beyond is out of scope.

Migration

The biggest win is the existing /projects/import wizard at ai-frontend/src/routes/projects/import/. Today it is four hand-coded steps, a localStorage draft, and a 1-second poll loop against /api/v1/projects/import. Tomorrow it is a playbook.

Before

ai-frontend/src/routes/projects/import/
├── +page.svelte                           # ~350 lines, $state wizard
├── +page.ts                               # loader: fetches repos + projects
└── $lib/components/projects/
    ├── WizardSteps.svelte                 # breadcrumb
    ├── StepDiscovery.svelte               # phase 1
    ├── StepTriage.svelte                  # phase 2
    ├── StepReview.svelte                  # phase 3
    └── StepExecute.svelte                 # phase 4 — transcript + poll

ai-frontend/src/lib/projects/
├── import-wizard.ts                       # pure state shape + helpers
├── import-api.ts                          # createImport / getImportStatus / isTerminal
├── import-heuristics.ts                   # triage suggestions (domain logic)
└── import-types.ts

After

ai-frontend/src/routes/projects/import/
├── +page.svelte                           # ~30 lines: renders <PlaybookRunner>
└── +page.ts                               # loader: resolves the playbook + initial repos

dark-factory/docs/playbooks/onboarding/
└── import-projects.md                     # kind: onboarding playbook + runtime section

ai-research/internal/runtime/steps/
└── triage_projects.go                     # one new StepExecutor (the triage heuristic
                                           # that was import-heuristics.ts — now server-side
                                           # because the runtime is the orchestrator)

ai-frontend/src/lib/components/playbooks/  # NEW — the runner itself

The playbook

---
kind: onboarding
id: import-projects
title: "Import repositories as projects"
persona: dev
owner: "@jon"
status: active
trigger: "Operator opens /projects/import"
success_metric: "N repos imported as projects; PR opened"
---

runtime:
  inputs: []                           # seeded from initialInputs
  steps:
    - { id: discover, type: pipeline, target: local, pipeline: projects-import-discover, save_as: discovery }
    - { id: triage,   type: triage_projects, target: local, with: { repos: "{{ outputs.discovery.repos }}" }, save_as: triaged }
    - { id: confirm,  type: approval, target: local, question: "Import {{ outputs.triaged.count }} repos as projects?" }
    - id: review-commit-message
      type: prompt
      target: local
      question: "Commit message (leave blank for default)"
      schema: { type: string }
      default: "feat(projects): import {{ outputs.triaged.count }} repos"
      save_as: commit_message
    - id: apply
      type: http
      target: host:ai-research
      method: POST
      url: "/api/v1/projects/import"
      body: { projects: "{{ outputs.triaged.projects }}", commitMessage: "{{ outputs.commit_message }}" }
      save_as: job
    - id: wait
      type: http
      target: host:ai-research
      method: GET
      url: "/api/v1/projects/import/{{ outputs.job.jobId }}"
      poll_until: "status == 'merged' || status == 'failed'"
      save_as: result
  outputs:
    pr_url:   "{{ outputs.job.prUrl }}"
    imported: "{{ outputs.triaged.count }}"

The route (post-migration)

<!-- ai-frontend/src/routes/projects/import/+page.svelte -->
<script lang="ts">
  import type { PageProps } from './$types';
  import PlaybookRunner from '$lib/components/playbooks/PlaybookRunner.svelte';
  import { goto } from '$app/navigation';
  let { data }: PageProps = $props();
</script>

<h1>Import projects</h1>
<PlaybookRunner
  playbookId="onboarding:import-projects"
  initialInputs={{ repos: data.repos, existing: data.projects }}
  on:complete={(e) => goto(`/projects/import/done?pr=${e.detail.outputs.pr_url}`)}
  on:failed={(e) => goto(`/runs/${e.detail.runId}`)}
/>

What moves, what goes

Was Becomes Notes
+page.svelte (350 lines of $state wizard) 20 lines embedding the runner Net deletion of ~330 LoC
WizardSteps.svelte (breadcrumb) (gone) The runner's phases are the breadcrumb; step list comes from the playbook
StepDiscovery.svelte pipeline: projects-import-discover step Pipeline already exists (ADR-061)
StepTriage.svelte (triage heuristic UI) type: triage_projects executor + a type: approval + type: prompt pair Heuristic moves server-side where it belongs; UI is standard
StepReview.svelte (commit message + PR body editor) type: prompt × 2 Two prompt steps with schema: string and multiline variants
StepExecute.svelte (transcript + poll loop) type: http with poll_until: Terminal pane is the transcript; runtime owns the poll
import-api.ts (createImport/getStatus) type: http step The step is a thin HTTP call; no client-side polling
import-heuristics.ts Input to a Go triage_projects executor Heuristic logic is pure; TS→Go port is ~100 lines; fixtures carry over
localStorage draft persistence (gone) A run is a durable Postgres row; a refresh re-subscribes to the same run_id
WizardState TS shape PlaybookRun server shape Authoritative state is the server; client state is derived from the event stream

Migration PR sequence (ai-frontend)

  1. PR 1 — Add <PlaybookRunner> skeleton under src/lib/components/playbooks/, synthetic event fixtures, Vitest + Playwright visual regression. No route wiring.
  2. PR 2 — Wire <PlaybookRunner> into a new /runs/new route that accepts ?playbook=… and ?inputs=… — the "generic" run page. First live consumer.
  3. PR 3 — Ship the import-projects playbook (dark-factory)
    • the triage_projects executor (ai-research). Land behind a feature flag.
  4. PR 4 — Replace /projects/import/+page.svelte with the runner embed. Delete the four step components and import-wizard.ts. Flip the flag.
  5. PR 5 — Delete import-api.ts and import-heuristics.ts once PR 4 has run in production for a week without rollback.

Steps 1-2 are in-scope for this ADR's shipping cohort; 3-5 are tracked on the ai-frontend / ai-research boards.

Other migrations

Existing flow Becomes Priority
/ops "ack this alert" kind: incident_response playbook + runner embed After PR 4
Release flow (gyrum-complete-pr from laptop) kind: release_flow:gyrum-complete-pr + /releases/new runner page After Phase 4 (SSE + approval)
New-developer onboarding (today: a Markdown README) kind: onboarding:first-commit playbook When the playbook lands (ADR-067 §7)
Individual runbook actions ("restart grafana") Small kind: service_runbook playbooks Incremental; one per runbook

The rule: any screen with a "do it" button that hits an endpoint and polls for status is a candidate. None carry their own poll loop after migration.

References

  • ADR-068 — playbook runtime architecture; the SSE event shapes in §3 and §6 are the authoritative contract this component consumes

  • ADR-067 — playbooks unified primitive; kind:id, the inputs: spec shape, and the retro/gaps H2 that the report's Retro section mirrors

  • ADR-066 — sparklines + timeline strips; <gy-sparkline> and <gy-timeline-strip> are the report's Metrics and Timeline sections verbatim, with their "report-rendering role" (§7) made concrete here

  • ADR-062 — design system; the "prove in ai-frontend, lift to @gyrum-labs/svelte, graduate to gyrum-ui Web Component" pattern from §Migration path

  • ADR-065 — persona-aware IA; the runner is embedded in pages under /me, /ops, /team, inheriting their mode-tinting

  • ADR-064 — gyrum-catalog; the hostname input's datalist resolves against the catalog's host list

  • ADR-059 — project-first IA; the /projects/import wizard (the biggest migration target) landed here

  • ADR-053 — frontend observability; SSE reconnect errors and modal-submit failures flow through /client-log

  • ai-frontend/src/routes/projects/import/+page.svelte — the canary migration; 350 LoC of $state wizard that collapses to 20 lines after this ADR lands

  • ai-frontend/src/lib/projects/import-wizard.ts — the pure state shape whose WizardState type is subsumed by the runtime's PlaybookRun server shape

  • anser — the ~15kb MIT library chosen for ANSI-escape → HTML rendering in the terminal pane

  • Operator framing, 2026-04-23 (via ADR-068):

    i like when you deploy you show the termianl output

    suppose we had a install grafana and it needed some expra answers the frontend user, it would be helpful if they could send back answers

Review cadence (for this ADR)

Re-review every 90 days (next: 2026-07-22) against:

  • Bundle weight — is the dynamic-import split holding? Is the runner's on-demand code under 50kb gzipped?
  • Modal surprise-cancel rate — how many runs end in state: cancelled within 3 seconds of an awaiting_input event? If that number is high, §4.3's Escape-cancels-run rule needs revisiting.
  • Migration progress — has /projects/import migrated? How many other bespoke wizards still exist? Target: zero bespoke polling-wizard routes by 2026-10-01.
  • Lift readiness — does @gyrum-labs/svelte have a PlaybookRunner.svelte yet? If no (after two consumers exist), what is the blocker?
  • Graduation readiness — is there a non-ai-frontend consumer asking? If so, plan the Web Component PR; if not, hold.
  • Input type count — has it grown beyond 7? If so, the playbook schema is doing something chartable we should revisit.

Stale flag on the Owner dashboard once last_reviewed on this ADR passes 90 days (per ADR-067 §11).


Supersedes: none Superseded by: leave blank until a later ADR reverses this one