@valescoagency/runway 0.14.3 → 0.15.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 +8 -1
- package/dist/config.js +22 -0
- package/dist/dashboard/projector.js +90 -0
- package/dist/dashboard/server.js +51 -2
- package/dist/dashboard/storage.js +121 -0
- package/dist/dashboard/views.js +77 -1
- package/dist/github.js +350 -0
- package/dist/orchestrator.js +44 -0
- package/dist/prompts.js +99 -0
- package/dist/shepherd.js +707 -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/README.md
CHANGED
|
@@ -218,6 +218,13 @@ export LINEAR_API_KEY=lin_api_...
|
|
|
218
218
|
# export RUNWAY_READY_LABEL="ready-for-agent"
|
|
219
219
|
# export RUNWAY_HITL_LABEL="ready-for-human"
|
|
220
220
|
# export RUNWAY_MAX_ITERATIONS=5
|
|
221
|
+
# export RUNWAY_SHEPHERD_ENABLED=true # VA-460: opt into post-PR shepherd loop (default off)
|
|
222
|
+
# export RUNWAY_SHEPHERD_MAX_ITERATIONS=3 # VA-464: max fix attempts per PR before HITL
|
|
223
|
+
# export RUNWAY_SHEPHERD_MAX_WALL_SECONDS=3600 # VA-464: wall-clock budget per PR before HITL
|
|
224
|
+
# export RUNWAY_SHEPHERD_POLL_INTERVAL_SECONDS=15 # VA-460: polling cadence for the shepherd loop
|
|
225
|
+
# export RUNWAY_PR_REVIEWER_BOT_LOGIN="runway-reviewer-bot"
|
|
226
|
+
# VA-463: GitHub login of the adversarial PR-reviewer agent.
|
|
227
|
+
# Unset → all PR comments routed to the human-feedback path.
|
|
221
228
|
# export RUNWAY_COMMENT_AUTHOR_ALLOWLIST="Reviewer Bot,Jane Reviewer"
|
|
222
229
|
# optional, comma-separated Linear user names whose comments on a
|
|
223
230
|
# re-queued issue surface as "Review feedback from prior attempts"
|
|
@@ -541,7 +548,7 @@ These are tractable, just not v1.
|
|
|
541
548
|
|
|
542
549
|
## Status
|
|
543
550
|
|
|
544
|
-
0.
|
|
551
|
+
0.15.0 — production-shaped and dogfooded against live Linear queues.
|
|
545
552
|
The end-to-end pipeline (init → run → review → PR) is stable; surface
|
|
546
553
|
may still shift as the orchestrator's policy and iteration mechanics
|
|
547
554
|
mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
|
package/dist/config.js
CHANGED
|
@@ -45,6 +45,23 @@ const configEffect = EConfig.all({
|
|
|
45
45
|
metaFilterThresholdsJson: EConfig.option(EConfig.string("RUNWAY_META_FILTER_THRESHOLDS")),
|
|
46
46
|
runwayRepoProjectName: EConfig.option(EConfig.string("RUNWAY_REPO_PROJECT_NAME")),
|
|
47
47
|
metaWeeklyModel: EConfig.option(EConfig.string("RUNWAY_META_WEEKLY_MODEL")),
|
|
48
|
+
// VA-460: feature gate; default `false` for the first ship so the
|
|
49
|
+
// composer's post-finalize behavior is unchanged until VA-461..463
|
|
50
|
+
// wire the subscriptions.
|
|
51
|
+
shepherdEnabled: EConfig.boolean("RUNWAY_SHEPHERD_ENABLED").pipe(EConfig.withDefault(false)),
|
|
52
|
+
shepherdPollIntervalSeconds: EConfig.integer("RUNWAY_SHEPHERD_POLL_INTERVAL_SECONDS").pipe(EConfig.withDefault(15), EConfig.validate({
|
|
53
|
+
message: "RUNWAY_SHEPHERD_POLL_INTERVAL_SECONDS must be a positive integer",
|
|
54
|
+
validation: (n) => n > 0,
|
|
55
|
+
})),
|
|
56
|
+
prReviewerBotLogin: EConfig.option(EConfig.string("RUNWAY_PR_REVIEWER_BOT_LOGIN")),
|
|
57
|
+
shepherdMaxIterations: EConfig.integer("RUNWAY_SHEPHERD_MAX_ITERATIONS").pipe(EConfig.withDefault(3), EConfig.validate({
|
|
58
|
+
message: "RUNWAY_SHEPHERD_MAX_ITERATIONS must be a positive integer",
|
|
59
|
+
validation: (n) => n > 0,
|
|
60
|
+
})),
|
|
61
|
+
shepherdMaxWallSeconds: EConfig.integer("RUNWAY_SHEPHERD_MAX_WALL_SECONDS").pipe(EConfig.withDefault(3600), EConfig.validate({
|
|
62
|
+
message: "RUNWAY_SHEPHERD_MAX_WALL_SECONDS must be a positive integer",
|
|
63
|
+
validation: (n) => n > 0,
|
|
64
|
+
})),
|
|
48
65
|
}).pipe(Effect.map((raw) => ({
|
|
49
66
|
linearApiKey: raw.linearApiKey,
|
|
50
67
|
opServiceAccountToken: Option.getOrUndefined(raw.opServiceAccountToken),
|
|
@@ -66,6 +83,11 @@ const configEffect = EConfig.all({
|
|
|
66
83
|
metaFilterThresholds: parseMetaFilterThresholdsJson(Option.getOrUndefined(raw.metaFilterThresholdsJson)),
|
|
67
84
|
runwayRepoProjectName: Option.getOrUndefined(raw.runwayRepoProjectName),
|
|
68
85
|
metaWeeklyModel: Option.getOrUndefined(raw.metaWeeklyModel),
|
|
86
|
+
shepherdEnabled: raw.shepherdEnabled,
|
|
87
|
+
shepherdPollIntervalSeconds: raw.shepherdPollIntervalSeconds,
|
|
88
|
+
prReviewerBotLogin: Option.getOrUndefined(raw.prReviewerBotLogin),
|
|
89
|
+
shepherdMaxIterations: raw.shepherdMaxIterations,
|
|
90
|
+
shepherdMaxWallSeconds: raw.shepherdMaxWallSeconds,
|
|
69
91
|
})));
|
|
70
92
|
/**
|
|
71
93
|
* VA-359: Context tag for the resolved RunwayConfig. Provided by
|
|
@@ -148,6 +148,16 @@ function strArrayAttr(v) {
|
|
|
148
148
|
* in lock-step with `orchestrator.ts`.
|
|
149
149
|
*/
|
|
150
150
|
export const DRAIN_STARTED_LOG = "drain.started";
|
|
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.
|
|
157
|
+
*/
|
|
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";
|
|
151
161
|
/**
|
|
152
162
|
* VA-455: scan an OTLP logs payload for `drain.started` markers.
|
|
153
163
|
* Each match becomes an `ActiveDrainMarker` carrying the drain's
|
|
@@ -188,6 +198,86 @@ export function extractActiveDrainMarkers(payload) {
|
|
|
188
198
|
}
|
|
189
199
|
return out;
|
|
190
200
|
}
|
|
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
|
+
}
|
|
191
281
|
/**
|
|
192
282
|
* VA-388: project an OTLP logs payload into `LogRecordRow`s. Records
|
|
193
283
|
* without a trace_id are dropped — every Effect log emitted under
|
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, extractShepherdMarkers, projectLogs, projectPayload, } from "./projector.js";
|
|
5
5
|
import { createStorage, } from "./storage.js";
|
|
6
6
|
import { META_REVIEW_PAGE_SIZE, renderDetailView, renderIssueProcessRows, renderListView, renderLogsSection, renderMetaReviewDetailView, renderMetaReviewListView, } 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: 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.
|
|
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
|
|
@@ -247,6 +256,39 @@ async function handleOtlpLogs(req, res, storage, events) {
|
|
|
247
256
|
for (const m of extractActiveDrainMarkers(payload)) {
|
|
248
257
|
storage.markDrainActive(m);
|
|
249
258
|
}
|
|
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
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
250
292
|
for (const r of projectLogs(payload)) {
|
|
251
293
|
storage.appendLogRecord(r);
|
|
252
294
|
// VA-391: the SSE detail-pane stream live-tails the Logs section
|
|
@@ -380,12 +422,19 @@ function handleListView(req, res, storage, linearEnabled, linearWorkspace) {
|
|
|
380
422
|
// `drains` row). `null` becomes the "Idle — no active drain"
|
|
381
423
|
// state in views.
|
|
382
424
|
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
|
+
: [];
|
|
383
431
|
const html = renderListView({
|
|
384
432
|
rows,
|
|
385
433
|
linearEnabled,
|
|
386
434
|
todoQueue,
|
|
387
435
|
snapshotsByIdentifier,
|
|
388
436
|
activeDrain,
|
|
437
|
+
activeShepherding,
|
|
389
438
|
filterState,
|
|
390
439
|
recentDrains,
|
|
391
440
|
isFiltered: isFilteredState(filterState),
|
|
@@ -159,6 +159,31 @@ 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);
|
|
162
187
|
`;
|
|
163
188
|
const DEFAULT_AGGREGATE_WINDOW = 30;
|
|
164
189
|
/**
|
|
@@ -673,6 +698,98 @@ export function createStorage(path, opts = {}) {
|
|
|
673
698
|
const markDrainActive = (m) => {
|
|
674
699
|
markActiveDrainStmt.run(m.traceId, m.spanId, m.startTimeUnixNano, m.lastSeenUnixNano);
|
|
675
700
|
};
|
|
701
|
+
// VA-465: shepherd-state prepared statements.
|
|
702
|
+
//
|
|
703
|
+
// `markShepherdIterationStartStmt` inserts on first iteration or
|
|
704
|
+
// updates an existing row to bump the iteration / subscription /
|
|
705
|
+
// description / last_seen. The CONFLICT branch only overwrites
|
|
706
|
+
// when (a) the row isn't already terminated AND (b) the incoming
|
|
707
|
+
// timestamp is newer than the stored last_seen — protects against
|
|
708
|
+
// out-of-order batch arrival.
|
|
709
|
+
const markShepherdIterationStartStmt = db.prepare(`
|
|
710
|
+
INSERT INTO shepherding_prs (
|
|
711
|
+
trace_id, pr_number, subscription, iteration, max_iterations,
|
|
712
|
+
state, description, started_unix_nano, last_seen_unix_nano
|
|
713
|
+
) VALUES (?, ?, ?, ?, ?, 'acting', ?, ?, ?)
|
|
714
|
+
ON CONFLICT (trace_id, pr_number) DO UPDATE SET
|
|
715
|
+
subscription = excluded.subscription,
|
|
716
|
+
iteration = excluded.iteration,
|
|
717
|
+
max_iterations = COALESCE(excluded.max_iterations, shepherding_prs.max_iterations),
|
|
718
|
+
state = 'acting',
|
|
719
|
+
description = excluded.description,
|
|
720
|
+
last_seen_unix_nano = excluded.last_seen_unix_nano
|
|
721
|
+
WHERE shepherding_prs.ended_unix_nano IS NULL
|
|
722
|
+
AND CAST(shepherding_prs.last_seen_unix_nano AS INTEGER)
|
|
723
|
+
< CAST(excluded.last_seen_unix_nano AS INTEGER)
|
|
724
|
+
`);
|
|
725
|
+
// `markShepherdIterationEndStmt` bumps last_seen + description on
|
|
726
|
+
// a row that exists. UPDATE-only (no INSERT) — an end without a
|
|
727
|
+
// start is dropped silently.
|
|
728
|
+
const markShepherdIterationEndStmt = db.prepare(`
|
|
729
|
+
UPDATE shepherding_prs
|
|
730
|
+
SET description = ?, last_seen_unix_nano = ?
|
|
731
|
+
WHERE trace_id = ? AND pr_number = ?
|
|
732
|
+
AND ended_unix_nano IS NULL
|
|
733
|
+
AND CAST(last_seen_unix_nano AS INTEGER) < CAST(? AS INTEGER)
|
|
734
|
+
`);
|
|
735
|
+
// `markShepherdEndedStmt` terminates the row. The CASE WHEN guard
|
|
736
|
+
// protects against a stale end-marker arriving after a fresher
|
|
737
|
+
// start (which can happen with batched OTLP flushes).
|
|
738
|
+
const markShepherdEndedStmt = db.prepare(`
|
|
739
|
+
INSERT INTO shepherding_prs (
|
|
740
|
+
trace_id, pr_number, state, started_unix_nano,
|
|
741
|
+
last_seen_unix_nano, ended_unix_nano
|
|
742
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
743
|
+
ON CONFLICT (trace_id, pr_number) DO UPDATE SET
|
|
744
|
+
state = excluded.state,
|
|
745
|
+
last_seen_unix_nano = excluded.last_seen_unix_nano,
|
|
746
|
+
ended_unix_nano = excluded.ended_unix_nano
|
|
747
|
+
WHERE shepherding_prs.ended_unix_nano IS NULL
|
|
748
|
+
OR CAST(shepherding_prs.ended_unix_nano AS INTEGER)
|
|
749
|
+
< CAST(excluded.ended_unix_nano AS INTEGER)
|
|
750
|
+
`);
|
|
751
|
+
const getActiveShepherdingStmt = db.prepare(`
|
|
752
|
+
SELECT
|
|
753
|
+
trace_id, pr_number, subscription, iteration, max_iterations,
|
|
754
|
+
state, description, started_unix_nano, last_seen_unix_nano,
|
|
755
|
+
ended_unix_nano
|
|
756
|
+
FROM shepherding_prs
|
|
757
|
+
WHERE trace_id = ? AND ended_unix_nano IS NULL
|
|
758
|
+
ORDER BY CAST(started_unix_nano AS INTEGER) ASC
|
|
759
|
+
`);
|
|
760
|
+
const markShepherdIterationStart = (m) => {
|
|
761
|
+
markShepherdIterationStartStmt.run(m.traceId, m.prNumber, m.subscription, m.iteration, m.maxIterations, m.description, m.timestampUnixNano, m.timestampUnixNano);
|
|
762
|
+
};
|
|
763
|
+
const markShepherdIterationEnd = (m) => {
|
|
764
|
+
markShepherdIterationEndStmt.run(m.detail, m.timestampUnixNano, m.traceId, m.prNumber, m.timestampUnixNano);
|
|
765
|
+
};
|
|
766
|
+
const markShepherdEnded = (m) => {
|
|
767
|
+
// VA-465: only `merged` terminates the row (sets ended_unix_nano
|
|
768
|
+
// → excluded from getActiveShepherding). `ready` and `hitl`
|
|
769
|
+
// leave ended_unix_nano NULL so the sub-strip continues to
|
|
770
|
+
// render them as "ready, awaiting human merge" / "HITL — needs
|
|
771
|
+
// human attention" rows while the drain is still active. The
|
|
772
|
+
// rows naturally disappear when the drain ends, since the list
|
|
773
|
+
// view only queries getActiveShepherding when activeDrain is
|
|
774
|
+
// non-null.
|
|
775
|
+
const endedNano = m.outcome === "merged" ? m.timestampUnixNano : null;
|
|
776
|
+
markShepherdEndedStmt.run(m.traceId, m.prNumber, m.outcome, m.timestampUnixNano, m.timestampUnixNano, endedNano);
|
|
777
|
+
};
|
|
778
|
+
const getActiveShepherding = (traceId) => {
|
|
779
|
+
const rows = getActiveShepherdingStmt.all(traceId);
|
|
780
|
+
return rows.map((r) => ({
|
|
781
|
+
traceId: r.trace_id,
|
|
782
|
+
prNumber: r.pr_number,
|
|
783
|
+
subscription: r.subscription,
|
|
784
|
+
iteration: r.iteration,
|
|
785
|
+
maxIterations: r.max_iterations,
|
|
786
|
+
state: r.state,
|
|
787
|
+
description: r.description,
|
|
788
|
+
startedUnixNano: r.started_unix_nano,
|
|
789
|
+
lastSeenUnixNano: r.last_seen_unix_nano,
|
|
790
|
+
endedUnixNano: r.ended_unix_nano,
|
|
791
|
+
}));
|
|
792
|
+
};
|
|
676
793
|
const saveIssueProcess = (p) => {
|
|
677
794
|
insertIssueProcess.run(p.traceId, p.spanId, p.parentSpanId, p.issueIdentifier, p.issueId, p.issueTitle,
|
|
678
795
|
// VA-387: labels round-trip as a JSON array string. Keeping them
|
|
@@ -950,6 +1067,10 @@ export function createStorage(path, opts = {}) {
|
|
|
950
1067
|
appendLogRecord,
|
|
951
1068
|
streamLogsFor,
|
|
952
1069
|
getActiveDrain,
|
|
1070
|
+
markShepherdIterationStart,
|
|
1071
|
+
markShepherdIterationEnd,
|
|
1072
|
+
markShepherdEnded,
|
|
1073
|
+
getActiveShepherding,
|
|
953
1074
|
saveMetaReview,
|
|
954
1075
|
listMetaReviews,
|
|
955
1076
|
countMetaReviews,
|
package/dist/dashboard/views.js
CHANGED
|
@@ -69,7 +69,11 @@ export function renderListView(input) {
|
|
|
69
69
|
: "";
|
|
70
70
|
// VA-391: drain summary strip renders ABOVE the queue + run list
|
|
71
71
|
// so the operator sees in-flight progress at the top of the page.
|
|
72
|
-
|
|
72
|
+
// VA-465: when the drain is active AND has shepherding PRs, a
|
|
73
|
+
// sub-strip lists each PR's current state below the drain strip.
|
|
74
|
+
const activeShepherding = vm.activeShepherding ?? [];
|
|
75
|
+
const drainStrip = renderDrainStrip(vm.activeDrain ?? null) +
|
|
76
|
+
renderShepherdSubStrip(vm.activeDrain ?? null, activeShepherding);
|
|
73
77
|
// VA-392: filter chips render between the drain strip and the table
|
|
74
78
|
// so the operator can narrow the run list without leaving the page.
|
|
75
79
|
const filterChips = renderFilterChips(filterState, recentDrains);
|
|
@@ -100,6 +104,20 @@ export function renderListView(input) {
|
|
|
100
104
|
.drain-strip .strip-counter strong { color: #e5e5e5; }
|
|
101
105
|
.drain-strip .counter-hitl strong { color: #facc15; }
|
|
102
106
|
.drain-strip .counter-err strong { color: #f87171; }
|
|
107
|
+
/* VA-465: shepherd sub-strip — one row per PR being shepherded. */
|
|
108
|
+
.shepherd-strip { margin: -10px 0 16px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
109
|
+
.shepherd-row { padding: 6px 12px; border: 1px solid #2a2a30;
|
|
110
|
+
border-top: none; border-radius: 0 0 4px 4px;
|
|
111
|
+
background: #0a0f1c; display: flex; gap: 12px;
|
|
112
|
+
align-items: center; font-size: 0.9em; }
|
|
113
|
+
.shepherd-row .strip-label { color: #9ca3af; }
|
|
114
|
+
.shepherd-row .shepherd-pr { color: #93c5fd; font-weight: 600; }
|
|
115
|
+
.shepherd-row .shepherd-iter { color: #d4d4d8; }
|
|
116
|
+
.shepherd-row .shepherd-state { color: #e5e5e5; }
|
|
117
|
+
.shepherd-state-ready { border-color: #4ade80; }
|
|
118
|
+
.shepherd-state-merged { border-color: #4ade80; opacity: 0.7; }
|
|
119
|
+
.shepherd-state-hitl { border-color: #facc15; }
|
|
120
|
+
.shepherd-state-acting { border-color: #60a5fa; }
|
|
103
121
|
.filters { margin: 0 0 16px; display: flex; flex-direction: column;
|
|
104
122
|
gap: 6px; font-size: 12px; }
|
|
105
123
|
.filter-row { display: flex; align-items: center; gap: 8px;
|
|
@@ -166,6 +184,51 @@ export function renderListView(input) {
|
|
|
166
184
|
* when one is active; otherwise emits the explicit "Idle" indicator
|
|
167
185
|
* so the operator can tell at a glance that no drain is running.
|
|
168
186
|
*/
|
|
187
|
+
/**
|
|
188
|
+
* VA-465: sub-strip under the active-drain card showing per-PR
|
|
189
|
+
* shepherding state. One row per PR. Empty when no drain is active
|
|
190
|
+
* or no shepherding rows exist — returns an empty string so the
|
|
191
|
+
* markup has no orphan container.
|
|
192
|
+
*/
|
|
193
|
+
function renderShepherdSubStrip(active, shepherding) {
|
|
194
|
+
if (!active || shepherding.length === 0)
|
|
195
|
+
return "";
|
|
196
|
+
const lines = shepherding
|
|
197
|
+
.map((s) => {
|
|
198
|
+
const stateLabel = shepherdStateLabel(s);
|
|
199
|
+
return `<div class="shepherd-row shepherd-state-${s.state}">
|
|
200
|
+
<span class="strip-label">Shepherding</span>
|
|
201
|
+
<span class="shepherd-pr">PR #${s.prNumber}</span>
|
|
202
|
+
${s.iteration !== null
|
|
203
|
+
? `<span class="shepherd-iter">iter ${s.iteration}${s.maxIterations !== null ? `/${s.maxIterations}` : ""}</span>`
|
|
204
|
+
: ""}
|
|
205
|
+
<span class="shepherd-state">${escapeHtml(stateLabel)}</span>
|
|
206
|
+
</div>`;
|
|
207
|
+
})
|
|
208
|
+
.join("");
|
|
209
|
+
return `<div class="shepherd-strip">${lines}</div>`;
|
|
210
|
+
}
|
|
211
|
+
function shepherdStateLabel(s) {
|
|
212
|
+
switch (s.state) {
|
|
213
|
+
case "acting":
|
|
214
|
+
// The subscription + description tell the operator WHAT is
|
|
215
|
+
// happening — a rebase, a CI re-impl, a reviewer-feedback fix.
|
|
216
|
+
// Both are best-effort; missing ones collapse to the bare verb.
|
|
217
|
+
if (s.description)
|
|
218
|
+
return s.description;
|
|
219
|
+
return s.subscription
|
|
220
|
+
? `acting on ${s.subscription}`
|
|
221
|
+
: "acting";
|
|
222
|
+
case "ready":
|
|
223
|
+
return "ready, awaiting human merge";
|
|
224
|
+
case "merged":
|
|
225
|
+
return "merged";
|
|
226
|
+
case "hitl":
|
|
227
|
+
return "HITL — needs human attention";
|
|
228
|
+
default:
|
|
229
|
+
return s.state;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
169
232
|
function renderDrainStrip(active) {
|
|
170
233
|
if (!active) {
|
|
171
234
|
return `<div class="drain-strip idle">Idle — no active drain</div>`;
|
|
@@ -454,6 +517,8 @@ export function renderDetailView(vm) {
|
|
|
454
517
|
.timeline .phase-implement { background: #60a5fa; }
|
|
455
518
|
.timeline .phase-review { background: #facc15; }
|
|
456
519
|
.timeline .phase-finalize { background: #4ade80; }
|
|
520
|
+
/* VA-460: shepherd phase (post-finalize PR loop). */
|
|
521
|
+
.timeline .phase-shepherd { background: #a78bfa; }
|
|
457
522
|
.timeline-legend { font-size: 11px; color: #9ca3af; margin-bottom: 16px; }
|
|
458
523
|
.iter-row td { background: #16161a; padding-left: 32px; }
|
|
459
524
|
.iter-status { font-weight: 600; }
|
|
@@ -569,6 +634,17 @@ function computePhases(vm) {
|
|
|
569
634
|
const endNs = maxBigInt(finalize.map((p) => parseNs(p.endTimeUnixNano)));
|
|
570
635
|
phases.push({ name: "finalize", startNs, endNs });
|
|
571
636
|
}
|
|
637
|
+
// VA-460: shepherd phase runs after finalize when the feature gate
|
|
638
|
+
// is on. Only present for drains with `shepherdEnabled=true`; pre-
|
|
639
|
+
// gate issue processes show the original three phases unchanged.
|
|
640
|
+
const shepherd = vm.phaseSpans.find((p) => p.name === "shepherd");
|
|
641
|
+
if (shepherd) {
|
|
642
|
+
phases.push({
|
|
643
|
+
name: "shepherd",
|
|
644
|
+
startNs: parseNs(shepherd.startTimeUnixNano),
|
|
645
|
+
endNs: parseNs(shepherd.endTimeUnixNano),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
572
648
|
return phases;
|
|
573
649
|
}
|
|
574
650
|
function renderPhaseTimeline(phases) {
|