@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.
- package/README.md +1 -1
- package/dist/commands/run.js +34 -4
- package/dist/config.js +37 -4
- package/dist/dashboard/otlp.js +55 -0
- package/dist/dashboard/projector.js +127 -0
- package/dist/dashboard/server.js +233 -0
- package/dist/dashboard/storage.js +304 -0
- package/dist/dashboard/views.js +288 -0
- package/dist/finalize.js +5 -2
- package/dist/implement.js +24 -9
- package/dist/linear.js +80 -4
- package/dist/orchestrator.js +45 -19
- package/package.json +2 -2
|
@@ -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
|
+
"&": "&",
|
|
281
|
+
"<": "<",
|
|
282
|
+
">": ">",
|
|
283
|
+
'"': """,
|
|
284
|
+
"'": "'",
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,
|