@valescoagency/runway 0.7.1 → 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 CHANGED
@@ -392,7 +392,7 @@ These are tractable, just not v1.
392
392
 
393
393
  ## Status
394
394
 
395
- 0.7.1 — production-shaped and dogfooded against live Linear queues.
395
+ 0.8.0 — production-shaped and dogfooded against live Linear queues.
396
396
  The end-to-end pipeline (init → run → review → PR) is stable; surface
397
397
  may still shift as the orchestrator's policy and iteration mechanics
398
398
  mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
@@ -49,6 +49,25 @@ export function parseRunArgs(argv) {
49
49
  else if (a?.startsWith("--allow-paths=")) {
50
50
  collectAllow(a.slice("--allow-paths=".length));
51
51
  }
52
+ else if (a === "--impl-turns") {
53
+ const v = argv[i + 1];
54
+ if (!v)
55
+ throw new Error("--impl-turns requires a number");
56
+ const n = Number.parseInt(v, 10);
57
+ if (!Number.isFinite(n) || n <= 0) {
58
+ throw new Error(`--impl-turns must be a positive integer, got "${v}"`);
59
+ }
60
+ opts.implTurns = n;
61
+ i += 1;
62
+ }
63
+ else if (a?.startsWith("--impl-turns=")) {
64
+ const v = a.slice("--impl-turns=".length);
65
+ const n = Number.parseInt(v, 10);
66
+ if (!Number.isFinite(n) || n <= 0) {
67
+ throw new Error(`--impl-turns must be a positive integer, got "${v}"`);
68
+ }
69
+ opts.implTurns = n;
70
+ }
52
71
  else if (a === "--help" || a === "-h") {
53
72
  printRunUsage();
54
73
  process.exit(0);
@@ -79,6 +98,10 @@ OPTIONS
79
98
  Example: --allow-paths='.github/workflows/**' lets
80
99
  the agent touch CI for issues whose AC require it.
81
100
  Repeatable; pairs with .runway/policy.yml.
101
+ --impl-turns N Sandcastle inner turn budget for each impl phase
102
+ (how many turns the Claude agent gets per attempt
103
+ before it has to signal IMPL: DONE / BLOCKED).
104
+ Overrides RUNWAY_IMPL_TURNS. Default: 3.
82
105
  --help, -h Show this help.
83
106
 
84
107
  ENVIRONMENT
@@ -93,7 +116,12 @@ ENVIRONMENT
93
116
  RUNWAY_IN_PROGRESS_STATUS default "In Progress"
94
117
  RUNWAY_IN_REVIEW_STATUS default "In Review"
95
118
  RUNWAY_HITL_LABEL default "ready-for-human"
96
- RUNWAY_MAX_ITERATIONS default 5
119
+ RUNWAY_MAX_ITERATIONS default 5 — outer impl re-prompt loop
120
+ (only fires when the agent fails to
121
+ signal IMPL: DONE / BLOCKED at all)
122
+ RUNWAY_IMPL_TURNS default 3 — sandcastle inner turn
123
+ budget per impl phase. Overridden by
124
+ --impl-turns.
97
125
  `);
98
126
  }
99
127
  export async function runCommand(argv) {
@@ -120,9 +148,11 @@ export async function runCommand(argv) {
120
148
  const MainLayer = Layer.mergeAll(ConfigLive, TelemetryLive, LoggerLive);
121
149
  const program = Effect.gen(function* () {
122
150
  const baseConfig = yield* ConfigTag;
123
- const config = opts.project
124
- ? { ...baseConfig, linearProject: opts.project }
125
- : baseConfig;
151
+ const config = {
152
+ ...baseConfig,
153
+ ...(opts.project ? { linearProject: opts.project } : {}),
154
+ ...(opts.implTurns !== undefined ? { implTurns: opts.implTurns } : {}),
155
+ };
126
156
  const scope = config.linearProject
127
157
  ? `team ${config.linearTeam} / project ${config.linearProject}`
128
158
  : `team ${config.linearTeam}`;
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Config as EConfig, Context, Effect, Layer, Option, } from "effect";
1
+ import { Config as EConfig, ConfigProvider, Context, Effect, Layer, Option, } from "effect";
2
2
  /**
3
3
  * VA-359: Effect.Config program that reads every var from
4
4
  * `process.env`, applies defaults, and yields a typed `RunwayConfig`.
@@ -20,6 +20,10 @@ const configEffect = EConfig.all({
20
20
  message: "RUNWAY_MAX_ITERATIONS must be a positive integer",
21
21
  validation: (n) => n > 0,
22
22
  })),
23
+ implTurns: EConfig.integer("RUNWAY_IMPL_TURNS").pipe(EConfig.withDefault(3), EConfig.validate({
24
+ message: "RUNWAY_IMPL_TURNS must be a positive integer",
25
+ validation: (n) => n > 0,
26
+ })),
23
27
  commentAuthorAllowlist: EConfig.option(EConfig.string("RUNWAY_COMMENT_AUTHOR_ALLOWLIST")),
24
28
  }).pipe(Effect.map((raw) => ({
25
29
  linearApiKey: raw.linearApiKey,
@@ -32,6 +36,7 @@ const configEffect = EConfig.all({
32
36
  inReviewStatus: raw.inReviewStatus,
33
37
  hitlLabel: raw.hitlLabel,
34
38
  maxIterations: raw.maxIterations,
39
+ implTurns: raw.implTurns,
35
40
  commentAuthorAllowlist: Option.getOrUndefined(raw.commentAuthorAllowlist)
36
41
  ?.split(",")
37
42
  .map((s) => s.trim())
@@ -44,11 +49,35 @@ const configEffect = EConfig.all({
44
49
  */
45
50
  export class ConfigTag extends Context.Tag("RunwayConfig")() {
46
51
  }
52
+ // VA-411: Build a ConfigProvider from an explicit env-like object so
53
+ // tests (and any other caller that needs hermetic isolation from the
54
+ // host environment) can drive config loading without mutating
55
+ // `process.env`. The path delimiter matches Effect's `fromEnv` so a
56
+ // `Map(process.env)` and a real env behave the same for our flat
57
+ // `RUNWAY_*` keys.
58
+ const providerFromEnv = (env) => {
59
+ const map = new Map();
60
+ for (const [k, v] of Object.entries(env)) {
61
+ if (typeof v === "string")
62
+ map.set(k, v);
63
+ }
64
+ return ConfigProvider.fromMap(map, { pathDelim: "_" });
65
+ };
66
+ /**
67
+ * VA-411: Build a `ConfigLive` Layer that resolves env vars from an
68
+ * explicit env map instead of `process.env`. Production callers use
69
+ * the default `ConfigLive` (which reads `process.env`); tests pass
70
+ * an explicit map so host env leaks (e.g. an `.envrc` that exports
71
+ * `RUNWAY_LINEAR_PROJECT`) can't break assertions.
72
+ */
73
+ export function makeConfigLive(env = process.env) {
74
+ return Layer.effect(ConfigTag, configEffect.pipe(Effect.withConfigProvider(providerFromEnv(env))));
75
+ }
47
76
  /**
48
77
  * Layer that resolves env vars once and makes the result available
49
78
  * via `ConfigTag` for the rest of the program.
50
79
  */
51
- export const ConfigLive = Layer.effect(ConfigTag, configEffect);
80
+ export const ConfigLive = makeConfigLive();
52
81
  /**
53
82
  * Sync helper for non-Effect callers (`runway doctor` early
54
83
  * validation, the `runway run` CLI bootstrap before it enters
@@ -56,7 +85,11 @@ export const ConfigLive = Layer.effect(ConfigTag, configEffect);
56
85
  * the `ConfigError` on a missing/invalid env var — the caller's
57
86
  * existing `catch (err) { … errMsg(err) … }` shape rendered the
58
87
  * Zod issue the same way it'll now render the Effect ConfigError.
88
+ *
89
+ * VA-411: Accepts an optional `env` map so tests can drive the
90
+ * loader hermetically. Defaults to `process.env` for production
91
+ * callers.
59
92
  */
60
- export function loadConfig() {
61
- return Effect.runSync(configEffect);
93
+ export function loadConfig(env = process.env) {
94
+ return Effect.runSync(configEffect.pipe(Effect.withConfigProvider(providerFromEnv(env))));
62
95
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * OTLP HTTP/JSON wire types — just enough of the
3
+ * `opentelemetry.proto.trace.v1` shape to project runway's spans. The
4
+ * full proto schema is much larger; we model only the fields we read.
5
+ *
6
+ * Reference: https://opentelemetry.io/docs/specs/otlp/#otlphttp-request
7
+ * The JSON encoding for int64 fields (timestamps, intValue) is
8
+ * `string` per the proto3 JSON mapping — we keep them as strings so
9
+ * we never lose precision through `number`.
10
+ */
11
+ /**
12
+ * Coerce an OTLP attribute value to a plain JS scalar. We collapse
13
+ * the typed wire variants (`stringValue` / `intValue` / `boolValue` /
14
+ * `doubleValue`) into one return path so callers downstream can
15
+ * pattern-match without knowing the OTLP shape.
16
+ *
17
+ * `intValue` round-trips as a string to preserve int64 precision.
18
+ * Callers that want a `number` (e.g. for counters under 2^53) should
19
+ * `Number(...)` it themselves.
20
+ */
21
+ export function attrValue(attr) {
22
+ if (!attr)
23
+ return undefined;
24
+ const v = attr.value;
25
+ if (v.stringValue !== undefined)
26
+ return v.stringValue;
27
+ if (v.boolValue !== undefined)
28
+ return v.boolValue;
29
+ if (v.doubleValue !== undefined)
30
+ return v.doubleValue;
31
+ if (v.intValue !== undefined) {
32
+ // OTLP int64 — JSON form is string per proto3, but the SDK
33
+ // sometimes emits as number for small ints. Accept both.
34
+ return typeof v.intValue === "string"
35
+ ? v.intValue
36
+ : v.intValue;
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * Build a key → scalar map from an OTLP attribute list. Unknown
42
+ * variants drop silently — projector code shouldn't crash on a span
43
+ * with an attribute shape we haven't enumerated.
44
+ */
45
+ export function attrMap(attrs) {
46
+ const out = {};
47
+ if (!attrs)
48
+ return out;
49
+ for (const a of attrs) {
50
+ const v = attrValue(a);
51
+ if (v !== undefined)
52
+ out[a.key] = v;
53
+ }
54
+ return out;
55
+ }
@@ -0,0 +1,127 @@
1
+ import { attrMap, } from "./otlp.js";
2
+ const DRAIN_SPAN = "drainQueue";
3
+ const ISSUE_PROCESS_SPAN = "processIssue";
4
+ const IMPL_ITER_SPAN_RE = /^impl-iter-(\d+)$/;
5
+ /**
6
+ * Project an OTLP traces payload into typed runway rows + the raw
7
+ * escape hatch. Every span (typed or not) lands in `raw_spans`; the
8
+ * domain spans additionally project into their typed tables.
9
+ */
10
+ export function projectPayload(payload) {
11
+ const drains = [];
12
+ const issueProcesses = [];
13
+ const agentIterations = [];
14
+ const rawSpans = [];
15
+ for (const rs of payload.resourceSpans ?? []) {
16
+ collectFromResourceSpan(rs, drains, issueProcesses, agentIterations, rawSpans);
17
+ }
18
+ return { drains, issueProcesses, agentIterations, rawSpans };
19
+ }
20
+ function collectFromResourceSpan(rs, drains, issueProcesses, agentIterations, rawSpans) {
21
+ for (const ss of rs.scopeSpans ?? []) {
22
+ for (const span of ss.spans ?? []) {
23
+ rawSpans.push(projectRawSpan(span));
24
+ if (span.name === DRAIN_SPAN) {
25
+ drains.push(projectDrain(span));
26
+ }
27
+ else if (span.name === ISSUE_PROCESS_SPAN) {
28
+ const ip = projectIssueProcess(span);
29
+ if (ip)
30
+ issueProcesses.push(ip);
31
+ }
32
+ else {
33
+ const iter = projectAgentIteration(span);
34
+ if (iter)
35
+ agentIterations.push(iter);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ function projectRawSpan(span) {
41
+ return {
42
+ traceId: span.traceId,
43
+ spanId: span.spanId,
44
+ name: span.name,
45
+ payload: JSON.stringify(span),
46
+ };
47
+ }
48
+ function projectDrain(span) {
49
+ const m = attrMap(span.attributes);
50
+ return {
51
+ traceId: span.traceId,
52
+ spanId: span.spanId,
53
+ startTimeUnixNano: span.startTimeUnixNano,
54
+ endTimeUnixNano: span.endTimeUnixNano,
55
+ attempts: numAttr(m["runway.drain.attempts"]),
56
+ opened: numAttr(m["runway.drain.opened"]),
57
+ hitl: numAttr(m["runway.drain.hitl"]),
58
+ errored: numAttr(m["runway.drain.errored"]),
59
+ statusCode: span.status?.code ?? null,
60
+ statusMessage: span.status?.message ?? null,
61
+ };
62
+ }
63
+ /**
64
+ * `processIssue` rows are keyed on `runway.issue.identifier`. A span
65
+ * named `processIssue` without that attribute is dropped from the
66
+ * typed table (it still lands in `raw_spans`) — without a stable
67
+ * identifier we have nothing to render on the row, and a silently
68
+ * blank row is more confusing than no row.
69
+ */
70
+ function projectIssueProcess(span) {
71
+ const m = attrMap(span.attributes);
72
+ const identifier = strAttr(m["runway.issue.identifier"]);
73
+ if (!identifier)
74
+ return undefined;
75
+ return {
76
+ traceId: span.traceId,
77
+ spanId: span.spanId,
78
+ parentSpanId: span.parentSpanId ?? null,
79
+ issueIdentifier: identifier,
80
+ issueId: strAttr(m["runway.issue.id"]) ?? null,
81
+ branch: strAttr(m["runway.branch"]) ?? null,
82
+ outcomeKind: strAttr(m["runway.outcome.kind"]) ?? null,
83
+ outcomeDetail: strAttr(m["runway.outcome.detail"]) ?? null,
84
+ startTimeUnixNano: span.startTimeUnixNano,
85
+ endTimeUnixNano: span.endTimeUnixNano,
86
+ statusCode: span.status?.code ?? null,
87
+ statusMessage: span.status?.message ?? null,
88
+ };
89
+ }
90
+ /**
91
+ * VA-389: project a span named `impl-iter-N` into an `AgentIteration`
92
+ * row. Iteration index comes from the span name (the canonical
93
+ * classifier); `runway.iteration.exit_status` and
94
+ * `runway.iteration.sandcastle_run_id` (annotated by the implement
95
+ * loop) carry the per-iteration metadata the detail pane shows.
96
+ */
97
+ function projectAgentIteration(span) {
98
+ const match = IMPL_ITER_SPAN_RE.exec(span.name);
99
+ if (!match)
100
+ return undefined;
101
+ const idx = Number(match[1]);
102
+ if (!Number.isFinite(idx))
103
+ return undefined;
104
+ const m = attrMap(span.attributes);
105
+ return {
106
+ traceId: span.traceId,
107
+ spanId: span.spanId,
108
+ issueProcessSpanId: span.parentSpanId ?? null,
109
+ iterationIndex: idx,
110
+ startTimeUnixNano: span.startTimeUnixNano,
111
+ endTimeUnixNano: span.endTimeUnixNano,
112
+ sandcastleRunId: strAttr(m["runway.iteration.sandcastle_run_id"]) ?? null,
113
+ exitStatus: strAttr(m["runway.iteration.exit_status"]) ?? null,
114
+ };
115
+ }
116
+ function strAttr(v) {
117
+ return typeof v === "string" ? v : undefined;
118
+ }
119
+ function numAttr(v) {
120
+ if (typeof v === "number")
121
+ return v;
122
+ if (typeof v === "string") {
123
+ const n = Number(v);
124
+ return Number.isFinite(n) ? n : null;
125
+ }
126
+ return null;
127
+ }
@@ -0,0 +1,233 @@
1
+ import { createServer } from "node:http";
2
+ import { projectPayload } from "./projector.js";
3
+ import { createStorage } from "./storage.js";
4
+ import { renderDetailView, renderListView } from "./views.js";
5
+ // VA-389: phase spans we surface on the detail page's timeline.
6
+ // Anything else stays in raw_spans for debugging but isn't rendered.
7
+ const DETAIL_PHASE_NAMES = ["review", "pushBranch", "openPullRequest"];
8
+ const ISSUE_DETAIL_RE = /^\/issue\/([^/?#]+)\/([^/?#]+)\/?$/;
9
+ const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MiB — generous; a runway drain is ~kilobytes per emit.
10
+ /**
11
+ * Construct a Node HTTP server wired to the given storage. The server
12
+ * is NOT listening yet — call `.listen()` on the returned `server` or
13
+ * use `startServer` for the all-in-one helper.
14
+ */
15
+ export function buildServer(storage) {
16
+ return createServer((req, res) => {
17
+ handle(req, res, storage).catch((err) => {
18
+ writeError(res, 500, "internal_error", asMessage(err));
19
+ });
20
+ });
21
+ }
22
+ /**
23
+ * Bind and start the dashboard server. Returns a handle the caller can
24
+ * use to close it gracefully (used by tests).
25
+ */
26
+ export async function startServer(opts) {
27
+ const server = buildServer(opts.storage);
28
+ const port = opts.port ?? 4318;
29
+ const host = opts.host ?? "0.0.0.0";
30
+ await new Promise((resolve, reject) => {
31
+ server.once("error", reject);
32
+ server.listen(port, host, () => {
33
+ server.off("error", reject);
34
+ resolve();
35
+ });
36
+ });
37
+ const addr = server.address();
38
+ const boundPort = addr && typeof addr === "object" ? addr.port : port;
39
+ return {
40
+ server,
41
+ port: boundPort,
42
+ close: () => new Promise((resolve, reject) => {
43
+ server.close((err) => (err ? reject(err) : resolve()));
44
+ }),
45
+ };
46
+ }
47
+ async function handle(req, res, storage) {
48
+ // Route on method + path. Nothing fancy — slice 1 only has two
49
+ // endpoints; a router becomes worth its weight in slice 2.
50
+ const url = req.url ?? "/";
51
+ const method = req.method ?? "GET";
52
+ if (method === "POST" && url === "/v1/traces") {
53
+ await handleOtlpTraces(req, res, storage);
54
+ return;
55
+ }
56
+ if (method === "GET" && (url === "/" || url.startsWith("/?"))) {
57
+ handleListView(res, storage);
58
+ return;
59
+ }
60
+ if (method === "GET") {
61
+ const detailMatch = ISSUE_DETAIL_RE.exec(url.split("?")[0] ?? "");
62
+ if (detailMatch) {
63
+ const traceId = decodeURIComponent(detailMatch[1] ?? "");
64
+ const spanId = decodeURIComponent(detailMatch[2] ?? "");
65
+ handleDetailView(res, storage, traceId, spanId);
66
+ return;
67
+ }
68
+ }
69
+ if (method === "GET" && url === "/healthz") {
70
+ res.writeHead(200, { "content-type": "text/plain" });
71
+ res.end("ok");
72
+ return;
73
+ }
74
+ writeError(res, 404, "not_found", `no route for ${method} ${url}`);
75
+ }
76
+ /**
77
+ * Read the request body, parse as OTLP HTTP/JSON, project, and
78
+ * persist. Responds with the OTLP success shape (`{ partialSuccess:
79
+ * {} }`) per spec so the SDK exporter doesn't retry.
80
+ *
81
+ * We only accept JSON. The OTLP HTTP/protobuf variant would need a
82
+ * proto parser in this hot path; the runway exporter
83
+ * (`@opentelemetry/exporter-trace-otlp-http`) emits JSON by default,
84
+ * which is the only encoding slice 1 is on the hook for.
85
+ */
86
+ async function handleOtlpTraces(req, res, storage) {
87
+ const ct = (req.headers["content-type"] ?? "").toString().toLowerCase();
88
+ if (!ct.includes("application/json")) {
89
+ writeError(res, 415, "unsupported_media_type", `expected application/json, got "${ct}"`);
90
+ return;
91
+ }
92
+ const body = await readBody(req);
93
+ if (body === undefined) {
94
+ writeError(res, 413, "payload_too_large", `body exceeds ${MAX_BODY_BYTES} bytes`);
95
+ return;
96
+ }
97
+ let payload;
98
+ try {
99
+ payload = JSON.parse(body);
100
+ }
101
+ catch (err) {
102
+ writeError(res, 400, "invalid_json", asMessage(err));
103
+ return;
104
+ }
105
+ const projection = projectPayload(payload);
106
+ for (const d of projection.drains)
107
+ storage.saveDrain(d);
108
+ for (const p of projection.issueProcesses)
109
+ storage.saveIssueProcess(p);
110
+ for (const a of projection.agentIterations)
111
+ storage.saveAgentIteration(a);
112
+ for (const r of projection.rawSpans)
113
+ storage.saveRawSpan(r);
114
+ // OTLP success body: a small JSON envelope. Required by the spec
115
+ // even on a "we accepted everything" path so SDKs don't treat the
116
+ // empty body as a partial failure.
117
+ res.writeHead(200, { "content-type": "application/json" });
118
+ res.end(JSON.stringify({ partialSuccess: {} }));
119
+ }
120
+ function handleListView(res, storage) {
121
+ const rows = storage.listIssueProcesses({ limit: 100 });
122
+ const html = renderListView(rows);
123
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
124
+ res.end(html);
125
+ }
126
+ /**
127
+ * VA-389: per-issue detail page. Composes the issue_process row,
128
+ * iteration list, and review/finalize phase spans into a single view
129
+ * model and hands it to `renderDetailView`. Returns 404 when the
130
+ * (trace_id, span_id) pair doesn't match a known issue process — the
131
+ * dashboard never auto-creates rows from a URL.
132
+ */
133
+ function handleDetailView(res, storage, traceId, spanId) {
134
+ const ip = storage.getIssueProcess(traceId, spanId);
135
+ if (!ip) {
136
+ writeError(res, 404, "not_found", `no issue process for trace=${traceId} span=${spanId}`);
137
+ return;
138
+ }
139
+ const iterations = storage.listAgentIterations(traceId, spanId);
140
+ const phaseSpans = storage.listPhaseSpans(traceId, spanId, [
141
+ ...DETAIL_PHASE_NAMES,
142
+ ]);
143
+ const html = renderDetailView({
144
+ issueProcess: ip,
145
+ iterations,
146
+ phaseSpans,
147
+ });
148
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
149
+ res.end(html);
150
+ }
151
+ async function readBody(req) {
152
+ const chunks = [];
153
+ let total = 0;
154
+ for await (const chunk of req) {
155
+ const buf = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
156
+ total += buf.length;
157
+ if (total > MAX_BODY_BYTES)
158
+ return undefined;
159
+ chunks.push(buf);
160
+ }
161
+ return Buffer.concat(chunks).toString("utf8");
162
+ }
163
+ function writeError(res, status, code, message) {
164
+ res.writeHead(status, { "content-type": "application/json" });
165
+ res.end(JSON.stringify({ error: { code, message } }));
166
+ }
167
+ function asMessage(err) {
168
+ if (err && typeof err === "object" && "message" in err) {
169
+ const m = err.message;
170
+ if (typeof m === "string")
171
+ return m;
172
+ }
173
+ return String(err);
174
+ }
175
+ /**
176
+ * Default entry point invoked by the docker container. Resolves
177
+ * `SQLITE_PATH`, `OTLP_PORT`, and `DASHBOARD_PORT` from env, opens
178
+ * storage, and binds two listeners against the same handler — the
179
+ * OTLP receiver and the dashboard UI both speak the same set of
180
+ * routes; the dual ports are conventional, not enforced. Exits the
181
+ * process on a SIGINT/SIGTERM after closing the DB.
182
+ */
183
+ export async function main() {
184
+ const sqlitePath = process.env.SQLITE_PATH ?? "/data/runway.sqlite";
185
+ const otlpPort = parsePort("OTLP_PORT", "4318");
186
+ const dashboardPort = parsePort("DASHBOARD_PORT", "3001");
187
+ const storage = createStorage(sqlitePath);
188
+ const otlp = await startServer({ storage, port: otlpPort });
189
+ const dashboard = dashboardPort === otlpPort
190
+ ? otlp
191
+ : await startServer({ storage, port: dashboardPort });
192
+ console.log(`[runway dashboard] OTLP :${otlp.port} · dashboard :${dashboard.port}; sqlite=${sqlitePath}`);
193
+ const shutdown = async (signal) => {
194
+ console.log(`[runway dashboard] ${signal} — shutting down`);
195
+ await otlp.close();
196
+ if (dashboard !== otlp)
197
+ await dashboard.close();
198
+ storage.close();
199
+ process.exit(0);
200
+ };
201
+ process.on("SIGINT", () => void shutdown("SIGINT"));
202
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
203
+ }
204
+ function parsePort(envName, fallback) {
205
+ const raw = process.env[envName] ?? fallback;
206
+ const n = Number.parseInt(raw, 10);
207
+ if (!Number.isFinite(n) || n <= 0) {
208
+ throw new Error(`invalid ${envName}: "${raw}"`);
209
+ }
210
+ return n;
211
+ }
212
+ // Run as a script when executed directly (e.g. inside the Docker
213
+ // container's CMD). Skipped when imported by tests.
214
+ const isMain = (() => {
215
+ try {
216
+ const argv1 = process.argv[1];
217
+ if (!argv1)
218
+ return false;
219
+ // Match either the compiled `dist/dashboard/server.js` or the
220
+ // tsx-source `src/dashboard/server.ts`, so both `runway dash` and
221
+ // `tsx src/dashboard/server.ts` work.
222
+ return /[/\\]dashboard[/\\]server\.(js|ts)$/.test(argv1);
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ })();
228
+ if (isMain) {
229
+ main().catch((err) => {
230
+ console.error("[runway dashboard] fatal:", asMessage(err));
231
+ process.exit(1);
232
+ });
233
+ }
@@ -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,
@@ -100,7 +100,32 @@ export const drainQueue = (deps, opts = {}) => Effect.gen(function* () {
100
100
  seen.add(issue.id);
101
101
  attempts += 1;
102
102
  const branch = `agent/${issue.identifier.toLowerCase()}`;
103
- const processed = yield* processIssue(issue, runDeps).pipe(Effect.either, Effect.withSpan("processIssue", {
103
+ // VA-386: the dashboard projector reads the final outcome from
104
+ // the processIssue span's attributes. Compute it inside the
105
+ // span scope (so `annotateCurrentSpan` targets `processIssue`,
106
+ // not `drainQueue`) and route afterwards.
107
+ const resolved = yield* Effect.gen(function* () {
108
+ const processed = yield* processIssue(issue, runDeps).pipe(Effect.either);
109
+ if (processed._tag === "Right") {
110
+ const result = processed.right;
111
+ yield* Effect.annotateCurrentSpan({
112
+ "runway.outcome.kind": result.kind,
113
+ "runway.outcome.detail": result.detail,
114
+ });
115
+ return { errored: false, outcome: result };
116
+ }
117
+ const failureOutcome = yield* handleProcessFailure(processed.left, issue, runDeps, branch);
118
+ yield* Effect.annotateCurrentSpan({
119
+ "runway.outcome.kind": failureOutcome.kind,
120
+ "runway.outcome.detail": failureOutcome.detail,
121
+ // True when the issue process exited via the failure path
122
+ // — distinguishes "planned HITL" (kind=hitl, errored=false)
123
+ // from "crashed and routed to HITL" (kind=hitl,
124
+ // errored=true) on the dashboard row.
125
+ "runway.outcome.errored": true,
126
+ });
127
+ return { errored: true, outcome: failureOutcome };
128
+ }).pipe(Effect.withSpan("processIssue", {
104
129
  attributes: {
105
130
  "runway.issue.identifier": issue.identifier,
106
131
  "runway.issue.id": issue.id,
@@ -110,29 +135,30 @@ export const drainQueue = (deps, opts = {}) => Effect.gen(function* () {
110
135
  issue: issue.identifier,
111
136
  branch,
112
137
  }));
113
- if (processed._tag === "Right") {
114
- const result = processed.right;
115
- if (result.kind === "opened")
116
- opened += 1;
117
- if (result.kind === "hitl")
118
- hitl += 1;
119
- outcomes.push({
120
- identifier: issue.identifier,
121
- kind: result.kind,
122
- detail: result.detail,
123
- });
138
+ if (resolved.errored) {
139
+ errored += 1;
124
140
  }
125
141
  else {
126
- errored += 1;
127
- const failureOutcome = yield* handleProcessFailure(processed.left, issue, runDeps, branch);
128
- outcomes.push({
129
- identifier: issue.identifier,
130
- kind: failureOutcome.kind,
131
- detail: failureOutcome.detail,
132
- });
142
+ if (resolved.outcome.kind === "opened")
143
+ opened += 1;
144
+ if (resolved.outcome.kind === "hitl")
145
+ hitl += 1;
133
146
  }
147
+ outcomes.push({
148
+ identifier: issue.identifier,
149
+ kind: resolved.outcome.kind,
150
+ detail: resolved.outcome.detail,
151
+ });
134
152
  }
135
153
  yield* printExitSummary(outcomes);
154
+ // VA-386: counter attributes on the drain span so the dashboard's
155
+ // drain row carries the same totals as runway's exit summary.
156
+ yield* Effect.annotateCurrentSpan({
157
+ "runway.drain.attempts": attempts,
158
+ "runway.drain.opened": opened,
159
+ "runway.drain.hitl": hitl,
160
+ "runway.drain.errored": errored,
161
+ });
136
162
  return {
137
163
  attempts,
138
164
  opened,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valescoagency/runway",
3
- "version": "0.7.1",
3
+ "version": "0.8.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": {
@@ -43,7 +43,7 @@
43
43
  "@effect/opentelemetry": "^0.63.0",
44
44
  "@linear/sdk": "^84.0.0",
45
45
  "@opentelemetry/api": "^1.9.1",
46
- "@opentelemetry/exporter-trace-otlp-http": "^0.217.0",
46
+ "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
47
47
  "@opentelemetry/resources": "^2.7.1",
48
48
  "@opentelemetry/sdk-trace-base": "^2.7.1",
49
49
  "@opentelemetry/sdk-trace-node": "^2.7.1",