@valescoagency/runway 0.10.0 → 0.11.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 +189 -40
- package/dist/cli.js +14 -0
- package/dist/commands/dash.js +324 -0
- package/dist/commands/review.js +315 -0
- package/dist/commands/run.js +21 -7
- package/dist/config.js +51 -6
- package/dist/dashboard/events.js +71 -0
- package/dist/dashboard/linear-sync.js +192 -0
- package/dist/dashboard/projector.js +77 -0
- package/dist/dashboard/server.js +468 -20
- package/dist/dashboard/storage.js +417 -16
- package/dist/dashboard/views.js +901 -8
- package/dist/diagnostics/git-signing.js +120 -0
- package/dist/diagnostics/index.js +2 -0
- package/dist/diagnostics/linear-config.js +19 -35
- package/dist/finalize.js +59 -13
- package/dist/git.js +48 -12
- package/dist/hitl.js +20 -28
- package/dist/implement.js +82 -1
- package/dist/linear.js +87 -73
- package/dist/meta/attribution.js +285 -0
- package/dist/meta/context.js +165 -0
- package/dist/meta/dashboard-read.js +609 -0
- package/dist/meta/format.js +49 -0
- package/dist/meta/heuristic-filter.js +53 -0
- package/dist/meta/hindsight.js +279 -0
- package/dist/meta/linear-meta.js +415 -0
- package/dist/meta/llm.js +205 -0
- package/dist/meta/out-of-scope.js +101 -0
- package/dist/meta/passes/drain-review.js +374 -0
- package/dist/meta/passes/run-review.js +475 -0
- package/dist/meta/passes/weekly-review.js +910 -0
- package/dist/meta/promoter.js +225 -0
- package/dist/meta/runner.js +221 -0
- package/dist/meta/span-attrs.js +65 -0
- package/dist/meta/templates.js +655 -0
- package/dist/orchestrator.js +54 -22
- package/dist/policy.js +6 -5
- package/dist/review.js +25 -8
- package/dist/runway-config-file.js +82 -0
- package/dist/scaffolder-varlock.js +9 -0
- package/dist/telemetry.js +38 -14
- package/package.json +6 -3
- package/prompts/implement.md +71 -0
- package/prompts/pr-review.md +127 -0
- package/prompts/review.md +64 -1
- package/templates/.env.schema.target-repo +26 -0
- package/templates/claude-shim.sh +47 -0
- package/templates/dockerfile-varlock.snippet +19 -12
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
import { dispatchDrainReview, dispatchRunReview, dispatchWeeklyReview, } from "../meta/runner.js";
|
|
4
|
+
export function printReviewUsage() {
|
|
5
|
+
console.log(`runway review — IRA retrospective passes
|
|
6
|
+
|
|
7
|
+
USAGE
|
|
8
|
+
runway review run --drain <trace-id> --issue <identifier> [--force]
|
|
9
|
+
runway review drain --id <trace-id>
|
|
10
|
+
runway review weekly [--start <iso>] [--end <iso>]
|
|
11
|
+
|
|
12
|
+
OPTIONS — run
|
|
13
|
+
--drain <trace-id> the drain's OTLP trace id (32 hex chars)
|
|
14
|
+
--issue <identifier> the Linear issue identifier (e.g. VA-401)
|
|
15
|
+
--force bypass the drain-age delay gate (VA-403)
|
|
16
|
+
|
|
17
|
+
OPTIONS — drain
|
|
18
|
+
--id <trace-id> the drain's OTLP trace id (32 hex chars)
|
|
19
|
+
|
|
20
|
+
OPTIONS — weekly
|
|
21
|
+
--start <iso> start of the window (default: 7 days ago, exclusive on --end)
|
|
22
|
+
--end <iso> end of the window (default: now)
|
|
23
|
+
|
|
24
|
+
ENV
|
|
25
|
+
ANTHROPIC_API_KEY required — Claude API key for the IRA call
|
|
26
|
+
RUNWAY_DASHBOARD_DB dashboard SQLite path (default: ./runway-dashboard.db)
|
|
27
|
+
RUNWAY_META_REVIEW_MODEL override the Run / Drain Review model (default: claude-sonnet-4-6)
|
|
28
|
+
RUNWAY_META_WEEKLY_MODEL override the Weekly Review model (default: claude-opus-4-7)
|
|
29
|
+
RUNWAY_META_PROJECT_NAME override the Linear meta-project name (default: runway-meta)
|
|
30
|
+
RUNWAY_REVIEW_DELAY_HOURS minimum drain age before grading (default: 18; 12-24h band per PRD VA-403)
|
|
31
|
+
RUNWAY_REPO_PROJECT_NAME optional — Linear project where severity-critical Drain
|
|
32
|
+
Review escalations + Weekly Review promotions are filed
|
|
33
|
+
(default: team-only)
|
|
34
|
+
GITHUB_TOKEN optional — authenticates GitHub API for hindsight PR fetch
|
|
35
|
+
|
|
36
|
+
EXIT CODES
|
|
37
|
+
0 success (run/drain reviews stub-and-continue on failure)
|
|
38
|
+
1 Weekly Review failed loud after retry exhaustion (PRD VA-408)
|
|
39
|
+
2 invalid arguments
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
export async function reviewCommand(argv) {
|
|
43
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
44
|
+
printReviewUsage();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const verb = argv[0];
|
|
48
|
+
const rest = argv.slice(1);
|
|
49
|
+
if (verb === "run") {
|
|
50
|
+
await runVerb(rest);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (verb === "drain") {
|
|
54
|
+
await drainVerb(rest);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (verb === "weekly") {
|
|
58
|
+
await weeklyVerb(rest);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.error(`[runway review] unknown verb: ${verb}`);
|
|
62
|
+
printReviewUsage();
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
async function runVerb(rest) {
|
|
66
|
+
const parsed = parseRunArgs(rest);
|
|
67
|
+
if (parsed.kind === "error") {
|
|
68
|
+
console.error(`[runway review run] ${parsed.message}`);
|
|
69
|
+
printReviewUsage();
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
const config = loadConfig();
|
|
73
|
+
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
74
|
+
if (!anthropicApiKey) {
|
|
75
|
+
console.error("[runway review run] ANTHROPIC_API_KEY is required — set it in the environment before running.");
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
const dashboardDbPath = process.env.RUNWAY_DASHBOARD_DB ?? "./runway-dashboard.db";
|
|
79
|
+
const outcome = await Effect.runPromise(dispatchRunReview({
|
|
80
|
+
traceId: parsed.traceId,
|
|
81
|
+
issueIdentifier: parsed.issueIdentifier,
|
|
82
|
+
force: parsed.force,
|
|
83
|
+
}, {
|
|
84
|
+
config,
|
|
85
|
+
cwd: process.cwd(),
|
|
86
|
+
dashboardDbPath,
|
|
87
|
+
anthropicApiKey,
|
|
88
|
+
// VA-403: optional GitHub token. The hindsight gateway runs
|
|
89
|
+
// unauthenticated when absent (60 reqs/hr, public repos
|
|
90
|
+
// only) — sufficient for OSS workflows. Set the env var
|
|
91
|
+
// before invoking for private-repo or rate-limit headroom.
|
|
92
|
+
githubToken: process.env.GITHUB_TOKEN,
|
|
93
|
+
}));
|
|
94
|
+
if (outcome.kind === "review") {
|
|
95
|
+
console.log(`[runway review run] ok — meta_review=${outcome.metaReviewId} linear=${outcome.linearIdentifier} (${outcome.linearUrl})`);
|
|
96
|
+
}
|
|
97
|
+
else if (outcome.kind === "delayed") {
|
|
98
|
+
console.log(`[runway review run] drain too fresh — age=${outcome.drainAgeHours}h, threshold=${outcome.thresholdHours}h. Retry after ${outcome.retryAfterIso} (or pass --force to override).`);
|
|
99
|
+
}
|
|
100
|
+
else if (outcome.kind === "skipped") {
|
|
101
|
+
// VA-404: heuristic pre-filter trip. No LLM, no Linear — just the
|
|
102
|
+
// skipped row. Operator audit reads the reason off `meta_reviews`.
|
|
103
|
+
console.log(`[runway review run] skipped — meta_review=${outcome.metaReviewId} reason: ${outcome.reason}`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
console.log(`[runway review run] stub-and-continue — meta_review=${outcome.metaReviewId} reason: ${outcome.reason}`);
|
|
107
|
+
}
|
|
108
|
+
// Run Review failures stub and continue per PRD — exit code stays 0
|
|
109
|
+
// regardless of outcome.kind. Weekly Review (VA-408) will diverge.
|
|
110
|
+
}
|
|
111
|
+
async function drainVerb(rest) {
|
|
112
|
+
const parsed = parseDrainArgs(rest);
|
|
113
|
+
if (parsed.kind === "error") {
|
|
114
|
+
console.error(`[runway review drain] ${parsed.message}`);
|
|
115
|
+
printReviewUsage();
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
const config = loadConfig();
|
|
119
|
+
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
120
|
+
if (!anthropicApiKey) {
|
|
121
|
+
console.error("[runway review drain] ANTHROPIC_API_KEY is required — set it in the environment before running.");
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
const dashboardDbPath = process.env.RUNWAY_DASHBOARD_DB ?? "./runway-dashboard.db";
|
|
125
|
+
const outcome = await Effect.runPromise(dispatchDrainReview({ traceId: parsed.traceId }, {
|
|
126
|
+
config,
|
|
127
|
+
cwd: process.cwd(),
|
|
128
|
+
dashboardDbPath,
|
|
129
|
+
anthropicApiKey,
|
|
130
|
+
githubToken: process.env.GITHUB_TOKEN,
|
|
131
|
+
}));
|
|
132
|
+
if (outcome.kind === "review") {
|
|
133
|
+
const escSuffix = outcome.severity === "critical"
|
|
134
|
+
? ` severity=critical escalation=${outcome.escalationIdentifier ?? "(failed)"}`
|
|
135
|
+
: ` severity=routine`;
|
|
136
|
+
console.log(`[runway review drain] ok — meta_review=${outcome.metaReviewId} linear=${outcome.linearIdentifier} (${outcome.linearUrl})${escSuffix}`);
|
|
137
|
+
}
|
|
138
|
+
else if (outcome.kind === "no-run-reviews") {
|
|
139
|
+
console.log(`[runway review drain] no Run Reviews for drain — nothing to grade. ${outcome.reason}`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
console.log(`[runway review drain] stub-and-continue — meta_review=${outcome.metaReviewId} reason: ${outcome.reason}`);
|
|
143
|
+
}
|
|
144
|
+
// Drain Review failures stub and continue per PRD — exit code stays
|
|
145
|
+
// 0 regardless of outcome.kind. Same contract as Run Review.
|
|
146
|
+
}
|
|
147
|
+
async function weeklyVerb(rest) {
|
|
148
|
+
const parsed = parseWeeklyArgs(rest, Date.now());
|
|
149
|
+
if (parsed.kind === "error") {
|
|
150
|
+
console.error(`[runway review weekly] ${parsed.message}`);
|
|
151
|
+
printReviewUsage();
|
|
152
|
+
process.exit(2);
|
|
153
|
+
}
|
|
154
|
+
const config = loadConfig();
|
|
155
|
+
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
156
|
+
if (!anthropicApiKey) {
|
|
157
|
+
console.error("[runway review weekly] ANTHROPIC_API_KEY is required — set it in the environment before running.");
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
const dashboardDbPath = process.env.RUNWAY_DASHBOARD_DB ?? "./runway-dashboard.db";
|
|
161
|
+
const outcome = await Effect.runPromise(dispatchWeeklyReview({ startIso: parsed.startIso, endIso: parsed.endIso }, {
|
|
162
|
+
config,
|
|
163
|
+
cwd: process.cwd(),
|
|
164
|
+
dashboardDbPath,
|
|
165
|
+
anthropicApiKey,
|
|
166
|
+
githubToken: process.env.GITHUB_TOKEN,
|
|
167
|
+
}));
|
|
168
|
+
if (outcome.kind === "review") {
|
|
169
|
+
console.log(`[runway review weekly] ok — meta_review=${outcome.metaReviewId} linear=${outcome.linearIdentifier} (${outcome.linearUrl}) promotions=${outcome.promotionsFiled}/${outcome.promotionsPlanned}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (outcome.kind === "no-reviews") {
|
|
173
|
+
console.log(`[runway review weekly] no Run / Drain Reviews in window — nothing to summarize. ${outcome.reason}`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// VA-408 AC: "exit code is non-zero after retry exhaustion".
|
|
177
|
+
console.error(`[runway review weekly] FAILED-LOUD — ${outcome.reason}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* VA-408: argv parser for `runway review weekly [--start <iso>]
|
|
182
|
+
* [--end <iso>]`. Both flags optional — default window is the
|
|
183
|
+
* trailing 7 days ending at `nowMs`. Supports space-separated and
|
|
184
|
+
* `=`-form. `nowMs` is parameterized so tests can drive a stable
|
|
185
|
+
* clock; production calls pass `Date.now()`.
|
|
186
|
+
*/
|
|
187
|
+
export function parseWeeklyArgs(argv, nowMs) {
|
|
188
|
+
let startIso;
|
|
189
|
+
let endIso;
|
|
190
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
191
|
+
const arg = argv[i];
|
|
192
|
+
if (arg === undefined)
|
|
193
|
+
continue;
|
|
194
|
+
const split = consumeFlag(arg, argv, i);
|
|
195
|
+
if (!split)
|
|
196
|
+
continue;
|
|
197
|
+
if (split.name === "--start") {
|
|
198
|
+
startIso = split.value;
|
|
199
|
+
i = split.advance;
|
|
200
|
+
}
|
|
201
|
+
else if (split.name === "--end") {
|
|
202
|
+
endIso = split.value;
|
|
203
|
+
i = split.advance;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
return { kind: "error", message: `unknown flag: ${split.name}` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const resolvedEndIso = endIso ?? new Date(nowMs).toISOString();
|
|
210
|
+
const resolvedStartIso = startIso ?? new Date(nowMs - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
211
|
+
// Permissive: trust the operator's ISO string. Date.parse will
|
|
212
|
+
// surface a malformed input as NaN; reject explicitly so the
|
|
213
|
+
// operator doesn't get a Z-shaped Linear query later.
|
|
214
|
+
if (Number.isNaN(Date.parse(resolvedStartIso))) {
|
|
215
|
+
return { kind: "error", message: `--start is not a valid ISO date: ${resolvedStartIso}` };
|
|
216
|
+
}
|
|
217
|
+
if (Number.isNaN(Date.parse(resolvedEndIso))) {
|
|
218
|
+
return { kind: "error", message: `--end is not a valid ISO date: ${resolvedEndIso}` };
|
|
219
|
+
}
|
|
220
|
+
if (Date.parse(resolvedStartIso) >= Date.parse(resolvedEndIso)) {
|
|
221
|
+
return {
|
|
222
|
+
kind: "error",
|
|
223
|
+
message: `--start (${resolvedStartIso}) must be before --end (${resolvedEndIso})`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { kind: "ok", startIso: resolvedStartIso, endIso: resolvedEndIso };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* VA-406: argv parser for `runway review drain --id <trace-id>`.
|
|
230
|
+
* Supports both space-separated (`--id X`) and equals (`--id=X`)
|
|
231
|
+
* forms. Unknown flags surface a typed error.
|
|
232
|
+
*/
|
|
233
|
+
export function parseDrainArgs(argv) {
|
|
234
|
+
let traceId;
|
|
235
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
236
|
+
const arg = argv[i];
|
|
237
|
+
if (arg === undefined)
|
|
238
|
+
continue;
|
|
239
|
+
const split = consumeFlag(arg, argv, i);
|
|
240
|
+
if (!split)
|
|
241
|
+
continue;
|
|
242
|
+
if (split.name === "--id") {
|
|
243
|
+
traceId = split.value;
|
|
244
|
+
i = split.advance;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
return { kind: "error", message: `unknown flag: ${split.name}` };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!traceId) {
|
|
251
|
+
return { kind: "error", message: "missing required --id <trace-id>" };
|
|
252
|
+
}
|
|
253
|
+
return { kind: "ok", traceId };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Minimal argv parser — supports `--drain <id> --issue <id>` (with
|
|
257
|
+
* `=` allowed in either form) plus the boolean `--force` flag.
|
|
258
|
+
* Order independent.
|
|
259
|
+
*/
|
|
260
|
+
export function parseRunArgs(argv) {
|
|
261
|
+
let traceId;
|
|
262
|
+
let issueIdentifier;
|
|
263
|
+
let force = false;
|
|
264
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
265
|
+
const arg = argv[i];
|
|
266
|
+
if (arg === undefined)
|
|
267
|
+
continue;
|
|
268
|
+
// VA-403: --force is a boolean flag — handled before consumeFlag
|
|
269
|
+
// so a stray positional after it doesn't get swallowed as the
|
|
270
|
+
// flag's value.
|
|
271
|
+
if (arg === "--force") {
|
|
272
|
+
force = true;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const split = consumeFlag(arg, argv, i);
|
|
276
|
+
if (!split)
|
|
277
|
+
continue;
|
|
278
|
+
if (split.name === "--drain") {
|
|
279
|
+
traceId = split.value;
|
|
280
|
+
i = split.advance;
|
|
281
|
+
}
|
|
282
|
+
else if (split.name === "--issue") {
|
|
283
|
+
issueIdentifier = split.value;
|
|
284
|
+
i = split.advance;
|
|
285
|
+
}
|
|
286
|
+
else if (split.name === "--force") {
|
|
287
|
+
// The `--force=true` form also accepted (operator scripted CI).
|
|
288
|
+
force = split.value !== "false";
|
|
289
|
+
i = split.advance;
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
return { kind: "error", message: `unknown flag: ${split.name}` };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!traceId) {
|
|
296
|
+
return { kind: "error", message: "missing required --drain <trace-id>" };
|
|
297
|
+
}
|
|
298
|
+
if (!issueIdentifier) {
|
|
299
|
+
return { kind: "error", message: "missing required --issue <identifier>" };
|
|
300
|
+
}
|
|
301
|
+
return { kind: "ok", traceId, issueIdentifier, force };
|
|
302
|
+
}
|
|
303
|
+
function consumeFlag(arg, argv, i) {
|
|
304
|
+
if (!arg.startsWith("--"))
|
|
305
|
+
return null;
|
|
306
|
+
const eq = arg.indexOf("=");
|
|
307
|
+
if (eq > 0) {
|
|
308
|
+
return { name: arg.slice(0, eq), value: arg.slice(eq + 1), advance: i };
|
|
309
|
+
}
|
|
310
|
+
const next = argv[i + 1];
|
|
311
|
+
if (next === undefined || next.startsWith("--")) {
|
|
312
|
+
return { name: arg, value: "", advance: i };
|
|
313
|
+
}
|
|
314
|
+
return { name: arg, value: next, advance: i + 1 };
|
|
315
|
+
}
|
package/dist/commands/run.js
CHANGED
|
@@ -139,10 +139,24 @@ ENVIRONMENT
|
|
|
139
139
|
branch (the branch runway diffs against
|
|
140
140
|
and targets with PRs). Detected from
|
|
141
141
|
origin/HEAD when unset.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
RUNWAY_READY_LABEL default "ready-for-agent" — the
|
|
143
|
+
flightplan v1.1.0 contract. Runway's
|
|
144
|
+
drain queue filters by this label, not
|
|
145
|
+
by workflow status, because Linear's
|
|
146
|
+
GitHub integration auto-mutates status
|
|
147
|
+
on PR cross-references. Labels are
|
|
148
|
+
immune to that integration. Runway
|
|
149
|
+
removes the label on pickup (the
|
|
150
|
+
atomic claim signal).
|
|
151
|
+
RUNWAY_HITL_LABEL default "ready-for-human" — applied
|
|
152
|
+
when the agent or reviewer can't
|
|
153
|
+
finish, AND when a run fails outright.
|
|
154
|
+
Runway never re-applies the ready
|
|
155
|
+
label on failure (terminal failures
|
|
156
|
+
shouldn't retry indefinitely); an
|
|
157
|
+
operator triages and re-applies
|
|
158
|
+
the ready label manually if the
|
|
159
|
+
cause was transient.
|
|
146
160
|
RUNWAY_MAX_ITERATIONS default 5 — outer impl re-prompt loop
|
|
147
161
|
(only fires when the agent fails to
|
|
148
162
|
signal IMPL: DONE / BLOCKED at all)
|
|
@@ -191,7 +205,7 @@ export async function runCommand(argv) {
|
|
|
191
205
|
const scope = config.linearProject
|
|
192
206
|
? `team ${config.linearTeam} / project ${config.linearProject}`
|
|
193
207
|
: `team ${config.linearTeam}`;
|
|
194
|
-
yield* Effect.logInfo(`draining queue from ${scope} (
|
|
208
|
+
yield* Effect.logInfo(`draining queue from ${scope} (label="${config.readyLabel}") against ${cwd}`);
|
|
195
209
|
const linearLimiter = yield* RateLimiter.make({
|
|
196
210
|
limit: 30,
|
|
197
211
|
interval: "1 minute",
|
|
@@ -201,12 +215,12 @@ export async function runCommand(argv) {
|
|
|
201
215
|
return yield* drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths });
|
|
202
216
|
}).pipe(Effect.scoped, Effect.provide(MainLayer));
|
|
203
217
|
const result = await Effect.runPromise(program);
|
|
204
|
-
console.log(`[runway] done — attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
|
|
218
|
+
console.log(`[runway] done — attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored} skipped=${result.skipped}`);
|
|
205
219
|
// Single-line, parser-friendly completion marker. Background
|
|
206
220
|
// watchers (Claude Code's `run_in_background` bash task, CI,
|
|
207
221
|
// scripts) can grep for `[runway:exit]` instead of guessing
|
|
208
222
|
// whether the drain is still in flight.
|
|
209
|
-
console.log(`[runway:exit] status=success attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
|
|
223
|
+
console.log(`[runway:exit] status=success attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored} skipped=${result.skipped}`);
|
|
210
224
|
// Hard exit so any lingering handle (OTel BatchSpanProcessor's
|
|
211
225
|
// interval when OTEL_EXPORTER_OTLP_ENDPOINT is set, a Docker
|
|
212
226
|
// stream Sandcastle left open, etc.) can't keep the process — and
|
package/dist/config.js
CHANGED
|
@@ -12,9 +12,7 @@ const configEffect = EConfig.all({
|
|
|
12
12
|
linearTeam: EConfig.string("RUNWAY_LINEAR_TEAM").pipe(EConfig.withDefault("VA")),
|
|
13
13
|
linearProject: EConfig.option(EConfig.string("RUNWAY_LINEAR_PROJECT")),
|
|
14
14
|
baseBranch: EConfig.option(EConfig.string("RUNWAY_BASE_BRANCH")),
|
|
15
|
-
|
|
16
|
-
inProgressStatus: EConfig.string("RUNWAY_IN_PROGRESS_STATUS").pipe(EConfig.withDefault("In Progress")),
|
|
17
|
-
inReviewStatus: EConfig.string("RUNWAY_IN_REVIEW_STATUS").pipe(EConfig.withDefault("In Review")),
|
|
15
|
+
readyLabel: EConfig.string("RUNWAY_READY_LABEL").pipe(EConfig.withDefault("ready-for-agent")),
|
|
18
16
|
hitlLabel: EConfig.string("RUNWAY_HITL_LABEL").pipe(EConfig.withDefault("ready-for-human")),
|
|
19
17
|
maxIterations: EConfig.integer("RUNWAY_MAX_ITERATIONS").pipe(EConfig.withDefault(5), EConfig.validate({
|
|
20
18
|
message: "RUNWAY_MAX_ITERATIONS must be a positive integer",
|
|
@@ -32,15 +30,28 @@ const configEffect = EConfig.all({
|
|
|
32
30
|
validation: (n) => n >= 0,
|
|
33
31
|
})),
|
|
34
32
|
commentAuthorAllowlist: EConfig.option(EConfig.string("RUNWAY_COMMENT_AUTHOR_ALLOWLIST")),
|
|
33
|
+
metaReviewModel: EConfig.option(EConfig.string("RUNWAY_META_REVIEW_MODEL")),
|
|
34
|
+
metaProjectName: EConfig.option(EConfig.string("RUNWAY_META_PROJECT_NAME")),
|
|
35
|
+
// VA-403: PRD constrains the configurable delay to the 12–24h
|
|
36
|
+
// band — too short and the IRA judges before hindsight lands; too
|
|
37
|
+
// long and operators lose the freshness operators expect from a
|
|
38
|
+
// ~daily retrospective. The explicit `--force` CLI flag (or
|
|
39
|
+
// `force: true` to dispatchRunReview) is the supported bypass
|
|
40
|
+
// for one-off operator overrides; persistent disable isn't.
|
|
41
|
+
metaReviewDelayHours: EConfig.option(EConfig.number("RUNWAY_REVIEW_DELAY_HOURS").pipe(EConfig.validate({
|
|
42
|
+
message: "RUNWAY_REVIEW_DELAY_HOURS must be in the 12-24 hour band (PRD VA-403); use --force on the CLI for one-off bypass",
|
|
43
|
+
validation: (n) => n >= 12 && n <= 24,
|
|
44
|
+
}))),
|
|
45
|
+
metaFilterThresholdsJson: EConfig.option(EConfig.string("RUNWAY_META_FILTER_THRESHOLDS")),
|
|
46
|
+
runwayRepoProjectName: EConfig.option(EConfig.string("RUNWAY_REPO_PROJECT_NAME")),
|
|
47
|
+
metaWeeklyModel: EConfig.option(EConfig.string("RUNWAY_META_WEEKLY_MODEL")),
|
|
35
48
|
}).pipe(Effect.map((raw) => ({
|
|
36
49
|
linearApiKey: raw.linearApiKey,
|
|
37
50
|
opServiceAccountToken: Option.getOrUndefined(raw.opServiceAccountToken),
|
|
38
51
|
linearTeam: raw.linearTeam,
|
|
39
52
|
linearProject: Option.getOrUndefined(raw.linearProject),
|
|
40
53
|
baseBranch: Option.getOrUndefined(raw.baseBranch),
|
|
41
|
-
|
|
42
|
-
inProgressStatus: raw.inProgressStatus,
|
|
43
|
-
inReviewStatus: raw.inReviewStatus,
|
|
54
|
+
readyLabel: raw.readyLabel,
|
|
44
55
|
hitlLabel: raw.hitlLabel,
|
|
45
56
|
maxIterations: raw.maxIterations,
|
|
46
57
|
implTurns: raw.implTurns,
|
|
@@ -49,6 +60,12 @@ const configEffect = EConfig.all({
|
|
|
49
60
|
?.split(",")
|
|
50
61
|
.map((s) => s.trim())
|
|
51
62
|
.filter(Boolean),
|
|
63
|
+
metaReviewModel: Option.getOrUndefined(raw.metaReviewModel),
|
|
64
|
+
metaProjectName: Option.getOrUndefined(raw.metaProjectName),
|
|
65
|
+
metaReviewDelayHours: Option.getOrUndefined(raw.metaReviewDelayHours),
|
|
66
|
+
metaFilterThresholds: parseMetaFilterThresholdsJson(Option.getOrUndefined(raw.metaFilterThresholdsJson)),
|
|
67
|
+
runwayRepoProjectName: Option.getOrUndefined(raw.runwayRepoProjectName),
|
|
68
|
+
metaWeeklyModel: Option.getOrUndefined(raw.metaWeeklyModel),
|
|
52
69
|
})));
|
|
53
70
|
/**
|
|
54
71
|
* VA-359: Context tag for the resolved RunwayConfig. Provided by
|
|
@@ -101,3 +118,31 @@ export const ConfigLive = makeConfigLive();
|
|
|
101
118
|
export function loadConfig(env = process.env) {
|
|
102
119
|
return Effect.runSync(configEffect.pipe(Effect.withConfigProvider(providerFromEnv(env))));
|
|
103
120
|
}
|
|
121
|
+
// VA-404: parse the optional `RUNWAY_META_FILTER_THRESHOLDS` env JSON.
|
|
122
|
+
// Malformed JSON or out-of-range numbers are silently ignored —
|
|
123
|
+
// per-threshold env vars are an anti-feature; if the operator wants
|
|
124
|
+
// safety they edit `.runway/config.yml`. We accept partial objects.
|
|
125
|
+
function parseMetaFilterThresholdsJson(raw) {
|
|
126
|
+
if (!raw)
|
|
127
|
+
return undefined;
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(raw);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const obj = parsed;
|
|
139
|
+
const out = {};
|
|
140
|
+
if (typeof obj.minIterationCount === "number" && obj.minIterationCount >= 1) {
|
|
141
|
+
out.minIterationCount = Math.floor(obj.minIterationCount);
|
|
142
|
+
}
|
|
143
|
+
if (typeof obj.minReviewerRejections === "number" &&
|
|
144
|
+
obj.minReviewerRejections >= 1) {
|
|
145
|
+
out.minReviewerRejections = Math.floor(obj.minReviewerRejections);
|
|
146
|
+
}
|
|
147
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
148
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VA-391: in-process pub/sub for live dashboard updates. The OTLP
|
|
3
|
+
* ingest path publishes events when storage rows change; SSE
|
|
4
|
+
* handlers subscribe and stream matching events to the browser.
|
|
5
|
+
* No external broker — single-process, single-listener-set, fan-out
|
|
6
|
+
* happens here.
|
|
7
|
+
*
|
|
8
|
+
* Two event shapes today; more can be added by extending the union:
|
|
9
|
+
*
|
|
10
|
+
* issueProcessChanged — a new or upserted issue_processes row.
|
|
11
|
+
* The list view polls every 3s and doesn't
|
|
12
|
+
* strictly need this, but the SSE detail
|
|
13
|
+
* pane uses it to refresh phase-timeline
|
|
14
|
+
* data when an iteration lands.
|
|
15
|
+
*
|
|
16
|
+
* logRecordAppended — a new log_records row. The SSE detail
|
|
17
|
+
* pane consumes this to live-tail logs into
|
|
18
|
+
* the Logs section.
|
|
19
|
+
*
|
|
20
|
+
* Subscribers pass a filter so the SSE handler at
|
|
21
|
+
* `/issue-processes/:id/stream` only sees events tied to that issue
|
|
22
|
+
* process's trace_id. The bus does the matching inside `publish` so
|
|
23
|
+
* an inactive subscription costs nothing on the hot path beyond a
|
|
24
|
+
* comparison.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Construct a fresh in-process event bus. Each dashboard server gets
|
|
28
|
+
* one; storage stays oblivious — the server is the publish boundary.
|
|
29
|
+
*/
|
|
30
|
+
export function createEventBus() {
|
|
31
|
+
// Set semantics: every subscribe returns a fresh `Entry`, so unsub
|
|
32
|
+
// can `Set.delete` the exact slot without comparing listeners. A
|
|
33
|
+
// duplicate subscribe with the same listener registers a second
|
|
34
|
+
// entry on purpose — the SSE handler doesn't, but we don't want a
|
|
35
|
+
// future caller's bug masked by silent dedup.
|
|
36
|
+
const entries = new Set();
|
|
37
|
+
return {
|
|
38
|
+
publish(event) {
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
if (e.filter.traceId !== undefined && e.filter.traceId !== event.traceId) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
e.listener(event);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// A misbehaving subscriber must not break the publish loop —
|
|
48
|
+
// SSE handlers stream over the network and can fail in any
|
|
49
|
+
// number of ways. Swallow and continue; the next iteration
|
|
50
|
+
// delivers to other subscribers.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
subscribe(filter, listener) {
|
|
55
|
+
const entry = { filter, listener };
|
|
56
|
+
entries.add(entry);
|
|
57
|
+
let removed = false;
|
|
58
|
+
return {
|
|
59
|
+
unsubscribe() {
|
|
60
|
+
if (removed)
|
|
61
|
+
return;
|
|
62
|
+
entries.delete(entry);
|
|
63
|
+
removed = true;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
subscriberCount() {
|
|
68
|
+
return entries.size;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|