@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.
- package/README.md +15 -2
- package/dist/cli.js +7 -0
- package/dist/commands/planner.js +265 -0
- package/dist/commands/run.js +35 -3
- package/dist/config.js +27 -0
- package/dist/dashboard/projector.js +177 -0
- package/dist/dashboard/server.js +234 -3
- package/dist/dashboard/storage.js +354 -0
- package/dist/dashboard/views.js +141 -0
- package/dist/github.js +350 -0
- package/dist/orchestrator.js +19 -1
- package/dist/planner-capability-mode.js +42 -0
- package/dist/planner-ci.js +283 -0
- package/dist/planner-drain.js +224 -0
- package/dist/planner-http.js +374 -0
- package/dist/planner-mergeability.js +344 -0
- package/dist/planner-queue-selection.js +104 -0
- package/dist/planner-reviewer.js +433 -0
- package/dist/planner-storage.js +339 -0
- package/dist/planner.js +150 -0
- package/dist/prompts.js +99 -0
- package/package.json +1 -1
- package/prompts/shepherd-ci-fix.md +47 -0
- package/prompts/shepherd-rebase.md +40 -0
- package/prompts/shepherd-review-fix.md +40 -0
- package/prompts/shepherd-review-respond.md +31 -0
- package/templates/com.valescoagency.runway-planner.plist +88 -0
package/dist/dashboard/server.js
CHANGED
|
@@ -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 = [
|
|
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 {
|