@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 +1 -1
- package/dist/commands/dash.js +79 -23
- package/dist/dashboard/linear-sync.js +26 -3
- package/dist/dashboard/server.js +27 -13
- package/dist/dashboard/storage.js +26 -7
- package/dist/dashboard/views.js +50 -10
- package/dist/linear.js +22 -0
- package/dist/telemetry.js +10 -8
- 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)
|
|
@@ -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([
|
|
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
|
-
|
|
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:
|
|
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"}`);
|
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) => {
|
|
@@ -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
|
-
|
|
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
|
/**
|
package/dist/dashboard/views.js
CHANGED
|
@@ -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
|
|
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="
|
|
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 {
|
|
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:
|
|
49
|
-
//
|
|
50
|
-
// `
|
|
51
|
-
//
|
|
52
|
-
|
|
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
|
-
?
|
|
56
|
+
? liveLayer
|
|
55
57
|
: NodeSdk.layerEmpty;
|
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": {
|