@valescoagency/runway 0.14.3 → 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.
@@ -1,12 +1,21 @@
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, 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
- const DETAIL_PHASE_NAMES = ["review", "pushBranch", "openPullRequest"];
9
+ const DETAIL_PHASE_NAMES = [
10
+ "review",
11
+ "pushBranch",
12
+ "openPullRequest",
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
+ "shepherd",
18
+ ];
10
19
  const ISSUE_DETAIL_RE = /^\/issue\/([^/?#]+)\/([^/?#]+)\/?$/;
11
20
  // VA-387: canonical detail route. `:id` is the issue process span_id;
12
21
  // the lookup falls back to the (trace_id, span_id) pair only for
@@ -143,6 +152,35 @@ async function handle(req, res, storage, linearEnabled, linearWorkspace, events)
143
152
  handleAggregates(res, storage);
144
153
  return;
145
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
+ }
146
184
  if (method === "GET" && url === "/healthz") {
147
185
  res.writeHead(200, { "content-type": "text/plain" });
148
186
  res.end("ok");
@@ -247,6 +285,71 @@ async function handleOtlpLogs(req, res, storage, events) {
247
285
  for (const m of extractActiveDrainMarkers(payload)) {
248
286
  storage.markDrainActive(m);
249
287
  }
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;
351
+ }
352
+ }
250
353
  for (const r of projectLogs(payload)) {
251
354
  storage.appendLogRecord(r);
252
355
  // VA-391: the SSE detail-pane stream live-tails the Logs section
@@ -380,6 +483,8 @@ function handleListView(req, res, storage, linearEnabled, linearWorkspace) {
380
483
  // `drains` row). `null` becomes the "Idle — no active drain"
381
484
  // state in views.
382
485
  const activeDrain = storage.getActiveDrain();
486
+ // VA-465 → VA-482: the inline shepherd sub-strip is retired; per-PR
487
+ // state now lives in the planner panel at `/planner`.
383
488
  const html = renderListView({
384
489
  rows,
385
490
  linearEnabled,
@@ -536,6 +641,132 @@ function handleAggregates(res, storage) {
536
641
  res.writeHead(200, { "content-type": "application/json" });
537
642
  res.end(JSON.stringify({ view: "evaluator_aggregates_v1", rows }));
538
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
+ }
539
770
  /**
540
771
  * VA-407: GET `/meta-reviews` — paginated list view of meta_reviews
541
772
  * rows. `?kind=<kind>` narrows to one kind; `?page=N` is 1-based and
@@ -159,6 +159,88 @@ const SCHEMA = `
159
159
 
160
160
  CREATE INDEX IF NOT EXISTS idx_meta_reviews_kind_created
161
161
  ON meta_reviews(kind, created_at DESC);
162
+
163
+ -- VA-465: per-PR shepherding state surfaced as a sub-card under the
164
+ -- active-drain card. One row per (trace_id, pr_number); the watcher
165
+ -- loop may iterate multiple times per PR before exiting. State
166
+ -- transitions acting -> ready/merged/hitl; the latter three are
167
+ -- terminal and set ended_unix_nano. The projector's three
168
+ -- shepherd.* log markers feed this table; the list view reads
169
+ -- getActiveShepherding(traceId) (non-terminal rows only).
170
+ CREATE TABLE IF NOT EXISTS shepherding_prs (
171
+ trace_id TEXT NOT NULL,
172
+ pr_number INTEGER NOT NULL,
173
+ subscription TEXT,
174
+ iteration INTEGER,
175
+ max_iterations INTEGER,
176
+ state TEXT NOT NULL,
177
+ description TEXT,
178
+ started_unix_nano TEXT NOT NULL,
179
+ last_seen_unix_nano TEXT NOT NULL,
180
+ ended_unix_nano TEXT,
181
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now')),
182
+ PRIMARY KEY (trace_id, pr_number)
183
+ );
184
+
185
+ CREATE INDEX IF NOT EXISTS idx_shepherding_prs_trace_active
186
+ ON shepherding_prs(trace_id, ended_unix_nano);
187
+
188
+ -- VA-474: planner-daemon event projections (ADR 0004). Two
189
+ -- read-projections written exclusively by handleOtlpLogs, one
190
+ -- per top-level event class. The planner itself never writes
191
+ -- here — these tables exist on the dashboard side of the OTLP
192
+ -- boundary.
193
+ --
194
+ -- evidence_json round-trips JSON; auto_approve_eligible is a
195
+ -- 0/1 INTEGER. decided_* are NULL while status='proposed'.
196
+ CREATE TABLE IF NOT EXISTS planner_recommendations (
197
+ recommendation_id TEXT PRIMARY KEY,
198
+ capability TEXT NOT NULL,
199
+ target TEXT NOT NULL,
200
+ summary TEXT NOT NULL,
201
+ evidence_json TEXT NOT NULL DEFAULT '{}',
202
+ status TEXT NOT NULL,
203
+ proposed_unix_nano TEXT NOT NULL,
204
+ decided_unix_nano TEXT,
205
+ decided_by TEXT,
206
+ decision_reason TEXT,
207
+ auto_approve_eligible INTEGER NOT NULL DEFAULT 0,
208
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now'))
209
+ );
210
+
211
+ CREATE INDEX IF NOT EXISTS idx_planner_recommendations_status
212
+ ON planner_recommendations(status, proposed_unix_nano);
213
+
214
+ -- recommendation_id is nullable because an action could in
215
+ -- principle fire without a prior recommendation (e.g. operator-
216
+ -- triggered direct invocation in VA-475). v1 callers always set
217
+ -- it; the column shape leaves room for the looser case.
218
+ CREATE TABLE IF NOT EXISTS planner_actions (
219
+ action_id TEXT PRIMARY KEY,
220
+ recommendation_id TEXT,
221
+ capability TEXT NOT NULL,
222
+ target TEXT NOT NULL,
223
+ status TEXT NOT NULL,
224
+ started_unix_nano TEXT NOT NULL,
225
+ ended_unix_nano TEXT,
226
+ outcome TEXT,
227
+ error TEXT,
228
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now'))
229
+ );
230
+
231
+ CREATE INDEX IF NOT EXISTS idx_planner_actions_status
232
+ ON planner_actions(status, started_unix_nano);
233
+
234
+ -- VA-481: per-capability mode (manual / suggest / auto). One row
235
+ -- per capability; defaults to 'suggest' on first observation. The
236
+ -- planner reads this table on every tick to decide whether to
237
+ -- propose, auto-accept, or stay silent for a given capability.
238
+ CREATE TABLE IF NOT EXISTS planner_capability_modes (
239
+ capability TEXT PRIMARY KEY,
240
+ mode TEXT NOT NULL,
241
+ updated_unix_nano TEXT NOT NULL,
242
+ updated_by TEXT NOT NULL
243
+ );
162
244
  `;
163
245
  const DEFAULT_AGGREGATE_WINDOW = 30;
164
246
  /**
@@ -673,6 +755,219 @@ export function createStorage(path, opts = {}) {
673
755
  const markDrainActive = (m) => {
674
756
  markActiveDrainStmt.run(m.traceId, m.spanId, m.startTimeUnixNano, m.lastSeenUnixNano);
675
757
  };
758
+ // VA-465: shepherd-state prepared statements.
759
+ //
760
+ // `markShepherdIterationStartStmt` inserts on first iteration or
761
+ // updates an existing row to bump the iteration / subscription /
762
+ // description / last_seen. The CONFLICT branch only overwrites
763
+ // when (a) the row isn't already terminated AND (b) the incoming
764
+ // timestamp is newer than the stored last_seen — protects against
765
+ // out-of-order batch arrival.
766
+ const markShepherdIterationStartStmt = db.prepare(`
767
+ INSERT INTO shepherding_prs (
768
+ trace_id, pr_number, subscription, iteration, max_iterations,
769
+ state, description, started_unix_nano, last_seen_unix_nano
770
+ ) VALUES (?, ?, ?, ?, ?, 'acting', ?, ?, ?)
771
+ ON CONFLICT (trace_id, pr_number) DO UPDATE SET
772
+ subscription = excluded.subscription,
773
+ iteration = excluded.iteration,
774
+ max_iterations = COALESCE(excluded.max_iterations, shepherding_prs.max_iterations),
775
+ state = 'acting',
776
+ description = excluded.description,
777
+ last_seen_unix_nano = excluded.last_seen_unix_nano
778
+ WHERE shepherding_prs.ended_unix_nano IS NULL
779
+ AND CAST(shepherding_prs.last_seen_unix_nano AS INTEGER)
780
+ < CAST(excluded.last_seen_unix_nano AS INTEGER)
781
+ `);
782
+ // `markShepherdIterationEndStmt` bumps last_seen + description on
783
+ // a row that exists. UPDATE-only (no INSERT) — an end without a
784
+ // start is dropped silently.
785
+ const markShepherdIterationEndStmt = db.prepare(`
786
+ UPDATE shepherding_prs
787
+ SET description = ?, last_seen_unix_nano = ?
788
+ WHERE trace_id = ? AND pr_number = ?
789
+ AND ended_unix_nano IS NULL
790
+ AND CAST(last_seen_unix_nano AS INTEGER) < CAST(? AS INTEGER)
791
+ `);
792
+ // `markShepherdEndedStmt` terminates the row. The CASE WHEN guard
793
+ // protects against a stale end-marker arriving after a fresher
794
+ // start (which can happen with batched OTLP flushes).
795
+ const markShepherdEndedStmt = db.prepare(`
796
+ INSERT INTO shepherding_prs (
797
+ trace_id, pr_number, state, started_unix_nano,
798
+ last_seen_unix_nano, ended_unix_nano
799
+ ) VALUES (?, ?, ?, ?, ?, ?)
800
+ ON CONFLICT (trace_id, pr_number) DO UPDATE SET
801
+ state = excluded.state,
802
+ last_seen_unix_nano = excluded.last_seen_unix_nano,
803
+ ended_unix_nano = excluded.ended_unix_nano
804
+ WHERE shepherding_prs.ended_unix_nano IS NULL
805
+ OR CAST(shepherding_prs.ended_unix_nano AS INTEGER)
806
+ < CAST(excluded.ended_unix_nano AS INTEGER)
807
+ `);
808
+ const getActiveShepherdingStmt = db.prepare(`
809
+ SELECT
810
+ trace_id, pr_number, subscription, iteration, max_iterations,
811
+ state, description, started_unix_nano, last_seen_unix_nano,
812
+ ended_unix_nano
813
+ FROM shepherding_prs
814
+ WHERE trace_id = ? AND ended_unix_nano IS NULL
815
+ ORDER BY CAST(started_unix_nano AS INTEGER) ASC
816
+ `);
817
+ const markShepherdIterationStart = (m) => {
818
+ markShepherdIterationStartStmt.run(m.traceId, m.prNumber, m.subscription, m.iteration, m.maxIterations, m.description, m.timestampUnixNano, m.timestampUnixNano);
819
+ };
820
+ const markShepherdIterationEnd = (m) => {
821
+ markShepherdIterationEndStmt.run(m.detail, m.timestampUnixNano, m.traceId, m.prNumber, m.timestampUnixNano);
822
+ };
823
+ const markShepherdEnded = (m) => {
824
+ // VA-465: only `merged` terminates the row (sets ended_unix_nano
825
+ // → excluded from getActiveShepherding). `ready` and `hitl`
826
+ // leave ended_unix_nano NULL so the sub-strip continues to
827
+ // render them as "ready, awaiting human merge" / "HITL — needs
828
+ // human attention" rows while the drain is still active. The
829
+ // rows naturally disappear when the drain ends, since the list
830
+ // view only queries getActiveShepherding when activeDrain is
831
+ // non-null.
832
+ const endedNano = m.outcome === "merged" ? m.timestampUnixNano : null;
833
+ markShepherdEndedStmt.run(m.traceId, m.prNumber, m.outcome, m.timestampUnixNano, m.timestampUnixNano, endedNano);
834
+ };
835
+ // VA-474: planner-daemon event projections. Five write methods
836
+ // (proposed / decision / action-started / action-completed /
837
+ // action-failed) all guarded for idempotency so OTLP retries +
838
+ // planner restarts can't corrupt state.
839
+ const insertPlannerRecommendationStmt = db.prepare(`
840
+ INSERT INTO planner_recommendations (
841
+ recommendation_id, capability, target, summary, evidence_json,
842
+ status, proposed_unix_nano, auto_approve_eligible
843
+ ) VALUES (?, ?, ?, ?, ?, 'proposed', ?, ?)
844
+ ON CONFLICT (recommendation_id) DO NOTHING
845
+ `);
846
+ const updatePlannerRecommendationDecisionStmt = db.prepare(`
847
+ UPDATE planner_recommendations
848
+ SET status = ?,
849
+ decided_unix_nano = ?,
850
+ decided_by = ?,
851
+ decision_reason = ?
852
+ WHERE recommendation_id = ? AND status = 'proposed'
853
+ `);
854
+ const insertPlannerActionStmt = db.prepare(`
855
+ INSERT INTO planner_actions (
856
+ action_id, recommendation_id, capability, target, status,
857
+ started_unix_nano
858
+ ) VALUES (?, ?, ?, ?, 'started', ?)
859
+ ON CONFLICT (action_id) DO NOTHING
860
+ `);
861
+ const updatePlannerActionCompletedStmt = db.prepare(`
862
+ UPDATE planner_actions
863
+ SET status = 'completed',
864
+ ended_unix_nano = ?,
865
+ outcome = ?
866
+ WHERE action_id = ? AND status = 'started'
867
+ `);
868
+ const updatePlannerActionFailedStmt = db.prepare(`
869
+ UPDATE planner_actions
870
+ SET status = 'failed',
871
+ ended_unix_nano = ?,
872
+ error = ?
873
+ WHERE action_id = ? AND status = 'started'
874
+ `);
875
+ const getRecentPlannerRecommendationsStmt = db.prepare(`
876
+ SELECT
877
+ recommendation_id, capability, target, summary, evidence_json,
878
+ status, proposed_unix_nano, decided_unix_nano, decided_by,
879
+ decision_reason, auto_approve_eligible
880
+ FROM planner_recommendations
881
+ ORDER BY CAST(proposed_unix_nano AS INTEGER) DESC
882
+ LIMIT ?
883
+ `);
884
+ const getPlannerRecommendationStmt = db.prepare(`
885
+ SELECT
886
+ recommendation_id, capability, target, summary, evidence_json,
887
+ status, proposed_unix_nano, decided_unix_nano, decided_by,
888
+ decision_reason, auto_approve_eligible
889
+ FROM planner_recommendations
890
+ WHERE recommendation_id = ?
891
+ `);
892
+ const getActivePlannerActionsStmt = db.prepare(`
893
+ SELECT
894
+ action_id, recommendation_id, capability, target, status,
895
+ started_unix_nano, ended_unix_nano, outcome, error
896
+ FROM planner_actions
897
+ WHERE status = 'started'
898
+ ORDER BY CAST(started_unix_nano AS INTEGER) ASC
899
+ `);
900
+ const recordPlannerRecommendationProposed = (m) => {
901
+ insertPlannerRecommendationStmt.run(m.recommendationId, m.capability, m.target, m.summary, JSON.stringify(m.evidence ?? {}), m.timestampUnixNano, m.autoApproveEligible ? 1 : 0);
902
+ };
903
+ const recordPlannerRecommendationDecision = (m) => {
904
+ updatePlannerRecommendationDecisionStmt.run(m.status, m.timestampUnixNano, m.source, m.reason, m.recommendationId);
905
+ };
906
+ const recordPlannerActionStarted = (m) => {
907
+ insertPlannerActionStmt.run(m.actionId, m.recommendationId, m.capability, m.target, m.timestampUnixNano);
908
+ };
909
+ const recordPlannerActionCompleted = (m) => {
910
+ updatePlannerActionCompletedStmt.run(m.timestampUnixNano, m.outcome, m.actionId);
911
+ };
912
+ const recordPlannerActionFailed = (m) => {
913
+ updatePlannerActionFailedStmt.run(m.timestampUnixNano, m.error, m.actionId);
914
+ };
915
+ // VA-481: capability-mode upsert. The WHERE clause ensures a stale
916
+ // marker doesn't clobber a newer mode change.
917
+ const upsertCapabilityModeStmt = db.prepare(`
918
+ INSERT INTO planner_capability_modes
919
+ (capability, mode, updated_unix_nano, updated_by)
920
+ VALUES (?, ?, ?, ?)
921
+ ON CONFLICT (capability) DO UPDATE SET
922
+ mode = excluded.mode,
923
+ updated_unix_nano = excluded.updated_unix_nano,
924
+ updated_by = excluded.updated_by
925
+ WHERE CAST(planner_capability_modes.updated_unix_nano AS INTEGER)
926
+ < CAST(excluded.updated_unix_nano AS INTEGER)
927
+ `);
928
+ const listCapabilityModesStmt = db.prepare(`
929
+ SELECT capability, mode, updated_unix_nano, updated_by
930
+ FROM planner_capability_modes
931
+ `);
932
+ const recordPlannerCapabilityModeChange = (m) => {
933
+ upsertCapabilityModeStmt.run(m.capability, m.mode, m.timestampUnixNano, m.updatedBy);
934
+ };
935
+ const listPlannerCapabilityModes = () => {
936
+ const rows = listCapabilityModesStmt.all();
937
+ return rows.map((r) => ({
938
+ capability: String(r.capability ?? ""),
939
+ mode: String(r.mode ?? "suggest"),
940
+ updatedUnixNano: String(r.updated_unix_nano ?? ""),
941
+ updatedBy: String(r.updated_by ?? "operator"),
942
+ }));
943
+ };
944
+ const getRecentPlannerRecommendations = (limit = 50) => {
945
+ const rows = getRecentPlannerRecommendationsStmt.all(limit);
946
+ return rows.map(rowToPlannerRecommendation);
947
+ };
948
+ const getPlannerRecommendation = (recommendationId) => {
949
+ const row = getPlannerRecommendationStmt.get(recommendationId);
950
+ return row ? rowToPlannerRecommendation(row) : undefined;
951
+ };
952
+ const getActivePlannerActions = () => {
953
+ const rows = getActivePlannerActionsStmt.all();
954
+ return rows.map(rowToPlannerAction);
955
+ };
956
+ const getActiveShepherding = (traceId) => {
957
+ const rows = getActiveShepherdingStmt.all(traceId);
958
+ return rows.map((r) => ({
959
+ traceId: r.trace_id,
960
+ prNumber: r.pr_number,
961
+ subscription: r.subscription,
962
+ iteration: r.iteration,
963
+ maxIterations: r.max_iterations,
964
+ state: r.state,
965
+ description: r.description,
966
+ startedUnixNano: r.started_unix_nano,
967
+ lastSeenUnixNano: r.last_seen_unix_nano,
968
+ endedUnixNano: r.ended_unix_nano,
969
+ }));
970
+ };
676
971
  const saveIssueProcess = (p) => {
677
972
  insertIssueProcess.run(p.traceId, p.spanId, p.parentSpanId, p.issueIdentifier, p.issueId, p.issueTitle,
678
973
  // VA-387: labels round-trip as a JSON array string. Keeping them
@@ -950,6 +1245,20 @@ export function createStorage(path, opts = {}) {
950
1245
  appendLogRecord,
951
1246
  streamLogsFor,
952
1247
  getActiveDrain,
1248
+ markShepherdIterationStart,
1249
+ markShepherdIterationEnd,
1250
+ markShepherdEnded,
1251
+ getActiveShepherding,
1252
+ recordPlannerRecommendationProposed,
1253
+ recordPlannerRecommendationDecision,
1254
+ recordPlannerActionStarted,
1255
+ recordPlannerActionCompleted,
1256
+ recordPlannerActionFailed,
1257
+ recordPlannerCapabilityModeChange,
1258
+ listPlannerCapabilityModes,
1259
+ getRecentPlannerRecommendations,
1260
+ getPlannerRecommendation,
1261
+ getActivePlannerActions,
953
1262
  saveMetaReview,
954
1263
  listMetaReviews,
955
1264
  countMetaReviews,
@@ -964,6 +1273,51 @@ export function createStorage(path, opts = {}) {
964
1273
  function asInt(n) {
965
1274
  return n === null || n === undefined ? null : n;
966
1275
  }
1276
+ // VA-474: planner_recommendations row → PlannerRecommendationRow.
1277
+ // `evidence_json` round-trips JSON; malformed JSON collapses to {}
1278
+ // rather than crashing the read path (an emitter bug shouldn't take
1279
+ // the dashboard down).
1280
+ function rowToPlannerRecommendation(row) {
1281
+ let evidence = {};
1282
+ const raw = row.evidence_json;
1283
+ if (typeof raw === "string" && raw.length > 0) {
1284
+ try {
1285
+ const parsed = JSON.parse(raw);
1286
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1287
+ evidence = parsed;
1288
+ }
1289
+ }
1290
+ catch {
1291
+ // Drop to default {} on malformed JSON.
1292
+ }
1293
+ }
1294
+ return {
1295
+ recommendationId: String(row.recommendation_id ?? ""),
1296
+ capability: String(row.capability ?? ""),
1297
+ target: String(row.target ?? ""),
1298
+ summary: String(row.summary ?? ""),
1299
+ evidence,
1300
+ status: String(row.status ?? "proposed"),
1301
+ proposedUnixNano: String(row.proposed_unix_nano ?? ""),
1302
+ decidedUnixNano: nullableStr(row.decided_unix_nano),
1303
+ decidedBy: nullableStr(row.decided_by),
1304
+ decisionReason: nullableStr(row.decision_reason),
1305
+ autoApproveEligible: Number(row.auto_approve_eligible ?? 0) === 1,
1306
+ };
1307
+ }
1308
+ function rowToPlannerAction(row) {
1309
+ return {
1310
+ actionId: String(row.action_id ?? ""),
1311
+ recommendationId: nullableStr(row.recommendation_id),
1312
+ capability: String(row.capability ?? ""),
1313
+ target: String(row.target ?? ""),
1314
+ status: String(row.status ?? "started"),
1315
+ startedUnixNano: String(row.started_unix_nano ?? ""),
1316
+ endedUnixNano: nullableStr(row.ended_unix_nano),
1317
+ outcome: nullableStr(row.outcome),
1318
+ error: nullableStr(row.error),
1319
+ };
1320
+ }
967
1321
  function rowToIssueProcess(row) {
968
1322
  const r = row;
969
1323
  return {