@valescoagency/runway 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +189 -40
  2. package/dist/cli.js +14 -0
  3. package/dist/commands/dash.js +324 -0
  4. package/dist/commands/review.js +315 -0
  5. package/dist/commands/run.js +21 -7
  6. package/dist/config.js +51 -6
  7. package/dist/dashboard/events.js +71 -0
  8. package/dist/dashboard/linear-sync.js +192 -0
  9. package/dist/dashboard/projector.js +77 -0
  10. package/dist/dashboard/server.js +468 -20
  11. package/dist/dashboard/storage.js +417 -16
  12. package/dist/dashboard/views.js +901 -8
  13. package/dist/diagnostics/git-signing.js +120 -0
  14. package/dist/diagnostics/index.js +2 -0
  15. package/dist/diagnostics/linear-config.js +19 -35
  16. package/dist/finalize.js +59 -13
  17. package/dist/git.js +48 -12
  18. package/dist/hitl.js +20 -28
  19. package/dist/implement.js +82 -1
  20. package/dist/linear.js +87 -73
  21. package/dist/meta/attribution.js +285 -0
  22. package/dist/meta/context.js +165 -0
  23. package/dist/meta/dashboard-read.js +609 -0
  24. package/dist/meta/format.js +49 -0
  25. package/dist/meta/heuristic-filter.js +53 -0
  26. package/dist/meta/hindsight.js +279 -0
  27. package/dist/meta/linear-meta.js +415 -0
  28. package/dist/meta/llm.js +205 -0
  29. package/dist/meta/out-of-scope.js +101 -0
  30. package/dist/meta/passes/drain-review.js +374 -0
  31. package/dist/meta/passes/run-review.js +475 -0
  32. package/dist/meta/passes/weekly-review.js +910 -0
  33. package/dist/meta/promoter.js +225 -0
  34. package/dist/meta/runner.js +221 -0
  35. package/dist/meta/span-attrs.js +65 -0
  36. package/dist/meta/templates.js +655 -0
  37. package/dist/orchestrator.js +54 -22
  38. package/dist/policy.js +6 -5
  39. package/dist/review.js +25 -8
  40. package/dist/runway-config-file.js +82 -0
  41. package/dist/scaffolder-varlock.js +9 -0
  42. package/dist/telemetry.js +38 -14
  43. package/package.json +6 -3
  44. package/prompts/implement.md +71 -0
  45. package/prompts/pr-review.md +127 -0
  46. package/prompts/review.md +64 -1
  47. package/templates/.env.schema.target-repo +26 -0
  48. package/templates/claude-shim.sh +47 -0
  49. package/templates/dockerfile-varlock.snippet +19 -12
package/README.md CHANGED
@@ -8,13 +8,15 @@ coding-agent runs, then **drain** a Linear queue against it. Wraps
8
8
  inside Docker), [varlock](https://varlock.dev) + 1Password for
9
9
  zero-secrets-at-rest, and the `gh` CLI for PR creation.
10
10
 
11
- ## Five commands
11
+ ## Seven commands
12
12
 
13
13
  | | |
14
14
  |---|---|
15
+ | `runway dash` | Bring up the operations dashboard (`up` / `logs` / `stop`). Wraps the published `ghcr.io/valescoagency/runway-dashboard` image so any runway-using project can run the dashboard without cloning runway. Ports bind to `127.0.0.1` only. |
15
16
  | `runway doctor` | Read-only preflight diagnostic: host tooling, env vars, repo state, and the agent docker image. Use when something stopped working and you want a sanity report. `--json` for CI / scripted health checks. |
16
17
  | `runway init` | Scaffold the cwd repo for runway: write `.sandcastle/Dockerfile` + (tier 2) `.env.schema` with op:// references. Run **once per target repo**. |
17
- | `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `ready-for-human` label). Run **whenever you want a batch of work done**. |
18
+ | `runway review` | Run an IRA retrospective pass. `runway review run --drain <trace-id> --issue <id>` grades one issue-process post-drain — loads the captured agent + reviewer reports from the dashboard, fetches hindsight (PR merge state, human review-thread comments, Linear follow-ups in the past 48h), pulls rolling norms for the issue's category (last 30 drains, via the VA-399 read-model), asks an Anthropic model for a structured `Run Review` with absolute + relative grading axes, writes it to a `runway-meta` Linear project + the dashboard's `meta_reviews` table. Scheduler-agnostic; usually invoked from cron or GitHub Actions ~18h after a drain. The drain-age delay is enforced by `RUNWAY_REVIEW_DELAY_HOURS` (default 18, constrained to the 12–24h band) — pass `--force` to override for one-off operator bypass. **`runway review drain --id <trace-id>`** (VA-406) grades a whole drain: reads every per-issue Run Review for the drain, asks the model for a structured `Drain Review` covering composition / sequencing / cross-issue patterns, files it in `runway-meta`. When the model marks a finding `severity: critical` (drain-unsafe or captured-data-lost), the IRA also escalates a `Bug` + `runway-meta-promoted` issue into the runway-repo project (set `RUNWAY_REPO_PROJECT_NAME` to scope by project; otherwise team-level). The Drain Review fires automatically in-process the moment every Run Review for a drain has landed manual invocation is for scheduled / catch-up runs. |
19
+ | `runway run` | Drain a Linear queue. For each issue carrying the `ready-for-agent` label: branch, agent works, sub-agent reviews, PR opens (or `ready-for-human` label). Run **whenever you want a batch of work done**. |
18
20
  | `runway upgrade` | Update the runway CLI itself: `git pull` the local clone, `pnpm install`, typecheck. `--check` for a dry-run, `--force` to override dirty/branch refusals. |
19
21
  | `runway upgrade-repo` | Re-render the cwd repo's runway scaffold against the current vendored templates. Use after a runway version bump that changed the Dockerfile or template shape — `init` writes them, `upgrade-repo` keeps them current without re-prompting for op:// values. |
20
22
 
@@ -39,10 +41,12 @@ or use `--check` for a CI dry-run that exits 1 on drift.
39
41
  ## Architecture
40
42
 
41
43
  ```
42
- Linear (Todo, team=VA)
44
+ Linear (label=ready-for-agent, team=VA)
43
45
  ↓ poll
44
46
  runway (this CLI, on your Mac, run from inside the target repo)
45
47
  ↓ for each issue
48
+ │ removeLabel(ready-for-agent) # claim signal — VA-423; skip
49
+ │ # if already absent (lost race)
46
50
  │ sandcastle.run({ agent: claudeCode, sandbox: docker, cwd: process.cwd(), ... })
47
51
  │ iter 1 → IMPL: DONE | IMPL: BLOCKED — <reason> | IMPL: CONTINUE
48
52
  │ iter 2 → same, with previous iteration's summary injected
@@ -54,7 +58,9 @@ runway (this CLI, on your Mac, run from inside the target repo)
54
58
  │ sandcastle.run({ ..., prompt: review template })
55
59
  │ → REVIEW: APPROVED | REVIEW: REJECTED — <reason>
56
60
 
57
- ├── approved → git push → gh pr createLinear "In Review"
61
+ ├── approved → git push → gh pr create (Linear's GitHub integration
62
+ │ auto-transitions: PR open
63
+ │ → In Progress, merge → Done)
58
64
  └── rejected → Linear comment with reason, then `ready-for-human` label
59
65
  ↓ next issue
60
66
 
@@ -153,6 +159,48 @@ varlock run --schema /path/to/runway/.env.schema -- runway --max 3
153
159
  Without varlock, runway falls back to plain `process.env` and
154
160
  sandcastle reads `.sandcastle/.env` per its docs.
155
161
 
162
+ ## Signed agent commits (optional)
163
+
164
+ Runway can sign every commit the agent produces so the PR lands on
165
+ GitHub with a **Verified** badge. Off by default; opt in per target
166
+ repo by populating four extra refs in `.env.schema`.
167
+
168
+ Setup (one-time per bot):
169
+
170
+ 1. Generate an Ed25519 SSH signing keypair for the runway agent:
171
+
172
+ ```bash
173
+ ssh-keygen -t ed25519 -C "runway-agent" -N "" -f ~/.ssh/runway_bot
174
+ ```
175
+
176
+ 2. Store the keypair plus the bot's git identity in 1Password under
177
+ `op://<vault>/runway-signing-ssh`:
178
+
179
+ | field | value |
180
+ |-----------|--------------------------------------------|
181
+ | `private` | contents of `~/.ssh/runway_bot` |
182
+ | `public` | contents of `~/.ssh/runway_bot.pub` |
183
+ | `name` | the bot's git `user.name` (e.g. `Runway Agent`) |
184
+ | `email` | the bot's git `user.email` (must match the GitHub agent account) |
185
+
186
+ 3. Register the public key as a **Signing Key** on the GitHub
187
+ account that owns the agent's `GH_TOKEN` (this is what makes
188
+ GitHub render Verified — both Authentication and Signing Keys
189
+ are managed under Settings → SSH and GPG keys).
190
+
191
+ 4. Uncomment the four `RUNWAY_SIGNING_*` lines at the bottom of
192
+ `.env.schema` (the file `runway init --tier=2` wrote at your repo
193
+ root). The lines come pre-filled with `op://` references
194
+ matching the layout above.
195
+
196
+ Validate with `runway doctor`: the **Environment / agent commit
197
+ signing** check goes from `warn (off)` to `ok` once all four refs
198
+ are declared. Tear down by re-commenting the four lines — runway
199
+ falls back to plain unsigned commits.
200
+
201
+ Design rationale + future migration path (GitHub App identity) is in
202
+ [`docs/adr/0003-agent-commit-signing.md`](docs/adr/0003-agent-commit-signing.md).
203
+
156
204
  ## Install
157
205
 
158
206
  ```bash
@@ -167,9 +215,7 @@ export LINEAR_API_KEY=lin_api_...
167
215
  # export RUNWAY_LINEAR_TEAM=VA
168
216
  # export RUNWAY_LINEAR_PROJECT=<project-id-or-slug> # optional, scopes queue to one project
169
217
  # export RUNWAY_BASE_BRANCH=master # optional, overrides auto-detected default branch
170
- # export RUNWAY_READY_STATUS="Todo"
171
- # export RUNWAY_IN_PROGRESS_STATUS="In Progress"
172
- # export RUNWAY_IN_REVIEW_STATUS="In Review"
218
+ # export RUNWAY_READY_LABEL="ready-for-agent"
173
219
  # export RUNWAY_HITL_LABEL="ready-for-human"
174
220
  # export RUNWAY_MAX_ITERATIONS=5
175
221
  # export RUNWAY_COMMENT_AUTHOR_ALLOWLIST="Reviewer Bot,Jane Reviewer"
@@ -182,14 +228,33 @@ export LINEAR_API_KEY=lin_api_...
182
228
  # Linear identities.
183
229
  ```
184
230
 
185
- `RUNWAY_HITL_LABEL` defaults to `ready-for-human`, matching the
186
- [Flightplan](https://github.com/valescoagency/flightplan) canonical
187
- state-label vocabulary (`needs-triage`, `needs-info`,
188
- `ready-for-agent`, `ready-for-human`, `wontfix`) that Bedrock and
189
- other Valesco repos use. Override the env var if your workspace uses
190
- a different label. `runway doctor` validates that the configured
191
- team, workflow states, and HITL label all exist before any agent run
192
- misconfiguration surfaces immediately instead of mid-drain.
231
+ Runway uses two labels from the
232
+ [Flightplan](https://github.com/valescoagency/flightplan) v1.1.0
233
+ state-label contract:
234
+
235
+ - `RUNWAY_READY_LABEL` (default `ready-for-agent`) marks an issue as
236
+ "ready for the agent to pick up." Runway's drain queue filters by
237
+ this label, **not** by workflow status Linear's GitHub integration
238
+ auto-mutates status when a PR cross-references an issue, which would
239
+ drain a status-gated queue silently every time someone mentioned the
240
+ issue from a PR. Labels are immune to that integration. Runway
241
+ removes the label on pickup as the claim signal: the gateway returns
242
+ whether the label was actually present at write time, and a runner
243
+ that loses the claim race (label was already absent) skips the issue
244
+ cleanly without posting a pickup comment or pushing an agent branch.
245
+ Linear's API has no label-level compare-and-swap, so two runners
246
+ that both read the labels before either has written can still both
247
+ proceed — the design assumes the predominant operator pattern of a
248
+ single drain instance running sequentially.
249
+ - `RUNWAY_HITL_LABEL` (default `ready-for-human`) is applied when the
250
+ agent or reviewer can't finish, AND when a run fails outright.
251
+ Runway never re-applies the ready label on failure — terminal
252
+ failures shouldn't retry indefinitely. The operator triages and
253
+ re-applies `ready-for-agent` manually if the failure was transient.
254
+
255
+ `runway doctor` validates that the configured team and both labels
256
+ exist before any agent run — misconfiguration surfaces immediately
257
+ instead of mid-drain.
193
258
 
194
259
  ### From source (development)
195
260
 
@@ -247,18 +312,17 @@ runway --help
247
312
  `runway` (no subcommand) is an alias for `runway run` for back-compat.
248
313
 
249
314
  `--max N` bounds **attempts**, not successes. Every issue picked up
250
- counts as one attempt, whether it ends in a PR, a `needs-human` label,
251
- or a revert-to-`Todo` after an infrastructure failure. An issue
252
- reverted in this invocation will not be re-picked in the same
253
- invocation — re-run runway after fixing the underlying config to retry
254
- it.
315
+ counts as one attempt, whether it ends in a PR, a `ready-for-human`
316
+ label, or an infrastructure-error flag. An issue picked up in this
317
+ invocation will not be re-picked in the same invocation — the
318
+ pickup-time `removeLabel(ready-for-agent)` plus a same-invocation
319
+ seen-set guard keep the drain from looping on it.
255
320
 
256
321
  The CLI exits with 0 even if some issues hit HITL or errored — those
257
322
  are normal outcomes. Every run prints a per-issue verdict trail on
258
323
  exit (`APPROVED → PR opened <url>` / `HITL <reason>` /
259
- `REVERTED → Todo <reason>` / `INFRA_ERROR <reason>`) so you can scan
260
- results without opening Linear; the same content also lives on the
261
- issue as a Linear comment.
324
+ `INFRA_ERROR <reason>`) so you can scan results without opening Linear;
325
+ the same content also lives on the issue as a Linear comment.
262
326
 
263
327
  ## Linear conventions
264
328
 
@@ -267,20 +331,36 @@ Runway picks up issues that are:
267
331
  - in team `RUNWAY_LINEAR_TEAM` (default `VA`)
268
332
  - (optionally) in project `RUNWAY_LINEAR_PROJECT` (override per-run
269
333
  with `runway run --project=<id-or-slug-or-name>`; unset = team-wide)
270
- - in workflow state `RUNWAY_READY_STATUS` (default `Todo`)
271
-
272
- It transitions them through:
273
-
274
- - `In Progress` while the agent is running (specifically: once the
275
- agent has committed to its branch startup failures before any
276
- commits revert the issue back to `Todo` rather than stranding it)
277
- - `In Review` when the PR opens
278
- - (label `ready-for-human`) if the agent or reviewer can't finish *after*
279
- the agent has committed real work
280
-
281
- These names are configurable per env var; the queries match by name so
282
- your Linear workspace's actual state names need to line up with what
283
- you set.
334
+ - carrying label `RUNWAY_READY_LABEL` (default `ready-for-agent`,
335
+ the flightplan v1.1.0 contract — see VA-423)
336
+ - not carrying `RUNWAY_HITL_LABEL` (default `ready-for-human`)
337
+ - not blocked by a non-terminal `blocks` relation
338
+ - **without any child sub-issues** parent PRDs and umbrella tickets
339
+ are skipped. The right unit of agent work is the leaf ticket, not
340
+ the parent that spans it. If your ticket's scope is bigger than one
341
+ PR, file children for the individual deliverables and let runway
342
+ pick those up instead.
343
+
344
+ Workflow status (`Triage` / `Todo` / `In Progress` / etc.) is **not**
345
+ part of the queue contract: Linear's GitHub integration auto-mutates
346
+ status whenever a PR cross-references the issue, which would drain a
347
+ status-gated queue silently every time someone mentioned the issue
348
+ from a PR.
349
+
350
+ What runway does on each pickup:
351
+
352
+ - removes the `ready-for-agent` label (claim signal — takes the
353
+ issue out of the next drain's queue; a runner that finds the
354
+ label already gone skips the issue without doing visible work)
355
+ - comments and works the issue on `agent/<id>`
356
+ - on approve, pushes the branch, opens the PR; Linear's GitHub
357
+ integration then auto-transitions the issue (`In Progress` on PR
358
+ open, `Done` on merge via the `Closes <issue>` line in the PR body)
359
+ - on reject / HITL / startup failure / mid-run crash, applies
360
+ `ready-for-human`. Runway never re-applies `ready-for-agent` on
361
+ failure — terminal failures shouldn't retry indefinitely; the
362
+ operator triages and re-applies the label manually if the cause
363
+ was transient.
284
364
 
285
365
  ## Write-path policy
286
366
 
@@ -329,8 +409,7 @@ can see what an agent run can and can't touch (e.g. `impl policy:
329
409
  Runway auto-detects the repo's default branch at the start of every
330
410
  `runway run` by reading `origin/HEAD` (with `git remote show origin`
331
411
  as a fallback for fresh clones). That branch is used for diffing the
332
- agent's work, counting commits when deciding whether a startup
333
- failure should revert to `Todo`, and as the `--base` for the PR.
412
+ agent's work and as the `--base` for the PR.
334
413
 
335
414
  Set `RUNWAY_BASE_BRANCH=<name>` to override detection — useful when
336
415
  you want runway to target a release branch instead of the default, or
@@ -380,6 +459,76 @@ the issue gets the HITL label and a comment with the reviewer's reason.
380
459
  The reviewer is intentionally adversarial — its job is to find reasons
381
460
  NOT to ship, not to rubber-stamp.
382
461
 
462
+ ## Dashboard
463
+
464
+ `runway run` emits OpenTelemetry traces + logs for every drain. The
465
+ operations dashboard projects those into a local SQLite db and serves
466
+ a single-page web UI for browsing recent runs, drilling into per-issue
467
+ timelines, and filtering by outcome / drain / date range. Binds to
468
+ `127.0.0.1` only — no auth, no LAN exposure by default.
469
+
470
+ ### v2: `runway dash` (recommended)
471
+
472
+ ```bash
473
+ # Bring up the dashboard from any directory — no runway clone required.
474
+ runway dash up
475
+
476
+ # In another shell, point runway run at it:
477
+ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
478
+ runway run
479
+
480
+ # Open http://localhost:3001/ in a browser.
481
+
482
+ # Tail the container logs.
483
+ runway dash logs --follow
484
+
485
+ # Tear down (volume + history preserved).
486
+ runway dash stop
487
+
488
+ # Tear down AND drop history.
489
+ runway dash stop --purge
490
+ ```
491
+
492
+ `runway dash up` pulls `ghcr.io/valescoagency/runway-dashboard:latest`,
493
+ creates a named volume (`runway-dashboard-data`) for the SQLite db,
494
+ and runs a detached container. Repeated `runway dash up` calls are
495
+ idempotent: an already-running container stays as-is, a stopped one
496
+ is started, otherwise a fresh container is created.
497
+
498
+ Override the image with `--image=…` or `RUNWAY_DASHBOARD_IMAGE`; the
499
+ ports with `--otlp-port=…` / `--dashboard-port=…`. Forward Linear
500
+ sync by exporting `LINEAR_API_KEY` (and optionally
501
+ `LINEAR_POLL_INTERVAL_SECONDS`, `RUNWAY_LINEAR_TEAM`,
502
+ `RUNWAY_READY_LABEL`) before `runway dash up` — the CLI passes them
503
+ through to the container without echoing the value through argv.
504
+
505
+ ### v1: docker-compose (for hacking on the dashboard itself)
506
+
507
+ The runway repo also ships a `docker-compose.yml` that builds the
508
+ dashboard locally from `Dockerfile.dashboard`. Use this when you're
509
+ developing the dashboard code and want to iterate without publishing
510
+ an image:
511
+
512
+ ```bash
513
+ git clone https://github.com/ValescoAgency/runway && cd runway
514
+ docker compose up # builds + runs the local image
515
+ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
516
+ ```
517
+
518
+ ### Migrating from compose to `runway dash`
519
+
520
+ 1. `docker compose down` (in the runway repo).
521
+ 2. `runway dash up` (anywhere).
522
+
523
+ The default named volume is different (`runway-dashboard-data` vs
524
+ compose's `runway-data`), so history doesn't carry over automatically.
525
+ To preserve it, copy the volume contents before switching:
526
+
527
+ ```bash
528
+ docker run --rm -v runway-data:/from -v runway-dashboard-data:/to \
529
+ alpine sh -c 'cp -a /from/. /to/'
530
+ ```
531
+
383
532
  ## What's deliberately missing in v1
384
533
 
385
534
  - Parallel runs (one issue at a time)
@@ -392,7 +541,7 @@ These are tractable, just not v1.
392
541
 
393
542
  ## Status
394
543
 
395
- 0.10.0 — production-shaped and dogfooded against live Linear queues.
544
+ 0.11.0 — production-shaped and dogfooded against live Linear queues.
396
545
  The end-to-end pipeline (init → run → review → PR) is stable; surface
397
546
  may still shift as the orchestrator's policy and iteration mechanics
398
547
  mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
package/dist/cli.js CHANGED
@@ -1,10 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ import { dashCommand, printDashUsage } from "./commands/dash.js";
2
3
  import { doctorCommand, printDoctorUsage } from "./commands/doctor.js";
3
4
  import { initCommand, printInitUsage } from "./commands/init.js";
5
+ import { reviewCommand, printReviewUsage } from "./commands/review.js";
4
6
  import { runCommand, printRunUsage } from "./commands/run.js";
5
7
  import { upgradeCommand, printUpgradeUsage } from "./commands/upgrade.js";
6
8
  import { upgradeRepoCommand, printUpgradeRepoUsage, } from "./commands/upgrade-repo.js";
7
9
  const SUBCOMMANDS = [
10
+ {
11
+ name: "dash",
12
+ summary: "Operate the runway operations dashboard (up / logs / stop).",
13
+ run: dashCommand,
14
+ help: printDashUsage,
15
+ },
8
16
  {
9
17
  name: "doctor",
10
18
  summary: "Read-only preflight: tooling, env, repo state, agent image.",
@@ -17,6 +25,12 @@ const SUBCOMMANDS = [
17
25
  run: initCommand,
18
26
  help: printInitUsage,
19
27
  },
28
+ {
29
+ name: "review",
30
+ summary: "Run an IRA retrospective pass (run / drain / weekly).",
31
+ run: reviewCommand,
32
+ help: printReviewUsage,
33
+ },
20
34
  {
21
35
  name: "run",
22
36
  summary: "Drain a Linear queue against the cwd repo (default verb).",
@@ -0,0 +1,324 @@
1
+ import { execa } from "execa";
2
+ /**
3
+ * VA-393 (dashboard slice 8): `runway dash` subcommand. Wraps
4
+ * `docker run` so users can operate the dashboard from any
5
+ * runway-using project without cloning the runway repo or maintaining
6
+ * a docker-compose file.
7
+ *
8
+ * Three verbs:
9
+ * up pull the published image and run it as a detached
10
+ * container (`runway-dashboard`) with loopback ports and a
11
+ * named volume for the SQLite db.
12
+ * logs stream `docker logs` for the container (`--follow` toggles
13
+ * `-f`; default tails recent output without following).
14
+ * stop stop and `rm` the container. The named volume stays so
15
+ * history survives across restarts; explicit `--purge` drops
16
+ * the volume too.
17
+ *
18
+ * Defaults to the `:latest` tag published by `.github/workflows/dashboard-image.yml`.
19
+ * Override via `--image` or the `RUNWAY_DASHBOARD_IMAGE` env var.
20
+ *
21
+ * Verb-agnostic env passthrough: when set in the caller's environment,
22
+ * `LINEAR_API_KEY`, `LINEAR_POLL_INTERVAL_SECONDS`, `RUNWAY_LINEAR_TEAM`,
23
+ * `RUNWAY_READY_LABEL` are forwarded into the container via `-e`.
24
+ * Absent → the container falls back to the same defaults as
25
+ * `docker-compose.yml` (Linear sync disabled, etc.).
26
+ */
27
+ const DEFAULT_IMAGE = "ghcr.io/valescoagency/runway-dashboard:latest";
28
+ const DEFAULT_CONTAINER_NAME = "runway-dashboard";
29
+ const DEFAULT_VOLUME_NAME = "runway-dashboard-data";
30
+ const DEFAULT_OTLP_PORT = "4318";
31
+ const DEFAULT_DASHBOARD_PORT = "3001";
32
+ /**
33
+ * Linear sync envs we forward when present. Kept narrow on purpose —
34
+ * the dashboard reads its own env at boot and we don't want a stray
35
+ * shell variable leaking into the container.
36
+ */
37
+ const FORWARDED_ENV_KEYS = [
38
+ "LINEAR_API_KEY",
39
+ "LINEAR_POLL_INTERVAL_SECONDS",
40
+ "RUNWAY_LINEAR_TEAM",
41
+ "RUNWAY_READY_LABEL",
42
+ ];
43
+ export function printDashUsage() {
44
+ console.log(`runway dash — operate the runway operations dashboard
45
+
46
+ Wraps \`docker run\` so the dashboard works from any runway-using
47
+ project without cloning the runway repo. The published image is
48
+ ${DEFAULT_IMAGE}; override via --image or RUNWAY_DASHBOARD_IMAGE.
49
+
50
+ USAGE
51
+ runway dash up [--image=…] [--otlp-port=N] [--dashboard-port=N]
52
+ runway dash logs [--follow]
53
+ runway dash stop [--purge]
54
+
55
+ VERBS
56
+ up Pull and start the dashboard as a detached container
57
+ (\`${DEFAULT_CONTAINER_NAME}\`). Ports publish to 127.0.0.1
58
+ only; a named volume (\`${DEFAULT_VOLUME_NAME}\`) persists
59
+ the SQLite db across runs.
60
+ logs Stream container logs (\`docker logs ${DEFAULT_CONTAINER_NAME}\`).
61
+ Pass --follow to tail with -f.
62
+ stop Stop and remove the container. The named volume stays unless
63
+ --purge is passed.
64
+
65
+ OPTIONS
66
+ --image=REF Override the dashboard image reference.
67
+ Default: ${DEFAULT_IMAGE} (env: RUNWAY_DASHBOARD_IMAGE).
68
+ --otlp-port=N Host port for the OTLP receiver. Default: ${DEFAULT_OTLP_PORT}.
69
+ --dashboard-port=N Host port for the dashboard UI. Default: ${DEFAULT_DASHBOARD_PORT}.
70
+ --follow, -f (logs) Tail container logs with -f.
71
+ --purge (stop) Also remove the named volume (deletes history).
72
+ --help, -h Show this help.
73
+
74
+ LINEAR SYNC (optional)
75
+ When LINEAR_API_KEY is set in the caller's environment, \`runway dash
76
+ up\` forwards it into the container so the dashboard polls Linear and
77
+ surfaces the Todo queue. LINEAR_POLL_INTERVAL_SECONDS,
78
+ RUNWAY_LINEAR_TEAM, and RUNWAY_READY_LABEL are forwarded too when
79
+ set. Absent → the dashboard runs without Linear surfaces.
80
+
81
+ OTEL EXPORTER
82
+ Point \`runway run\` at the dashboard by exporting:
83
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:${DEFAULT_OTLP_PORT}
84
+ `);
85
+ }
86
+ export function parseDashArgs(argv) {
87
+ if (argv.length === 0) {
88
+ throw new Error("missing verb — expected one of: up, logs, stop. Run `runway dash --help`.");
89
+ }
90
+ const [verbRaw, ...rest] = argv;
91
+ if (verbRaw === "--help" || verbRaw === "-h") {
92
+ printDashUsage();
93
+ process.exit(0);
94
+ }
95
+ if (verbRaw !== "up" && verbRaw !== "logs" && verbRaw !== "stop") {
96
+ throw new Error(`unknown verb "${verbRaw}" — expected one of: up, logs, stop.`);
97
+ }
98
+ const verb = verbRaw;
99
+ let image;
100
+ let otlpPort;
101
+ let dashboardPort;
102
+ let follow = false;
103
+ let purge = false;
104
+ for (const arg of rest) {
105
+ if (arg === "--help" || arg === "-h") {
106
+ printDashUsage();
107
+ process.exit(0);
108
+ }
109
+ else if (arg.startsWith("--image=")) {
110
+ image = arg.slice("--image=".length);
111
+ }
112
+ else if (arg.startsWith("--otlp-port=")) {
113
+ otlpPort = arg.slice("--otlp-port=".length);
114
+ }
115
+ else if (arg.startsWith("--dashboard-port=")) {
116
+ dashboardPort = arg.slice("--dashboard-port=".length);
117
+ }
118
+ else if (arg === "--follow" || arg === "-f") {
119
+ follow = true;
120
+ }
121
+ else if (arg === "--purge") {
122
+ purge = true;
123
+ }
124
+ else {
125
+ throw new Error(`unknown argument: ${arg}`);
126
+ }
127
+ }
128
+ return { verb, image, otlpPort, dashboardPort, follow, purge };
129
+ }
130
+ /**
131
+ * Resolve effective options by layering CLI flags over env vars over
132
+ * built-in defaults. The resolved shape is pure data so tests can
133
+ * assert on the command we'd hand to docker without invoking it.
134
+ */
135
+ export function resolveDashOptions(parsed, env = process.env) {
136
+ const image = parsed.image ?? env.RUNWAY_DASHBOARD_IMAGE ?? DEFAULT_IMAGE;
137
+ const otlpPort = parsed.otlpPort ?? env.OTLP_PORT ?? DEFAULT_OTLP_PORT;
138
+ const dashboardPort = parsed.dashboardPort ?? env.DASHBOARD_PORT ?? DEFAULT_DASHBOARD_PORT;
139
+ return {
140
+ verb: parsed.verb,
141
+ image,
142
+ containerName: DEFAULT_CONTAINER_NAME,
143
+ volumeName: DEFAULT_VOLUME_NAME,
144
+ otlpPort,
145
+ dashboardPort,
146
+ follow: parsed.follow,
147
+ purge: parsed.purge,
148
+ };
149
+ }
150
+ /**
151
+ * Build the `docker run` argv for `runway dash up`. Extracted so
152
+ * tests can assert the bindings (loopback prefixes, named volume,
153
+ * forwarded env vars) without spawning docker. Caller decides how to
154
+ * execute the args.
155
+ */
156
+ export function buildDockerRunArgs(opts, env = process.env) {
157
+ const args = [
158
+ "run",
159
+ "--detach",
160
+ "--name",
161
+ opts.containerName,
162
+ "--restart",
163
+ "unless-stopped",
164
+ // VA-393: host-side bindings stay on 127.0.0.1 by default so the
165
+ // dashboard is unreachable from the LAN. Container-side stays
166
+ // 0.0.0.0 because the Dockerfile sets DASHBOARD_HOST=0.0.0.0 so
167
+ // Docker's port-forward can reach the listener.
168
+ "-p",
169
+ `127.0.0.1:${opts.dashboardPort}:${opts.dashboardPort}`,
170
+ "-p",
171
+ `127.0.0.1:${opts.otlpPort}:${opts.otlpPort}`,
172
+ "-v",
173
+ `${opts.volumeName}:/data`,
174
+ "-e",
175
+ `DASHBOARD_PORT=${opts.dashboardPort}`,
176
+ "-e",
177
+ `OTLP_PORT=${opts.otlpPort}`,
178
+ ];
179
+ // VA-393: forward Linear-sync env keys when set in the caller's
180
+ // environment. We pass each key with no value so docker reads the
181
+ // current process env — that way the key never echoes through the
182
+ // shell history.
183
+ for (const key of FORWARDED_ENV_KEYS) {
184
+ if (env[key] !== undefined && env[key] !== "") {
185
+ args.push("-e", key);
186
+ }
187
+ }
188
+ args.push(opts.image);
189
+ return args;
190
+ }
191
+ export async function dashCommand(argv) {
192
+ const parsed = parseDashArgs(argv);
193
+ const opts = resolveDashOptions(parsed);
194
+ await ensureDockerAvailable();
195
+ switch (opts.verb) {
196
+ case "up":
197
+ await dashUp(opts);
198
+ return;
199
+ case "logs":
200
+ await dashLogs(opts);
201
+ return;
202
+ case "stop":
203
+ await dashStop(opts);
204
+ return;
205
+ }
206
+ }
207
+ /**
208
+ * Surface a clear error when docker isn't on PATH or the daemon isn't
209
+ * reachable, instead of letting an opaque ENOENT bubble. `docker info`
210
+ * is the canonical "is the daemon up?" probe.
211
+ */
212
+ async function ensureDockerAvailable() {
213
+ try {
214
+ await execa("docker", ["info"], { stdio: "ignore" });
215
+ }
216
+ catch (err) {
217
+ const e = err;
218
+ if (e.code === "ENOENT") {
219
+ throw new Error("docker not found on PATH. Install Docker Desktop (or Podman with a `docker` shim) and retry.");
220
+ }
221
+ throw new Error("docker daemon not reachable (`docker info` failed). Start Docker and retry.");
222
+ }
223
+ }
224
+ async function dashUp(opts) {
225
+ // If a container with this name already exists (running or stopped),
226
+ // print a helpful hint instead of letting docker error with a
227
+ // "container already in use" diagnostic. Up-on-running is a no-op.
228
+ const existing = await containerState(opts.containerName);
229
+ if (existing === "running") {
230
+ console.log(`[runway dash] container ${opts.containerName} already running.`);
231
+ await printAccessHints(opts);
232
+ return;
233
+ }
234
+ if (existing === "stopped") {
235
+ console.log(`[runway dash] container ${opts.containerName} exists but is stopped — starting it.`);
236
+ await execa("docker", ["start", opts.containerName], {
237
+ stdio: "inherit",
238
+ });
239
+ await printAccessHints(opts);
240
+ return;
241
+ }
242
+ console.log(`[runway dash] pulling ${opts.image}`);
243
+ try {
244
+ await execa("docker", ["pull", opts.image], { stdio: "inherit" });
245
+ }
246
+ catch {
247
+ throw new Error(`failed to pull ${opts.image}. ` +
248
+ "If the image is private, run `docker login ghcr.io` first.");
249
+ }
250
+ const args = buildDockerRunArgs(opts);
251
+ console.log(`[runway dash] starting container ${opts.containerName}`);
252
+ await execa("docker", args, { stdio: "inherit" });
253
+ await printAccessHints(opts);
254
+ }
255
+ async function dashLogs(opts) {
256
+ const args = ["logs"];
257
+ if (opts.follow)
258
+ args.push("-f");
259
+ args.push(opts.containerName);
260
+ try {
261
+ await execa("docker", args, { stdio: "inherit" });
262
+ }
263
+ catch (err) {
264
+ const e = err;
265
+ // SIGINT during `docker logs -f` returns a non-zero exit; treat
266
+ // it as a clean operator-driven cancel rather than a failure.
267
+ if (opts.follow && e.signal === "SIGINT")
268
+ return;
269
+ throw err;
270
+ }
271
+ }
272
+ async function dashStop(opts) {
273
+ const state = await containerState(opts.containerName);
274
+ if (state === "absent") {
275
+ console.log(`[runway dash] container ${opts.containerName} is not present — nothing to stop.`);
276
+ }
277
+ else {
278
+ if (state === "running") {
279
+ await execa("docker", ["stop", opts.containerName], {
280
+ stdio: "inherit",
281
+ });
282
+ }
283
+ await execa("docker", ["rm", opts.containerName], { stdio: "inherit" });
284
+ }
285
+ if (opts.purge) {
286
+ console.log(`[runway dash] --purge: removing volume ${opts.volumeName}`);
287
+ try {
288
+ await execa("docker", ["volume", "rm", opts.volumeName], {
289
+ stdio: "inherit",
290
+ });
291
+ }
292
+ catch {
293
+ console.log(`[runway dash] volume ${opts.volumeName} not present (already removed).`);
294
+ }
295
+ }
296
+ else {
297
+ console.log(`[runway dash] volume ${opts.volumeName} kept — pass --purge to delete history.`);
298
+ }
299
+ }
300
+ /**
301
+ * `docker inspect` reports `.State.Status` (`running`, `exited`,
302
+ * `created`, etc.) when the container exists, and exits non-zero
303
+ * otherwise. We collapse the status set to three buckets.
304
+ */
305
+ async function containerState(name) {
306
+ try {
307
+ const { stdout } = await execa("docker", [
308
+ "inspect",
309
+ "--format",
310
+ "{{.State.Status}}",
311
+ name,
312
+ ]);
313
+ return stdout.trim() === "running" ? "running" : "stopped";
314
+ }
315
+ catch {
316
+ return "absent";
317
+ }
318
+ }
319
+ async function printAccessHints(opts) {
320
+ console.log("");
321
+ console.log(`[runway dash] dashboard: http://localhost:${opts.dashboardPort}`);
322
+ console.log(`[runway dash] OTLP endpoint: http://localhost:${opts.otlpPort} (set as OTEL_EXPORTER_OTLP_ENDPOINT)`);
323
+ console.log("[runway dash] stop with: runway dash stop");
324
+ }