@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 +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/orchestrator.js +45 -19
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -392,7 +392,7 @@ These are tractable, just not v1.
|
|
|
392
392
|
|
|
393
393
|
## Status
|
|
394
394
|
|
|
395
|
-
0.
|
|
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.
|
package/dist/commands/run.js
CHANGED
|
@@ -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 =
|
|
124
|
-
|
|
125
|
-
:
|
|
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 =
|
|
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
|
+
"&": "&",
|
|
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,
|
package/dist/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
114
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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.
|
|
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",
|