@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.
- package/README.md +13 -7
- package/dist/cli.js +7 -0
- package/dist/commands/planner.js +265 -0
- package/dist/commands/run.js +35 -3
- package/dist/config.js +22 -17
- package/dist/dashboard/projector.js +175 -88
- package/dist/dashboard/server.js +226 -44
- package/dist/dashboard/storage.js +233 -0
- package/dist/dashboard/views.js +112 -47
- package/dist/orchestrator.js +19 -45
- 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/package.json +1 -1
- package/templates/com.valescoagency.runway-planner.plist +88 -0
- package/dist/shepherd.js +0 -707
|
@@ -149,15 +149,182 @@ function strArrayAttr(v) {
|
|
|
149
149
|
*/
|
|
150
150
|
export const DRAIN_STARTED_LOG = "drain.started";
|
|
151
151
|
/**
|
|
152
|
-
* VA-
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
159
|
-
export const
|
|
160
|
-
export const
|
|
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
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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,
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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-
|
|
260
|
-
//
|
|
261
|
-
// `
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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:
|
|
426
|
-
//
|
|
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
|