@valescoagency/runway 0.7.0 → 0.8.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.
@@ -0,0 +1,304 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ const SCHEMA = `
3
+ CREATE TABLE IF NOT EXISTS drains (
4
+ trace_id TEXT NOT NULL,
5
+ span_id TEXT NOT NULL,
6
+ start_time_unix_nano TEXT NOT NULL,
7
+ end_time_unix_nano TEXT NOT NULL,
8
+ attempts INTEGER,
9
+ opened INTEGER,
10
+ hitl INTEGER,
11
+ errored INTEGER,
12
+ status_code INTEGER,
13
+ status_message TEXT,
14
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now')),
15
+ PRIMARY KEY (trace_id, span_id)
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS issue_processes (
19
+ trace_id TEXT NOT NULL,
20
+ span_id TEXT NOT NULL,
21
+ parent_span_id TEXT,
22
+ issue_identifier TEXT NOT NULL,
23
+ issue_id TEXT,
24
+ branch TEXT,
25
+ outcome_kind TEXT,
26
+ outcome_detail TEXT,
27
+ start_time_unix_nano TEXT NOT NULL,
28
+ end_time_unix_nano TEXT NOT NULL,
29
+ status_code INTEGER,
30
+ status_message TEXT,
31
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now')),
32
+ PRIMARY KEY (trace_id, span_id)
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_issue_processes_inserted_at
36
+ ON issue_processes(inserted_at DESC);
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_issue_processes_trace_id
39
+ ON issue_processes(trace_id);
40
+
41
+ CREATE TABLE IF NOT EXISTS raw_spans (
42
+ trace_id TEXT NOT NULL,
43
+ span_id TEXT NOT NULL,
44
+ name TEXT NOT NULL,
45
+ payload TEXT NOT NULL,
46
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now')),
47
+ PRIMARY KEY (trace_id, span_id)
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS agent_iterations (
51
+ trace_id TEXT NOT NULL,
52
+ id TEXT NOT NULL,
53
+ issue_process_id TEXT,
54
+ iteration_index INTEGER NOT NULL,
55
+ started_at TEXT NOT NULL,
56
+ ended_at TEXT NOT NULL,
57
+ sandcastle_run_id TEXT,
58
+ exit_status TEXT,
59
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now')),
60
+ PRIMARY KEY (trace_id, id)
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_agent_iterations_issue_process
64
+ ON agent_iterations(trace_id, issue_process_id, iteration_index);
65
+ `;
66
+ /**
67
+ * Open (or create) a SQLite database at `path` and return a typed
68
+ * `Storage` handle. Pass `:memory:` for tests — the in-memory db
69
+ * lives for the lifetime of the returned handle.
70
+ *
71
+ * INSERTs use `ON CONFLICT ... DO UPDATE` so retried emits (e.g. the
72
+ * OTel SDK retrying a flush) don't blow up the receiver — last writer
73
+ * wins on (trace_id, span_id).
74
+ */
75
+ export function createStorage(path) {
76
+ const db = new DatabaseSync(path);
77
+ db.exec(SCHEMA);
78
+ const insertDrain = db.prepare(`
79
+ INSERT INTO drains (
80
+ trace_id, span_id, start_time_unix_nano, end_time_unix_nano,
81
+ attempts, opened, hitl, errored, status_code, status_message
82
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
83
+ ON CONFLICT (trace_id, span_id) DO UPDATE SET
84
+ start_time_unix_nano = excluded.start_time_unix_nano,
85
+ end_time_unix_nano = excluded.end_time_unix_nano,
86
+ attempts = excluded.attempts,
87
+ opened = excluded.opened,
88
+ hitl = excluded.hitl,
89
+ errored = excluded.errored,
90
+ status_code = excluded.status_code,
91
+ status_message = excluded.status_message
92
+ `);
93
+ const insertIssueProcess = db.prepare(`
94
+ INSERT INTO issue_processes (
95
+ trace_id, span_id, parent_span_id, issue_identifier, issue_id,
96
+ branch, outcome_kind, outcome_detail,
97
+ start_time_unix_nano, end_time_unix_nano, status_code, status_message
98
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
99
+ ON CONFLICT (trace_id, span_id) DO UPDATE SET
100
+ parent_span_id = excluded.parent_span_id,
101
+ issue_identifier = excluded.issue_identifier,
102
+ issue_id = excluded.issue_id,
103
+ branch = excluded.branch,
104
+ outcome_kind = excluded.outcome_kind,
105
+ outcome_detail = excluded.outcome_detail,
106
+ start_time_unix_nano = excluded.start_time_unix_nano,
107
+ end_time_unix_nano = excluded.end_time_unix_nano,
108
+ status_code = excluded.status_code,
109
+ status_message = excluded.status_message
110
+ `);
111
+ const insertRawSpan = db.prepare(`
112
+ INSERT INTO raw_spans (trace_id, span_id, name, payload)
113
+ VALUES (?, ?, ?, ?)
114
+ ON CONFLICT (trace_id, span_id) DO UPDATE SET
115
+ name = excluded.name,
116
+ payload = excluded.payload
117
+ `);
118
+ const insertAgentIteration = db.prepare(`
119
+ INSERT INTO agent_iterations (
120
+ trace_id, id, issue_process_id, iteration_index,
121
+ started_at, ended_at, sandcastle_run_id, exit_status
122
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
123
+ ON CONFLICT (trace_id, id) DO UPDATE SET
124
+ issue_process_id = excluded.issue_process_id,
125
+ iteration_index = excluded.iteration_index,
126
+ started_at = excluded.started_at,
127
+ ended_at = excluded.ended_at,
128
+ sandcastle_run_id = excluded.sandcastle_run_id,
129
+ exit_status = excluded.exit_status
130
+ `);
131
+ // Two list variants instead of one with conditional SQL — keeps
132
+ // each prepared statement static.
133
+ const listAll = db.prepare(`
134
+ SELECT
135
+ trace_id, span_id, parent_span_id, issue_identifier, issue_id,
136
+ branch, outcome_kind, outcome_detail,
137
+ start_time_unix_nano, end_time_unix_nano, status_code, status_message,
138
+ inserted_at
139
+ FROM issue_processes
140
+ ORDER BY inserted_at DESC, span_id DESC
141
+ LIMIT ?
142
+ `);
143
+ const listByTrace = db.prepare(`
144
+ SELECT
145
+ trace_id, span_id, parent_span_id, issue_identifier, issue_id,
146
+ branch, outcome_kind, outcome_detail,
147
+ start_time_unix_nano, end_time_unix_nano, status_code, status_message,
148
+ inserted_at
149
+ FROM issue_processes
150
+ WHERE trace_id = ?
151
+ ORDER BY inserted_at DESC, span_id DESC
152
+ LIMIT ?
153
+ `);
154
+ const getProcessStmt = db.prepare(`
155
+ SELECT
156
+ trace_id, span_id, parent_span_id, issue_identifier, issue_id,
157
+ branch, outcome_kind, outcome_detail,
158
+ start_time_unix_nano, end_time_unix_nano, status_code, status_message,
159
+ inserted_at
160
+ FROM issue_processes
161
+ WHERE trace_id = ? AND span_id = ?
162
+ `);
163
+ const listIterations = db.prepare(`
164
+ SELECT
165
+ trace_id, id, issue_process_id, iteration_index,
166
+ started_at, ended_at, sandcastle_run_id, exit_status,
167
+ inserted_at
168
+ FROM agent_iterations
169
+ WHERE trace_id = ? AND issue_process_id = ?
170
+ ORDER BY iteration_index ASC
171
+ `);
172
+ const saveDrain = (d) => {
173
+ 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);
174
+ };
175
+ const saveIssueProcess = (p) => {
176
+ insertIssueProcess.run(p.traceId, p.spanId, p.parentSpanId, p.issueIdentifier, p.issueId, p.branch, p.outcomeKind, p.outcomeDetail, p.startTimeUnixNano, p.endTimeUnixNano, asInt(p.statusCode), p.statusMessage);
177
+ };
178
+ const saveAgentIteration = (a) => {
179
+ insertAgentIteration.run(a.traceId, a.spanId, a.issueProcessSpanId, asInt(a.iterationIndex), a.startTimeUnixNano, a.endTimeUnixNano, a.sandcastleRunId, a.exitStatus);
180
+ };
181
+ const saveRawSpan = (r) => {
182
+ insertRawSpan.run(r.traceId, r.spanId, r.name, r.payload);
183
+ };
184
+ const listIssueProcesses = (filter) => {
185
+ const limit = filter?.limit ?? 100;
186
+ const rows = filter?.traceId
187
+ ? listByTrace.all(filter.traceId, limit)
188
+ : listAll.all(limit);
189
+ return rows.map(rowToIssueProcess);
190
+ };
191
+ const getIssueProcess = (traceId, spanId) => {
192
+ const row = getProcessStmt.get(traceId, spanId);
193
+ return row ? rowToIssueProcess(row) : undefined;
194
+ };
195
+ const listAgentIterations = (traceId, issueProcessSpanId) => {
196
+ const rows = listIterations.all(traceId, issueProcessSpanId);
197
+ return rows.map(rowToAgentIteration);
198
+ };
199
+ /**
200
+ * VA-389: phase spans (review, pushBranch, openPullRequest) live in
201
+ * `raw_spans` as JSON. SQLite's `json_extract` lets us pull
202
+ * parentSpanId + start/end without re-projecting the payload at
203
+ * insert time. The names allowlist becomes a comma-bind so each
204
+ * call is one prepared statement; we don't precompute a fixed
205
+ * statement because the allowlist could grow per slice.
206
+ */
207
+ const listPhaseSpans = (traceId, issueProcessSpanId, names) => {
208
+ if (names.length === 0)
209
+ return [];
210
+ const placeholders = names.map(() => "?").join(", ");
211
+ const sql = `
212
+ SELECT
213
+ trace_id,
214
+ span_id,
215
+ name,
216
+ json_extract(payload, '$.parentSpanId') AS parent_span_id,
217
+ json_extract(payload, '$.startTimeUnixNano') AS start_time_unix_nano,
218
+ json_extract(payload, '$.endTimeUnixNano') AS end_time_unix_nano
219
+ FROM raw_spans
220
+ WHERE trace_id = ?
221
+ AND json_extract(payload, '$.parentSpanId') = ?
222
+ AND name IN (${placeholders})
223
+ ORDER BY start_time_unix_nano ASC
224
+ `;
225
+ const rows = db
226
+ .prepare(sql)
227
+ .all(traceId, issueProcessSpanId, ...names);
228
+ return rows.map(rowToPhaseSpan);
229
+ };
230
+ const close = () => {
231
+ db.close();
232
+ };
233
+ return {
234
+ saveDrain,
235
+ saveIssueProcess,
236
+ saveAgentIteration,
237
+ saveRawSpan,
238
+ listIssueProcesses,
239
+ getIssueProcess,
240
+ listAgentIterations,
241
+ listPhaseSpans,
242
+ close,
243
+ };
244
+ }
245
+ // node:sqlite's bind types are TEXT/INTEGER/BLOB/REAL/null. `undefined`
246
+ // throws; coerce to `null` so an "unset counter" column round-trips
247
+ // cleanly.
248
+ function asInt(n) {
249
+ return n === null || n === undefined ? null : n;
250
+ }
251
+ function rowToIssueProcess(row) {
252
+ const r = row;
253
+ return {
254
+ traceId: String(r.trace_id ?? ""),
255
+ spanId: String(r.span_id ?? ""),
256
+ parentSpanId: nullableStr(r.parent_span_id),
257
+ issueIdentifier: String(r.issue_identifier ?? ""),
258
+ issueId: nullableStr(r.issue_id),
259
+ branch: nullableStr(r.branch),
260
+ outcomeKind: nullableStr(r.outcome_kind),
261
+ outcomeDetail: nullableStr(r.outcome_detail),
262
+ startTimeUnixNano: String(r.start_time_unix_nano ?? ""),
263
+ endTimeUnixNano: String(r.end_time_unix_nano ?? ""),
264
+ statusCode: nullableNum(r.status_code),
265
+ statusMessage: nullableStr(r.status_message),
266
+ insertedAt: String(r.inserted_at ?? ""),
267
+ };
268
+ }
269
+ function rowToAgentIteration(row) {
270
+ const r = row;
271
+ return {
272
+ traceId: String(r.trace_id ?? ""),
273
+ spanId: String(r.id ?? ""),
274
+ issueProcessSpanId: nullableStr(r.issue_process_id),
275
+ iterationIndex: Number(r.iteration_index ?? 0),
276
+ startTimeUnixNano: String(r.started_at ?? ""),
277
+ endTimeUnixNano: String(r.ended_at ?? ""),
278
+ sandcastleRunId: nullableStr(r.sandcastle_run_id),
279
+ exitStatus: nullableStr(r.exit_status),
280
+ insertedAt: String(r.inserted_at ?? ""),
281
+ };
282
+ }
283
+ function rowToPhaseSpan(row) {
284
+ const r = row;
285
+ return {
286
+ traceId: String(r.trace_id ?? ""),
287
+ spanId: String(r.span_id ?? ""),
288
+ parentSpanId: nullableStr(r.parent_span_id),
289
+ name: String(r.name ?? ""),
290
+ startTimeUnixNano: String(r.start_time_unix_nano ?? ""),
291
+ endTimeUnixNano: String(r.end_time_unix_nano ?? ""),
292
+ };
293
+ }
294
+ function nullableStr(v) {
295
+ if (v === null || v === undefined)
296
+ return null;
297
+ return String(v);
298
+ }
299
+ function nullableNum(v) {
300
+ if (v === null || v === undefined)
301
+ return null;
302
+ const n = Number(v);
303
+ return Number.isFinite(n) ? n : null;
304
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * VA-386: minimal SSR HTML for the dashboard list view. No framework,
3
+ * no client JS — slice 1 just needs one row per issue process with
4
+ * its identifier and outcome (per AC). VA-389 adds the per-issue
5
+ * detail pane (phase timeline + agent iterations).
6
+ *
7
+ * `escapeHtml` is the only XSS guard between SQLite and the rendered
8
+ * page — every dynamic field passes through it.
9
+ */
10
+ const SHARED_STYLE = `
11
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
12
+ background: #0e0e10; color: #e5e5e5; margin: 0; padding: 24px; }
13
+ a { color: #93c5fd; text-decoration: none; }
14
+ a:hover { text-decoration: underline; }
15
+ h1 { font-size: 16px; font-weight: 600; margin: 0 0 16px; }
16
+ h2 { font-size: 13px; font-weight: 500; color: #9ca3af;
17
+ margin: 24px 0 8px; text-transform: uppercase; letter-spacing: 0.05em; }
18
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
19
+ th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a2a2e; }
20
+ th { color: #9ca3af; font-weight: 500; }
21
+ .outcome { font-weight: 600; }
22
+ .outcome-opened { color: #4ade80; }
23
+ .outcome-hitl { color: #facc15; }
24
+ .outcome-reverted { color: #fb923c; }
25
+ .outcome-errored { color: #f87171; }
26
+ .outcome-pending { color: #9ca3af; }
27
+ .empty { color: #9ca3af; padding: 24px; text-align: center; }
28
+ .id { color: #93c5fd; }
29
+ .detail { color: #d4d4d8; }
30
+ .muted { color: #9ca3af; }
31
+ code { background: #1f2937; padding: 1px 6px; border-radius: 3px; }
32
+ `;
33
+ export function renderListView(rows) {
34
+ const tableBody = rows.length === 0
35
+ ? `<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>`
36
+ : rows.map(renderRow).join("");
37
+ return `<!doctype html>
38
+ <html lang="en">
39
+ <head>
40
+ <meta charset="utf-8" />
41
+ <title>runway dashboard</title>
42
+ <style>${SHARED_STYLE}</style>
43
+ </head>
44
+ <body>
45
+ <h1>runway · issue processes</h1>
46
+ <table>
47
+ <thead>
48
+ <tr>
49
+ <th>Issue</th>
50
+ <th>Outcome</th>
51
+ <th>Detail</th>
52
+ <th>Seen at</th>
53
+ </tr>
54
+ </thead>
55
+ <tbody>${tableBody}</tbody>
56
+ </table>
57
+ </body>
58
+ </html>`;
59
+ }
60
+ function renderRow(r) {
61
+ const kind = r.outcomeKind ?? "pending";
62
+ const outcomeCls = `outcome outcome-${escapeHtml(kind)}`;
63
+ const href = `/issue/${encodeURIComponent(r.traceId)}/${encodeURIComponent(r.spanId)}`;
64
+ return `<tr>
65
+ <td class="id"><a href="${escapeHtml(href)}">${escapeHtml(r.issueIdentifier)}</a></td>
66
+ <td class="${outcomeCls}">${escapeHtml(kind)}</td>
67
+ <td class="detail">${escapeHtml(r.outcomeDetail ?? "")}</td>
68
+ <td>${escapeHtml(r.insertedAt)}</td>
69
+ </tr>`;
70
+ }
71
+ const FINALIZE_PHASE_NAMES = ["pushBranch", "openPullRequest"];
72
+ /**
73
+ * VA-389: render the per-issue detail page. The phase timeline is the
74
+ * core of the page — it shows implement / review / finalize as a
75
+ * horizontal bar with durations and per-iteration expansion for
76
+ * implement. Phases without timing data (e.g. a HITL run that never
77
+ * reached finalize) are silently skipped.
78
+ */
79
+ export function renderDetailView(vm) {
80
+ const phases = computePhases(vm);
81
+ const phaseTimeline = renderPhaseTimeline(phases);
82
+ const phaseTable = renderPhaseTable(phases, vm.iterations);
83
+ const ip = vm.issueProcess;
84
+ const kind = ip.outcomeKind ?? "pending";
85
+ const outcomeCls = `outcome outcome-${escapeHtml(kind)}`;
86
+ return `<!doctype html>
87
+ <html lang="en">
88
+ <head>
89
+ <meta charset="utf-8" />
90
+ <title>${escapeHtml(ip.issueIdentifier)} · runway dashboard</title>
91
+ <style>${SHARED_STYLE}
92
+ .breadcrumb { color: #9ca3af; margin-bottom: 16px; font-size: 12px; }
93
+ .meta { margin: 4px 0 16px; }
94
+ .meta .label { color: #9ca3af; margin-right: 4px; }
95
+ .timeline { position: relative; height: 28px; background: #18181b;
96
+ border-radius: 4px; margin: 12px 0 4px; overflow: hidden; }
97
+ .timeline .phase { position: absolute; top: 0; bottom: 0;
98
+ display: flex; align-items: center; padding: 0 8px;
99
+ font-size: 11px; color: #0e0e10; font-weight: 600;
100
+ white-space: nowrap; overflow: hidden; }
101
+ .timeline .phase-implement { background: #60a5fa; }
102
+ .timeline .phase-review { background: #facc15; }
103
+ .timeline .phase-finalize { background: #4ade80; }
104
+ .timeline-legend { font-size: 11px; color: #9ca3af; margin-bottom: 16px; }
105
+ .iter-row td { background: #16161a; padding-left: 32px; }
106
+ .iter-status { font-weight: 600; }
107
+ .iter-status-done { color: #4ade80; }
108
+ .iter-status-blocked { color: #f87171; }
109
+ .iter-status-continue { color: #facc15; }
110
+ .iter-status-missing { color: #9ca3af; }
111
+ </style>
112
+ </head>
113
+ <body>
114
+ <div class="breadcrumb"><a href="/">← all issue processes</a></div>
115
+ <h1>${escapeHtml(ip.issueIdentifier)} · <span class="${outcomeCls}">${escapeHtml(kind)}</span></h1>
116
+ <div class="meta">
117
+ <div><span class="label">branch:</span><code>${escapeHtml(ip.branch ?? "—")}</code></div>
118
+ <div><span class="label">detail:</span><span class="detail">${escapeHtml(ip.outcomeDetail ?? "")}</span></div>
119
+ <div><span class="label">seen at:</span>${escapeHtml(ip.insertedAt)}</div>
120
+ </div>
121
+
122
+ <h2>Phase timeline</h2>
123
+ ${phaseTimeline}
124
+ ${phaseTable}
125
+ </body>
126
+ </html>`;
127
+ }
128
+ /**
129
+ * Assemble the three phase rows from the typed iteration table
130
+ * (implement) and the raw_spans phase lookup (review, finalize).
131
+ * Returns the phases that actually have timing data — a hitl issue
132
+ * that never reached review still renders cleanly.
133
+ */
134
+ function computePhases(vm) {
135
+ const phases = [];
136
+ if (vm.iterations.length > 0) {
137
+ const startNs = minBigInt(vm.iterations.map((i) => parseNs(i.startTimeUnixNano)));
138
+ const endNs = maxBigInt(vm.iterations.map((i) => parseNs(i.endTimeUnixNano)));
139
+ phases.push({ name: "implement", startNs, endNs });
140
+ }
141
+ const review = vm.phaseSpans.find((p) => p.name === "review");
142
+ if (review) {
143
+ phases.push({
144
+ name: "review",
145
+ startNs: parseNs(review.startTimeUnixNano),
146
+ endNs: parseNs(review.endTimeUnixNano),
147
+ });
148
+ }
149
+ const finalize = vm.phaseSpans.filter((p) => FINALIZE_PHASE_NAMES.includes(p.name));
150
+ if (finalize.length > 0) {
151
+ const startNs = minBigInt(finalize.map((p) => parseNs(p.startTimeUnixNano)));
152
+ const endNs = maxBigInt(finalize.map((p) => parseNs(p.endTimeUnixNano)));
153
+ phases.push({ name: "finalize", startNs, endNs });
154
+ }
155
+ return phases;
156
+ }
157
+ function renderPhaseTimeline(phases) {
158
+ if (phases.length === 0) {
159
+ return `<div class="empty">No phase spans recorded yet.</div>`;
160
+ }
161
+ const totalStart = minBigInt(phases.map((p) => p.startNs));
162
+ const totalEnd = maxBigInt(phases.map((p) => p.endNs));
163
+ const totalSpanNs = totalEnd - totalStart;
164
+ // Guard against a single-point timeline (all phases collapsed).
165
+ // Render each phase at fixed width so the bar still appears.
166
+ const denom = totalSpanNs === 0n ? 1n : totalSpanNs;
167
+ const bars = phases
168
+ .map((p) => {
169
+ const leftPct = pct(p.startNs - totalStart, denom);
170
+ const widthPct = pct(p.endNs - p.startNs, denom);
171
+ const safeWidth = widthPct < 1 ? 1 : widthPct;
172
+ return `<div class="phase phase-${p.name}" style="left:${leftPct}%;width:${safeWidth}%">${p.name} · ${escapeHtml(formatDuration(p.endNs - p.startNs))}</div>`;
173
+ })
174
+ .join("");
175
+ return `
176
+ <div class="timeline">${bars}</div>
177
+ <div class="timeline-legend">${escapeHtml(formatTimestamp(totalStart))} → ${escapeHtml(formatTimestamp(totalEnd))} (total ${escapeHtml(formatDuration(totalSpanNs))})</div>`;
178
+ }
179
+ function renderPhaseTable(phases, iterations) {
180
+ if (phases.length === 0) {
181
+ return "";
182
+ }
183
+ const rows = phases
184
+ .map((p) => {
185
+ const dur = formatDuration(p.endNs - p.startNs);
186
+ const start = formatTimestamp(p.startNs);
187
+ const end = formatTimestamp(p.endNs);
188
+ const head = `<tr>
189
+ <td>${escapeHtml(p.name)}</td>
190
+ <td>${escapeHtml(start)}</td>
191
+ <td>${escapeHtml(end)}</td>
192
+ <td>${escapeHtml(dur)}</td>
193
+ </tr>`;
194
+ if (p.name === "implement") {
195
+ return head + iterations.map(renderIterationRow).join("");
196
+ }
197
+ return head;
198
+ })
199
+ .join("");
200
+ return `<table>
201
+ <thead>
202
+ <tr>
203
+ <th>Phase</th>
204
+ <th>Start</th>
205
+ <th>End</th>
206
+ <th>Duration</th>
207
+ </tr>
208
+ </thead>
209
+ <tbody>${rows}</tbody>
210
+ </table>`;
211
+ }
212
+ function renderIterationRow(it) {
213
+ const status = it.exitStatus ?? "missing";
214
+ const statusCls = `iter-status iter-status-${escapeHtml(status)}`;
215
+ const dur = formatDuration(parseNs(it.endTimeUnixNano) - parseNs(it.startTimeUnixNano));
216
+ return `<tr class="iter-row">
217
+ <td class="muted">↳ iter ${it.iterationIndex}</td>
218
+ <td colspan="2"><span class="${statusCls}">${escapeHtml(status)}</span></td>
219
+ <td>${escapeHtml(dur)}</td>
220
+ </tr>`;
221
+ }
222
+ /**
223
+ * Parse an OTLP int64 unix-nanos string into a BigInt. The OTLP wire
224
+ * keeps these as strings so we don't lose precision through `number`;
225
+ * the dashboard never displays sub-microsecond detail so we drop back
226
+ * to `Number` only at format time.
227
+ */
228
+ function parseNs(ns) {
229
+ if (!ns)
230
+ return 0n;
231
+ try {
232
+ return BigInt(ns);
233
+ }
234
+ catch {
235
+ return 0n;
236
+ }
237
+ }
238
+ function minBigInt(xs) {
239
+ let m = xs[0] ?? 0n;
240
+ for (const x of xs)
241
+ if (x < m)
242
+ m = x;
243
+ return m;
244
+ }
245
+ function maxBigInt(xs) {
246
+ let m = xs[0] ?? 0n;
247
+ for (const x of xs)
248
+ if (x > m)
249
+ m = x;
250
+ return m;
251
+ }
252
+ function pct(num, denom) {
253
+ if (denom === 0n)
254
+ return 0;
255
+ // Multiply by 10000 in BigInt land first, then drop to Number — keeps
256
+ // sub-percent precision on long traces without overflowing.
257
+ const scaled = Number((num * 10000n) / denom);
258
+ return scaled / 100;
259
+ }
260
+ function formatDuration(ns) {
261
+ if (ns <= 0n)
262
+ return "0ms";
263
+ const ms = Number(ns / 1000000n);
264
+ if (ms < 1000)
265
+ return `${ms}ms`;
266
+ const s = ms / 1000;
267
+ if (s < 60)
268
+ return `${s.toFixed(2)}s`;
269
+ const m = Math.floor(s / 60);
270
+ const rem = s - m * 60;
271
+ return `${m}m${rem.toFixed(1)}s`;
272
+ }
273
+ function formatTimestamp(ns) {
274
+ if (ns <= 0n)
275
+ return "—";
276
+ const ms = Number(ns / 1000000n);
277
+ return new Date(ms).toISOString();
278
+ }
279
+ const ESC = {
280
+ "&": "&amp;",
281
+ "<": "&lt;",
282
+ ">": "&gt;",
283
+ '"': "&quot;",
284
+ "'": "&#39;",
285
+ };
286
+ export function escapeHtml(input) {
287
+ return input.replace(/[&<>"']/g, (c) => ESC[c] ?? c);
288
+ }
package/dist/finalize.js CHANGED
@@ -20,7 +20,10 @@ export const finalize = (issue, deps, branch) => Effect.gen(function* () {
20
20
  yield* linear.comment(issue.id, `Runway opened a PR for review: ${prUrl}`);
21
21
  return { kind: "opened", detail: prUrl };
22
22
  });
23
- function buildPrBody(issue) {
23
+ // VA-412: `Closes` (not `Refs`) is the Linear GitHub-integration magic
24
+ // word that auto-transitions the issue to Done on PR merge. `Refs`
25
+ // only attaches the PR to the issue and leaves it stuck In Progress.
26
+ export function buildPrBody(issue) {
24
27
  return [
25
28
  `Runway-generated PR for **${issue.identifier} — ${issue.title}**.`,
26
29
  "",
@@ -30,6 +33,6 @@ function buildPrBody(issue) {
30
33
  "",
31
34
  issue.description || "(no description)",
32
35
  "",
33
- `Refs ${issue.identifier}`,
36
+ `Closes ${issue.identifier}`,
34
37
  ].join("\n");
35
38
  }
package/dist/implement.js CHANGED
@@ -61,15 +61,30 @@ export const runImplementLoop = (issue, deps, branch) => Effect.gen(function* ()
61
61
  // toward the same code paths until corrected.
62
62
  priorReviewFeedback: priorFeedback,
63
63
  }));
64
- implementResult = yield* runSandcastle({
65
- agent: claudeCode("claude-opus-4-7"),
66
- sandbox: docker({ env: dockerEnv(config) }),
67
- cwd,
68
- prompt: implementPrompt,
69
- branchStrategy: { type: "branch", branch },
70
- maxIterations: 1,
71
- completionSignal: [...IMPL_COMPLETION_SIGNALS],
72
- name: `impl-${issue.identifier}-iter-${iter}`,
64
+ const sandcastleRunId = `impl-${issue.identifier}-iter-${iter}`;
65
+ // VA-389: parse the verdict inside the span scope so the
66
+ // dashboard projector can read `runway.iteration.exit_status`
67
+ // off the `impl-iter-N` span without joining against the agent
68
+ // log. `runway.iteration.sandcastle_run_id` lets a future link
69
+ // to the per-run output (raw stdout, etc.) without needing to
70
+ // rebuild the name from issue identifier + iteration.
71
+ implementResult = yield* Effect.gen(function* () {
72
+ const result = yield* runSandcastle({
73
+ agent: claudeCode("claude-opus-4-7"),
74
+ sandbox: docker({ env: dockerEnv(config) }),
75
+ cwd,
76
+ prompt: implementPrompt,
77
+ branchStrategy: { type: "branch", branch },
78
+ maxIterations: config.implTurns,
79
+ completionSignal: [...IMPL_COMPLETION_SIGNALS],
80
+ name: sandcastleRunId,
81
+ });
82
+ const verdict = parseImplVerdict(result);
83
+ yield* Effect.annotateCurrentSpan({
84
+ "runway.iteration.exit_status": verdict.kind,
85
+ "runway.iteration.sandcastle_run_id": sandcastleRunId,
86
+ });
87
+ return result;
73
88
  }).pipe(Effect.withSpan(`impl-iter-${iter}`, {
74
89
  attributes: {
75
90
  "runway.iteration": iter,