@valescoagency/runway 0.15.0 → 0.16.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.
@@ -149,15 +149,182 @@ function strArrayAttr(v) {
149
149
  */
150
150
  export const DRAIN_STARTED_LOG = "drain.started";
151
151
  /**
152
- * VA-465: log body strings the shepherd loop emits at three points.
153
- * Each carries structured attributes the projector reads to update
154
- * the `shepherding_prs` table. Kept in lock-step with `shepherd.ts`'s
155
- * `Effect.logInfo` call sites drift between the two means the
156
- * sub-card stops rendering for the affected lifecycle event.
152
+ * VA-474: log body strings the planner daemon emits for its six
153
+ * event types (ADR 0004 § Writer model). Kept in lock-step with the
154
+ * planner's `Effect.logInfo` call sites drift between projector
155
+ * and planner means events stop projecting into the read-model.
157
156
  */
158
- export const SHEPHERD_ITERATION_START_LOG = "shepherd.iteration.start";
159
- export const SHEPHERD_ITERATION_END_LOG = "shepherd.iteration.end";
160
- export const SHEPHERD_ENDED_LOG = "shepherd.ended";
157
+ export const PLANNER_RECOMMENDATION_PROPOSED_LOG = "planner.recommendation.proposed";
158
+ export const PLANNER_RECOMMENDATION_ACCEPTED_LOG = "planner.recommendation.accepted";
159
+ export const PLANNER_RECOMMENDATION_REJECTED_LOG = "planner.recommendation.rejected";
160
+ export const PLANNER_ACTION_STARTED_LOG = "planner.action.started";
161
+ export const PLANNER_ACTION_COMPLETED_LOG = "planner.action.completed";
162
+ export const PLANNER_ACTION_FAILED_LOG = "planner.action.failed";
163
+ /**
164
+ * VA-481: emitted by the planner's HTTP PUT /planner/capabilities/:capability
165
+ * handler when an operator (or the planner's auto-promotion logic)
166
+ * changes a capability's mode (manual / suggest / auto). The dashboard
167
+ * projects each event into the `planner_capability_modes` table; the
168
+ * planner reads the table on every tick so toggles take effect on
169
+ * the next iteration without a daemon restart.
170
+ */
171
+ export const PLANNER_CAPABILITY_MODE_CHANGED_LOG = "planner.capability.mode_changed";
172
+ export const PLANNER_CAPABILITY_MODES = new Set([
173
+ "manual",
174
+ "suggest",
175
+ "auto",
176
+ ]);
177
+ const PLANNER_CAPABILITIES = new Set([
178
+ "queue-selection",
179
+ "shepherd-mergeability",
180
+ "shepherd-ci",
181
+ "shepherd-reviewer",
182
+ ]);
183
+ /**
184
+ * VA-474: scan an OTLP logs payload for the six planner-event
185
+ * markers. Records that fail validation (missing required
186
+ * attribute, unknown capability, unparseable `evidence`) are
187
+ * dropped — the projector never crashes on a bad payload, but it
188
+ * also never fabricates fields the emitter didn't send.
189
+ */
190
+ export function extractPlannerMarkers(payload) {
191
+ const out = [];
192
+ for (const rl of payload.resourceLogs ?? []) {
193
+ for (const sl of rl.scopeLogs ?? []) {
194
+ for (const rec of sl.logRecords ?? []) {
195
+ const body = rec.body?.stringValue;
196
+ if (!body || !body.startsWith("planner."))
197
+ continue;
198
+ const ts = rec.timeUnixNano ?? rec.observedTimeUnixNano;
199
+ if (!ts)
200
+ continue;
201
+ const attrs = attributesToStringMap(rec.attributes);
202
+ if (body === PLANNER_RECOMMENDATION_PROPOSED_LOG) {
203
+ const recommendationId = attrs.recommendationId;
204
+ const capability = attrs.capability;
205
+ const target = attrs.target;
206
+ const summary = attrs.summary;
207
+ if (!recommendationId ||
208
+ !capability ||
209
+ !PLANNER_CAPABILITIES.has(capability) ||
210
+ !target ||
211
+ !summary) {
212
+ continue;
213
+ }
214
+ let evidence = {};
215
+ const rawEvidence = attrs.evidence;
216
+ if (rawEvidence) {
217
+ try {
218
+ const parsed = JSON.parse(rawEvidence);
219
+ if (parsed &&
220
+ typeof parsed === "object" &&
221
+ !Array.isArray(parsed)) {
222
+ evidence = parsed;
223
+ }
224
+ }
225
+ catch {
226
+ // Malformed evidence JSON is non-fatal — emit with {}
227
+ // so the recommendation still lands.
228
+ }
229
+ }
230
+ out.push({
231
+ kind: "recommendation.proposed",
232
+ recommendationId,
233
+ capability: capability,
234
+ target,
235
+ summary,
236
+ evidence,
237
+ autoApproveEligible: attrs.autoApproveEligible === "true",
238
+ timestampUnixNano: ts,
239
+ });
240
+ }
241
+ else if (body === PLANNER_RECOMMENDATION_ACCEPTED_LOG ||
242
+ body === PLANNER_RECOMMENDATION_REJECTED_LOG) {
243
+ const recommendationId = attrs.recommendationId;
244
+ const source = attrs.source;
245
+ if (!recommendationId ||
246
+ (source !== "operator" && source !== "auto")) {
247
+ continue;
248
+ }
249
+ const status = body === PLANNER_RECOMMENDATION_ACCEPTED_LOG
250
+ ? "accepted"
251
+ : "rejected";
252
+ out.push({
253
+ kind: status === "accepted"
254
+ ? "recommendation.accepted"
255
+ : "recommendation.rejected",
256
+ recommendationId,
257
+ status,
258
+ source,
259
+ reason: attrs.reason ?? null,
260
+ timestampUnixNano: ts,
261
+ });
262
+ }
263
+ else if (body === PLANNER_ACTION_STARTED_LOG) {
264
+ const actionId = attrs.actionId;
265
+ const capability = attrs.capability;
266
+ const target = attrs.target;
267
+ if (!actionId ||
268
+ !capability ||
269
+ !PLANNER_CAPABILITIES.has(capability) ||
270
+ !target) {
271
+ continue;
272
+ }
273
+ out.push({
274
+ kind: "action.started",
275
+ actionId,
276
+ recommendationId: attrs.recommendationId ?? null,
277
+ capability: capability,
278
+ target,
279
+ timestampUnixNano: ts,
280
+ });
281
+ }
282
+ else if (body === PLANNER_ACTION_COMPLETED_LOG) {
283
+ const actionId = attrs.actionId;
284
+ const outcome = attrs.outcome;
285
+ if (!actionId || !outcome)
286
+ continue;
287
+ out.push({
288
+ kind: "action.completed",
289
+ actionId,
290
+ outcome,
291
+ timestampUnixNano: ts,
292
+ });
293
+ }
294
+ else if (body === PLANNER_ACTION_FAILED_LOG) {
295
+ const actionId = attrs.actionId;
296
+ const error = attrs.error;
297
+ if (!actionId || !error)
298
+ continue;
299
+ out.push({
300
+ kind: "action.failed",
301
+ actionId,
302
+ error,
303
+ timestampUnixNano: ts,
304
+ });
305
+ }
306
+ else if (body === PLANNER_CAPABILITY_MODE_CHANGED_LOG) {
307
+ const capability = attrs.capability;
308
+ const mode = attrs.mode;
309
+ if (!capability ||
310
+ !PLANNER_CAPABILITIES.has(capability) ||
311
+ !mode ||
312
+ !PLANNER_CAPABILITY_MODES.has(mode)) {
313
+ continue;
314
+ }
315
+ out.push({
316
+ kind: "capability.mode_changed",
317
+ capability: capability,
318
+ mode: mode,
319
+ updatedBy: attrs.updatedBy ?? "operator",
320
+ timestampUnixNano: ts,
321
+ });
322
+ }
323
+ }
324
+ }
325
+ }
326
+ return out;
327
+ }
161
328
  /**
162
329
  * VA-455: scan an OTLP logs payload for `drain.started` markers.
163
330
  * Each match becomes an `ActiveDrainMarker` carrying the drain's
@@ -198,86 +365,6 @@ export function extractActiveDrainMarkers(payload) {
198
365
  }
199
366
  return out;
200
367
  }
201
- /**
202
- * VA-465: scan an OTLP logs payload for shepherd lifecycle markers.
203
- * Three body strings produce three marker kinds; each is tagged so
204
- * the server can dispatch to the right storage method. Records
205
- * missing trace_id, a recognised body, or a timestamp are dropped —
206
- * we won't fabricate any of those.
207
- *
208
- * Attribute reading is lenient: the gateway encodes numbers as
209
- * strings via OTLP's `stringValue`, so we re-parse with
210
- * `Number.parseInt`. Missing attributes get sensible defaults
211
- * (e.g., empty subscription, null max_iterations) rather than
212
- * causing the marker to be dropped — a partial marker is still
213
- * useful for the dashboard.
214
- */
215
- export function extractShepherdMarkers(payload) {
216
- const out = [];
217
- for (const rl of payload.resourceLogs ?? []) {
218
- for (const sl of rl.scopeLogs ?? []) {
219
- for (const rec of sl.logRecords ?? []) {
220
- const body = rec.body?.stringValue;
221
- if (body !== SHEPHERD_ITERATION_START_LOG &&
222
- body !== SHEPHERD_ITERATION_END_LOG &&
223
- body !== SHEPHERD_ENDED_LOG) {
224
- continue;
225
- }
226
- const traceId = rec.traceId?.trim();
227
- if (!traceId)
228
- continue;
229
- const ts = rec.timeUnixNano ?? rec.observedTimeUnixNano;
230
- if (!ts)
231
- continue;
232
- const attrs = attributesToStringMap(rec.attributes);
233
- const prNumber = Number.parseInt(attrs.pr ?? "", 10);
234
- if (!Number.isFinite(prNumber))
235
- continue;
236
- if (body === SHEPHERD_ITERATION_START_LOG) {
237
- const iteration = Number.parseInt(attrs.iteration ?? "", 10);
238
- if (!Number.isFinite(iteration))
239
- continue;
240
- const maxIter = Number.parseInt(attrs.maxIterations ?? "", 10);
241
- out.push({
242
- kind: "iteration.start",
243
- traceId,
244
- prNumber,
245
- subscription: attrs.subscription ?? "",
246
- iteration,
247
- maxIterations: Number.isFinite(maxIter) ? maxIter : null,
248
- description: attrs.description ?? "",
249
- timestampUnixNano: ts,
250
- });
251
- }
252
- else if (body === SHEPHERD_ITERATION_END_LOG) {
253
- const outcome = attrs.outcome === "hitl" ? "hitl" : "fixed";
254
- out.push({
255
- kind: "iteration.end",
256
- traceId,
257
- prNumber,
258
- outcome,
259
- detail: attrs.detail ?? "",
260
- timestampUnixNano: ts,
261
- });
262
- }
263
- else {
264
- const raw = attrs.outcome;
265
- const outcome = raw === "merged" || raw === "ready" || raw === "hitl"
266
- ? raw
267
- : "hitl"; // Conservative fallback — terminal hitl is the safe default.
268
- out.push({
269
- kind: "ended",
270
- traceId,
271
- prNumber,
272
- outcome,
273
- timestampUnixNano: ts,
274
- });
275
- }
276
- }
277
- }
278
- }
279
- return out;
280
- }
281
368
  /**
282
369
  * VA-388: project an OTLP logs payload into `LogRecordRow`s. Records
283
370
  * without a trace_id are dropped — every Effect log emitted under
@@ -1,19 +1,19 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createLinearAdapter, startLinearSync, } from "./linear-sync.js";
3
3
  import { createEventBus } from "./events.js";
4
- import { extractActiveDrainMarkers, extractShepherdMarkers, projectLogs, projectPayload, } from "./projector.js";
4
+ import { extractActiveDrainMarkers, extractPlannerMarkers, projectLogs, projectPayload, } from "./projector.js";
5
5
  import { createStorage, } from "./storage.js";
6
- import { META_REVIEW_PAGE_SIZE, renderDetailView, renderIssueProcessRows, renderListView, renderLogsSection, renderMetaReviewDetailView, renderMetaReviewListView, } from "./views.js";
6
+ import { META_REVIEW_PAGE_SIZE, renderDetailView, renderIssueProcessRows, renderListView, renderLogsSection, renderMetaReviewDetailView, renderMetaReviewListView, renderPlannerPanel, } from "./views.js";
7
7
  // VA-389: phase spans we surface on the detail page's timeline.
8
8
  // Anything else stays in raw_spans for debugging but isn't rendered.
9
9
  const DETAIL_PHASE_NAMES = [
10
10
  "review",
11
11
  "pushBranch",
12
12
  "openPullRequest",
13
- // VA-460: shepherd span emitted by runShepherdPass when
14
- // `config.shepherdEnabled=true`. Surfaced alongside review so the
15
- // operator sees post-finalize loop time on the timeline. Missing
16
- // for any issue process whose drain ran without the feature gate.
13
+ // VA-460 → VA-482: legacy `shepherd` span surfaced inline by the
14
+ // retired post-finalize loop. Kept in the allowlist so historical
15
+ // traces (issue_processes rows written before VA-482) still
16
+ // render their fourth phase row; new traces never emit it.
17
17
  "shepherd",
18
18
  ];
19
19
  const ISSUE_DETAIL_RE = /^\/issue\/([^/?#]+)\/([^/?#]+)\/?$/;
@@ -152,6 +152,35 @@ async function handle(req, res, storage, linearEnabled, linearWorkspace, events)
152
152
  handleAggregates(res, storage);
153
153
  return;
154
154
  }
155
+ // VA-481: planner panel — recommendations + capability modes.
156
+ if (method === "GET" && (url === "/planner" || url.startsWith("/planner?"))) {
157
+ handlePlannerPanel(res, storage);
158
+ return;
159
+ }
160
+ // VA-481: proxy routes that forward to the Tailscale-gated planner
161
+ // HTTP API. Browser forms POST to the dashboard's same-origin path;
162
+ // the dashboard server-side forwards to RUNWAY_PLANNER_BASE_URL so
163
+ // the planner's same-origin CORS guard is satisfied. The
164
+ // dashboard never inspects request bodies beyond passthrough — the
165
+ // planner remains the source of truth.
166
+ const pathOnly = url.split("?")[0] ?? "";
167
+ if (method === "POST") {
168
+ const acceptMatch = pathOnly.match(/^\/planner-actions\/recommendations\/([^/]+)\/accept$/);
169
+ if (acceptMatch) {
170
+ await handlePlannerProxy(req, res, `/planner/recommendations/${encodeURIComponent(acceptMatch[1] ?? "")}/accept`, "POST");
171
+ return;
172
+ }
173
+ const rejectMatch = pathOnly.match(/^\/planner-actions\/recommendations\/([^/]+)\/reject$/);
174
+ if (rejectMatch) {
175
+ await handlePlannerProxy(req, res, `/planner/recommendations/${encodeURIComponent(rejectMatch[1] ?? "")}/reject`, "POST");
176
+ return;
177
+ }
178
+ const capMatch = pathOnly.match(/^\/planner-actions\/capabilities\/([a-z-]+)$/i);
179
+ if (capMatch) {
180
+ await handlePlannerProxy(req, res, `/planner/capabilities/${encodeURIComponent(capMatch[1] ?? "")}`, "PUT");
181
+ return;
182
+ }
183
+ }
155
184
  if (method === "GET" && url === "/healthz") {
156
185
  res.writeHead(200, { "content-type": "text/plain" });
157
186
  res.end("ok");
@@ -256,37 +285,69 @@ async function handleOtlpLogs(req, res, storage, events) {
256
285
  for (const m of extractActiveDrainMarkers(payload)) {
257
286
  storage.markDrainActive(m);
258
287
  }
259
- // VA-465: shepherd lifecycle markers drive the per-PR sub-card.
260
- // Three log bodies map to three storage methods see
261
- // `extractShepherdMarkers` for the body strings.
262
- for (const m of extractShepherdMarkers(payload)) {
263
- if (m.kind === "iteration.start") {
264
- storage.markShepherdIterationStart({
265
- traceId: m.traceId,
266
- prNumber: m.prNumber,
267
- subscription: m.subscription,
268
- iteration: m.iteration,
269
- maxIterations: m.maxIterations,
270
- description: m.description,
271
- timestampUnixNano: m.timestampUnixNano,
272
- });
273
- }
274
- else if (m.kind === "iteration.end") {
275
- storage.markShepherdIterationEnd({
276
- traceId: m.traceId,
277
- prNumber: m.prNumber,
278
- outcome: m.outcome,
279
- detail: m.detail,
280
- timestampUnixNano: m.timestampUnixNano,
281
- });
282
- }
283
- else {
284
- storage.markShepherdEnded({
285
- traceId: m.traceId,
286
- prNumber: m.prNumber,
287
- outcome: m.outcome,
288
- timestampUnixNano: m.timestampUnixNano,
289
- });
288
+ // VA-482: the inline shepherd's three lifecycle markers
289
+ // (shepherd.iteration.start / iteration.end / ended) are gone
290
+ // the planner emits `planner.action.*` for the same shape of work
291
+ // via the capability dispatchers (VA-477..479).
292
+ // VA-474: planner event markers — six log bodies cover the
293
+ // recommendation lifecycle (proposed / accepted / rejected) and
294
+ // the action lifecycle (started / completed / failed). All five
295
+ // storage writers are idempotent on their primary keys; an OTLP
296
+ // retry or duplicate delivery is a no-op.
297
+ for (const m of extractPlannerMarkers(payload)) {
298
+ switch (m.kind) {
299
+ case "recommendation.proposed":
300
+ storage.recordPlannerRecommendationProposed({
301
+ recommendationId: m.recommendationId,
302
+ capability: m.capability,
303
+ target: m.target,
304
+ summary: m.summary,
305
+ evidence: m.evidence,
306
+ autoApproveEligible: m.autoApproveEligible,
307
+ timestampUnixNano: m.timestampUnixNano,
308
+ });
309
+ break;
310
+ case "recommendation.accepted":
311
+ case "recommendation.rejected":
312
+ storage.recordPlannerRecommendationDecision({
313
+ recommendationId: m.recommendationId,
314
+ status: m.status,
315
+ source: m.source,
316
+ reason: m.reason,
317
+ timestampUnixNano: m.timestampUnixNano,
318
+ });
319
+ break;
320
+ case "action.started":
321
+ storage.recordPlannerActionStarted({
322
+ actionId: m.actionId,
323
+ recommendationId: m.recommendationId,
324
+ capability: m.capability,
325
+ target: m.target,
326
+ timestampUnixNano: m.timestampUnixNano,
327
+ });
328
+ break;
329
+ case "action.completed":
330
+ storage.recordPlannerActionCompleted({
331
+ actionId: m.actionId,
332
+ outcome: m.outcome,
333
+ timestampUnixNano: m.timestampUnixNano,
334
+ });
335
+ break;
336
+ case "action.failed":
337
+ storage.recordPlannerActionFailed({
338
+ actionId: m.actionId,
339
+ error: m.error,
340
+ timestampUnixNano: m.timestampUnixNano,
341
+ });
342
+ break;
343
+ case "capability.mode_changed":
344
+ storage.recordPlannerCapabilityModeChange({
345
+ capability: m.capability,
346
+ mode: m.mode,
347
+ updatedBy: m.updatedBy,
348
+ timestampUnixNano: m.timestampUnixNano,
349
+ });
350
+ break;
290
351
  }
291
352
  }
292
353
  for (const r of projectLogs(payload)) {
@@ -422,19 +483,14 @@ function handleListView(req, res, storage, linearEnabled, linearWorkspace) {
422
483
  // `drains` row). `null` becomes the "Idle — no active drain"
423
484
  // state in views.
424
485
  const activeDrain = storage.getActiveDrain();
425
- // VA-465: when a drain is active, surface its in-flight shepherding
426
- // PRs as a sub-strip under the drain strip. Idle drain → no
427
- // shepherd rows to query.
428
- const activeShepherding = activeDrain
429
- ? storage.getActiveShepherding(activeDrain.traceId)
430
- : [];
486
+ // VA-465 → VA-482: the inline shepherd sub-strip is retired; per-PR
487
+ // state now lives in the planner panel at `/planner`.
431
488
  const html = renderListView({
432
489
  rows,
433
490
  linearEnabled,
434
491
  todoQueue,
435
492
  snapshotsByIdentifier,
436
493
  activeDrain,
437
- activeShepherding,
438
494
  filterState,
439
495
  recentDrains,
440
496
  isFiltered: isFilteredState(filterState),
@@ -585,6 +641,132 @@ function handleAggregates(res, storage) {
585
641
  res.writeHead(200, { "content-type": "application/json" });
586
642
  res.end(JSON.stringify({ view: "evaluator_aggregates_v1", rows }));
587
643
  }
644
+ const KNOWN_PLANNER_CAPABILITIES = [
645
+ { capability: "queue-selection" },
646
+ { capability: "shepherd-mergeability" },
647
+ { capability: "shepherd-ci" },
648
+ { capability: "shepherd-reviewer" },
649
+ ];
650
+ /**
651
+ * VA-481: forward an operator-initiated POST/PUT from the dashboard
652
+ * UI to the planner's HTTP API. The dashboard runs as a separate
653
+ * process; making the call server-side preserves the planner's
654
+ * same-origin CORS guard without an allowlist. Body is parsed from
655
+ * the operator's HTML form (urlencoded `reason=...` for reject,
656
+ * `mode=...` for capability toggle) and re-serialized as JSON for
657
+ * the planner. On success/202 from the planner, the dashboard 303s
658
+ * back to `/planner` so the operator sees the refreshed list.
659
+ */
660
+ async function handlePlannerProxy(req, res, plannerPath, method) {
661
+ const baseUrl = process.env.RUNWAY_PLANNER_BASE_URL ?? "http://127.0.0.1:3002";
662
+ // Parse the form body — content-type may be application/json (api)
663
+ // or application/x-www-form-urlencoded (form submit).
664
+ const chunks = [];
665
+ for await (const chunk of req) {
666
+ chunks.push(chunk);
667
+ // Match the planner's 64 KiB cap so a giant body doesn't OOM.
668
+ const total = chunks.reduce((s, c) => s + c.length, 0);
669
+ if (total > 64 * 1024) {
670
+ res.writeHead(413, { "content-type": "application/json" });
671
+ res.end(JSON.stringify({ error: { code: "payload_too_large", message: "body too large" } }));
672
+ return;
673
+ }
674
+ }
675
+ const raw = Buffer.concat(chunks).toString("utf8");
676
+ const ct = String(req.headers["content-type"] ?? "").toLowerCase();
677
+ let bodyJson;
678
+ if (ct.includes("application/x-www-form-urlencoded")) {
679
+ const params = new URLSearchParams(raw);
680
+ const obj = {};
681
+ for (const [k, v] of params.entries()) {
682
+ if (v.length > 0)
683
+ obj[k] = v;
684
+ }
685
+ bodyJson = JSON.stringify(obj);
686
+ }
687
+ else if (raw.length === 0) {
688
+ bodyJson = "{}";
689
+ }
690
+ else {
691
+ bodyJson = raw; // assume JSON; let planner validate
692
+ }
693
+ let upstream;
694
+ try {
695
+ upstream = await fetch(`${baseUrl}${plannerPath}`, {
696
+ method,
697
+ headers: { "content-type": "application/json" },
698
+ body: bodyJson,
699
+ });
700
+ }
701
+ catch (err) {
702
+ res.writeHead(502, { "content-type": "application/json" });
703
+ res.end(JSON.stringify({
704
+ error: {
705
+ code: "planner_unreachable",
706
+ message: err instanceof Error ? err.message : String(err),
707
+ },
708
+ }));
709
+ return;
710
+ }
711
+ // Form submissions expect a 303 redirect back to the panel. JSON
712
+ // clients (curl / fetch from a script) get the planner's status
713
+ // mirrored verbatim. The dashboard distinguishes via the
714
+ // `Accept` header — browser form posts default to text/html.
715
+ const accept = String(req.headers.accept ?? "").toLowerCase();
716
+ const wantsRedirect = accept.includes("text/html") &&
717
+ upstream.status >= 200 &&
718
+ upstream.status < 400;
719
+ if (wantsRedirect) {
720
+ res.writeHead(303, { Location: "/planner" });
721
+ res.end();
722
+ return;
723
+ }
724
+ const upstreamBody = await upstream.text();
725
+ res.writeHead(upstream.status, {
726
+ "content-type": upstream.headers.get("content-type") ?? "application/json",
727
+ });
728
+ res.end(upstreamBody);
729
+ }
730
+ function handlePlannerPanel(res, storage) {
731
+ const explicit = storage.listPlannerCapabilityModes();
732
+ const byCap = new Map(explicit.map((r) => [r.capability, r]));
733
+ const capabilities = KNOWN_PLANNER_CAPABILITIES.map((c) => {
734
+ const row = byCap.get(c.capability);
735
+ return row
736
+ ? {
737
+ capability: row.capability,
738
+ mode: row.mode,
739
+ updatedUnixNano: row.updatedUnixNano,
740
+ updatedBy: row.updatedBy,
741
+ }
742
+ : {
743
+ capability: c.capability,
744
+ mode: "suggest",
745
+ updatedUnixNano: null,
746
+ updatedBy: null,
747
+ };
748
+ });
749
+ const recommendations = storage
750
+ .getRecentPlannerRecommendations(20)
751
+ .map((r) => ({
752
+ recommendationId: r.recommendationId,
753
+ capability: r.capability,
754
+ target: r.target,
755
+ summary: r.summary,
756
+ status: r.status,
757
+ autoApproveEligible: r.autoApproveEligible,
758
+ proposedUnixNano: r.proposedUnixNano,
759
+ decidedBy: r.decidedBy,
760
+ }));
761
+ const plannerBaseUrl = process.env.RUNWAY_PLANNER_BASE_URL ?? "http://127.0.0.1:3002";
762
+ const html = renderPlannerPanel({
763
+ capabilities,
764
+ recommendations,
765
+ plannerBaseUrl,
766
+ });
767
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
768
+ res.end(html);
769
+ }
588
770
  /**
589
771
  * VA-407: GET `/meta-reviews` — paginated list view of meta_reviews
590
772
  * rows. `?kind=<kind>` narrows to one kind; `?page=N` is 1-based and