@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.
Files changed (49) hide show
  1. package/README.md +189 -40
  2. package/dist/cli.js +14 -0
  3. package/dist/commands/dash.js +324 -0
  4. package/dist/commands/review.js +315 -0
  5. package/dist/commands/run.js +21 -7
  6. package/dist/config.js +51 -6
  7. package/dist/dashboard/events.js +71 -0
  8. package/dist/dashboard/linear-sync.js +192 -0
  9. package/dist/dashboard/projector.js +77 -0
  10. package/dist/dashboard/server.js +468 -20
  11. package/dist/dashboard/storage.js +417 -16
  12. package/dist/dashboard/views.js +901 -8
  13. package/dist/diagnostics/git-signing.js +120 -0
  14. package/dist/diagnostics/index.js +2 -0
  15. package/dist/diagnostics/linear-config.js +19 -35
  16. package/dist/finalize.js +59 -13
  17. package/dist/git.js +48 -12
  18. package/dist/hitl.js +20 -28
  19. package/dist/implement.js +82 -1
  20. package/dist/linear.js +87 -73
  21. package/dist/meta/attribution.js +285 -0
  22. package/dist/meta/context.js +165 -0
  23. package/dist/meta/dashboard-read.js +609 -0
  24. package/dist/meta/format.js +49 -0
  25. package/dist/meta/heuristic-filter.js +53 -0
  26. package/dist/meta/hindsight.js +279 -0
  27. package/dist/meta/linear-meta.js +415 -0
  28. package/dist/meta/llm.js +205 -0
  29. package/dist/meta/out-of-scope.js +101 -0
  30. package/dist/meta/passes/drain-review.js +374 -0
  31. package/dist/meta/passes/run-review.js +475 -0
  32. package/dist/meta/passes/weekly-review.js +910 -0
  33. package/dist/meta/promoter.js +225 -0
  34. package/dist/meta/runner.js +221 -0
  35. package/dist/meta/span-attrs.js +65 -0
  36. package/dist/meta/templates.js +655 -0
  37. package/dist/orchestrator.js +54 -22
  38. package/dist/policy.js +6 -5
  39. package/dist/review.js +25 -8
  40. package/dist/runway-config-file.js +82 -0
  41. package/dist/scaffolder-varlock.js +9 -0
  42. package/dist/telemetry.js +38 -14
  43. package/package.json +6 -3
  44. package/prompts/implement.md +71 -0
  45. package/prompts/pr-review.md +127 -0
  46. package/prompts/review.md +64 -1
  47. package/templates/.env.schema.target-repo +26 -0
  48. package/templates/claude-shim.sh +47 -0
  49. 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
+ }
@@ -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
- RUNWAY_READY_STATUS default "Todo"
143
- RUNWAY_IN_PROGRESS_STATUS default "In Progress"
144
- RUNWAY_IN_REVIEW_STATUS default "In Review"
145
- RUNWAY_HITL_LABEL default "ready-for-human"
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} (status="${config.readyStatus}") against ${cwd}`);
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
- readyStatus: EConfig.string("RUNWAY_READY_STATUS").pipe(EConfig.withDefault("Todo")),
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
- readyStatus: raw.readyStatus,
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
+ }