@valescoagency/runway 0.12.0 → 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 +1 -1
- package/dist/commands/dash.js +79 -23
- package/dist/dashboard/linear-sync.js +15 -2
- package/dist/dashboard/server.js +27 -13
- package/dist/dashboard/storage.js +14 -7
- package/dist/dashboard/views.js +38 -10
- package/dist/linear.js +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -541,7 +541,7 @@ These are tractable, just not v1.
|
|
|
541
541
|
|
|
542
542
|
## Status
|
|
543
543
|
|
|
544
|
-
0.
|
|
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.
|
package/dist/commands/dash.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
* up
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* logs
|
|
13
|
-
*
|
|
14
|
-
* stop
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
52
|
-
runway dash
|
|
53
|
-
runway dash
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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" &&
|
|
96
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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) => {
|
|
@@ -101,7 +101,8 @@ const SCHEMA = `
|
|
|
101
101
|
labels_json TEXT,
|
|
102
102
|
queue_position INTEGER,
|
|
103
103
|
project_id TEXT,
|
|
104
|
-
project_name TEXT
|
|
104
|
+
project_name TEXT,
|
|
105
|
+
status_type TEXT
|
|
105
106
|
);
|
|
106
107
|
|
|
107
108
|
CREATE INDEX IF NOT EXISTS idx_linear_snapshots_queue_position
|
|
@@ -294,6 +295,10 @@ export function createStorage(path, opts = {}) {
|
|
|
294
295
|
// VA-450: project Name + UUID for the Todo queue Project column.
|
|
295
296
|
`ALTER TABLE linear_snapshots ADD COLUMN project_id TEXT`,
|
|
296
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`,
|
|
297
302
|
]) {
|
|
298
303
|
try {
|
|
299
304
|
db.exec(sql);
|
|
@@ -409,8 +414,8 @@ export function createStorage(path, opts = {}) {
|
|
|
409
414
|
const upsertLinearSnapshot = db.prepare(`
|
|
410
415
|
INSERT INTO linear_snapshots (
|
|
411
416
|
issue_identifier, snapshot_at, status, title, labels_json, queue_position,
|
|
412
|
-
project_id, project_name
|
|
413
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
417
|
+
project_id, project_name, status_type
|
|
418
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
414
419
|
ON CONFLICT (issue_identifier) DO UPDATE SET
|
|
415
420
|
snapshot_at = excluded.snapshot_at,
|
|
416
421
|
status = excluded.status,
|
|
@@ -418,19 +423,20 @@ export function createStorage(path, opts = {}) {
|
|
|
418
423
|
labels_json = excluded.labels_json,
|
|
419
424
|
queue_position = excluded.queue_position,
|
|
420
425
|
project_id = excluded.project_id,
|
|
421
|
-
project_name = excluded.project_name
|
|
426
|
+
project_name = excluded.project_name,
|
|
427
|
+
status_type = excluded.status_type
|
|
422
428
|
`);
|
|
423
429
|
const clearQueuePositionsStmt = db.prepare(`UPDATE linear_snapshots SET queue_position = NULL`);
|
|
424
430
|
const listTodoQueueStmt = db.prepare(`
|
|
425
431
|
SELECT issue_identifier, snapshot_at, status, title, labels_json, queue_position,
|
|
426
|
-
project_id, project_name
|
|
432
|
+
project_id, project_name, status_type
|
|
427
433
|
FROM linear_snapshots
|
|
428
434
|
WHERE queue_position IS NOT NULL
|
|
429
435
|
ORDER BY queue_position ASC
|
|
430
436
|
`);
|
|
431
437
|
const listAllSnapshotsStmt = db.prepare(`
|
|
432
438
|
SELECT issue_identifier, snapshot_at, status, title, labels_json, queue_position,
|
|
433
|
-
project_id, project_name
|
|
439
|
+
project_id, project_name, status_type
|
|
434
440
|
FROM linear_snapshots
|
|
435
441
|
`);
|
|
436
442
|
// VA-391: "active drain" = a trace_id with one or more
|
|
@@ -626,7 +632,7 @@ export function createStorage(path, opts = {}) {
|
|
|
626
632
|
};
|
|
627
633
|
const listAggregates = () => selectAggregates.all().map(rowToAggregate);
|
|
628
634
|
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);
|
|
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);
|
|
630
636
|
};
|
|
631
637
|
const clearLinearQueuePositions = () => {
|
|
632
638
|
clearQueuePositionsStmt.run();
|
|
@@ -872,6 +878,7 @@ function rowToLinearSnapshot(row) {
|
|
|
872
878
|
queuePosition: nullableNum(r.queue_position),
|
|
873
879
|
projectId: r.project_id == null ? null : String(r.project_id),
|
|
874
880
|
projectName: r.project_name == null ? null : String(r.project_name),
|
|
881
|
+
statusType: r.status_type == null ? "" : String(r.status_type),
|
|
875
882
|
};
|
|
876
883
|
}
|
|
877
884
|
/**
|
package/dist/dashboard/views.js
CHANGED
|
@@ -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
|
|
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="
|
|
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)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valescoagency/runway",
|
|
3
|
-
"version": "0.
|
|
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": {
|