A pane that shows linear-runner batches: each batch's issues at their stage, with time and tokens, and how the batches follow from one another

A sequential controller for explicitly authorized Linear issue batches. Deterministic Python owns scheduling, gates, Linear synchronization, checks, the Git commit and publication. A model CLI (Codex CLI or Claude Code) is invoked only for three bounded phases: implementation, repair and an independent read-only review. No extra Python packages are required.
The engine has no project-specific prompts, owner, workspace or toolchain. Policy lives in the public registry/; everything private (hosts, workspaces, projects, batches) lives in a private configuration home outside this repository. Your own workflow and authorization rules belong in project or batch guidance files. One command, runner.py launch, runs a preflight (model-free except one tiny start check per model backend, reused while the backend is unchanged), starts a host supervisor and exits; no outer model session is needed to start, continue or recover a batch.
Requires Python 3.10+, Git, a POSIX host (tested on Linux), an authenticated Codex CLI (and, for Claude-backed pools, Claude Code with a claude.ai subscription: its login or a long-lived token, see "Claude authentication") and a Linear credential reachable through an environment variable or the Codex credential cache. CLI event parsing was established on Codex CLI 0.154.0 and Claude Code 2.1.281; probe changed versions before unattended use.
The code is one Python package, linear_runner/, with no dependencies outside the standard library. runner.py at the checkout root is the only entry point you run; it forwards to linear_runner/cli.py. Data the code reads (registry/, schema/, templates/) stays at the checkout root, which is also ${runner_root}.
| Path | Contents |
|---|---|
runner.py | Entry point for every command (validate-config, dry-run, run, launch, supervise, recover, status, wait, stop, clear-stop, watchdog, sync-linear-template, and the offline report and measure; --version); launch units and comment commands run it by path |
render_samples.py | Entry point that writes (or --checks) docs/template-samples.md |
CHANGELOG.md | One entry per release, each with its project impact and canary tier (see "Releases") |
interface-migrations.json | Machine-readable migrations of the runner-project interface (see "Interface version") |
linear_runner/version.py | The current release (RELEASE), --version and the batch runner_version check |
linear_runner/cli.py | The command line: argument parsing and dispatch of every command |
linear_runner/config.py | Layered loading, schema validation, ${variable} substitution, name resolution pinning, the configuration fingerprint and runner identity |
linear_runner/engine/runner.py | The per-issue state machine: gates, model phases, checks and validation, the controller commit, delivery, review, publication and terminal reporting |
linear_runner/engine/delivery.py | Check outcomes and the generic, config-driven delivery integrity step |
linear_runner/engine/intake.py | Worker intake packets: compact schema 2 (default) and the full schema 1 |
linear_runner/backends/__init__.py | The model-backend interface (SessionRequest, Backend) and the backend registry |
linear_runner/backends/codex.py | Everything Codex-specific: the codex exec argv, its JSONL events, execution evidence and the host model catalog |
linear_runner/backends/claude.py | Everything Claude Code-specific: the claude -p argv and isolation flags, its stream-json events, execution evidence and the probe-verified model list |
linear_runner/linear/client.py | Direct HTTPS JSON-RPC client for the official Linear MCP endpoint; append-only comments with read-back |
linear_runner/linear/updates.py | Template rendering, draft lint, hidden event markers and the exactly-once event ledger |
linear_runner/linear/messages.py | Builds each human-review comment from saved state |
linear_runner/linear/template.py | The Linear issue template "Runner issue contract" (runner.py sync-linear-template) |
linear_runner/linear/attention.py | Stop classification, needs-input mechanisms and the out-of-band notifier |
linear_runner/supervision/launcher.py | Launch preflight with identity-keyed reuse; systemd-user and foreground host backends |
linear_runner/supervision/backend_start.py | The backend_start.<backend> preflight step: start each selectable model backend once in the batch worktree |
linear_runner/supervision/supervisor.py | The generic supervisor a launched unit runs: scheduling, lifecycle read-back, checkpoints, reporting |
linear_runner/supervision/recovery.py | Named, recorded recovery commands and the hash-chained recovery log |
linear_runner/supervision/watchdog.py | Model-free check for a vanished or stalled supervisor (runner.py watchdog, run by a launch-started timer) |
linear_runner/supervision/wait.py | Model-free, read-only wait for a launched batch to stop (runner.py wait) |
linear_runner/supervision/rules.py | One-line decision rules written in issue descriptions |
linear_runner/reporting/records.py | Reads saved session, check and intake records from evidence roots and deduplicates copies |
linear_runner/reporting/trajectory.py | Deterministic trajectory/usage/attempts/validation-audit/batch-comparison report (runner.py report, and terminal-trajectory.* at the terminal step) |
linear_runner/reporting/measure.py | Offline context-cost measurement: intake components, prompt/tool-output bytes, per-call context growth (runner.py measure) |
linear_runner/reporting/board.py | The compact record of a batch for a progress board (runner.py status --board): model-free and read-only |
linear_runner/reporting/report.py | Standalone terminal HTML/JSON report |
linear_runner/reporting/render_samples.py | Builds one sample comment per template for docs/template-samples.md |
mods/runner-board/ | The progress board: a Claude Code mod that draws batches, their issues and how batches follow (see "Progress board"); .claude-plugin/marketplace.json at the root lists it for claude plugin install |
templates/ | Human-review Linear comment templates and the worker/reviewer draft templates |
registry/ | Public policy defaults; each file's notes explain its values |
schema/ | JSON schemas for every registry file and configuration layer |
examples/home/ | Placeholder private home: site, workspace, project and batch files |
prompts/ | Generic worker guidance |
testdata/ | Fictional runner records for the renderer and measurement tests, and the status --board example (testdata/board/) that the runner's tests keep current |
tests/ | Offline tests, laid out like the package (tests/engine/, tests/backends/, ...); shared fixtures in tests/fixtures.py; tests/test_public_tree.py fails on private identifiers in any tracked file |
Run the tests from the checkout root; they need no network, Linear, model CLI or systemd:
python3 -m unittest -v # everything
python3 -m unittest tests.engine.test_runner # one module (or tests.engine.test_runner.EngineTests)
python3 render_samples.py --check # docs/template-samples.md is current
Layers are loaded and merged in this order; later layers never repeat earlier policy.
registry/*.json in this repository.<home>/registry/<name>.json, deep-merged onto the matching public file (objects merge, lists and scalars replace).<home>/site.json: host bindings.<home>/workspaces/<slug>.json: one Linear workspace.<home>/projects/<name>.json: one repository and Linear project.--batch: one authorized queue.The private home is --home, else $LINEAR_RUNNER_HOME, else ~/.config/linear-runner. Every file is validated offline against schema/; unknown keys, unknown registry files and bad references (for example a profile naming an unregistered model or an effort that model does not allow) are errors.
| Registry file | Holds |
|---|---|
models.json | Effort IDs the CLIs accept; models policy may select, the backend that runs each and their allowed efforts |
labels.json | Task-kind labels and profile labels (exactly one of each per issue) |
profiles.json | Profile order, review floors, phase overrides, escalation target, routing version, the review_routing low-risk rule |
pools.json | Ordered {backend, model, effort} pools per task kind (or *), profile and phase (see "Model backends and pools") |
phases.json | Per-phase soft budgets and timeouts, the largest per-issue timeout a batch may set (max_timeout_seconds), the shared repair limit (≤ 2), the default and the largest per-check timeout (check_timeout_seconds, max_check_timeout_seconds), bounded_sessions thresholds |
linear.json | Default workflow state names and whether a milestone is required |
interface.json | The runner-project interface version (see "Interface version"); not a policy layer, so a private registry cannot override it |
| Layer | Fields |
|---|---|
| Site | executables (must include codex, and claude when a pool uses the Claude backend), variables, state_root, artifact_root, model_catalog, optional claude (auth: exactly one of oauth_token_file or oauth_token_env; see "Claude authentication"), optional launcher, optional attention |
| Workspace | slug (matches the file name), auth (exactly one of token_env or credentials_file, optional timeout_seconds; with credentials_file, optional refresh_command, auto_refresh (false), min_lifetime_minutes (0: off) and warn_lifetime_minutes (720), see "Running a batch"), assignee ("me" or an exact name/email; default "me"), optional states renames, optional attention |
| Project | optional interface_version, workspace, linear_project (exact Linear project name), repo, artifact_owner, retention, optional backup_status, guidance_files, optional context_files, optional criterion_lint (see "Writing issues"), contract_file, intake_mode (compact default, or full), identity_files, check_environment, checks (each: name, kind, tier, inputs, cwd, command, optional allow_empty, timeout_seconds, last_issue, base_parity), optional delivery_checks, delivery_integrity |
| Batch | optional interface_version, id, project, issues (ordered allowlist), terminal_issue, branch, optional worktree (defaults to the project repo), guidance_files (appended after the project's), required_done, human_gates, supervision, context_controls, model_overrides, phase_overrides (per-issue phase timeouts, see "Lifecycle"), variables (values for site variables in this batch only, see below), runner_version (see "Releases"), purpose, follows, reason, from_issues (the batch's record for people, see below) |
Supervisor, launcher and delivery-integrity fields:
| Layer.field | Key | Default | Meaning |
|---|---|---|---|
site launcher | backend | systemd-user | systemd-user or foreground (in-process; tests and debugging) |
python | the interpreter running launch | Interpreter for the supervisor unit; ${name} allowed | |
cpu_list | none | Wrap the supervisor in taskset -c <list> | |
environment | {} | Extra --setenv values for the unit, for example PATH | |
unit_prefix | linear-runner | Unit name is <prefix>-<batch id>-<launch id>.service | |
startup_timeout_seconds | 30 | How long launch waits to confirm the supervisor started | |
backend_start_timeout_seconds | 180 | Timeout of each preflight backend start check (backend_start.<backend>) | |
stop_on_exit | true | Write the STOP marker when the supervisor exits (also via ExecStopPost) | |
batch supervision | stop_after | [] | Planned checkpoints: stop after these issues are accepted |
on_block | stop | Pause the batch on an issue-level block; continue_independent opts in to deferring the issue and continuing with independent issues | |
report_issues | [] | Extra issues that receive the batch-finished and batch-paused comments | |
decision_rules | honor | ignore disables rule blocks in issue descriptions | |
baseline_checks | false | Run the default-tier checks on the clean baseline during launch preflight | |
workspace attention | owner_mention | "" | Optional text (for example @handle) put as its own paragraph under the first sentence of comments that need action |
needs_input.mechanism | mention | How a stopped issue is marked: label, state or mention (see "Stops") | |
needs_input.label | Needs input | Label added on a stop and removed when a recovery runs (must exist in the team) | |
needs_input.state | Blocked | Workflow state for the state mechanism (must exist in the team) | |
site attention | command_prefix | python3 ${runner_root}/runner.py | How commands in comments start; ${name} allowed |
notifier.backend | none | none, command or linear-mention-only | |
notifier.command | [] | argv run once per stop and watchdog alert; message on stdin, {subject} replaced | |
notifier.timeout_seconds | 30 | Notifier command timeout | |
watchdog.stall_minutes | 120 | Watchdog alert after this long without recorded progress | |
watchdog.interval_minutes | 10 | How often the launch-started timer runs the watchdog | |
watchdog.paused_minutes | 60 | Remind once (comment and notifier) when a stop has waited this long without a recorded recovery; 0 turns it off | |
lint.max_chars / max_lines / max_first_sentence_chars | 1500 / 30 / 240 | Limits for worker and reviewer drafts | |
outbox.poll_seconds / settle_seconds | 15 / 3 | Outbox poll interval while the model runs; drafts younger than this are left for the next poll | |
project delivery_integrity | manifest | required | Renderer manifest, relative to the issue's delivery/ directory |
revision_field | required | Manifest field that must equal the committed revision | |
required_checks | [] | Check names that must be in the validated evidence | |
file_hashes | {} | Manifest field → file (relative to the manifest) whose SHA-256 it must equal | |
true_fields | [] | {"file", "field"} pairs (relative to the manifest) that must be literally true |
The batch state directory is <state_root>/<batch id>. Relative paths resolve against the file that names them. Paths, check arguments, check environment values and credential-file paths may use ${name}, where name is a site executables or variables entry or a built-in: home (the private home), runner_root (this checkout), batch (the batch id) and worktree (the batch worktree; not usable in repo/worktree themselves). An undefined name is an error. A batch's variables replace site variables of the same name for that batch only (~ is expanded as in the site), for example {"venv": "~/envs/other"} to run one batch's checks in another Python environment without changing site.json for every batch. Each name must already be a site variable; executables, the built-ins and new names are refused, so a misspelt name fails instead of doing nothing. The values are part of the fingerprinted configuration (recover repin-config adopts a change), and validate-config lists them under variable_overrides. Guidance files may also use ${name} for host paths; there only defined names are replaced and any other ${...} text stays literal. {run_dir} (no dollar sign) in a check argument is still replaced by that check's validation directory at run time. Commands are argv arrays; no shell is involved.
A batch file may say what it is for and where it comes from, in four optional keys that the operator writes with the batch: purpose (one sentence), follows (the id of the batch this one stems from, or null for the first batch of a line of work), reason (why it follows: who decided what, and from which review or finding) and from_issues (the issues whose result led to it; they need not be in the allowlist). They are a record for people, so that the reason for a batch is not only prose in its guidance, forgotten within days (W-443). The engine never reads them: they change no order, gate or prompt, and they are outside the configuration fingerprint, so correcting a sentence on a pinned batch needs no recover repin-config. validate-config prints them under lineage, and status --json under lineage as the batch file holds them now; a key the file lacks is absent there. When follows names no batch file of the home (neither <home>/batches/<id>.json nor a file there with that id), validate-config says so in one line under warnings and still passes.
Credentials are never stored: the workspace names an environment variable or a credential cache path, and linear_runner/linear/client.py re-reads it on each request. Do not put secrets in check_environment; it is recorded in manifests.
A private home depends on the runner through five things: the project profile schema, the guidance slots (guidance_files, context_files, contract_file), the site.json keys, the task-kind, profile and model labels, and the batch file schema. registry/interface.json gives them one number, interface_version (currently 1, the shape of release 2.0.0). Each project profile and batch file declares the version it was written for:
{"interface_version": 1, "workspace": "...", "linear_project": "...", ...}
validate-config (and every command that loads the configuration) reads the declared versions before any schema check. An older version fails with each migration step between it and the current one, from interface-migrations.json (the release, what changed and what to do), ending "Then set interface_version to N". A newer version asks for a newer runner. An undeclared version is accepted and listed under interface.undeclared in the validate-config report, with a note to declare it. The declared versions are not part of the configuration fingerprint, so stamping a file never blocks a paused batch.
A release that changes one of the five incompatibly bumps interface_version, adds the migration entry and names it in its CHANGELOG project impact.
validate-config is fully offline: it creates no state and contacts neither Linear nor Codex, so the Linear project and assignee remain unresolved names in its report. dry-run selects the next issue as the supervisor does: it skips done and deferred issues, logs each issue that waits for an unfinished prerequisite ("Dry run: W-9 waits for W-8"), and writes no batch state (before 2.6.1 it stopped on a first issue that waits, W-351). It does keep the title and link of every allowlisted issue in <state dir>/titles.json, as each launch does, so that status --board can name an issue before the batch claims it. dry-run, run and launch resolve the exact project name and the assignee to IDs (no match or more than one exact match is an error) and write <state dir>/resolved-config.json with the resolved IDs, the effective configuration, the source layer of every value and the layer files used. Project names must match exactly within the authenticated workspace. Later runs reuse the pinned IDs without contacting Linear for resolution; a renamed project or assignee, or any change to the resolved configuration, is refused. A paused or stopped batch can adopt a changed configuration or runner commit explicitly with recover repin-config (see "Stop, recovery and continuation"). Use a new batch id for a new queue.
The pin binds once the batch has state. A batch that was only dry-run has resolved-config.json and no state.json: it has claimed nothing. When its batch file, its guidance or the runner changed since that dry-run, the next dry-run, run or launch resolves the names again, replaces the pin and says so in one line ("This batch has not run yet, so the configuration pinned at ... is replaced by the configuration files as they are now."). Before W-378 such a batch was stuck: dry-run refused the change, and recover repin-config refused a batch with no state to re-pin.
The configuration fingerprint in state.json and resolved-config.json covers the whole resolved configuration: guidance text, commands, environment, policy, IDs and the runner identity. The runner is identified by its release, Git commit and a clean/dirty flag, not by where it is checked out: paths under the runner checkout are normalized to ${runner_root} before hashing. Moving or re-cloning the runner at the same commit therefore resumes normally, while a different runner commit, uncommitted runner edits or any configuration change is refused. The dirty flag is a boolean, so further edits to an already dirty checkout are not distinguished; run batches from a clean commit (recover repin-config adopts a new commit for a paused or stopped batch). The site launcher block and the site and workspace attention blocks are the exceptions: they only choose how the supervisor process starts and how a person is told about progress and stops, so they are left out of the fingerprint and changing them never blocks resuming. The batch's purpose, follows, reason and from_issues are left out as well: they are a record for people. Each launch record (<state dir>/launches/<launch id>.json) stores the launcher settings actually used, and status shows those of the latest launch.
Releases follow semantic versioning and are Git tags v<version> on a release commit; CHANGELOG.md has one entry per release and linear_runner/version.py names the newest one.
python3 runner.py --version
# linear-runner 2.0.0 (commit <sha>, clean)
# linear-runner 2.0.0 with unreleased changes (commit <sha>, dirty; not the tagged release v2.0.0)
resolved-config.json records the same release, commit and dirty flag under runner, so every batch state names the runner it ran with. A batch may pin a release with "runner_version": "2.0.0": the launch preflight's config step then refuses unless the runner checkout is exactly the clean, tagged release (The batch pins runner_version 2.1.0, but this checkout is release 2.0.0 at commit ...). validate-config reports the same problem under runner_version without failing, and recover repin-config refuses with it. Without runner_version any checkout runs, as before.
Every CHANGELOG entry states its project impact (none, or the exact configuration change and the validate-config message that asks for it) and its canary tier: first-issue checkpoint for a change to preflight, reporting or documentation only, canary batch for a change to the engine, prompts, backends or models. Changes land under ## Unreleased as they are committed. To cut a release:
python3 -m unittest), including the public-tree leak test (tests/test_public_tree.py), and validate-config passes for the example batch.## Unreleased to ## v<version> — <date> with both required lines (a test checks them), start a new empty ## Unreleased, and set RELEASE in linear_runner/version.py (a test checks it matches the newest entry).v<version> (annotated) and push the commit and the tag.hooks/register.tsx 1101 lines1import type { Color, EngineInterface, PluginOptions, Register } from 'claude-code'
2
3const PANE = 'runner-board'
4const TITLE = 'Runner board'
5const POLL_MS = 10_000
6// The record `runner.py status --board` prints, as this board reads it.
7const SCHEMA = 'linear-runner.board/1'
8
9// Where the runner is: the plugin's options, with the defaults plugin.json gives them. A path that starts with `~/`
10// is under the user's home, a bare name is a program on the PATH, and another relative path is under the plugin's
11// own folder, which is `mods/runner-board` of a runner checkout: two folders up is the checkout's `runner.py`.
12const DEFAULTS = { python: 'python3', runner: '../../runner.py', home: '~/.config/linear-runner' }
13let config = DEFAULTS
14
15// A total as the runner's report counts it: exact, or a bound where an attempt reported nothing.
16type Count = { value: number | null; bound: string | null }
17// One issue of the record. `stage` is done, deferred, queued, or the step of the active issue.
18type Issue = {
19 id: string
20 title: string | null
21 url: string | null
22 stage: string
23 repairs: number
24 blocked: { worker: number; review: number }
25 summary: string | null
26 accepted_at: string | null
27 commit: string | null
28 files: string[]
29 elapsed_seconds: number | null
30 tokens: { input_tokens: Count; cached_input_tokens: Count; output_tokens: Count }
31 pending_invocations: number
32}
33// Why a batch exists and which batch it stems from: the batch file's own keys, or, while a batch file has none,
34// its entry in a file of `<runner home>/lineage/`.
35type Link = {
36 purpose?: string
37 follows?: string | null
38 reason?: string
39 from_issues?: string[]
40 note?: string
41 // Written but not yet reviewed by the person.
42 isDraft?: boolean
43}
44export type Board = {
45 schema: string
46 batch: {
47 id: string
48 phase: string | null
49 supervisor: { status: string | null; outcome: string | null }
50 first_launch_at: string | null
51 last_launch_at: string | null
52 stop_marker: string | null
53 pending_recovery: { id?: string; kind?: string } | null
54 error: string | null
55 lineage: Link
56 }
57 issues: Issue[]
58}
59type Batch = {
60 id: string
61 issues: string[]
62 board?: Board
63 problem?: string
64}
65type Detail = { label: string; text?: string; items?: string[] }
66type Kind = 'done' | 'running' | 'stopped' | 'deferred' | 'queued'
67// A status icon in Linear's manner: a ring to do, a pie that fills as the work moves on, a check when done.
68type Icon = {
69 shape: 'ring' | 'dashed' | 'pie' | 'check' | 'pause' | 'alert' | 'live'
70 hex: string
71 part?: number
72 isLive?: boolean
73}
74// `glyph` stands for the icon where no picture is drawn; `color` paints the stage's words.
75type Stage = { kind: Kind; glyph: string; icon: Icon; label: string; color: Color }
76type Look = { glyph: string; icon: Icon; color: Color; border: Color }
77
78let batches: Batch[] = []
79let isLoaded = false
80let isRefreshing = false
81let updatedAt: number | null = null
82let expanded: string | null = null
83// What the files of `<runner home>/lineage/` record, for the batches whose own file says nothing.
84let recorded: Record<string, Link> = {}
85let view: 'board' | 'lineage' = 'board'
86let lineageAs: 'graph' | 'list' = 'graph'
87// How large the graph is drawn, as a share of its own size.
88let zoom = 1
89// Batches the person opened or closed by hand; the others open unless their queue is complete.
90const toggled = new Map<string, boolean>()
91
92// A path without its `.` and `..` steps.
93function tidy(path: string) {
94 const steps: string[] = []
95 for (const step of path.split('/')) {
96 if (step === '..') steps.pop()
97 else if (step !== '.' && step !== '') steps.push(step)
98 }
99
100 return '/' + steps.join('/')
101}
102
103async function places($: EngineInterface) {
104 const home = (await $.env.get('HOME')) ?? ''
105 const at = (path: string) => {
106 if (path.startsWith('~/')) return home + path.slice(1)
107
108 return path.startsWith('/') || !path.includes('/') ? path : tidy($.plugin.root + '/' + path)
109 }
110 // As the runner finds its home: the one that is named, then $LINEAR_RUNNER_HOME, then the default.
111 const fromEnv = config.home === DEFAULTS.home ? await $.env.get('LINEAR_RUNNER_HOME') : undefined
112
113 return { python: at(config.python), runner: at(config.runner), runnerHome: at(fromEnv || config.home) }
114}
115
116const LAST = 'batches:last'
117
118async function storeKey($: EngineInterface) {
119 try {
120 return 'batches:' + (await $.session.id())
121 } catch {
122 return 'batches:default'
123 }
124}
125
126async function load($: EngineInterface, id: string): Promise<Batch> {
127 const { python, runner, runnerHome } = await places($)
128 const file = `${runnerHome}/batches/${id}.json`
129 let issues: string[] = []
130 try {
131 issues = (JSON.parse(await $.fs.read(file)) as { issues?: string[] }).issues ?? []
132 } catch {
133 return { id, issues, problem: `no batch file ${file}` }
134 }
135 try {
136 // The runner reads the batch's state directory and the records of its issues' runs; it writes nothing.
137 const ran = await $.process.run([python, runner, 'status', '--board', '--batch', file, '--home', runnerHome], {
138 timeoutMs: 60_000,
139 })
140 if (ran.exitCode !== 0) return { id, issues, problem: ran.stderr.trim().split('\n').pop() || 'status failed' }
141 const board = JSON.parse(ran.stdout) as Partial<Board>
142 if (board.schema !== SCHEMA || !board.batch || !Array.isArray(board.issues)) {
143 return { id, issues, problem: `the runner printed a record this board cannot read (${board.schema ?? 'no schema'}, not ${SCHEMA})` }
144 }
145
146 return { id, issues: board.issues.map(issue => issue.id), board: board as Board }
147 } catch (error) {
148 return { id, issues, problem: String(error) }
149 }
150}
151
152async function readLineage($: EngineInterface) {
153 const { runnerHome } = await places($)
154 const dir = `${runnerHome}/lineage`
155 const files = await $.fs.list(dir).catch(() => [])
156 const next: Record<string, Link> = {}
157 for (const file of files) {
158 if (!file.name.endsWith('.json')) continue
159 try {
160 const record = JSON.parse(await $.fs.read(`${dir}/${file.name}`)) as { status?: string; batches?: Record<string, Link> }
161 for (const [id, link] of Object.entries(record.batches ?? {})) next[id] = { ...link, isDraft: record.status === 'draft' }
162 } catch {
163 // A file that does not read leaves its batches without a recorded reason.
164 }
165 }
166 recorded = next
167}
168
169// A batch file that holds any of the four keys speaks for itself; the lineage file is read for the others.
170function linkOf(batch: Batch): Link | undefined {
171 const own = batch.board?.batch.lineage
172
173 return own && Object.keys(own).length ? own : recorded[batch.id]
174}
175
176const issueOf = (board: Board | undefined, id: string) => board?.issues.find(issue => issue.id === id)
177// The first launch, so that a batch keeps its place on the board when it is launched again.
178const startedAt = (batch: Batch) => batch.board?.batch.first_launch_at ?? '9'
179const isLive = (batch: Batch) => !batch.board || batch.board.batch.supervisor.status === 'running'
180
181// What of a batch the board draws: the record is all of it. A read that leaves it as it was calls for no new
182// drawing, and a new drawing of the graph is a flash on the desktop, which draws it afresh each time.
183const shown = (batch: Batch) => JSON.stringify([batch.issues, batch.problem, batch.board])
184
185// With `only`, the timer's read of the batches under way: it draws again only when one of them has changed.
186async function refresh($: EngineInterface, only?: (batch: Batch) => boolean) {
187 if (isRefreshing || (only && !batches.some(only))) return
188 isRefreshing = true
189 try {
190 const before = batches.map(shown).join()
191 const fresh = new Map<string, Batch>()
192 await Promise.all(
193 batches.filter(batch => !only || only(batch)).map(async batch => fresh.set(batch.id, await load($, batch.id))),
194 )
195 // A failed read keeps what the last good read showed.
196 batches = batches
197 .map(batch => {
198 const next = fresh.get(batch.id)
199 if (!next) return batch
200
201 return !next.board && batch.board ? { ...batch, problem: next.problem } : next
202 })
203 .sort((a, b) => startedAt(a).localeCompare(startedAt(b)))
204 if (!only) await readLineage($)
205 updatedAt = await $.clock.now()
206 if (!only || batches.map(shown).join() !== before) $.ui.invalidate('ui.render')
207 } finally {
208 isRefreshing = false
209 }
210}
211
212async function restore($: EngineInterface) {
213 if (isLoaded) return
214 isLoaded = true
215 // From here on the batches under way are read again every few seconds.
216 $.clock.every(POLL_MS, () => void refresh($, isLive))
217 // A session with no board of its own yet starts from the board saved last: a resumed session has a new id.
218 let saved = (await $.store.get(await storeKey($))) ?? (await $.store.get(LAST))
219 if (!Array.isArray(saved)) {
220 // Before the last board was kept under its own key, each session's was kept only under that session's.
221 const keys = (await $.store.keys().catch(() => [])).filter(key => key.startsWith('batches:'))
222 const boards = await Promise.all(keys.map(key => $.store.get(key)))
223 saved = boards.filter(Array.isArray).sort((a, b) => b.length - a.length)[0]
224 }
225 if (Array.isArray(saved)) {
226 // A name with no batch file behind it is not a batch: it leaves the board.
227 const ids = saved.filter((id): id is string => typeof id === 'string')
228 const { found } = await sift($, ids)
229 batches = found.map(id => ({ id, issues: [] }))
230 if (found.length !== ids.length) await save($)
231 }
232 await refresh($)
233}
234
235// Which of these names have a batch file.
236async function sift($: EngineInterface, ids: string[]) {
237 const { runnerHome } = await places($)
238 const has = await Promise.all(ids.map(id => $.fs.exists(`${runnerHome}/batches/${id}.json`).catch(() => false)))
239
240 return { found: ids.filter((_, index) => has[index]), missing: ids.filter((_, index) => !has[index]) }
241}
242
243async function save($: EngineInterface) {
244 const ids = batches.map(batch => batch.id)
245 await $.store.set(await storeKey($), ids)
246 await $.store.set(LAST, ids)
247}
248
249// A name ending in `*` stands for every batch file whose name starts with what is before it.
250async function resolve($: EngineInterface, names: string[]) {
251 const { runnerHome } = await places($)
252 const ids: string[] = []
253 for (const name of names) {
254 if (!name.endsWith('*')) {
255 ids.push(name.replace(/^.*\//, '').replace(/\.json$/, ''))
256 continue
257 }
258 const files = await $.fs.list(`${runnerHome}/batches`)
259 for (const file of files) {
260 if (file.name.startsWith(name.slice(0, -1)) && file.name.endsWith('.json')) ids.push(file.name.slice(0, -5))
261 }
262 }
263
264 return [...new Set(ids)]
265}
266
267async function add($: EngineInterface, names: string[]) {
268 await restore($)
269 const known = new Set(batches.map(batch => batch.id))
270 const { found, missing } = await sift($, await resolve($, names))
271 const added = found.filter(id => !known.has(id))
272 batches = [...batches, ...added.map(id => ({ id, issues: [] }))]
273 await save($)
274 await refresh($, batch => added.includes(batch.id))
275
276 return { added, missing }
277}
278
279// The batches a shell command launches: `runner.py launch ... --batch <file>`.
280// Only the command line counts: what a here-document feeds a program is text, not a launch.
281export function launched(command: string) {
282 const ids: string[] = []
283 const line = command.split(/<<-?\s*['"]?\w/)[0] ?? ''
284 for (const call of line.matchAll(/runner\.py\s+launch\b([^\n;&|]*)/g)) {
285 const batch = /--batch[ =]+(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(call[1] ?? '')
286 const path = batch?.[1] ?? batch?.[2] ?? batch?.[3]
287 if (path) ids.push(path.replace(/^.*\//, '').replace(/\.json$/, ''))
288 }
289
290 return ids
291}
292
293// Linear's status colors.
294const HEX = {
295 todo: '#9ca3af',
296 started: '#f2c94c',
297 review: '#27ae60',
298 done: '#5e6ad2',
299 stopped: '#f2994a',
300 failed: '#eb5757',
301}
302const RING: Icon = { shape: 'ring', hex: HEX.todo }
303const CHECK: Icon = { shape: 'check', hex: HEX.done }
304
305// How far each step of a running issue fills its pie, and in which color.
306const STEPS: Record<string, { part: number; hex: string; color: Color }> = {
307 implement: { part: 0.25, hex: HEX.started, color: 'warning' },
308 validate: { part: 0.5, hex: HEX.started, color: 'warning' },
309 commit: { part: 0.5, hex: HEX.started, color: 'warning' },
310 delivery: { part: 0.6, hex: HEX.started, color: 'warning' },
311 repair: { part: 0.5, hex: HEX.stopped, color: 'warning' },
312 review: { part: 0.75, hex: HEX.review, color: 'success' },
313 publish: { part: 0.9, hex: HEX.review, color: 'success' },
314}
315const pieGlyph = (part: number) => (part < 0.34 ? '◔' : part < 0.67 ? '◑' : '◕')
316const pie = (step: string, isLive = false): Icon => {
317 const { part, hex } = STEPS[step] ?? { part: 0.25, hex: HEX.started }
318
319 return { shape: 'pie', hex, part, isLive }
320}
321const LEGEND: [Icon, string, string][] = [
322 [RING, '○', 'queued'],
323 [pie('implement'), '◔', 'implement'],
324 [pie('validate'), '◑', 'validate'],
325 [pie('review'), '◕', 'review'],
326 [CHECK, '✓', 'done'],
327 [{ shape: 'pause', hex: HEX.stopped }, '⏸', 'stopped'],
328 [{ shape: 'dashed', hex: HEX.todo }, '◌', 'deferred'],
329]
330
331// The 14 by 14 shapes of an icon.
332function shapesOf(icon: Icon) {
333 const { hex } = icon
334 const ring = (more = '') => `<circle cx="7" cy="7" r="6" fill="none" stroke="${hex}" stroke-width="1.5"${more}/>`
335 const bar = (x: number) => `<rect x="${x}" y="4.3" width="1.6" height="5.4" rx="0.5" fill="${hex}"/>`
336 const fade = (from: string, to: string) =>
337 `<animate attributeName="opacity" values="${from};${to}" dur="1.6s" repeatCount="indefinite"/>`
338 const shapes: Record<Icon['shape'], string> = {
339 ring: ring(),
340 dashed: ring(' stroke-dasharray="1.4 1.74" stroke-dashoffset="0.65"'),
341 pie:
342 ring() +
343 `<circle cx="7" cy="7" r="2" fill="none" stroke="${hex}" stroke-width="4" transform="rotate(-90 7 7)" ` +
344 `stroke-dasharray="${(12.566 * (icon.part ?? 0.5)).toFixed(2)} 100"/>`,
345 check:
346 `<circle cx="7" cy="7" r="7" fill="${hex}"/>` +
347 '<path d="M4 7.3 6.2 9.5 10.1 5" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
348 pause: ring() + bar(4.8) + bar(7.6),
349 alert:
350 `<circle cx="7" cy="7" r="7" fill="${hex}"/>` +
351 '<rect x="6.2" y="3.2" width="1.6" height="5" rx="0.8" fill="#fff"/><circle cx="7" cy="10.3" r="1" fill="#fff"/>',
352 live:
353 `<circle cx="7" cy="7" r="3" fill="${hex}"/>` +
354 `<circle cx="7" cy="7" r="3" fill="none" stroke="${hex}" stroke-width="1.5">` +
355 `<animate attributeName="r" values="3;6.5" dur="1.6s" repeatCount="indefinite"/>${fade('0.8', '0')}</circle>`,
356 }
357 const pulse = icon.isLive ? fade('1;0.35', '1') : ''
358
359 return `<g>${shapes[icon.shape]}${pulse}</g>`
360}
361
362const svg = (icon: Icon) =>
363 `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">${shapesOf(icon)}</svg>`
364
365function stageOf(board: Board | undefined, id: string): Stage {
366 const issue = issueOf(board, id)
367 if (!board || !issue || issue.stage === 'queued') return { kind: 'queued', glyph: '○', icon: RING, label: 'queued', color: 'inactive' }
368 if (issue.stage === 'done') return { kind: 'done', glyph: '✓', icon: CHECK, label: 'done', color: HEX.done }
369 if (issue.stage === 'deferred') {
370 return { kind: 'deferred', glyph: '◌', icon: { shape: 'dashed', hex: HEX.todo }, label: 'deferred', color: 'inactive' }
371 }
372 // Any other stage is the step the active issue is at.
373 const step = issue.stage
374 const repairs = issue.repairs ? `, repair ${issue.repairs}` : ''
375 if (board.batch.supervisor.status === 'running') {
376 const icon = pie(step, true)
377
378 return {
379 kind: 'running',
380 glyph: pieGlyph(icon.part ?? 0.25),
381 icon,
382 label: (board.batch.phase ?? step) + repairs,
383 color: STEPS[step]?.color ?? 'warning',
384 }
385 }
386 const how = board.batch.supervisor.outcome ?? 'stopped'
387 const hasFailed = Boolean(board.batch.error)
388
389 return {
390 kind: 'stopped',
391 glyph: hasFailed ? '!' : '⏸',
392 icon: hasFailed ? { shape: 'alert', hex: HEX.failed } : { shape: 'pause', hex: HEX.stopped },
393 label: `${how} at ${step}${repairs}`,
394 color: hasFailed ? 'error' : 'warning',
395 }
396}
397
398function batchState(batch: Batch) {
399 const about = batch.board?.batch
400 if (!about) return batch.problem ? 'cannot read' : 'loading'
401 if (about.supervisor.status === 'running') return 'running'
402 if (about.phase === 'queue_complete') return 'complete'
403 if (about.phase === 'not started') return 'not launched'
404
405 return about.supervisor.outcome ?? about.phase ?? 'unknown'
406}
407
408function batchLook(batch: Batch, state: string): Look {
409 if (state === 'cannot read' || batch.board?.batch.error) {
410 return { glyph: '!', icon: { shape: 'alert', hex: HEX.failed }, color: 'error', border: HEX.failed }
411 }
412 if (state === 'running') return { glyph: '●', icon: { shape: 'live', hex: HEX.started }, color: 'warning', border: HEX.started }
413 if (state === 'complete') return { glyph: '✓', icon: CHECK, color: HEX.done, border: 'inactive' }
414 if (state === 'loading' || state === 'not launched') return { glyph: '○', icon: RING, color: 'inactive', border: 'inactive' }
415
416 return { glyph: '⏸', icon: { shape: 'pause', hex: HEX.stopped }, color: 'warning', border: HEX.stopped }
417}
418
419const cut = (text: string, length: number) => (text.length > length ? text.slice(0, Math.max(1, length - 1)) + '…' : text)
420
421function eastern(time: string | number, zone = false) {
422 try {
423 return new Date(time).toLocaleString('en-US', {
424 timeZone: 'America/New_York',
425 month: 'short',
426 day: 'numeric',
427 hour: 'numeric',
428 minute: '2-digit',
429 ...(zone ? { timeZoneName: 'short' as const } : {}),
430 })
431 } catch {
432 return String(time)
433 }
434}
435
436// Which batch each batch stems from, among the batches on the board.
437function family(all: Batch[]) {
438 const ids = new Set(all.map(batch => batch.id))
439 const parentOf = (batch: Batch) => {
440 const parent = linkOf(batch)?.follows
441
442 return parent && ids.has(parent) && parent !== batch.id ? parent : null
443 }
444 const childrenOf = (id: string | null) => all.filter(batch => parentOf(batch) === id)
445
446 return { parentOf, childrenOf }
447}
448
449const NODE = { w: 118, h: 34, col: 136, row: 66, pad: 14 }
450const TIP = { w: 322, line: 15, chars: 44 }
451type TipLine = { text: string; style?: 'b' | 'd' | 'i' }
452
453const esc = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
454
455function wrap(text: string, chars: number) {
456 const lines: string[] = []
457 let line = ''
458 for (const word of text.split(/\s+/).filter(Boolean)) {
459 let rest = word
460 while (rest.length > chars) {
461 if (line) lines.push(line)
462 lines.push(rest.slice(0, chars))
463 line = ''
464 rest = rest.slice(chars)
465 }
466 if (line && line.length + 1 + rest.length > chars) {
467 lines.push(line)
468 line = rest
469 } else {
470 line = line ? line + ' ' + rest : rest
471 }
472 }
473 if (line) lines.push(line)
474
475 return lines
476}
477
478// The lineage as one drawing: a node per batch under the batch it stems from, and a card that shows while the
479// pointer is over a node (what the batch did) or over a line (why the batch followed). The cards are drawn last
480// and shown by a rule each, since a drawing runs no script.
481function lineageGraph(all: Batch[]) {
482 const { parentOf, childrenOf } = family(all)
483 const bare = (batch: Batch) => batch.id.replace(/-\d{8}$/, '')
484 const first = all[0] ? bare(all[0]) : ''
485 const prefix = first.slice(0, first.indexOf('-') + 1)
486 const shared = all.length > 1 && prefix && all.every(batch => bare(batch).startsWith(prefix)) ? prefix : ''
487 const short = (batch: Batch) => cut(bare(batch).slice(shared.length), 11)
488
489 // A leaf takes the next column; a batch sits over the middle of the batches that stem from it.
490 const at = new Map<string, { x: number; y: number }>()
491 let leaves = 0
492 let depth = 0
493 const place = (batch: Batch, y: number): number => {
494 at.set(batch.id, { x: 0, y })
495 const below = childrenOf(batch.id)
496 .filter(one => !at.has(one.id))
497 .map(one => place(one, y + 1))
498 const x = below.length ? below.reduce((sum, one) => sum + one, 0) / below.length : leaves++
499 at.set(batch.id, { x, y })
500 depth = Math.max(depth, y)
501
502 return x
503 }
504 for (const batch of all) if (parentOf(batch) === null) place(batch, 0)
505 for (const batch of all) if (!at.has(batch.id)) place(batch, 0)
506
507 const width = Math.max(leaves * NODE.col + 2 * NODE.pad, TIP.w + 2 * NODE.pad)
508 const left = (width - leaves * NODE.col) / 2
509 const middle = (batch: Batch) => left + (at.get(batch.id)?.x ?? 0) * NODE.col + NODE.col / 2
510 const top = (batch: Batch) => NODE.pad + (at.get(batch.id)?.y ?? 0) * NODE.row
511 const base = 2 * NODE.pad + depth * NODE.row + NODE.h
512 let height = base
513
514 const lines: string[] = []
515 const nodes: string[] = []
516 const cards: string[] = []
517 const rules: string[] = []
518 // A card under what it explains, or over it where the drawing ends; the drawing grows if neither fits.
519 const card = (id: string, owner: string, x: number, above: number, below: number, text: TipLine[]) => {
520 const tall = text.length * TIP.line + 14
521 const cardLeft = Math.min(Math.max(x - TIP.w / 2, 4), width - TIP.w - 4)
522 const cardTop = below + 8 + tall <= base - 4 || above - 8 - tall < 4 ? below + 8 : above - 8 - tall
523 height = Math.max(height, cardTop + tall + 4)
524 const spans = text.map(
525 (line, index) => `<tspan x="10" y="${18 + index * TIP.line}"${line.style ? ` class="${line.style}"` : ''}>${esc(line.text)}</tspan>`,
526 )
527 cards.push(
528 `<g id="${id}" class="tip" transform="translate(${cardLeft.toFixed(1)},${cardTop.toFixed(1)})">` +
529 `<rect width="${TIP.w}" height="${tall}" rx="8"/><text>${spans.join('')}</text></g>`,
530 )
531 rules.push(`#${owner}:hover~#${id}`)
532 }
533
534 all.forEach((batch, index) => {
535 const link = linkOf(batch)
536 const state = batchState(batch)
537 const look = batchLook(batch, state)
538 const stages = batch.issues.map(issue => stageOf(batch.board, issue))
539 const done = stages.filter(stage => stage.kind === 'done').length
540 const x = middle(batch)
541 const y = top(batch)
542 const parent = all.find(one => one.id === parentOf(batch))
543
544 if (parent) {
545 const fromY = top(parent) + NODE.h
546 const half = (fromY + y) / 2
547 const path = `M${middle(parent).toFixed(1)},${fromY} C${middle(parent).toFixed(1)},${half} ${x.toFixed(1)},${half} ${x.toFixed(1)},${y}`
548 const kind = !link?.reason ? ' none' : link.note ? ' guess' : ''
549 lines.push(
550 `<g id="e${index}" class="edge"><path class="line${kind}" d="${path}"/><path class="hit" d="${path}"/>` +
551 `<circle class="dot" cx="${((middle(parent) + x) / 2).toFixed(1)}" cy="${half}" r="4"/></g>`,
552 )
553 card(`te${index}`, `e${index}`, (middle(parent) + x) / 2, half - 6, half + 6, [
554 { text: `Why ${short(batch)} followed ${short(parent)}`, style: 'b' },
555 ...wrap(link?.reason ?? 'No reason is recorded.', TIP.chars).map(text => ({ text })),
556 ...(link?.from_issues?.length ? wrap('from ' + link.from_issues.join(', '), TIP.chars).map(text => ({ text, style: 'd' as const })) : []),
557 ...(link?.note ? wrap(link.note, TIP.chars).map(text => ({ text, style: 'i' as const })) : []),
558 ])
559 }
560
561 nodes.push(
562 `<g id="n${index}" class="node${state === 'running' ? ' live' : ''}" transform="translate(${(x - NODE.w / 2).toFixed(1)},${y})">` +
563 `<rect width="${NODE.w}" height="${NODE.h}" rx="8"/><g transform="translate(9,10)">${shapesOf(look.icon)}</g>` +
564 `<text x="30" y="21.5" class="b">${esc(short(batch))}</text>` +
565 `<text x="${NODE.w - 9}" y="21.5" text-anchor="end" class="d">${done}/${batch.issues.length}</text></g>`,
566 )
567 const started = batch.board?.batch.first_launch_at
568 const listed = batch.issues.slice(0, 10).map((issue, at) => {
569 const title = issueOf(batch.board, issue)?.title
570 const shown = title ? ' ' + cut(title, TIP.chars - issue.length - 4) : ''
571
572 return { text: `${stages[at]?.glyph ?? '○'} ${issue}${shown}` }
573 })
574 card(`tn${index}`, `n${index}`, x, y, y + NODE.h, [
575 { text: `${bare(batch)} ${state} ${done}/${batch.issues.length}`, style: 'b' },
576 ...(started ? [{ text: eastern(started, true), style: 'd' as const }] : []),
577 ...wrap(link?.purpose ?? 'No purpose is recorded.', TIP.chars).map(text => ({ text })),
578 ...(!parent && link?.reason ? wrap('Started: ' + link.reason, TIP.chars).map(text => ({ text, style: 'i' as const })) : []),
579 { text: '' },
580 ...listed,
581 ...(batch.issues.length > 10 ? [{ text: `and ${batch.issues.length - 10} more`, style: 'd' as const }] : []),
582 ])
583 })
584
585 const style =
586 'text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;fill:#e8e8ea}' +
587 '.node rect{fill:#2b2d31;stroke:#5a5d66;stroke-width:1.2}.node.live rect{stroke:#f2c94c}' +
588 '.node:hover rect{stroke:#8f97ff;stroke-width:2}' +
589 '.line{fill:none;stroke:#8a8f98;stroke-width:1.5}.line.guess{stroke-dasharray:5 4}.line.none{stroke:#f2994a}' +
590 '.hit{fill:none;stroke:transparent;stroke-width:16;pointer-events:stroke}.dot{fill:#2b2d31;stroke:#8a8f98;stroke-width:1.5}' +
591 '.edge:hover .line,.edge:hover .dot{stroke:#8f97ff;stroke-width:2.5}' +
592 '.tip{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .12s}' +
593 '.tip rect{fill:#17181b;stroke:#8f97ff;stroke-width:1}.b{font-weight:700}.d{fill:#9aa0aa}.i{font-style:italic;fill:#c5c8d0}' +
594 (rules.length ? rules.join(',') + '{opacity:1;visibility:visible}' : '')
595 const source =
596 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
597 `<style>${style}</style>${lines.join('')}${nodes.join('')}${cards.join('')}</svg>`
598 const steps = all.filter(batch => parentOf(batch)).map(batch => `${parentOf(batch)} to ${batch.id}`)
599
600 return { source, width, height, alt: `Lineage of ${all.length} batches. ${steps.join('; ')}` }
601}
602
603function span(seconds: number) {
604 const minutes = Math.round(seconds / 60)
605 if (minutes < 1) return `${Math.round(seconds)}s`
606
607 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
608}
609
610function amount(count: number) {
611 if (count >= 1e9) return (count / 1e9).toFixed(2) + 'B'
612 if (count >= 1e6) return (count / 1e6).toFixed(1) + 'M'
613
614 return count >= 1e3 ? Math.round(count / 1e3) + 'K' : String(count)
615}
616
617// How the runner marks a total that is not exact: at least, or at most.
618const MARK: Record<string, string> = { exact: '', 'lower-bound': '≥', 'upper-bound': '≤' }
619const isKnown = (count: Count | undefined): count is Count & { value: number } => typeof count?.value === 'number'
620const tokens = (count: Count | undefined) => (isKnown(count) ? (MARK[count.bound ?? ''] ?? '≥') + amount(count.value) : 'unknown')
621
622// The short form at the end of an issue's row: its time, and input and output tokens together; a trailing
623// "+" marks an issue under way, whose figures cover the phases that have ended.
624function spent(issue: Issue | undefined, isUnderWay: boolean) {
625 const parts: string[] = []
626 if (typeof issue?.elapsed_seconds === 'number') parts.push(span(issue.elapsed_seconds))
627 const { input_tokens: input, output_tokens: output } = issue?.tokens ?? {}
628 if (isKnown(input) && isKnown(output)) {
629 // The sum is exact only when both parts are; a part that is a lower bound makes it one.
630 const bounds = [input.bound, output.bound]
631 const mark = bounds.every(bound => bound === 'exact') ? '' : bounds.includes('upper-bound') && !bounds.includes('lower-bound') ? '≤' : '≥'
632 parts.push(`${mark}${amount(input.value + output.value)} tok`)
633 }
634
635 return parts.join(' · ') + (isUnderWay && parts.length ? ' +' : '')
636}
637
638// What the runner's record holds of one issue.
639function details(board: Board | undefined, id: string) {
640 const lines: Detail[] = []
641 const issue = issueOf(board, id)
642 if (!issue) return [{ label: '', text: 'The runner has no record of this issue yet.' }]
643 const times = (n: number) => (n === 1 ? 'once' : `${n} times`)
644 const isDone = issue.stage === 'done'
645 const isWaiting = issue.stage === 'queued' || issue.stage === 'deferred'
646 if (isDone) {
647 const when = issue.accepted_at ? eastern(issue.accepted_at, true) : 'at an unrecorded time'
648 lines.push({ label: 'Accepted', text: `${when}${issue.commit ? `, commit ${issue.commit.slice(0, 9)}` : ''}` })
649 }
650 if (issue.summary) {
651 lines.push({ label: isDone ? 'Result' : 'Latest result', text: cut(issue.summary.replace(/\s+/g, ' '), 600) })
652 }
653 if (typeof issue.elapsed_seconds === 'number') {
654 const sofar = !isDone && !isWaiting ? ', phases ended so far' : ''
655 lines.push({ label: 'Time', text: `${span(issue.elapsed_seconds)} from first start to last finish${sofar}` })
656 }
657 if (isKnown(issue.tokens.input_tokens) || isKnown(issue.tokens.output_tokens)) {
658 const cached = isKnown(issue.tokens.cached_input_tokens) ? ` (${tokens(issue.tokens.cached_input_tokens)} of it read from cache)` : ''
659 lines.push({ label: 'Tokens', text: `${tokens(issue.tokens.input_tokens)} input${cached}, ${tokens(issue.tokens.output_tokens)} output` })
660 }
661 if (issue.blocked.review) lines.push({ label: 'Review', text: `blocked it ${times(issue.blocked.review)}` })
662 if (issue.blocked.worker) lines.push({ label: 'Worker', text: `blocked ${times(issue.blocked.worker)}` })
663 if (issue.files.length) {
664 const more = issue.files.length > 12 ? [`and ${issue.files.length - 12} more`] : []
665 lines.push({ label: 'Files for you', items: [...issue.files.slice(0, 12), ...more] })
666 }
667 if (issue.stage === 'queued') {
668 lines.push({ label: 'Not started', text: 'The batch has not claimed this issue' + (issue.url ? '; what it asks is in Linear, by the link above.' : '.') })
669 }
670 if (!lines.length) lines.push({ label: '', text: 'No more is recorded in the batch state.' })
671
672 return lines
673}
674
675const named = (options: PluginOptions, name: keyof typeof DEFAULTS) => {
676 const value = options[name]
677
678 return typeof value === 'string' && value.trim() ? value.trim() : DEFAULTS[name]
679}
680
681export const register: Register = (on, options) => {
682 config = { python: named(options, 'python'), runner: named(options, 'runner'), home: named(options, 'home') }
683
684 on('session.start', async ($, e, next) => {
685 await $.command.register({
686 name: 'runner-board',
687 description: 'Show runner batches in a pane: add <batch id or prefix*>, remove <id>, clear',
688 })
689 await restore($)
690 if (batches.length) void $.ui.open({ id: PANE, title: TITLE })
691
692 return next(e)
693 })
694
695 on('command.run', { command: 'runner-board' }, async ($, e) => {
696 const [verb, ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
697 const said: string[] = []
698 await restore($)
699 if (verb === 'clear') {
700 batches = []
701 await save($)
702 said.push('Board cleared.')
703 } else if (verb === 'remove') {
704 const gone = new Set(await resolve($, rest))
705 batches = batches.filter(batch => !gone.has(batch.id))
706 await save($)
707 said.push(`Removed ${gone.size} batch(es).`)
708 } else if (verb) {
709 const { added, missing } = await add($, verb === 'add' ? rest : [verb, ...rest])
710 said.push(added.length ? `Added ${added.join(', ')}.` : 'Nothing new to add.')
711 if (missing.length) said.push(`No batch file for ${missing.join(', ')}.`)
712 } else {
713 void refresh($)
714 }
715 const opened = await $.ui.open({ id: PANE, title: TITLE })
716 if (!opened.isPlaced) said.push(`The pane is not shown: ${opened.reason}`)
717 $.ui.invalidate('ui.render')
718
719 return { text: said.join(' ') || 'Runner board opened.' }
720 })
721
722 // A batch this session launches joins its board; the call itself goes on untouched.
723 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
724 const ran = await next(e)
725 try {
726 // A launch that started prints its launch id; a command that only mentions one does not.
727 const hasStarted = ran.deny === undefined && ran.isError !== true && Boolean(ran.text?.includes('"launch_id"'))
728 const ids = hasStarted ? launched(e.command) : []
729 if (ids.length) void add($, ids).catch(() => undefined)
730 } catch {
731 // Watching must never cost the call its result.
732 }
733
734 return ran
735 }).catch(($, e, next) => next(e))
736
737 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
738 const { Box, Text, Button, Link } = $.ui.resolve(e)
739 const redraw = () => $.ui.invalidate('ui.render')
740 const columns = Math.max(40, e.props.bodyColumns)
741 const isDesktop = e.surface === 'desktop'
742 // The desktop app draws the icon; elsewhere its glyph stands for it. A live icon moves, so it is drawn in a frame.
743 const mark = (icon: Icon, glyph: string, color: Color, alt: string) =>
744 e.surface === 'desktop' ? (
745 $.ui.resolve(e).Svg({
746 source: svg(icon),
747 alt,
748 width: 14,
749 height: 14,
750 ...(icon.isLive || icon.shape === 'live' ? { isInteractive: true } : {}),
751 })
752 ) : (
753 <Text color={color}>{glyph}</Text>
754 )
755 const all = batches.flatMap(batch => batch.issues.map(issue => stageOf(batch.board, issue)))
756 const tally = (kind: Kind) => all.filter(stage => stage.kind === kind).length
757 const totals: [Kind, string, Color][] = [
758 ['done', 'done', HEX.done],
759 ['running', 'in progress', 'warning'],
760 ['stopped', 'stopped', 'warning'],
761 ['deferred', 'deferred', 'inactive'],
762 ['queued', 'queued', 'inactive'],
763 ]
764
765 const tab = (name: typeof view, label: string, hotkey: string) => (
766 <Button
767 key={'view-' + name}
768 label={label}
769 hotkey={hotkey}
770 variant={view === name ? 'primary' : 'secondary'}
771 onPress={() => {
772 view = name
773 redraw()
774 }}
775 />
776 )
777
778 // The lineage view: each batch under the batch it stems from, the reason written on the way down.
779 const { parentOf, childrenOf } = family(batches)
780 // A drawing past the size a surface takes gives way to the list.
781 const drawn = view === 'lineage' && lineageAs === 'graph' && batches.length ? lineageGraph(batches) : null
782 const graph =
783 drawn && drawn.source.length <= 131_072 && e.surface === 'desktop'
784 ? // Its size is given outright: a drawing in a frame is not sized by its own markup.
785 $.ui.resolve(e).Svg({
786 source: drawn.source,
787 alt: drawn.alt,
788 width: Math.round(drawn.width * zoom),
789 height: Math.round(drawn.height * zoom),
790 isInteractive: true,
791 })
792 : null
793 const shape = (name: typeof lineageAs, label: string) => (
794 <Button
795 key={'lineage-' + name}
796 label={label}
797 variant={lineageAs === name ? 'primary' : 'secondary'}
798 onPress={() => {
799 lineageAs = name
800 redraw()
801 }}
802 />
803 )
804 const node = (batch: Batch) => {
805 const state = batchState(batch)
806 const look = batchLook(batch, state)
807 const done = batch.issues.filter(issue => stageOf(batch.board, issue).kind === 'done').length
808 const started = batch.board?.batch.first_launch_at
809
810 return (
811 <Box flexDirection="column">
812 <Box flexDirection="row" alignItems="center" columnGap={1}>
813 {mark(look.icon, look.glyph, look.color, state)}
814 <Box flexGrow={1}>
815 <Button
816 key={'node-' + batch.id}
817 plain
818 onPress={() => {
819 view = 'board'
820 toggled.set(batch.id, true)
821 redraw()
822 }}
823 >
824 <Text bold>{batch.id.replace(/-\d{8}$/, '')}</Text>
825 {state !== 'complete' && <Text color={look.color}>{' ' + state}</Text>}
826 <Text dimColor>{' ' + done + '/' + batch.issues.length}</Text>
827 </Button>
828 </Box>
829 {started && <Text dimColor>{eastern(started)}</Text>}
830 </Box>
831 <Box paddingLeft={3}>
832 <Text dimColor wrap="wrap">
833 {linkOf(batch)?.purpose ?? 'No purpose recorded.'}
834 </Text>
835 </Box>
836 </Box>
837 )
838 }
839 const why = (batch: Batch) => {
840 const link = linkOf(batch)
841 if (!link?.reason) return <Text color="warning">↓ No reason recorded for this batch.</Text>
842
843 return (
844 <Box flexDirection="column">
845 <Text wrap="wrap">
846 <Text color="suggestion" bold>
847 {parentOf(batch) ? '↓ ' : '● '}
848 </Text>
849 <Text italic>{link.reason}</Text>
850 </Text>
851 {link.from_issues?.length ? <Text dimColor>{' from ' + link.from_issues.join(', ')}</Text> : null}
852 {link.note && (
853 <Text dimColor italic wrap="wrap">
854 {' ' + link.note}
855 </Text>
856 )}
857 </Box>
858 )
859 }
860 // A batch with one successor continues straight down; where several stem from it, each gets its own branch.
861 const seen = new Set<string>()
862 const chain = (batch: Batch): JSX.Element[] => {
863 if (seen.has(batch.id)) return []
864 seen.add(batch.id)
865 const next = childrenOf(batch.id)
866 const [only] = next
867 if (next.length === 1 && only) return [node(batch), why(only), ...chain(only)]
868
869 return [
870 node(batch),
871 ...next.map(one => (
872 <Box
873 flexDirection="column"
874 marginLeft={1}
875 paddingLeft={1}
876 rowGap={isDesktop ? 1 : 0}
877 borderStyle="quote"
878 borderColor="inactive"
879 borderDimColor
880 >
881 {why(one)}
882 {chain(one)}
883 </Box>
884 )),
885 ]
886 }
887
888 return (
889 <Box flexDirection="column">
890 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
891 <Box flexDirection="row" columnGap={1}>
892 {tab('board', 'Board', 'b')}
893 {tab('lineage', 'Lineage', 'l')}
894 </Box>
895 <Button
896 key="refresh"
897 label="Refresh"
898 hotkey="r"
899 onPress={() => void refresh($)}
900 />
901 </Box>
902 <Text>
903 <Text bold>{batches.length} batches</Text>
904 <Text dimColor>{' · ' + all.length + ' issues'}</Text>
905 {totals
906 .filter(([kind]) => tally(kind) > 0)
907 .map(([kind, word, color]) => (
908 <Text color={color}>{' · ' + tally(kind) + ' ' + word}</Text>
909 ))}
910 {updatedAt !== null && <Text dimColor>{' · read ' + eastern(updatedAt, true)}</Text>}
911 </Text>
912 {batches.length === 0 && (
913 <Text dimColor>No batch here yet. Add one with /runner-board add <batch id>; a trailing * adds several.</Text>
914 )}
915 {view === 'lineage' && (
916 <Box flexDirection="column" marginTop={1} rowGap={isDesktop ? 1 : 0}>
917 {batches.some(batch => linkOf(batch)?.isDraft) && (
918 <Text color="warning" wrap="wrap">
919 Draft: purposes and reasons from a lineage file that is not yet reviewed.
920 </Text>
921 )}
922 {isDesktop && (
923 <Box flexDirection="row" alignItems="center" columnGap={1}>
924 {shape('graph', 'Graph')}
925 {shape('list', 'List')}
926 {graph && (
927 <Button
928 key="zoom-out"
929 label="−"
930 onPress={() => {
931 zoom = Math.max(0.6, Math.round((zoom - 0.2) * 10) / 10)
932 redraw()
933 }}
934 />
935 )}
936 {graph && (
937 <Button
938 key="zoom-in"
939 label="+"
940 onPress={() => {
941 zoom = Math.min(2.4, Math.round((zoom + 0.2) * 10) / 10)
942 redraw()
943 }}
944 />
945 )}
946 {graph && <Text dimColor>{Math.round(zoom * 100)}% · hover a batch or a line</Text>}
947 </Box>
948 )}
949 {graph ?? childrenOf(null).flatMap(root => [why(root), ...chain(root)])}
950 </Box>
951 )}
952 <Box flexDirection="column" marginTop={1} rowGap={isDesktop ? 1 : 0} display={view === 'board' ? 'flex' : 'none'}>
953 {batches.map(batch => {
954 const about = batch.board?.batch
955 const state = batchState(batch)
956 const look = batchLook(batch, state)
957 const stages = batch.issues.map(issue => stageOf(batch.board, issue))
958 const done = stages.filter(stage => stage.kind === 'done').length
959 const isOpen = toggled.get(batch.id) ?? state !== 'complete'
960 const isQuiet = state === 'complete'
961 const name = batch.id.replace(/-\d{8}$/, '')
962 const started = about?.first_launch_at
963 const purpose = linkOf(batch)?.purpose
964 const flags = [about?.stop_marker ? 'STOP marker' : '', about?.pending_recovery ? 'recovery pending' : '']
965 .filter(Boolean)
966 .join(' · ')
967
968 return (
969 <Box
970 key={batch.id}
971 flexDirection="column"
972 paddingX={1}
973 borderStyle="round"
974 borderColor={look.border}
975 borderDimColor={isQuiet}
976 hover={{ borderColor: 'text', borderDimColor: false }}
977 {...(state === 'running' && isDesktop ? { backgroundColor: HEX.started + '14' } : {})}
978 >
979 <Box flexDirection="row" alignItems="center" columnGap={1}>
980 {mark(look.icon, look.glyph, look.color, state)}
981 <Box flexGrow={1}>
982 <Button
983 key={'batch-' + batch.id}
984 plain
985 onPress={() => {
986 toggled.set(batch.id, !isOpen)
987 redraw()
988 }}
989 >
990 <Text bold={!isQuiet}>{name}</Text>
991 {!isQuiet && (
992 <Text color={look.color} bold>
993 {' ' + state}
994 </Text>
995 )}
996 <Text dimColor>{' ' + done + '/' + batch.issues.length}</Text>
997 {isOpen && !isQuiet && flags && <Text color="warning">{' ' + flags}</Text>}
998 <Text dimColor>{isOpen ? ' ▾' : ' ▸'}</Text>
999 </Button>
1000 </Box>
1001 {started && <Text dimColor>{eastern(started)}</Text>}
1002 </Box>
1003 {purpose && (
1004 <Box paddingLeft={3}>
1005 <Text dimColor wrap="wrap">
1006 {purpose}
1007 </Text>
1008 </Box>
1009 )}
1010 {isOpen && batch.problem && <Text color="error">{cut(batch.problem, 300)}</Text>}
1011 {isOpen && about?.error && <Text color="error">{cut(String(about.error), 300)}</Text>}
1012 {isOpen && batch.issues.length > 0 && (
1013 // A bar down the left with a dash to each issue: the batch's issues as a tree.
1014 <Box
1015 flexDirection="column"
1016 marginLeft={isDesktop ? 1 : 0}
1017 borderStyle="quote"
1018 borderColor="inactive"
1019 borderDimColor
1020 >
1021 {batch.issues.map((issue, index) => {
1022 const stage = stages[index] ?? stageOf(batch.board, issue)
1023 const held = issueOf(batch.board, issue)
1024 const key = batch.id + '/' + issue
1025 const isPlain = stage.kind === 'done' || stage.kind === 'queued'
1026 const cost = spent(held, stage.kind === 'running')
1027 const used = issue.length + (isPlain ? 0 : stage.label.length + 2) + cost.length + 12
1028 const title = held?.title ? cut(held.title, Math.max(16, columns - used)) : ''
1029
1030 return (
1031 <Box key={key} flexDirection="column">
1032 <Box flexDirection="row" alignItems="center" columnGap={1}>
1033 <Text color="inactive">─</Text>
1034 {mark(stage.icon, stage.glyph, stage.color, stage.label)}
1035 <Box flexGrow={1}>
1036 <Button
1037 key={'issue-' + key}
1038 plain
1039 onPress={() => {
1040 expanded = expanded === key ? null : key
1041 redraw()
1042 }}
1043 >
1044 <Text bold={!isPlain} dimColor={stage.kind === 'queued'}>
1045 {issue}
1046 </Text>
1047 {!isPlain && <Text color={stage.color}>{' ' + stage.label}</Text>}
1048 {title && <Text dimColor>{' ' + title}</Text>}
1049 </Button>
1050 </Box>
1051 {cost && <Text dimColor>{cost}</Text>}
1052 </Box>
1053 {expanded === key && (
1054 <Box
1055 flexDirection="column"
1056 marginLeft={4}
1057 marginBottom={1}
1058 paddingLeft={1}
1059 borderStyle="quote"
1060 borderColor={stage.icon.hex}
1061 >
1062 {held?.url && <Link href={held.url} label={held.title ?? `${issue} in Linear`} />}
1063 {details(batch.board, issue).map(line => (
1064 <Box flexDirection="column">
1065 <Text wrap="wrap">
1066 {line.label && <Text bold>{line.label + ' '}</Text>}
1067 {line.text && <Text dimColor>{line.text}</Text>}
1068 </Text>
1069 {line.items?.map(item => (
1070 <Text dimColor wrap="wrap">
1071 {' • ' + item}
1072 </Text>
1073 ))}
1074 </Box>
1075 ))}
1076 </Box>
1077 )}
1078 </Box>
1079 )
1080 })}
1081 </Box>
1082 )}
1083 </Box>
1084 )
1085 })}
1086 </Box>
1087 {all.length > 0 && (
1088 <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
1089 {LEGEND.map(([icon, glyph, word]) => (
1090 <Box flexDirection="row" alignItems="center" columnGap={1}>
1091 {mark(icon, glyph, icon.hex, word)}
1092 <Text dimColor>{word}</Text>
1093 </Box>
1094 ))}
1095 </Box>
1096 )}
1097 </Box>
1098 )
1099 })
1100}
1101