@valescoagency/runway 0.11.1 → 0.13.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.
package/README.md CHANGED
@@ -541,7 +541,7 @@ These are tractable, just not v1.
541
541
 
542
542
  ## Status
543
543
 
544
- 0.11.1 — production-shaped and dogfooded against live Linear queues.
544
+ 0.13.0 — production-shaped and dogfooded against live Linear queues.
545
545
  The end-to-end pipeline (init → run → review → PR) is stable; surface
546
546
  may still shift as the orchestrator's policy and iteration mechanics
547
547
  mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
@@ -5,15 +5,22 @@ import { execa } from "execa";
5
5
  * runway-using project without cloning the runway repo or maintaining
6
6
  * a docker-compose file.
7
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.
8
+ * Four 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
+ * upgrade VA-451: pull a fresh image, then stop + `rm` the existing
18
+ * container and start a new one against the same named
19
+ * volume. The pull happens first so a failed pull leaves the
20
+ * existing dashboard running. Schema migrations on the
21
+ * SQLite db are idempotent (ALTER TABLE ADD COLUMN swallowed
22
+ * on duplicate), so column-add releases like VA-450 carry
23
+ * forward without operator intervention.
17
24
  *
18
25
  * Defaults to the `:latest` tag published by `.github/workflows/dashboard-image.yml`.
19
26
  * Override via `--image` or the `RUNWAY_DASHBOARD_IMAGE` env var.
@@ -39,6 +46,9 @@ const FORWARDED_ENV_KEYS = [
39
46
  "LINEAR_POLL_INTERVAL_SECONDS",
40
47
  "RUNWAY_LINEAR_TEAM",
41
48
  "RUNWAY_READY_LABEL",
49
+ // VA-452: workspace slug for the dashboard's `↗ open in Linear`
50
+ // affordance. Unset → identifiers render as plain text.
51
+ "RUNWAY_LINEAR_WORKSPACE",
42
52
  ];
43
53
  export function printDashUsage() {
44
54
  console.log(`runway dash — operate the runway operations dashboard
@@ -48,19 +58,25 @@ project without cloning the runway repo. The published image is
48
58
  ${DEFAULT_IMAGE}; override via --image or RUNWAY_DASHBOARD_IMAGE.
49
59
 
50
60
  USAGE
51
- runway dash up [--image=…] [--otlp-port=N] [--dashboard-port=N]
52
- runway dash logs [--follow]
53
- runway dash stop [--purge]
61
+ runway dash up [--image=…] [--otlp-port=N] [--dashboard-port=N]
62
+ runway dash upgrade [--image=…] [--otlp-port=N] [--dashboard-port=N]
63
+ runway dash logs [--follow]
64
+ runway dash stop [--purge]
54
65
 
55
66
  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.
67
+ up Pull and start the dashboard as a detached container
68
+ (\`${DEFAULT_CONTAINER_NAME}\`). Ports publish to 127.0.0.1
69
+ only; a named volume (\`${DEFAULT_VOLUME_NAME}\`) persists
70
+ the SQLite db across runs.
71
+ upgrade Pull a fresh image and replace the running container with
72
+ one based on it. The pull runs first, so if it fails the
73
+ existing container is left untouched. The named volume
74
+ (\`${DEFAULT_VOLUME_NAME}\`) is preserved across the
75
+ replacement — SQLite history + schema migrate forward.
76
+ logs Stream container logs (\`docker logs ${DEFAULT_CONTAINER_NAME}\`).
77
+ Pass --follow to tail with -f.
78
+ stop Stop and remove the container. The named volume stays unless
79
+ --purge is passed.
64
80
 
65
81
  OPTIONS
66
82
  --image=REF Override the dashboard image reference.
@@ -85,15 +101,18 @@ OTEL EXPORTER
85
101
  }
86
102
  export function parseDashArgs(argv) {
87
103
  if (argv.length === 0) {
88
- throw new Error("missing verb — expected one of: up, logs, stop. Run `runway dash --help`.");
104
+ throw new Error("missing verb — expected one of: up, upgrade, logs, stop. Run `runway dash --help`.");
89
105
  }
90
106
  const [verbRaw, ...rest] = argv;
91
107
  if (verbRaw === "--help" || verbRaw === "-h") {
92
108
  printDashUsage();
93
109
  process.exit(0);
94
110
  }
95
- if (verbRaw !== "up" && verbRaw !== "logs" && verbRaw !== "stop") {
96
- throw new Error(`unknown verb "${verbRaw}" — expected one of: up, logs, stop.`);
111
+ if (verbRaw !== "up" &&
112
+ verbRaw !== "logs" &&
113
+ verbRaw !== "stop" &&
114
+ verbRaw !== "upgrade") {
115
+ throw new Error(`unknown verb "${verbRaw}" — expected one of: up, upgrade, logs, stop.`);
97
116
  }
98
117
  const verb = verbRaw;
99
118
  let image;
@@ -196,6 +215,9 @@ export async function dashCommand(argv) {
196
215
  case "up":
197
216
  await dashUp(opts);
198
217
  return;
218
+ case "upgrade":
219
+ await dashUpgrade(opts);
220
+ return;
199
221
  case "logs":
200
222
  await dashLogs(opts);
201
223
  return;
@@ -252,6 +274,40 @@ async function dashUp(opts) {
252
274
  await execa("docker", args, { stdio: "inherit" });
253
275
  await printAccessHints(opts);
254
276
  }
277
+ /**
278
+ * VA-451: pull-then-replace. The pull runs BEFORE the stop so a
279
+ * failed pull (network, auth, manifest mismatch) leaves the existing
280
+ * container running — we never take the dashboard down on a failed
281
+ * upgrade attempt. The named volume is preserved across the
282
+ * replacement; the dashboard's idempotent ALTER TABLE migrations
283
+ * handle any column adds on the new container's next boot.
284
+ */
285
+ async function dashUpgrade(opts) {
286
+ console.log(`[runway dash] upgrade: pulling ${opts.image}`);
287
+ try {
288
+ await execa("docker", ["pull", opts.image], { stdio: "inherit" });
289
+ }
290
+ catch {
291
+ throw new Error(`failed to pull ${opts.image}. ` +
292
+ "The existing container is unchanged. " +
293
+ "If the image is private, run `docker login ghcr.io` first.");
294
+ }
295
+ const existing = await containerState(opts.containerName);
296
+ if (existing === "running") {
297
+ console.log(`[runway dash] upgrade: stopping ${opts.containerName}`);
298
+ await execa("docker", ["stop", opts.containerName], {
299
+ stdio: "inherit",
300
+ });
301
+ }
302
+ if (existing !== "absent") {
303
+ console.log(`[runway dash] upgrade: removing ${opts.containerName} (volume ${opts.volumeName} kept)`);
304
+ await execa("docker", ["rm", opts.containerName], { stdio: "inherit" });
305
+ }
306
+ const args = buildDockerRunArgs(opts);
307
+ console.log(`[runway dash] starting container ${opts.containerName}`);
308
+ await execa("docker", args, { stdio: "inherit" });
309
+ await printAccessHints(opts);
310
+ }
255
311
  async function dashLogs(opts) {
256
312
  const args = ["logs"];
257
313
  if (opts.follow)
@@ -15,12 +15,19 @@ export function createLinearAdapter(opts) {
15
15
  const teamKey = opts.team ?? "VA";
16
16
  const readyLabel = opts.readyLabel ?? "ready-for-agent";
17
17
  async function snapshotFromIssue(raw) {
18
- const [state, labels] = await Promise.all([raw.state, raw.labels()]);
18
+ const [state, labels, project] = await Promise.all([
19
+ raw.state,
20
+ raw.labels(),
21
+ raw.project,
22
+ ]);
19
23
  return {
20
24
  identifier: raw.identifier,
21
25
  title: raw.title,
22
26
  status: state?.name ?? "",
27
+ statusType: state?.type ?? "",
23
28
  labels: labels.nodes.map((l) => l.name),
29
+ projectId: project?.id ?? null,
30
+ projectName: project?.name ?? null,
24
31
  };
25
32
  }
26
33
  return {
@@ -104,16 +111,29 @@ export function startLinearSync(opts) {
104
111
  // the preview.
105
112
  opts.storage.clearLinearQueuePositions();
106
113
  const at = new Date().toISOString();
107
- queue.forEach((q, i) => {
114
+ // VA-453: issues whose Linear workflow-state type is `completed`
115
+ // or `canceled` get saved without a queue position so they drop
116
+ // out of the Todo queue preview even when they still carry the
117
+ // `ready-for-agent` label. The snapshot row stays in
118
+ // linear_snapshots so per-row status badges on the
119
+ // issue-process list continue to resolve.
120
+ let position = 0;
121
+ queue.forEach((q) => {
108
122
  queuedIds.add(q.identifier);
123
+ const isInactive = q.statusType === "completed" || q.statusType === "canceled";
109
124
  opts.storage.saveLinearSnapshot({
110
125
  issueIdentifier: q.identifier,
111
126
  snapshotAt: at,
112
127
  status: q.status,
128
+ statusType: q.statusType,
113
129
  title: q.title,
114
130
  labels: q.labels,
115
- queuePosition: i,
131
+ queuePosition: isInactive ? null : position,
132
+ projectId: q.projectId,
133
+ projectName: q.projectName,
116
134
  });
135
+ if (!isInactive)
136
+ position += 1;
117
137
  });
118
138
  log("info", `[runway dashboard] linear sync: refreshed ready queue (${queue.length} issue${queue.length === 1 ? "" : "s"})`);
119
139
  }
@@ -136,9 +156,12 @@ export function startLinearSync(opts) {
136
156
  issueIdentifier: r.identifier,
137
157
  snapshotAt: at,
138
158
  status: r.status,
159
+ statusType: r.statusType,
139
160
  title: r.title,
140
161
  labels: r.labels,
141
162
  queuePosition: null,
163
+ projectId: r.projectId,
164
+ projectName: r.projectName,
142
165
  });
143
166
  }
144
167
  log("info", `[runway dashboard] linear sync: refreshed status for ${refreshed.length} of ${recent.length} recent issue${recent.length === 1 ? "" : "s"}`);
@@ -34,6 +34,7 @@ const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MiB — generous; a runway drain
34
34
  */
35
35
  export function buildServer(storage, opts = {}) {
36
36
  const linearEnabled = opts.linearEnabled ?? false;
37
+ const linearWorkspace = opts.linearWorkspace ?? null;
37
38
  // VA-391: an in-process event bus is plumbed through every request
38
39
  // handler so the OTLP ingest paths can publish change events that
39
40
  // the SSE detail-pane stream re-emits to the browser. Callers can
@@ -41,7 +42,7 @@ export function buildServer(storage, opts = {}) {
41
42
  // production gets a fresh per-server bus.
42
43
  const events = opts.events ?? createEventBus();
43
44
  return createServer((req, res) => {
44
- handle(req, res, storage, linearEnabled, events).catch((err) => {
45
+ handle(req, res, storage, linearEnabled, linearWorkspace, events).catch((err) => {
45
46
  writeError(res, 500, "internal_error", asMessage(err));
46
47
  });
47
48
  });
@@ -53,6 +54,7 @@ export function buildServer(storage, opts = {}) {
53
54
  export async function startServer(opts) {
54
55
  const server = buildServer(opts.storage, {
55
56
  linearEnabled: opts.linearEnabled,
57
+ linearWorkspace: opts.linearWorkspace,
56
58
  events: opts.events,
57
59
  });
58
60
  const port = opts.port ?? 4318;
@@ -74,7 +76,7 @@ export async function startServer(opts) {
74
76
  }),
75
77
  };
76
78
  }
77
- async function handle(req, res, storage, linearEnabled, events) {
79
+ async function handle(req, res, storage, linearEnabled, linearWorkspace, events) {
78
80
  // Route on method + path. Nothing fancy — slice 1 only has two
79
81
  // endpoints; a router becomes worth its weight in slice 2.
80
82
  const url = req.url ?? "/";
@@ -88,14 +90,14 @@ async function handle(req, res, storage, linearEnabled, events) {
88
90
  return;
89
91
  }
90
92
  if (method === "GET" && (url === "/" || url.startsWith("/?"))) {
91
- handleListView(req, res, storage, linearEnabled);
93
+ handleListView(req, res, storage, linearEnabled, linearWorkspace);
92
94
  return;
93
95
  }
94
96
  // VA-391: fragment endpoint returns just the tbody so the list view
95
97
  // can poll via htmx without redrawing the rest of the page.
96
98
  if (method === "GET" &&
97
99
  (url === "/issue-processes" || url.startsWith("/issue-processes?"))) {
98
- handleIssueProcessRowsFragment(req, res, storage, linearEnabled);
100
+ handleIssueProcessRowsFragment(req, res, storage, linearEnabled, linearWorkspace);
99
101
  return;
100
102
  }
101
103
  if (method === "GET") {
@@ -111,7 +113,7 @@ async function handle(req, res, storage, linearEnabled, events) {
111
113
  const issueProcessMatch = ISSUE_PROCESS_DETAIL_RE.exec(pathOnly);
112
114
  if (issueProcessMatch) {
113
115
  const spanId = decodeURIComponent(issueProcessMatch[1] ?? "");
114
- handleIssueProcessDetailView(res, storage, spanId);
116
+ handleIssueProcessDetailView(res, storage, spanId, linearWorkspace);
115
117
  return;
116
118
  }
117
119
  // VA-407: meta-review list + detail. List matches the bare path or
@@ -133,7 +135,7 @@ async function handle(req, res, storage, linearEnabled, events) {
133
135
  if (detailMatch) {
134
136
  const traceId = decodeURIComponent(detailMatch[1] ?? "");
135
137
  const spanId = decodeURIComponent(detailMatch[2] ?? "");
136
- handleDetailView(res, storage, traceId, spanId);
138
+ handleDetailView(res, storage, traceId, spanId, linearWorkspace);
137
139
  return;
138
140
  }
139
141
  }
@@ -318,7 +320,7 @@ function handleIssueProcessStream(req, res, storage, events, spanId) {
318
320
  * preview and drain summary strip stay static between polls — they
319
321
  * have their own swap triggers (or sit above the polling target).
320
322
  */
321
- function handleIssueProcessRowsFragment(req, res, storage, linearEnabled) {
323
+ function handleIssueProcessRowsFragment(req, res, storage, linearEnabled, linearWorkspace) {
322
324
  // VA-392: the fragment endpoint applies the same filter state as the
323
325
  // full list view so a polled refresh doesn't reset the user's chips.
324
326
  // The query string is the source of truth — htmx mirrors the page's
@@ -336,6 +338,7 @@ function handleIssueProcessRowsFragment(req, res, storage, linearEnabled) {
336
338
  linearEnabled,
337
339
  snapshotsByIdentifier,
338
340
  isFiltered: isFilteredState(filterState),
341
+ linearWorkspace,
339
342
  });
340
343
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
341
344
  res.end(html);
@@ -346,7 +349,7 @@ function writeSseFrame(res, event, data) {
346
349
  const lines = data.split("\n").map((line) => `data: ${line}`).join("\n");
347
350
  res.write(`event: ${event}\n${lines}\n\n`);
348
351
  }
349
- function handleListView(req, res, storage, linearEnabled) {
352
+ function handleListView(req, res, storage, linearEnabled, linearWorkspace) {
350
353
  // VA-392: filter chips state is encoded in the URL query string so
351
354
  // the view is shareable. Parse once and reuse both for the storage
352
355
  // query and the chip render (the latter needs to know which chips
@@ -378,6 +381,7 @@ function handleListView(req, res, storage, linearEnabled) {
378
381
  filterState,
379
382
  recentDrains,
380
383
  isFiltered: isFilteredState(filterState),
384
+ linearWorkspace,
381
385
  });
382
386
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
383
387
  res.end(html);
@@ -471,28 +475,28 @@ function formatSqliteDatetime(d) {
471
475
  * (trace_id, span_id) pair doesn't match a known issue process — the
472
476
  * dashboard never auto-creates rows from a URL.
473
477
  */
474
- function handleDetailView(res, storage, traceId, spanId) {
478
+ function handleDetailView(res, storage, traceId, spanId, linearWorkspace) {
475
479
  const ip = storage.getIssueProcess(traceId, spanId);
476
480
  if (!ip) {
477
481
  writeError(res, 404, "not_found", `no issue process for trace=${traceId} span=${spanId}`);
478
482
  return;
479
483
  }
480
- renderDetailFor(res, storage, ip);
484
+ renderDetailFor(res, storage, ip, linearWorkspace);
481
485
  }
482
486
  /**
483
487
  * VA-387: detail-route handler keyed on the issue process span_id
484
488
  * alone. Reuses the same view model as the older two-segment route
485
489
  * once the row is resolved.
486
490
  */
487
- function handleIssueProcessDetailView(res, storage, spanId) {
491
+ function handleIssueProcessDetailView(res, storage, spanId, linearWorkspace) {
488
492
  const ip = storage.getIssueProcessBySpanId(spanId);
489
493
  if (!ip) {
490
494
  writeError(res, 404, "not_found", `no issue process for span=${spanId}`);
491
495
  return;
492
496
  }
493
- renderDetailFor(res, storage, ip);
497
+ renderDetailFor(res, storage, ip, linearWorkspace);
494
498
  }
495
- function renderDetailFor(res, storage, ip) {
499
+ function renderDetailFor(res, storage, ip, linearWorkspace) {
496
500
  const iterations = storage.listAgentIterations(ip.traceId, ip.spanId);
497
501
  const phaseSpans = storage.listPhaseSpans(ip.traceId, ip.spanId, [
498
502
  ...DETAIL_PHASE_NAMES,
@@ -507,6 +511,7 @@ function renderDetailFor(res, storage, ip) {
507
511
  iterations,
508
512
  phaseSpans,
509
513
  logs,
514
+ linearWorkspace,
510
515
  });
511
516
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
512
517
  res.end(html);
@@ -652,6 +657,13 @@ export async function main() {
652
657
  // and start polling at LINEAR_POLL_INTERVAL_SECONDS (default 300).
653
658
  const linearApiKey = process.env.LINEAR_API_KEY;
654
659
  const linearEnabled = Boolean(linearApiKey);
660
+ // VA-452: workspace slug for the `↗ open in Linear` affordance.
661
+ // Trim + collapse empty to null so a stray empty env var doesn't
662
+ // produce broken `linear.app//issue/...` URLs.
663
+ const linearWorkspaceRaw = process.env.RUNWAY_LINEAR_WORKSPACE?.trim();
664
+ const linearWorkspace = linearWorkspaceRaw && linearWorkspaceRaw.length > 0
665
+ ? linearWorkspaceRaw
666
+ : null;
655
667
  let linearSync = null;
656
668
  if (linearApiKey) {
657
669
  const adapter = createLinearAdapter({
@@ -675,6 +687,7 @@ export async function main() {
675
687
  port: otlpPort,
676
688
  host: otlpHost,
677
689
  linearEnabled,
690
+ linearWorkspace,
678
691
  });
679
692
  // The two listeners can share a server only when both port AND host
680
693
  // match — different bind addresses need different listeners.
@@ -685,6 +698,7 @@ export async function main() {
685
698
  port: dashboardPort,
686
699
  host: dashboardHost,
687
700
  linearEnabled,
701
+ linearWorkspace,
688
702
  });
689
703
  console.log(`[runway dashboard] OTLP ${otlpHost}:${otlp.port} · dashboard ${dashboardHost}:${dashboard.port}; sqlite=${sqlitePath}`);
690
704
  const shutdown = async (signal) => {
@@ -99,7 +99,10 @@ const SCHEMA = `
99
99
  status TEXT NOT NULL,
100
100
  title TEXT NOT NULL,
101
101
  labels_json TEXT,
102
- queue_position INTEGER
102
+ queue_position INTEGER,
103
+ project_id TEXT,
104
+ project_name TEXT,
105
+ status_type TEXT
103
106
  );
104
107
 
105
108
  CREATE INDEX IF NOT EXISTS idx_linear_snapshots_queue_position
@@ -289,6 +292,13 @@ export function createStorage(path, opts = {}) {
289
292
  `ALTER TABLE issue_processes ADD COLUMN issue_labels TEXT`,
290
293
  `ALTER TABLE issue_processes ADD COLUMN pr_url TEXT`,
291
294
  `ALTER TABLE issue_processes ADD COLUMN hitl_reason TEXT`,
295
+ // VA-450: project Name + UUID for the Todo queue Project column.
296
+ `ALTER TABLE linear_snapshots ADD COLUMN project_id TEXT`,
297
+ `ALTER TABLE linear_snapshots ADD COLUMN project_name TEXT`,
298
+ // VA-453: workflow-state TYPE (not name) for cancelled/done queue
299
+ // filtering. Old rows decode to "" — the poller only filters on
300
+ // known type values so pre-migration rows behave unchanged.
301
+ `ALTER TABLE linear_snapshots ADD COLUMN status_type TEXT`,
292
302
  ]) {
293
303
  try {
294
304
  db.exec(sql);
@@ -403,24 +413,30 @@ export function createStorage(path, opts = {}) {
403
413
  const selectAggregates = db.prepare(`SELECT * FROM evaluator_aggregates_v1`);
404
414
  const upsertLinearSnapshot = db.prepare(`
405
415
  INSERT INTO linear_snapshots (
406
- issue_identifier, snapshot_at, status, title, labels_json, queue_position
407
- ) VALUES (?, ?, ?, ?, ?, ?)
416
+ issue_identifier, snapshot_at, status, title, labels_json, queue_position,
417
+ project_id, project_name, status_type
418
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
408
419
  ON CONFLICT (issue_identifier) DO UPDATE SET
409
420
  snapshot_at = excluded.snapshot_at,
410
421
  status = excluded.status,
411
422
  title = excluded.title,
412
423
  labels_json = excluded.labels_json,
413
- queue_position = excluded.queue_position
424
+ queue_position = excluded.queue_position,
425
+ project_id = excluded.project_id,
426
+ project_name = excluded.project_name,
427
+ status_type = excluded.status_type
414
428
  `);
415
429
  const clearQueuePositionsStmt = db.prepare(`UPDATE linear_snapshots SET queue_position = NULL`);
416
430
  const listTodoQueueStmt = db.prepare(`
417
- SELECT issue_identifier, snapshot_at, status, title, labels_json, queue_position
431
+ SELECT issue_identifier, snapshot_at, status, title, labels_json, queue_position,
432
+ project_id, project_name, status_type
418
433
  FROM linear_snapshots
419
434
  WHERE queue_position IS NOT NULL
420
435
  ORDER BY queue_position ASC
421
436
  `);
422
437
  const listAllSnapshotsStmt = db.prepare(`
423
- SELECT issue_identifier, snapshot_at, status, title, labels_json, queue_position
438
+ SELECT issue_identifier, snapshot_at, status, title, labels_json, queue_position,
439
+ project_id, project_name, status_type
424
440
  FROM linear_snapshots
425
441
  `);
426
442
  // VA-391: "active drain" = a trace_id with one or more
@@ -616,7 +632,7 @@ export function createStorage(path, opts = {}) {
616
632
  };
617
633
  const listAggregates = () => selectAggregates.all().map(rowToAggregate);
618
634
  const saveLinearSnapshot = (s) => {
619
- upsertLinearSnapshot.run(s.issueIdentifier, s.snapshotAt, s.status, s.title, s.labels.length === 0 ? null : JSON.stringify(s.labels), s.queuePosition);
635
+ upsertLinearSnapshot.run(s.issueIdentifier, s.snapshotAt, s.status, s.title, s.labels.length === 0 ? null : JSON.stringify(s.labels), s.queuePosition, s.projectId, s.projectName, s.statusType);
620
636
  };
621
637
  const clearLinearQueuePositions = () => {
622
638
  clearQueuePositionsStmt.run();
@@ -860,6 +876,9 @@ function rowToLinearSnapshot(row) {
860
876
  title: String(r.title ?? ""),
861
877
  labels: parseLabels(r.labels_json),
862
878
  queuePosition: nullableNum(r.queue_position),
879
+ projectId: r.project_id == null ? null : String(r.project_id),
880
+ projectName: r.project_name == null ? null : String(r.project_name),
881
+ statusType: r.status_type == null ? "" : String(r.status_type),
863
882
  };
864
883
  }
865
884
  /**
@@ -33,6 +33,10 @@ const SHARED_STYLE = `
33
33
  .id { color: #93c5fd; }
34
34
  .detail { color: #d4d4d8; }
35
35
  .muted { color: #9ca3af; }
36
+ .project-uuid { color: #9ca3af; font-size: 11px; display: block; }
37
+ .linear-link { color: #6b7280; text-decoration: none; margin-left: 6px;
38
+ font-size: 11px; vertical-align: middle; }
39
+ .linear-link:hover { color: #93c5fd; }
36
40
  code { background: #1f2937; padding: 1px 6px; border-radius: 3px; }
37
41
  .status-badge { display: inline-block; margin-left: 8px;
38
42
  padding: 1px 6px; border-radius: 3px; font-size: 11px;
@@ -52,13 +56,17 @@ export function renderListView(input) {
52
56
  const filterState = vm.filterState ?? EMPTY_FILTER_STATE;
53
57
  const isFiltered = vm.isFiltered ?? false;
54
58
  const recentDrains = vm.recentDrains ?? [];
59
+ const linearWorkspace = vm.linearWorkspace ?? null;
55
60
  const tableBody = renderIssueProcessRows({
56
61
  rows: vm.rows,
57
62
  linearEnabled: vm.linearEnabled,
58
63
  snapshotsByIdentifier: vm.snapshotsByIdentifier,
59
64
  isFiltered,
65
+ linearWorkspace,
60
66
  });
61
- const queueSection = vm.linearEnabled ? renderTodoQueue(vm.todoQueue) : "";
67
+ const queueSection = vm.linearEnabled
68
+ ? renderTodoQueue(vm.todoQueue, linearWorkspace)
69
+ : "";
62
70
  // VA-391: drain summary strip renders ABOVE the queue + run list
63
71
  // so the operator sees in-flight progress at the top of the page.
64
72
  const drainStrip = renderDrainStrip(vm.activeDrain ?? null);
@@ -190,10 +198,24 @@ export function renderIssueProcessRows(input) {
190
198
  }
191
199
  return `<tr><td colspan="4" class="empty">No issue processes yet. Run <code>runway run</code> with <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> pointing here to populate.</td></tr>`;
192
200
  }
201
+ const linearWorkspace = input.linearWorkspace ?? null;
193
202
  return input.rows
194
- .map((r) => renderRow(r, input.snapshotsByIdentifier))
203
+ .map((r) => renderRow(r, input.snapshotsByIdentifier, linearWorkspace))
195
204
  .join("");
196
205
  }
206
+ /**
207
+ * VA-452: render the `↗` anchor that opens the issue in Linear, or
208
+ * an empty string when the workspace slug isn't configured. Kept
209
+ * tiny + identifier-agnostic so every render site uses the same
210
+ * shape. Linear's URL accepts the identifier alone — no slug needed,
211
+ * the server redirects to the canonical URL.
212
+ */
213
+ function linearIssueLink(identifier, workspace) {
214
+ if (workspace == null || workspace === "")
215
+ return "";
216
+ const url = `https://linear.app/${encodeURIComponent(workspace)}/issue/${encodeURIComponent(identifier)}`;
217
+ return ` <a class="linear-link" target="_blank" rel="noopener noreferrer" title="Open ${escapeHtml(identifier)} in Linear" href="${escapeHtml(url)}">↗</a>`;
218
+ }
197
219
  // ---------------------------------------------------------------------------
198
220
  // VA-392: filter chips
199
221
  // ---------------------------------------------------------------------------
@@ -314,10 +336,10 @@ export function filterStateToQueryString(state) {
314
336
  * with an empty-state row so the operator knows the poller ran and
315
337
  * found nothing to pick up.
316
338
  */
317
- function renderTodoQueue(queue) {
339
+ function renderTodoQueue(queue, linearWorkspace) {
318
340
  const body = queue.length === 0
319
- ? `<tr><td colspan="3" class="empty">Linear Todo queue is empty.</td></tr>`
320
- : queue.map(renderQueueRow).join("");
341
+ ? `<tr><td colspan="5" class="empty">Linear Todo queue is empty.</td></tr>`
342
+ : queue.map((s) => renderQueueRow(s, linearWorkspace)).join("");
321
343
  return `<h2>Todo queue (next up)</h2>
322
344
  <table>
323
345
  <thead>
@@ -325,20 +347,38 @@ function renderTodoQueue(queue) {
325
347
  <th>Issue</th>
326
348
  <th>Title</th>
327
349
  <th>Labels</th>
350
+ <th>Status</th>
351
+ <th>Project</th>
328
352
  </tr>
329
353
  </thead>
330
354
  <tbody>${body}</tbody>
331
355
  </table>`;
332
356
  }
333
- function renderQueueRow(s) {
357
+ function renderQueueRow(s, linearWorkspace) {
334
358
  const labels = s.labels.length === 0 ? "" : s.labels.join(", ");
359
+ // VA-450: project cell shows the name on top with the UUID muted
360
+ // underneath in the same column. Unprojected issues degrade to an
361
+ // em-dash so the row still renders (the drain WILL pick them up).
362
+ const project = s.projectName == null && s.projectId == null
363
+ ? `<span class="muted">—</span>`
364
+ : `<span>${escapeHtml(s.projectName ?? "")}</span>` +
365
+ (s.projectId == null
366
+ ? ""
367
+ : `<span class="project-uuid">${escapeHtml(s.projectId)}</span>`);
368
+ // VA-453: workflow status display name. Empty → em-dash so the
369
+ // column is never visually empty.
370
+ const status = s.status === ""
371
+ ? `<span class="muted">—</span>`
372
+ : escapeHtml(s.status);
335
373
  return `<tr>
336
- <td class="id">${escapeHtml(s.issueIdentifier)}</td>
374
+ <td class="id">${escapeHtml(s.issueIdentifier)}${linearIssueLink(s.issueIdentifier, linearWorkspace)}</td>
337
375
  <td class="detail">${escapeHtml(s.title)}</td>
338
376
  <td class="muted">${escapeHtml(labels)}</td>
377
+ <td class="detail">${status}</td>
378
+ <td class="detail">${project}</td>
339
379
  </tr>`;
340
380
  }
341
- function renderRow(r, snapshots) {
381
+ function renderRow(r, snapshots, linearWorkspace) {
342
382
  const kind = r.outcomeKind ?? "pending";
343
383
  const outcomeCls = `outcome outcome-${escapeHtml(kind)}`;
344
384
  // VA-387: canonical detail link uses the span_id alone.
@@ -355,7 +395,7 @@ function renderRow(r, snapshots) {
355
395
  ? `<span class="status-badge">${escapeHtml(snapshot.status)}</span>`
356
396
  : "";
357
397
  return `<tr>
358
- <td class="id"><a href="${escapeHtml(href)}">${escapeHtml(r.issueIdentifier)}</a>${statusBadge}</td>
398
+ <td class="id"><a href="${escapeHtml(href)}">${escapeHtml(r.issueIdentifier)}</a>${linearIssueLink(r.issueIdentifier, linearWorkspace)}${statusBadge}</td>
359
399
  <td class="${outcomeCls}">${escapeHtml(kind)}</td>
360
400
  <td class="detail">${escapeHtml(r.outcomeDetail ?? "")}</td>
361
401
  <td>${escapeHtml(r.insertedAt)}</td>
@@ -438,7 +478,7 @@ export function renderDetailView(vm) {
438
478
  </head>
439
479
  <body>
440
480
  <div class="breadcrumb"><a href="/">← all issue processes</a></div>
441
- <h1>${escapeHtml(ip.issueIdentifier)} · <span class="${outcomeCls}">${escapeHtml(kind)}</span></h1>
481
+ <h1>${escapeHtml(ip.issueIdentifier)}${linearIssueLink(ip.issueIdentifier, vm.linearWorkspace ?? null)} · <span class="${outcomeCls}">${escapeHtml(kind)}</span></h1>
442
482
  ${titleLine}
443
483
  <div class="meta">
444
484
  <div><span class="label">branch:</span><code>${escapeHtml(ip.branch ?? "—")}</code></div>
package/dist/linear.js CHANGED
@@ -357,6 +357,28 @@ export function createLinearGateway(config, limiter = null) {
357
357
  continue;
358
358
  if (labels.includes(config.hitlLabel))
359
359
  continue;
360
+ // VA-453: skip issues whose workflow state is already
361
+ // terminal (`completed` / `canceled`). Operators sometimes
362
+ // mark an issue Done but forget to strip
363
+ // `ready-for-agent`; without this check the drain would
364
+ // pick up a Done ticket and waste a sandcastle run.
365
+ // Filtering by `state.type` (not display name) is robust
366
+ // to teams renaming "Cancelled" to "Won't fix" or
367
+ // similar. Missing/undecodable state falls through
368
+ // (treated as non-terminal) — matches the
369
+ // `hasActiveBlocker` posture for the same edge case.
370
+ try {
371
+ const state = await raw.state;
372
+ if (state) {
373
+ const stateType = decodeWorkflowStateTypeNode(state).type;
374
+ if (isTerminalStateType(stateType))
375
+ continue;
376
+ }
377
+ }
378
+ catch {
379
+ // Schema drift or transient SDK error — fall through
380
+ // to the existing checks rather than blocking the drain.
381
+ }
360
382
  if (await hasActiveBlocker(raw))
361
383
  continue;
362
384
  if (await hasChildIssues(raw)) {
package/dist/telemetry.js CHANGED
@@ -1,9 +1,8 @@
1
- import { Logger as OtelLogger, NodeSdk } from "@effect/opentelemetry";
1
+ import { NodeSdk } from "@effect/opentelemetry";
2
2
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
3
3
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
4
4
  import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
5
5
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
6
- import { Layer } from "effect";
7
6
  /**
8
7
  * VA-358 + VA-388: OpenTelemetry telemetry layer for the orchestrator.
9
8
  *
@@ -45,11 +44,14 @@ const liveLayer = NodeSdk.layer(() => ({
45
44
  // paths.
46
45
  logRecordProcessor: new BatchLogRecordProcessor(new OTLPLogExporter()),
47
46
  }));
48
- // VA-388: replace Effect's default logger with one that fans out to
49
- // the OtelLoggerProvider built by `NodeSdk.layer`. Provided ON TOP of
50
- // `liveLayer` so the OtelLoggerProvider context is satisfied; without
51
- // the SDK behind it, this layer would fail to construct.
52
- const loggerLayer = Layer.provide(OtelLogger.layerLoggerReplace, liveLayer);
47
+ // VA-388: when `logRecordProcessor` is set above, `NodeSdk.layer`
48
+ // internally composes `Logger.layerLoggerAdd` against an internal
49
+ // `OtelLoggerProvider`, so `Effect.log` calls fan out to OTLP in
50
+ // addition to the default stderr logger. An earlier revision wrapped
51
+ // `Logger.layerLoggerReplace` around `liveLayer` to silence stderr,
52
+ // but `NodeSdk.layer` consumes `OtelLoggerProvider` via `Layer.provide`
53
+ // internally and does not re-export it — that composition raised
54
+ // "Service not found: OtelLoggerProvider" at startup.
53
55
  export const TelemetryLive = isTelemetryEnabled()
54
- ? Layer.merge(liveLayer, loggerLayer)
56
+ ? liveLayer
55
57
  : NodeSdk.layerEmpty;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valescoagency/runway",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Linear-driven orchestrator + scaffolder for coding agents on Sandcastle. `runway init` scaffolds a target repo (sandcastle + varlock + 1Password); `runway run` drains a Linear queue against it; `runway doctor`, `runway upgrade`, `runway upgrade-repo` round out the lifecycle.",
5
5
  "license": "MIT",
6
6
  "author": {