@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 +1 -1
- package/dist/commands/dash.js +79 -23
- package/dist/dashboard/linear-sync.js +15 -2
- package/dist/dashboard/projector.js +51 -0
- package/dist/dashboard/server.js +36 -14
- package/dist/dashboard/storage.js +205 -31
- package/dist/dashboard/views.js +38 -10
- package/dist/linear.js +22 -0
- package/dist/orchestrator.js +14 -1
- package/dist/review.js +8 -1
- package/dist/sandcastle.js +62 -3
- package/package.json +1 -1
- package/templates/Dockerfile.claude-code.base +13 -3
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.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.
|
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,
|
|
@@ -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
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
|
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"
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
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
|
|
446
|
-
SELECT
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
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
|
|
461
|
-
|
|
462
|
-
GROUP BY
|
|
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
|
-
|
|
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
|
/**
|
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/dist/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/dist/sandcastle.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|