@valescoagency/runway 0.12.0 → 0.14.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.12.0 — production-shaped and dogfooded against live Linear queues.
544
+ 0.14.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)
@@ -24,6 +24,7 @@ export function createLinearAdapter(opts) {
24
24
  identifier: raw.identifier,
25
25
  title: raw.title,
26
26
  status: state?.name ?? "",
27
+ statusType: state?.type ?? "",
27
28
  labels: labels.nodes.map((l) => l.name),
28
29
  projectId: project?.id ?? null,
29
30
  projectName: project?.name ?? null,
@@ -110,18 +111,29 @@ export function startLinearSync(opts) {
110
111
  // the preview.
111
112
  opts.storage.clearLinearQueuePositions();
112
113
  const at = new Date().toISOString();
113
- 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) => {
114
122
  queuedIds.add(q.identifier);
123
+ const isInactive = q.statusType === "completed" || q.statusType === "canceled";
115
124
  opts.storage.saveLinearSnapshot({
116
125
  issueIdentifier: q.identifier,
117
126
  snapshotAt: at,
118
127
  status: q.status,
128
+ statusType: q.statusType,
119
129
  title: q.title,
120
130
  labels: q.labels,
121
- queuePosition: i,
131
+ queuePosition: isInactive ? null : position,
122
132
  projectId: q.projectId,
123
133
  projectName: q.projectName,
124
134
  });
135
+ if (!isInactive)
136
+ position += 1;
125
137
  });
126
138
  log("info", `[runway dashboard] linear sync: refreshed ready queue (${queue.length} issue${queue.length === 1 ? "" : "s"})`);
127
139
  }
@@ -144,6 +156,7 @@ export function startLinearSync(opts) {
144
156
  issueIdentifier: r.identifier,
145
157
  snapshotAt: at,
146
158
  status: r.status,
159
+ statusType: r.statusType,
147
160
  title: r.title,
148
161
  labels: r.labels,
149
162
  queuePosition: null,
@@ -52,6 +52,10 @@ function projectDrain(span) {
52
52
  spanId: span.spanId,
53
53
  startTimeUnixNano: span.startTimeUnixNano,
54
54
  endTimeUnixNano: span.endTimeUnixNano,
55
+ // VA-455: an ended drain's "last activity" is its end time —
56
+ // the storage layer keeps last_seen monotonic, so setting it
57
+ // here ensures the value never regresses below the real end.
58
+ lastSeenUnixNano: span.endTimeUnixNano,
55
59
  attempts: numAttr(m["runway.drain.attempts"]),
56
60
  opened: numAttr(m["runway.drain.opened"]),
57
61
  hitl: numAttr(m["runway.drain.hitl"]),
@@ -137,6 +141,53 @@ function numAttr(v) {
137
141
  function strArrayAttr(v) {
138
142
  return Array.isArray(v) ? v : [];
139
143
  }
144
+ /**
145
+ * VA-455: the canonical body string runway emits at the top of the
146
+ * `drainQueue` span. The projector matches on this exact value to
147
+ * decide which log records become active-drain markers — keep them
148
+ * in lock-step with `orchestrator.ts`.
149
+ */
150
+ export const DRAIN_STARTED_LOG = "drain.started";
151
+ /**
152
+ * VA-455: scan an OTLP logs payload for `drain.started` markers.
153
+ * Each match becomes an `ActiveDrainMarker` carrying the drain's
154
+ * trace_id, the drainQueue span_id, and the log timestamp. The
155
+ * storage layer's `markDrainActive` upserts a `drains` row on each
156
+ * marker so the dashboard's active-drain card lights up within ~1s
157
+ * of `runway run` starting, instead of waiting for the first
158
+ * `processIssue` span to end.
159
+ *
160
+ * Records missing trace_id, span_id, or a timestamp are dropped —
161
+ * we won't fabricate any of those, and a drain.started marker
162
+ * without them has nothing useful to bind.
163
+ */
164
+ export function extractActiveDrainMarkers(payload) {
165
+ const out = [];
166
+ for (const rl of payload.resourceLogs ?? []) {
167
+ for (const sl of rl.scopeLogs ?? []) {
168
+ for (const rec of sl.logRecords ?? []) {
169
+ if (rec.body?.stringValue !== DRAIN_STARTED_LOG)
170
+ continue;
171
+ const traceId = rec.traceId?.trim();
172
+ if (!traceId)
173
+ continue;
174
+ const spanId = rec.spanId?.trim();
175
+ if (!spanId)
176
+ continue;
177
+ const ts = rec.timeUnixNano ?? rec.observedTimeUnixNano;
178
+ if (!ts)
179
+ continue;
180
+ out.push({
181
+ traceId,
182
+ spanId,
183
+ startTimeUnixNano: ts,
184
+ lastSeenUnixNano: ts,
185
+ });
186
+ }
187
+ }
188
+ }
189
+ return out;
190
+ }
140
191
  /**
141
192
  * VA-388: project an OTLP logs payload into `LogRecordRow`s. Records
142
193
  * without a trace_id are dropped — every Effect log emitted under
@@ -1,7 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createLinearAdapter, startLinearSync, } from "./linear-sync.js";
3
3
  import { createEventBus } from "./events.js";
4
- import { projectLogs, projectPayload } from "./projector.js";
4
+ import { extractActiveDrainMarkers, projectLogs, projectPayload, } from "./projector.js";
5
5
  import { createStorage, } from "./storage.js";
6
6
  import { META_REVIEW_PAGE_SIZE, renderDetailView, renderIssueProcessRows, renderListView, renderLogsSection, renderMetaReviewDetailView, renderMetaReviewListView, } from "./views.js";
7
7
  // VA-389: phase spans we surface on the detail page's timeline.
@@ -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
  }
@@ -237,6 +239,14 @@ async function handleOtlpLogs(req, res, storage, events) {
237
239
  writeError(res, 400, "invalid_json", asMessage(err));
238
240
  return;
239
241
  }
242
+ // VA-455: surface in-flight drains the moment the runway process
243
+ // emits its `drain.started` log marker — well before the first
244
+ // processIssue span ends. `markDrainActive` is no-op when the
245
+ // drain has already closed, so reordering across OTLP retries is
246
+ // safe.
247
+ for (const m of extractActiveDrainMarkers(payload)) {
248
+ storage.markDrainActive(m);
249
+ }
240
250
  for (const r of projectLogs(payload)) {
241
251
  storage.appendLogRecord(r);
242
252
  // VA-391: the SSE detail-pane stream live-tails the Logs section
@@ -318,7 +328,7 @@ function handleIssueProcessStream(req, res, storage, events, spanId) {
318
328
  * preview and drain summary strip stay static between polls — they
319
329
  * have their own swap triggers (or sit above the polling target).
320
330
  */
321
- function handleIssueProcessRowsFragment(req, res, storage, linearEnabled) {
331
+ function handleIssueProcessRowsFragment(req, res, storage, linearEnabled, linearWorkspace) {
322
332
  // VA-392: the fragment endpoint applies the same filter state as the
323
333
  // full list view so a polled refresh doesn't reset the user's chips.
324
334
  // The query string is the source of truth — htmx mirrors the page's
@@ -336,6 +346,7 @@ function handleIssueProcessRowsFragment(req, res, storage, linearEnabled) {
336
346
  linearEnabled,
337
347
  snapshotsByIdentifier,
338
348
  isFiltered: isFilteredState(filterState),
349
+ linearWorkspace,
339
350
  });
340
351
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
341
352
  res.end(html);
@@ -346,7 +357,7 @@ function writeSseFrame(res, event, data) {
346
357
  const lines = data.split("\n").map((line) => `data: ${line}`).join("\n");
347
358
  res.write(`event: ${event}\n${lines}\n\n`);
348
359
  }
349
- function handleListView(req, res, storage, linearEnabled) {
360
+ function handleListView(req, res, storage, linearEnabled, linearWorkspace) {
350
361
  // VA-392: filter chips state is encoded in the URL query string so
351
362
  // the view is shareable. Parse once and reuse both for the storage
352
363
  // query and the chip render (the latter needs to know which chips
@@ -378,6 +389,7 @@ function handleListView(req, res, storage, linearEnabled) {
378
389
  filterState,
379
390
  recentDrains,
380
391
  isFiltered: isFilteredState(filterState),
392
+ linearWorkspace,
381
393
  });
382
394
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
383
395
  res.end(html);
@@ -471,28 +483,28 @@ function formatSqliteDatetime(d) {
471
483
  * (trace_id, span_id) pair doesn't match a known issue process — the
472
484
  * dashboard never auto-creates rows from a URL.
473
485
  */
474
- function handleDetailView(res, storage, traceId, spanId) {
486
+ function handleDetailView(res, storage, traceId, spanId, linearWorkspace) {
475
487
  const ip = storage.getIssueProcess(traceId, spanId);
476
488
  if (!ip) {
477
489
  writeError(res, 404, "not_found", `no issue process for trace=${traceId} span=${spanId}`);
478
490
  return;
479
491
  }
480
- renderDetailFor(res, storage, ip);
492
+ renderDetailFor(res, storage, ip, linearWorkspace);
481
493
  }
482
494
  /**
483
495
  * VA-387: detail-route handler keyed on the issue process span_id
484
496
  * alone. Reuses the same view model as the older two-segment route
485
497
  * once the row is resolved.
486
498
  */
487
- function handleIssueProcessDetailView(res, storage, spanId) {
499
+ function handleIssueProcessDetailView(res, storage, spanId, linearWorkspace) {
488
500
  const ip = storage.getIssueProcessBySpanId(spanId);
489
501
  if (!ip) {
490
502
  writeError(res, 404, "not_found", `no issue process for span=${spanId}`);
491
503
  return;
492
504
  }
493
- renderDetailFor(res, storage, ip);
505
+ renderDetailFor(res, storage, ip, linearWorkspace);
494
506
  }
495
- function renderDetailFor(res, storage, ip) {
507
+ function renderDetailFor(res, storage, ip, linearWorkspace) {
496
508
  const iterations = storage.listAgentIterations(ip.traceId, ip.spanId);
497
509
  const phaseSpans = storage.listPhaseSpans(ip.traceId, ip.spanId, [
498
510
  ...DETAIL_PHASE_NAMES,
@@ -507,6 +519,7 @@ function renderDetailFor(res, storage, ip) {
507
519
  iterations,
508
520
  phaseSpans,
509
521
  logs,
522
+ linearWorkspace,
510
523
  });
511
524
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
512
525
  res.end(html);
@@ -652,6 +665,13 @@ export async function main() {
652
665
  // and start polling at LINEAR_POLL_INTERVAL_SECONDS (default 300).
653
666
  const linearApiKey = process.env.LINEAR_API_KEY;
654
667
  const linearEnabled = Boolean(linearApiKey);
668
+ // VA-452: workspace slug for the `↗ open in Linear` affordance.
669
+ // Trim + collapse empty to null so a stray empty env var doesn't
670
+ // produce broken `linear.app//issue/...` URLs.
671
+ const linearWorkspaceRaw = process.env.RUNWAY_LINEAR_WORKSPACE?.trim();
672
+ const linearWorkspace = linearWorkspaceRaw && linearWorkspaceRaw.length > 0
673
+ ? linearWorkspaceRaw
674
+ : null;
655
675
  let linearSync = null;
656
676
  if (linearApiKey) {
657
677
  const adapter = createLinearAdapter({
@@ -675,6 +695,7 @@ export async function main() {
675
695
  port: otlpPort,
676
696
  host: otlpHost,
677
697
  linearEnabled,
698
+ linearWorkspace,
678
699
  });
679
700
  // The two listeners can share a server only when both port AND host
680
701
  // match — different bind addresses need different listeners.
@@ -685,6 +706,7 @@ export async function main() {
685
706
  port: dashboardPort,
686
707
  host: dashboardHost,
687
708
  linearEnabled,
709
+ linearWorkspace,
688
710
  });
689
711
  console.log(`[runway dashboard] OTLP ${otlpHost}:${otlp.port} · dashboard ${dashboardHost}:${dashboard.port}; sqlite=${sqlitePath}`);
690
712
  const shutdown = async (signal) => {
@@ -1,4 +1,11 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
+ /**
3
+ * VA-455: how stale a drain's last_seen can be before getActiveDrain
4
+ * treats it as crashed and hides it. Runway emits a heartbeat log
5
+ * every 30s while drainQueue is active; 90s gives 3× headroom so a
6
+ * single dropped heartbeat doesn't toggle the dashboard card.
7
+ */
8
+ export const ACTIVE_DRAIN_STALENESS_NANOS = 90n * 1000000000n;
2
9
  /**
3
10
  * VA-406: named constants for the `meta_reviews.kind` alphabet.
4
11
  * Used by the IRA passes when stamping rows + by gateway queries
@@ -23,11 +30,17 @@ export const META_RUN_REVIEW_COMPLETION_KINDS = [
23
30
  META_REVIEW_KIND.FAILED,
24
31
  ];
25
32
  const SCHEMA = `
33
+ -- VA-455: end_time_unix_nano is nullable so an "active drain" row
34
+ -- inserted by the drain.started log marker can exist before the
35
+ -- drainQueue span has actually ended. last_seen_unix_nano carries
36
+ -- the most recent log record's timestamp for the trace so the
37
+ -- dashboard's active-drain query can age out crashed drains.
26
38
  CREATE TABLE IF NOT EXISTS drains (
27
39
  trace_id TEXT NOT NULL,
28
40
  span_id TEXT NOT NULL,
29
41
  start_time_unix_nano TEXT NOT NULL,
30
- end_time_unix_nano TEXT NOT NULL,
42
+ end_time_unix_nano TEXT,
43
+ last_seen_unix_nano TEXT,
31
44
  attempts INTEGER,
32
45
  opened INTEGER,
33
46
  hitl INTEGER,
@@ -101,7 +114,8 @@ const SCHEMA = `
101
114
  labels_json TEXT,
102
115
  queue_position INTEGER,
103
116
  project_id TEXT,
104
- project_name TEXT
117
+ project_name TEXT,
118
+ status_type TEXT
105
119
  );
106
120
 
107
121
  CREATE INDEX IF NOT EXISTS idx_linear_snapshots_queue_position
@@ -164,6 +178,59 @@ const DEFAULT_AGGREGATE_WINDOW = 30;
164
178
  * of `hitl_escape_rate` — a review rejection routes to HITL, so both
165
179
  * rates count the same row.
166
180
  */
181
+ /**
182
+ * VA-455: relax the `end_time_unix_nano TEXT NOT NULL` constraint on
183
+ * pre-VA-455 `drains` tables so the log-driven active-drain row can
184
+ * land with end_time = NULL. SQLite has no `ALTER COLUMN`, so the
185
+ * idiomatic move is a rebuild — guarded by PRAGMA so fresh installs
186
+ * (which already created the new schema) skip the work.
187
+ */
188
+ function relaxDrainsEndTimeNotNull(db) {
189
+ const cols = db
190
+ .prepare("PRAGMA table_info('drains')")
191
+ .all();
192
+ const endTime = cols.find((c) => c.name === "end_time_unix_nano");
193
+ if (!endTime || endTime.notnull === 0)
194
+ return;
195
+ // Single transaction so the `drains` name is never absent from the
196
+ // schema between DROP and RENAME — concurrent readers (none today,
197
+ // but cheap insurance) keep seeing the old table until COMMIT.
198
+ db.prepare("BEGIN").run();
199
+ try {
200
+ db.prepare(`CREATE TABLE drains_v2 (
201
+ trace_id TEXT NOT NULL,
202
+ span_id TEXT NOT NULL,
203
+ start_time_unix_nano TEXT NOT NULL,
204
+ end_time_unix_nano TEXT,
205
+ last_seen_unix_nano TEXT,
206
+ attempts INTEGER,
207
+ opened INTEGER,
208
+ hitl INTEGER,
209
+ errored INTEGER,
210
+ status_code INTEGER,
211
+ status_message TEXT,
212
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now')),
213
+ PRIMARY KEY (trace_id, span_id)
214
+ )`).run();
215
+ db.prepare(`INSERT INTO drains_v2 (
216
+ trace_id, span_id, start_time_unix_nano, end_time_unix_nano,
217
+ last_seen_unix_nano, attempts, opened, hitl, errored,
218
+ status_code, status_message, inserted_at
219
+ )
220
+ SELECT
221
+ trace_id, span_id, start_time_unix_nano, end_time_unix_nano,
222
+ last_seen_unix_nano, attempts, opened, hitl, errored,
223
+ status_code, status_message, inserted_at
224
+ FROM drains`).run();
225
+ db.prepare("DROP TABLE drains").run();
226
+ db.prepare("ALTER TABLE drains_v2 RENAME TO drains").run();
227
+ db.prepare("COMMIT").run();
228
+ }
229
+ catch (err) {
230
+ db.prepare("ROLLBACK").run();
231
+ throw err;
232
+ }
233
+ }
167
234
  function aggregatesViewDdl(windowDrains) {
168
235
  // windowDrains is the only spot we interpolate rather than
169
236
  // parameter-bind (CREATE VIEW can't take params). Coerce to a
@@ -294,6 +361,14 @@ export function createStorage(path, opts = {}) {
294
361
  // VA-450: project Name + UUID for the Todo queue Project column.
295
362
  `ALTER TABLE linear_snapshots ADD COLUMN project_id TEXT`,
296
363
  `ALTER TABLE linear_snapshots ADD COLUMN project_name TEXT`,
364
+ // VA-453: workflow-state TYPE (not name) for cancelled/done queue
365
+ // filtering. Old rows decode to "" — the poller only filters on
366
+ // known type values so pre-migration rows behave unchanged.
367
+ `ALTER TABLE linear_snapshots ADD COLUMN status_type TEXT`,
368
+ // VA-455: per-trace heartbeat timestamp for the log-driven active
369
+ // drain query (see SCHEMA above for semantics). Older DBs need
370
+ // the column added in-place.
371
+ `ALTER TABLE drains ADD COLUMN last_seen_unix_nano TEXT`,
297
372
  ]) {
298
373
  try {
299
374
  db.exec(sql);
@@ -303,6 +378,12 @@ export function createStorage(path, opts = {}) {
303
378
  // dashboard boot ran the same migration.
304
379
  }
305
380
  }
381
+ // VA-455: SQLite has no `ALTER COLUMN`, so relaxing the original
382
+ // `end_time_unix_nano TEXT NOT NULL` constraint on legacy DBs
383
+ // requires a table-rebuild. Only fire when PRAGMA reports the
384
+ // column is still NOT NULL — fresh installs go through the new
385
+ // SCHEMA above and skip this branch.
386
+ relaxDrainsEndTimeNotNull(db);
306
387
  // VA-399: install the evaluator-facing read-model view after the
307
388
  // base tables exist (and after VA-387's column migrations above),
308
389
  // but before any prepared statement is created — a
@@ -312,17 +393,58 @@ export function createStorage(path, opts = {}) {
312
393
  const insertDrain = db.prepare(`
313
394
  INSERT INTO drains (
314
395
  trace_id, span_id, start_time_unix_nano, end_time_unix_nano,
396
+ last_seen_unix_nano,
315
397
  attempts, opened, hitl, errored, status_code, status_message
316
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
398
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
317
399
  ON CONFLICT (trace_id, span_id) DO UPDATE SET
318
400
  start_time_unix_nano = excluded.start_time_unix_nano,
319
401
  end_time_unix_nano = excluded.end_time_unix_nano,
402
+ last_seen_unix_nano = excluded.last_seen_unix_nano,
320
403
  attempts = excluded.attempts,
321
404
  opened = excluded.opened,
322
405
  hitl = excluded.hitl,
323
406
  errored = excluded.errored,
324
407
  status_code = excluded.status_code,
325
408
  status_message = excluded.status_message
409
+ `);
410
+ // VA-455: insert (or refresh last_seen on) the active-drain row
411
+ // when runway emits its `drain.started` log marker. ON CONFLICT
412
+ // guards against two cases:
413
+ // 1. The drainQueue span has already ended (end_time NOT NULL)
414
+ // — a late-arriving marker can't reanimate a closed drain,
415
+ // so we leave the row untouched.
416
+ // 2. Repeated markers (shouldn't happen, but if they do we just
417
+ // bump last_seen forward; monotonic via the MAX comparison).
418
+ const markActiveDrainStmt = db.prepare(`
419
+ INSERT INTO drains (
420
+ trace_id, span_id, start_time_unix_nano,
421
+ end_time_unix_nano, last_seen_unix_nano,
422
+ attempts, opened, hitl, errored,
423
+ status_code, status_message
424
+ ) VALUES (?, ?, ?, NULL, ?, 0, 0, 0, 0, NULL, NULL)
425
+ ON CONFLICT (trace_id, span_id) DO UPDATE SET
426
+ last_seen_unix_nano = excluded.last_seen_unix_nano
427
+ WHERE drains.end_time_unix_nano IS NULL
428
+ AND (
429
+ drains.last_seen_unix_nano IS NULL
430
+ OR CAST(drains.last_seen_unix_nano AS INTEGER)
431
+ < CAST(excluded.last_seen_unix_nano AS INTEGER)
432
+ )
433
+ `);
434
+ // VA-455: every log record carrying a trace_id pushes that
435
+ // trace's active drain's last_seen forward, so the active-drain
436
+ // query can age out crashed drains. UPDATE-only (never inserts)
437
+ // so a stray log record from a trace without a `drain.started`
438
+ // marker doesn't fabricate an active drain.
439
+ const bumpDrainLastSeenStmt = db.prepare(`
440
+ UPDATE drains
441
+ SET last_seen_unix_nano = ?
442
+ WHERE trace_id = ?
443
+ AND end_time_unix_nano IS NULL
444
+ AND (
445
+ last_seen_unix_nano IS NULL
446
+ OR CAST(last_seen_unix_nano AS INTEGER) < CAST(? AS INTEGER)
447
+ )
326
448
  `);
327
449
  const insertIssueProcess = db.prepare(`
328
450
  INSERT INTO issue_processes (
@@ -409,8 +531,8 @@ export function createStorage(path, opts = {}) {
409
531
  const upsertLinearSnapshot = db.prepare(`
410
532
  INSERT INTO linear_snapshots (
411
533
  issue_identifier, snapshot_at, status, title, labels_json, queue_position,
412
- project_id, project_name
413
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
534
+ project_id, project_name, status_type
535
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
414
536
  ON CONFLICT (issue_identifier) DO UPDATE SET
415
537
  snapshot_at = excluded.snapshot_at,
416
538
  status = excluded.status,
@@ -418,48 +540,80 @@ export function createStorage(path, opts = {}) {
418
540
  labels_json = excluded.labels_json,
419
541
  queue_position = excluded.queue_position,
420
542
  project_id = excluded.project_id,
421
- project_name = excluded.project_name
543
+ project_name = excluded.project_name,
544
+ status_type = excluded.status_type
422
545
  `);
423
546
  const clearQueuePositionsStmt = db.prepare(`UPDATE linear_snapshots SET queue_position = NULL`);
424
547
  const listTodoQueueStmt = db.prepare(`
425
548
  SELECT issue_identifier, snapshot_at, status, title, labels_json, queue_position,
426
- project_id, project_name
549
+ project_id, project_name, status_type
427
550
  FROM linear_snapshots
428
551
  WHERE queue_position IS NOT NULL
429
552
  ORDER BY queue_position ASC
430
553
  `);
431
554
  const listAllSnapshotsStmt = db.prepare(`
432
555
  SELECT issue_identifier, snapshot_at, status, title, labels_json, queue_position,
433
- project_id, project_name
556
+ project_id, project_name, status_type
434
557
  FROM linear_snapshots
435
558
  `);
436
- // VA-391: "active drain" = a trace_id with one or more
437
- // issue_processes rows but no row in `drains`. The drain span
438
- // hasn't been emitted yet (BatchSpanProcessor flushes on end), so
439
- // we infer in-flight by the absence of the parent. Earliest
440
- // start_time across the trace's issue_processes is the drain
441
- // start; the counters slice by outcome_kind. LIMIT 1 picks the
442
- // most-recently-started in-flight drain v1 only runs one drain
443
- // at a time but the SQL is robust to a future parallel mode.
559
+ // VA-391 + VA-455: "active drain" comes from two paths today.
560
+ //
561
+ // (a) VA-455 log-driven: a `drains` row with end_time NULL and a
562
+ // fresh `last_seen_unix_nano` (within the staleness window).
563
+ // runway emits `Effect.logInfo("drain.started")` at the top
564
+ // of the drainQueue span; the projector recognises that body
565
+ // and inserts the row via `markDrainActive`. Subsequent log
566
+ // records bump last_seen.
567
+ //
568
+ // (b) VA-391 legacy fallback: a trace_id with issue_processes
569
+ // rows but no `drains` row at all. Kept so dashboards running
570
+ // against pre-VA-455 runway binaries still light up — once
571
+ // the first processIssue ends.
572
+ //
573
+ // The two paths are mutually exclusive by construction (path (a)
574
+ // writes a `drains` row, which excludes the trace from path (b)).
575
+ // Among candidates, most-recently-started wins; v1 only runs one
576
+ // drain at a time but the SQL is robust to a future parallel mode.
577
+ //
578
+ // Bind param: the staleness floor in unix-nanos as a TEXT-encoded
579
+ // integer. Pass `String(BigInt(Date.now()) * 1_000_000n - staleness)`
580
+ // at call time so the query stays a static prepared statement.
444
581
  const getActiveDrainStmt = db.prepare(`
445
- WITH active AS (
446
- SELECT trace_id
582
+ WITH active_candidates AS (
583
+ SELECT
584
+ trace_id,
585
+ start_time_unix_nano AS started_at_unix_nano
586
+ FROM drains
587
+ WHERE end_time_unix_nano IS NULL
588
+ AND last_seen_unix_nano IS NOT NULL
589
+ AND CAST(last_seen_unix_nano AS INTEGER) > CAST(? AS INTEGER)
590
+
591
+ UNION ALL
592
+
593
+ SELECT
594
+ trace_id,
595
+ CAST(MIN(CAST(start_time_unix_nano AS INTEGER)) AS TEXT)
596
+ AS started_at_unix_nano
447
597
  FROM issue_processes
448
598
  WHERE trace_id NOT IN (SELECT trace_id FROM drains)
449
599
  GROUP BY trace_id
450
- ORDER BY MIN(CAST(start_time_unix_nano AS INTEGER)) DESC
600
+ ),
601
+ chosen AS (
602
+ SELECT trace_id, started_at_unix_nano
603
+ FROM active_candidates
604
+ ORDER BY CAST(started_at_unix_nano AS INTEGER) DESC
451
605
  LIMIT 1
452
606
  )
453
607
  SELECT
454
- ip.trace_id,
455
- MIN(ip.start_time_unix_nano) AS started_at_unix_nano,
456
- COUNT(*) AS issue_count,
457
- SUM(CASE WHEN ip.outcome_kind = 'opened' THEN 1 ELSE 0 END) AS opened_count,
458
- SUM(CASE WHEN ip.outcome_kind = 'hitl' THEN 1 ELSE 0 END) AS hitl_count,
459
- SUM(CASE WHEN ip.outcome_kind = 'errored' THEN 1 ELSE 0 END) AS errored_count
460
- FROM issue_processes ip
461
- WHERE ip.trace_id = (SELECT trace_id FROM active)
462
- GROUP BY ip.trace_id
608
+ c.trace_id,
609
+ c.started_at_unix_nano,
610
+ COUNT(ip.span_id) AS issue_count,
611
+ COALESCE(SUM(CASE WHEN ip.outcome_kind = 'opened' THEN 1 ELSE 0 END), 0) AS opened_count,
612
+ COALESCE(SUM(CASE WHEN ip.outcome_kind = 'hitl' THEN 1 ELSE 0 END), 0) AS hitl_count,
613
+ COALESCE(SUM(CASE WHEN ip.outcome_kind = 'errored' THEN 1 ELSE 0 END), 0) AS errored_count
614
+ FROM chosen c
615
+ LEFT JOIN issue_processes ip ON ip.trace_id = c.trace_id
616
+ GROUP BY c.trace_id, c.started_at_unix_nano
463
617
  `);
464
618
  const insertLogRecord = db.prepare(`
465
619
  INSERT INTO log_records (
@@ -501,7 +655,15 @@ export function createStorage(path, opts = {}) {
501
655
  ORDER BY CAST(timestamp_unix_nano AS INTEGER) ASC, span_id ASC
502
656
  `);
503
657
  const saveDrain = (d) => {
504
- insertDrain.run(d.traceId, d.spanId, d.startTimeUnixNano, d.endTimeUnixNano, asInt(d.attempts), asInt(d.opened), asInt(d.hitl), asInt(d.errored), asInt(d.statusCode), d.statusMessage);
658
+ insertDrain.run(d.traceId, d.spanId, d.startTimeUnixNano, d.endTimeUnixNano, d.lastSeenUnixNano, asInt(d.attempts), asInt(d.opened), asInt(d.hitl), asInt(d.errored), asInt(d.statusCode), d.statusMessage);
659
+ };
660
+ // VA-455: log-marker-driven insert for in-flight drains. Called
661
+ // from server.ts when `projectLogs` surfaces a `drain.started`
662
+ // record (see `extractActiveDrainMarkers`). The first call lands
663
+ // the row with end_time NULL; subsequent calls (or any log on the
664
+ // trace flowing through `appendLogRecord`) push last_seen forward.
665
+ const markDrainActive = (m) => {
666
+ markActiveDrainStmt.run(m.traceId, m.spanId, m.startTimeUnixNano, m.lastSeenUnixNano);
505
667
  };
506
668
  const saveIssueProcess = (p) => {
507
669
  insertIssueProcess.run(p.traceId, p.spanId, p.parentSpanId, p.issueIdentifier, p.issueId, p.issueTitle,
@@ -626,7 +788,7 @@ export function createStorage(path, opts = {}) {
626
788
  };
627
789
  const listAggregates = () => selectAggregates.all().map(rowToAggregate);
628
790
  const saveLinearSnapshot = (s) => {
629
- 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);
791
+ 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);
630
792
  };
631
793
  const clearLinearQueuePositions = () => {
632
794
  clearQueuePositionsStmt.run();
@@ -641,10 +803,20 @@ export function createStorage(path, opts = {}) {
641
803
  Object.keys(r.attributes).length === 0
642
804
  ? null
643
805
  : JSON.stringify(r.attributes));
806
+ // VA-455: piggy-back on every log record to keep the trace's
807
+ // active-drain row "alive" — drives the staleness TTL in
808
+ // getActiveDrain. No-op when no active drain exists for the
809
+ // trace (UPDATE matches zero rows).
810
+ bumpDrainLastSeenStmt.run(r.timestampUnixNano, r.traceId, r.timestampUnixNano);
644
811
  };
645
812
  const streamLogsFor = (traceId) => listLogsByTrace.all(traceId).map(rowToLogRecord);
646
813
  const getActiveDrain = () => {
647
- const row = getActiveDrainStmt.get();
814
+ // VA-455: staleness floor — drains whose most recent log record
815
+ // is older than this no longer count as active. Matches the 30s
816
+ // heartbeat cadence on the runway side with a 3× safety margin
817
+ // so a single dropped heartbeat doesn't toggle the card to Idle.
818
+ const stalenessFloorNanos = BigInt(Date.now()) * 1000000n - ACTIVE_DRAIN_STALENESS_NANOS;
819
+ const row = getActiveDrainStmt.get(String(stalenessFloorNanos));
648
820
  if (!row || row.trace_id == null)
649
821
  return null;
650
822
  return {
@@ -752,6 +924,7 @@ export function createStorage(path, opts = {}) {
752
924
  };
753
925
  return {
754
926
  saveDrain,
927
+ markDrainActive,
755
928
  saveIssueProcess,
756
929
  saveAgentIteration,
757
930
  saveRawSpan,
@@ -872,6 +1045,7 @@ function rowToLinearSnapshot(row) {
872
1045
  queuePosition: nullableNum(r.queue_position),
873
1046
  projectId: r.project_id == null ? null : String(r.project_id),
874
1047
  projectName: r.project_name == null ? null : String(r.project_name),
1048
+ statusType: r.status_type == null ? "" : String(r.status_type),
875
1049
  };
876
1050
  }
877
1051
  /**
@@ -34,6 +34,9 @@ const SHARED_STYLE = `
34
34
  .detail { color: #d4d4d8; }
35
35
  .muted { color: #9ca3af; }
36
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; }
37
40
  code { background: #1f2937; padding: 1px 6px; border-radius: 3px; }
38
41
  .status-badge { display: inline-block; margin-left: 8px;
39
42
  padding: 1px 6px; border-radius: 3px; font-size: 11px;
@@ -53,13 +56,17 @@ export function renderListView(input) {
53
56
  const filterState = vm.filterState ?? EMPTY_FILTER_STATE;
54
57
  const isFiltered = vm.isFiltered ?? false;
55
58
  const recentDrains = vm.recentDrains ?? [];
59
+ const linearWorkspace = vm.linearWorkspace ?? null;
56
60
  const tableBody = renderIssueProcessRows({
57
61
  rows: vm.rows,
58
62
  linearEnabled: vm.linearEnabled,
59
63
  snapshotsByIdentifier: vm.snapshotsByIdentifier,
60
64
  isFiltered,
65
+ linearWorkspace,
61
66
  });
62
- const queueSection = vm.linearEnabled ? renderTodoQueue(vm.todoQueue) : "";
67
+ const queueSection = vm.linearEnabled
68
+ ? renderTodoQueue(vm.todoQueue, linearWorkspace)
69
+ : "";
63
70
  // VA-391: drain summary strip renders ABOVE the queue + run list
64
71
  // so the operator sees in-flight progress at the top of the page.
65
72
  const drainStrip = renderDrainStrip(vm.activeDrain ?? null);
@@ -191,10 +198,24 @@ export function renderIssueProcessRows(input) {
191
198
  }
192
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>`;
193
200
  }
201
+ const linearWorkspace = input.linearWorkspace ?? null;
194
202
  return input.rows
195
- .map((r) => renderRow(r, input.snapshotsByIdentifier))
203
+ .map((r) => renderRow(r, input.snapshotsByIdentifier, linearWorkspace))
196
204
  .join("");
197
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
+ }
198
219
  // ---------------------------------------------------------------------------
199
220
  // VA-392: filter chips
200
221
  // ---------------------------------------------------------------------------
@@ -315,10 +336,10 @@ export function filterStateToQueryString(state) {
315
336
  * with an empty-state row so the operator knows the poller ran and
316
337
  * found nothing to pick up.
317
338
  */
318
- function renderTodoQueue(queue) {
339
+ function renderTodoQueue(queue, linearWorkspace) {
319
340
  const body = queue.length === 0
320
- ? `<tr><td colspan="4" class="empty">Linear Todo queue is empty.</td></tr>`
321
- : 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("");
322
343
  return `<h2>Todo queue (next up)</h2>
323
344
  <table>
324
345
  <thead>
@@ -326,13 +347,14 @@ function renderTodoQueue(queue) {
326
347
  <th>Issue</th>
327
348
  <th>Title</th>
328
349
  <th>Labels</th>
350
+ <th>Status</th>
329
351
  <th>Project</th>
330
352
  </tr>
331
353
  </thead>
332
354
  <tbody>${body}</tbody>
333
355
  </table>`;
334
356
  }
335
- function renderQueueRow(s) {
357
+ function renderQueueRow(s, linearWorkspace) {
336
358
  const labels = s.labels.length === 0 ? "" : s.labels.join(", ");
337
359
  // VA-450: project cell shows the name on top with the UUID muted
338
360
  // underneath in the same column. Unprojected issues degrade to an
@@ -343,14 +365,20 @@ function renderQueueRow(s) {
343
365
  (s.projectId == null
344
366
  ? ""
345
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);
346
373
  return `<tr>
347
- <td class="id">${escapeHtml(s.issueIdentifier)}</td>
374
+ <td class="id">${escapeHtml(s.issueIdentifier)}${linearIssueLink(s.issueIdentifier, linearWorkspace)}</td>
348
375
  <td class="detail">${escapeHtml(s.title)}</td>
349
376
  <td class="muted">${escapeHtml(labels)}</td>
377
+ <td class="detail">${status}</td>
350
378
  <td class="detail">${project}</td>
351
379
  </tr>`;
352
380
  }
353
- function renderRow(r, snapshots) {
381
+ function renderRow(r, snapshots, linearWorkspace) {
354
382
  const kind = r.outcomeKind ?? "pending";
355
383
  const outcomeCls = `outcome outcome-${escapeHtml(kind)}`;
356
384
  // VA-387: canonical detail link uses the span_id alone.
@@ -367,7 +395,7 @@ function renderRow(r, snapshots) {
367
395
  ? `<span class="status-badge">${escapeHtml(snapshot.status)}</span>`
368
396
  : "";
369
397
  return `<tr>
370
- <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>
371
399
  <td class="${outcomeCls}">${escapeHtml(kind)}</td>
372
400
  <td class="detail">${escapeHtml(r.outcomeDetail ?? "")}</td>
373
401
  <td>${escapeHtml(r.insertedAt)}</td>
@@ -450,7 +478,7 @@ export function renderDetailView(vm) {
450
478
  </head>
451
479
  <body>
452
480
  <div class="breadcrumb"><a href="/">← all issue processes</a></div>
453
- <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>
454
482
  ${titleLine}
455
483
  <div class="meta">
456
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)) {
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { Effect } from "effect";
3
+ import { Duration, Effect, Schedule } from "effect";
4
4
  import { detectBaseBranch, pruneStaleAgentBranch } from "./git.js";
5
5
  import { loadPolicy } from "./policy.js";
6
6
  import { flagHitl, handleProcessFailure } from "./hitl.js";
@@ -41,6 +41,19 @@ export function assertSandcastleInitialised(cwd) {
41
41
  export const drainQueue = (deps, opts = {}) => Effect.gen(function* () {
42
42
  const { config, linear } = deps;
43
43
  const max = opts.max ?? Number.POSITIVE_INFINITY;
44
+ // VA-455: announce the drain to the dashboard before any other
45
+ // work. The log carries the drainQueue span's trace_id/span_id
46
+ // (we're already inside `Effect.withSpan("drainQueue")`), and the
47
+ // dashboard's projector matches on the literal body string —
48
+ // keep it in lock-step with `DRAIN_STARTED_LOG` in projector.ts.
49
+ yield* Effect.logInfo("drain.started");
50
+ // VA-455: keep the dashboard's "still alive" signal warm during
51
+ // long impl/review phases. `Effect.fork` ties the heartbeat fiber
52
+ // to this gen's lifetime, so it's interrupted automatically when
53
+ // drainQueue completes (success, failure, or interrupt). The 30s
54
+ // cadence matches `ACTIVE_DRAIN_STALENESS_NANOS / 3` on the
55
+ // dashboard so a single dropped flush doesn't toggle the card.
56
+ yield* Effect.fork(Effect.logInfo("drain.heartbeat").pipe(Effect.repeat(Schedule.spaced(Duration.seconds(30)))));
44
57
  // Resolve the base branch once at startup so every issue in the
45
58
  // drain sees the same answer (and so a misconfigured repo fails
46
59
  // fast, before we touch any Linear state).
package/dist/review.js CHANGED
@@ -79,7 +79,14 @@ export const runReviewPass = (issue, deps, branch) => Effect.gen(function* () {
79
79
  sandbox: docker({ env: dockerEnv(config) }),
80
80
  cwd,
81
81
  prompt: reviewPrompt,
82
- branchStrategy: { type: "head" },
82
+ // VA-456: review must check out the impl agent's branch
83
+ // explicitly. The previous `{ type: "head" }` inherited
84
+ // whatever branch the operator happened to be on in the
85
+ // main checkout — when that wasn't `branch`, the reviewer
86
+ // saw a working tree that didn't reconcile with the diff in
87
+ // its prompt and stalled silently until sandcastle's idle
88
+ // timeout fired (10 min of dead air, then INFRA_ERROR).
89
+ branchStrategy: { type: "branch", branch },
83
90
  maxIterations: 1,
84
91
  name: `review-${issue.identifier}`,
85
92
  });
@@ -1,5 +1,55 @@
1
1
  import { run } from "@ai-hero/sandcastle";
2
2
  import { Effect, Redacted } from "effect";
3
+ import { readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ /**
6
+ * VA-454: when sandcastle throws, its `Error.message` is opaque
7
+ * (`claude-code exited with code 1:` with no body — the colon is
8
+ * literal, the stderr text goes only to the log file). To stop
9
+ * operators from having to hunt `.sandcastle/logs/` for the real
10
+ * cause on every failure, we read the tail of the most-recently
11
+ * modified log file in the project's `.sandcastle/logs/` directory
12
+ * and append it to the error message.
13
+ *
14
+ * Tail is read by capping the byte window (cheap on huge logs) and
15
+ * then trimming to the last `maxLines`. A missing directory or a
16
+ * read error degrades silently — the error path must not be the
17
+ * source of a NEW error.
18
+ */
19
+ const SANDCASTLE_LOG_TAIL_LINES = 20;
20
+ const SANDCASTLE_LOG_TAIL_MAX_BYTES = 16 * 1024;
21
+ export function readLatestSandcastleLogTail(cwd, maxLines = SANDCASTLE_LOG_TAIL_LINES) {
22
+ try {
23
+ const dir = join(cwd, ".sandcastle", "logs");
24
+ const entries = readdirSync(dir);
25
+ let newestPath = null;
26
+ let newestMtime = -Infinity;
27
+ for (const name of entries) {
28
+ if (!name.endsWith(".log"))
29
+ continue;
30
+ const path = join(dir, name);
31
+ const mtime = statSync(path).mtimeMs;
32
+ if (mtime > newestMtime) {
33
+ newestMtime = mtime;
34
+ newestPath = path;
35
+ }
36
+ }
37
+ if (!newestPath)
38
+ return null;
39
+ const size = statSync(newestPath).size;
40
+ const offset = Math.max(0, size - SANDCASTLE_LOG_TAIL_MAX_BYTES);
41
+ const buf = readFileSync(newestPath);
42
+ // Trim trailing newline BEFORE splitting so a `…line 200\n` file
43
+ // doesn't yield an empty final element that eats one slot in the
44
+ // `slice(-maxLines)` window.
45
+ const tail = buf.subarray(offset).toString("utf8").replace(/\n+$/, "");
46
+ const lines = tail.split("\n");
47
+ return lines.slice(-maxLines).join("\n");
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
3
53
  /**
4
54
  * VA-358: thin Effect wrapper around `sandcastle.run`. The agent run
5
55
  * happens inside Docker — sandcastle doesn't (yet) expose a kill
@@ -12,9 +62,18 @@ import { Effect, Redacted } from "effect";
12
62
  */
13
63
  export const runSandcastle = (args) => Effect.tryPromise({
14
64
  try: () => run(args),
15
- catch: (err) => ({
16
- message: err instanceof Error ? err.message : String(err),
17
- }),
65
+ catch: (err) => {
66
+ const base = err instanceof Error ? err.message : String(err);
67
+ // VA-454: append the tail of the most-recent `.sandcastle/logs/`
68
+ // file so the operator sees the real cause (e.g. "Invalid API
69
+ // key · Fix external API key") on the same line as the
70
+ // INFRA_ERROR — instead of having to scroll back to the run-
71
+ // start banner for the log path and open it.
72
+ const tail = readLatestSandcastleLogTail(args.cwd ?? process.cwd());
73
+ return {
74
+ message: tail ? `${base}\n${tail}` : base,
75
+ };
76
+ },
18
77
  });
19
78
  /**
20
79
  * Env vars to inject into every sandcastle container. Today this is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valescoagency/runway",
3
- "version": "0.12.0",
3
+ "version": "0.14.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": {
@@ -49,19 +49,29 @@ ENV HOME=/home/agent
49
49
  ENV XDG_CACHE_HOME=/home/agent/.cache
50
50
  ENV TURBO_CACHE_DIR=/tmp/turbo-cache
51
51
  ENV pnpm_config_cache=/home/agent/.cache/pnpm
52
+ # VA-457: pin corepack's data dir under the agent's cache. `corepack
53
+ # prepare` below runs as root (before the USER switch), so without
54
+ # COREPACK_HOME it would write the cached pnpm tarball to /root's
55
+ # default and the agent UID couldn't read it. Anything that resolves
56
+ # `pnpm` through the corepack shim after `USER` — git hooks invoked
57
+ # by lefthook in particular — then fails or hangs trying to refetch.
58
+ ENV COREPACK_HOME=/home/agent/.cache/corepack
52
59
 
53
60
  # Pre-create cache dirs with agent ownership so the first pnpm/turbo
54
61
  # run doesn't have to chown them. Both are inside paths the agent owns
55
62
  # anyway; this just makes them exist.
56
- RUN mkdir -p /home/agent/.cache /home/agent/.cache/pnpm /tmp/turbo-cache \
63
+ RUN mkdir -p /home/agent/.cache /home/agent/.cache/pnpm /home/agent/.cache/corepack /tmp/turbo-cache \
57
64
  && chown -R $AGENT_UID:$AGENT_GID /home/agent/.cache /tmp/turbo-cache
58
65
 
59
66
  # Bake pnpm via corepack at build time so `pnpm` is on PATH inside the
60
67
  # container before any agent command runs. Pin a default; target repos
61
68
  # can override at runtime via `packageManager` in package.json +
62
- # `corepack use`.
69
+ # `corepack use`. COREPACK_HOME is set above so the data dir lands
70
+ # under /home/agent/.cache/corepack; the trailing chown re-asserts
71
+ # agent ownership over the files root just wrote there.
63
72
  RUN corepack enable \
64
- && corepack prepare pnpm@11.1.1 --activate
73
+ && corepack prepare pnpm@11.1.1 --activate \
74
+ && chown -R $AGENT_UID:$AGENT_GID /home/agent/.cache/corepack
65
75
 
66
76
  USER ${AGENT_UID}:${AGENT_GID}
67
77