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/importall 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/importwizard is the canary. The four-step Discover → Triage → Review → Execute flow atai-frontend/src/routes/projects/import/+page.svelteis 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 aStepExecute.sveltetranscript 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)
- Form — renders the playbook's
inputs:spec; submit POSTs/playbook-runsand transitions to Run. - Run — terminal pane + cancel button; SSE streams events;
awaiting_inputtransitions to Pause. - Pause — modal over Run; submit POSTs
/input; success transitions back to Run. - 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 tonumber;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 fromvalues:; default selected if present, else first.multiline—<textarea rows={spec.rows ?? 4}>; grows on content up to12 * line-height.
2.4 Validation rules
A value is valid if:
- It is not empty when
required: true. - Its coerced type matches (
intis a finite number,boolis a boolean,enumis invalues:). min:/max:(int) are honoured.- 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 atopacity: 0.85. - ANSI rendering:
anserparses the line server-side is not an option (the runtime forwards raw bytes per ADR-068 §8); the frontend callsanser.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
currentColorrule atopacity: 0.2between steps.
3.2 Auto-scroll, fold, and copy
- The current step auto-scrolls its own body to the bottom on
every
stdout/stderrevent. 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 livebutton 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 droppedmarker. 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 runas primary andKeep waitingas secondary. - A confirmed cancel POSTs
/playbook-runs/{id}/cancel; the SSE stream will emitrun_completedwithstate: cancelledand 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 isstatus-upfor success,status-downfor failure,status-warningfor acontinue-tolerated error,status-infoforawaiting_inputpauses. 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 atime_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'sretro_markdownfield. 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/svelte — PlaybookRunner.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 -fexperience 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 /cancelendpoint are already ADR-068 contracts. - Migration is mostly deletion. The
/projects/importmigration 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-splitPlaybookRunneras 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_inputtakes 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
InputFileor anInputDateis 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
anserdependency. If it goes unmaintained, we vendor it (it is ~400 lines of JS).
Alternatives considered
xterm.jsinstead ofanser. 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 hostingvim.A separate component per step type (e.g.
<ShellStepView>,<ClaudeStepView>,<ApprovalView>). Rejected. The runtime's event stream is uniform (ADR-068 §8) — every step producesstep_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
stdoutas HTML via server-sideanser). 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.anseris 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
/inputover 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
skipoption in theawaiting_inputmodal. Rejected. A UI-only "skip" creates a state the runtime cannot express; the author controls skippability viarequired: falseon 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)
- PR 1 — Add
<PlaybookRunner>skeleton undersrc/lib/components/playbooks/, synthetic event fixtures, Vitest + Playwright visual regression. No route wiring. - PR 2 — Wire
<PlaybookRunner>into a new/runs/newroute that accepts?playbook=…and?inputs=…— the "generic" run page. First live consumer. - PR 3 — Ship the
import-projectsplaybook (dark-factory)- the
triage_projectsexecutor (ai-research). Land behind a feature flag.
- the
- PR 4 — Replace
/projects/import/+page.sveltewith the runner embed. Delete the four step components andimport-wizard.ts. Flip the flag. - PR 5 — Delete
import-api.tsandimport-heuristics.tsonce 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, theinputs:spec shape, and the retro/gaps H2 that the report's Retro section mirrorsADR-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 hereADR-062 — design system; the "prove in ai-frontend, lift to
@gyrum-labs/svelte, graduate togyrum-uiWeb Component" pattern from §Migration pathADR-065 — persona-aware IA; the runner is embedded in pages under
/me,/ops,/team, inheriting their mode-tintingADR-064 — gyrum-catalog; the hostname input's datalist resolves against the catalog's host list
ADR-059 — project-first IA; the
/projects/importwizard (the biggest migration target) landed hereADR-053 — frontend observability; SSE reconnect errors and modal-submit failures flow through
/client-logai-frontend/src/routes/projects/import/+page.svelte— the canary migration; 350 LoC of$statewizard that collapses to 20 lines after this ADR landsai-frontend/src/lib/projects/import-wizard.ts— the pure state shape whoseWizardStatetype is subsumed by the runtime'sPlaybookRunserver shapeanser— the ~15kb MIT library chosen for ANSI-escape → HTML rendering in the terminal paneOperator 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: cancelledwithin 3 seconds of anawaiting_inputevent? If that number is high, §4.3's Escape-cancels-run rule needs revisiting. - Migration progress — has
/projects/importmigrated? How many other bespoke wizards still exist? Target: zero bespoke polling-wizard routes by 2026-10-01. - Lift readiness — does
@gyrum-labs/sveltehave aPlaybookRunner.svelteyet? 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