@dev-loops/core 0.7.2 → 0.9.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/package.json +7 -1
- package/src/claude/asset-generation.mjs +23 -1
- package/src/claude/hook-decisions.mjs +5 -4
- package/src/config/config.mjs +277 -0
- package/src/config/extension-defaults.yaml +0 -1
- package/src/loop/bash-command-classify.mjs +7 -6
- package/src/loop/handoff-envelope.mjs +30 -0
- package/src/loop/issue-refinement-artifact.mjs +42 -0
- package/src/loop/plan-file-promote-contract.mjs +23 -1
- package/src/loop/pr-gate-coordination.mjs +20 -2
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- package/src/loop/refinement-grill-state.mjs +173 -0
- package/src/loop/ui-review-diagnose.mjs +291 -0
- package/src/loop/ui-review-drive.mjs +348 -0
- package/src/loop/ui-review-provision.mjs +264 -0
- package/src/loop/ui-review-report.mjs +287 -0
- package/src/loop/ui-review-teardown.mjs +250 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive orchestrator for the ui_review route (Stage 2).
|
|
3
|
+
*
|
|
4
|
+
* Authenticates as the change's target role via a project-provided dev-login
|
|
5
|
+
* recipe, then walks the changed UI flows against the arbitrary running-app URL
|
|
6
|
+
* handed off by Stage 1 — rendering each page, exercising its declared
|
|
7
|
+
* interactions, and capturing an ordered set of step screenshots. While it
|
|
8
|
+
* drives, response/requestfailed/pageerror listeners and a server-log tail run
|
|
9
|
+
* so a swallowed error response (a 500 the UI hides) is still recorded.
|
|
10
|
+
*
|
|
11
|
+
* This module is PURE orchestration: all browser/page IO, the auth recipe, the
|
|
12
|
+
* interstitial dismissal, the per-step capture, the event collection, and the
|
|
13
|
+
* server-log tail are injected seams. The thin CLI/harness wires real Playwright
|
|
14
|
+
* (WebKit). The decision logic that lives here is: which flows to drive
|
|
15
|
+
* (a bounded changed-flow heuristic over an explicit allowlist), cap
|
|
16
|
+
* enforcement (max screenshots, screens skipped, no-retry) with explicit logs,
|
|
17
|
+
* and failure classification (collating error responses, request failures,
|
|
18
|
+
* page errors, and server-log exceptions into one structured list).
|
|
19
|
+
*
|
|
20
|
+
* Fail closed: a can't-authenticate condition STOPS with a stated reason and
|
|
21
|
+
* drives nothing. The structured captured-failures list feeds the next stage.
|
|
22
|
+
*
|
|
23
|
+
* Out of scope (later stages): exception -> source-line mapping, review
|
|
24
|
+
* posting, visual-regression/pixel-diffing, cross-browser matrix.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const MUST_FIX = "must-fix";
|
|
28
|
+
|
|
29
|
+
/** The one owner of the error-response threshold: an error response is anything
|
|
30
|
+
* outside 2xx/3xx. 3xx redirects are normal navigation (login/canonical), not
|
|
31
|
+
* errors, so they are not flagged. Shared by the CLI listener's pre-filter (for
|
|
32
|
+
* buffer bounding) and the classifier, so the policy has a single source. */
|
|
33
|
+
export function isErrorResponseStatus(status) {
|
|
34
|
+
return typeof status === "number" && (status < 200 || status >= 400);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bound the stack text carried onto a page-error failure so a runaway stack
|
|
38
|
+
* (or a synthetic error with a huge stack) can't bloat the feed envelope. Keeps
|
|
39
|
+
* the head — the top frames, where the throwing file:line sits. */
|
|
40
|
+
const PAGE_ERROR_STACK_MAX_CHARS = 4000;
|
|
41
|
+
|
|
42
|
+
/** Lines of context to preserve on each side of a matching server-log line, so
|
|
43
|
+
* the traceback frames that carry file:line (often on adjacent, non-matching
|
|
44
|
+
* lines) survive into the Stage 3 feed. */
|
|
45
|
+
const SERVER_LOG_CONTEXT_LINES = 4;
|
|
46
|
+
/** Char cap on the preserved server-log context window per failure entry. */
|
|
47
|
+
const SERVER_LOG_CONTEXT_MAX_CHARS = 2000;
|
|
48
|
+
|
|
49
|
+
/** Bounded caps. A project cannot raise these past the ceilings — the walker is
|
|
50
|
+
* a diagnostic pass over the changed flows, never an unbounded crawl. */
|
|
51
|
+
export const DEFAULT_DRIVE_CAPS = Object.freeze({
|
|
52
|
+
maxScreenshots: 40,
|
|
53
|
+
maxFlows: 12,
|
|
54
|
+
maxStepsPerFlow: 20,
|
|
55
|
+
// No-retry is a fixed policy, not a tunable: a flaky step is a finding, not
|
|
56
|
+
// something to paper over by re-running. Logged explicitly on every run.
|
|
57
|
+
retries: 0,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Merge project caps onto the defaults, clamping each to its ceiling so a
|
|
61
|
+
* recipe can only tighten a cap, never loosen it past the diagnostic budget. */
|
|
62
|
+
export function resolveCaps(caps = {}) {
|
|
63
|
+
const clamp = (v, ceiling) =>
|
|
64
|
+
Number.isInteger(v) && v >= 0 ? Math.min(v, ceiling) : ceiling;
|
|
65
|
+
return {
|
|
66
|
+
maxScreenshots: clamp(caps.maxScreenshots, DEFAULT_DRIVE_CAPS.maxScreenshots),
|
|
67
|
+
maxFlows: clamp(caps.maxFlows, DEFAULT_DRIVE_CAPS.maxFlows),
|
|
68
|
+
maxStepsPerFlow: clamp(caps.maxStepsPerFlow, DEFAULT_DRIVE_CAPS.maxStepsPerFlow),
|
|
69
|
+
retries: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Changed-flow discovery: pick which allowlisted flows to drive.
|
|
75
|
+
*
|
|
76
|
+
* This is a DOCUMENTED HEURISTIC over an EXPLICIT allowlist, never an unbounded
|
|
77
|
+
* crawl. Each flow declares `pathPatterns` (plain substrings matched against the
|
|
78
|
+
* PR's changed file paths). A flow is in scope when any changed path contains
|
|
79
|
+
* any of its patterns. A flow with no `pathPatterns` is always in scope (the
|
|
80
|
+
* project opted it into every run). When `changedPaths` is empty/absent the diff
|
|
81
|
+
* is unknown, so every allowlisted flow is driven — the safe over-approximation.
|
|
82
|
+
* The selection is then capped at `caps.maxFlows`; the overflow is skipped and
|
|
83
|
+
* logged, never silently dropped.
|
|
84
|
+
*
|
|
85
|
+
* @returns {{ selected: object[], skipped: {name:string, reason:string}[] }}
|
|
86
|
+
*/
|
|
87
|
+
export function selectFlows({ flows = [], changedPaths = [], caps = DEFAULT_DRIVE_CAPS } = {}) {
|
|
88
|
+
const paths = Array.isArray(changedPaths) ? changedPaths : [];
|
|
89
|
+
const haveDiff = paths.length > 0;
|
|
90
|
+
const matched = [];
|
|
91
|
+
const skipped = [];
|
|
92
|
+
for (const flow of flows) {
|
|
93
|
+
const patterns = Array.isArray(flow.pathPatterns) ? flow.pathPatterns : [];
|
|
94
|
+
let inScope;
|
|
95
|
+
if (!haveDiff || patterns.length === 0) {
|
|
96
|
+
inScope = true; // unknown diff, or an always-on flow
|
|
97
|
+
} else {
|
|
98
|
+
inScope = patterns.some((p) => paths.some((cp) => cp.includes(p)));
|
|
99
|
+
}
|
|
100
|
+
if (inScope) matched.push(flow);
|
|
101
|
+
else skipped.push({ name: flow.name, reason: "no changed path matched its pathPatterns" });
|
|
102
|
+
}
|
|
103
|
+
const selected = matched.slice(0, caps.maxFlows);
|
|
104
|
+
for (const flow of matched.slice(caps.maxFlows)) {
|
|
105
|
+
skipped.push({ name: flow.name, reason: `maxFlows cap (${caps.maxFlows}) reached` });
|
|
106
|
+
}
|
|
107
|
+
return { selected, skipped };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Classify raw captured events + the server-log tail into one structured failure
|
|
112
|
+
* list. Pure. This is where a swallowed error response surfaces twice — once
|
|
113
|
+
* from the response listener and once from the server-log tail — so a 500 the UI
|
|
114
|
+
* hid is still recorded.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} input
|
|
117
|
+
* @param {{url?:string,status:number}[]} [input.responses] - from page.on('response')
|
|
118
|
+
* @param {{url?:string,failure?:string}[]} [input.requestFailures] - from page.on('requestfailed')
|
|
119
|
+
* @param {{message?:string,stack?:string|null}[]} [input.pageErrors] - from page.on('pageerror'); `stack` (file:line) feeds Stage 3
|
|
120
|
+
* @param {string} [input.serverLogTail] - tail text of the project server log
|
|
121
|
+
* @param {string} [input.serverLogExceptionPattern] - regex (source) flagging a log exception line
|
|
122
|
+
* @returns {{kind:string, severity:string, message:string, [k:string]:unknown}[]}
|
|
123
|
+
*/
|
|
124
|
+
export function classifyFailures({
|
|
125
|
+
responses = [],
|
|
126
|
+
requestFailures = [],
|
|
127
|
+
pageErrors = [],
|
|
128
|
+
serverLogTail = "",
|
|
129
|
+
serverLogExceptionPattern,
|
|
130
|
+
} = {}) {
|
|
131
|
+
const failures = [];
|
|
132
|
+
|
|
133
|
+
for (const r of responses) {
|
|
134
|
+
// A swallowed 500 lands here even when the page rendered a success state,
|
|
135
|
+
// because the listener sees the wire. The error-response threshold has one
|
|
136
|
+
// owner: isErrorResponseStatus.
|
|
137
|
+
if (isErrorResponseStatus(r.status)) {
|
|
138
|
+
failures.push({
|
|
139
|
+
kind: "error-response",
|
|
140
|
+
severity: MUST_FIX,
|
|
141
|
+
status: r.status,
|
|
142
|
+
url: r.url ?? null,
|
|
143
|
+
message: `error response ${r.status}${r.url ? ` at ${r.url}` : ""}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const f of requestFailures) {
|
|
149
|
+
failures.push({
|
|
150
|
+
kind: "request-failed",
|
|
151
|
+
severity: MUST_FIX,
|
|
152
|
+
url: f.url ?? null,
|
|
153
|
+
message: `request failed${f.url ? ` at ${f.url}` : ""}${f.failure ? `: ${f.failure}` : ""}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const e of pageErrors) {
|
|
158
|
+
// Carry the bounded stack so Stage 3's exception -> source-line mapping has
|
|
159
|
+
// the file:line signal; null when the listener captured no stack.
|
|
160
|
+
const stack = typeof e.stack === "string" && e.stack.length > 0 ? e.stack.slice(0, PAGE_ERROR_STACK_MAX_CHARS) : null;
|
|
161
|
+
failures.push({
|
|
162
|
+
kind: "page-error",
|
|
163
|
+
severity: MUST_FIX,
|
|
164
|
+
message: `uncaught page error: ${e.message ?? "(no message)"}`,
|
|
165
|
+
stack,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (serverLogTail && serverLogExceptionPattern) {
|
|
170
|
+
// Config validates the pattern only on the CLI path; a direct caller can
|
|
171
|
+
// pass an invalid regex. Guard the compile so a bad pattern degrades to a
|
|
172
|
+
// surfaced note instead of throwing and breaking the whole drive envelope.
|
|
173
|
+
let re;
|
|
174
|
+
try {
|
|
175
|
+
re = new RegExp(serverLogExceptionPattern, "iu");
|
|
176
|
+
} catch (err) {
|
|
177
|
+
failures.push({
|
|
178
|
+
kind: "server-log-pattern-invalid",
|
|
179
|
+
severity: "note",
|
|
180
|
+
message: `server-log exception pattern is not a valid regex; skipped server-log classification: ${err?.message ?? String(err)}`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (re) {
|
|
184
|
+
const lines = serverLogTail.split("\n");
|
|
185
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
186
|
+
const trimmed = lines[i].trim();
|
|
187
|
+
if (trimmed.length > 0 && re.test(trimmed)) {
|
|
188
|
+
// Preserve the contiguous frames around the match: the file:line the
|
|
189
|
+
// traceback carries usually sits on adjacent, non-matching lines that
|
|
190
|
+
// the per-line match alone would drop. Bounded on both axes.
|
|
191
|
+
const from = Math.max(0, i - SERVER_LOG_CONTEXT_LINES);
|
|
192
|
+
const to = Math.min(lines.length, i + SERVER_LOG_CONTEXT_LINES + 1);
|
|
193
|
+
const context = lines.slice(from, to).join("\n").slice(0, SERVER_LOG_CONTEXT_MAX_CHARS);
|
|
194
|
+
failures.push({
|
|
195
|
+
kind: "server-log-exception",
|
|
196
|
+
severity: MUST_FIX,
|
|
197
|
+
message: `server log exception: ${trimmed.slice(0, 500)}`,
|
|
198
|
+
context,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return failures;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run the auth + drive sequence over the changed flows.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} input
|
|
212
|
+
* @param {string} input.appUrl - The arbitrary running-app URL from Stage 1.
|
|
213
|
+
* @param {object} input.login - Resolved dev-login recipe (loginUrl + selectors).
|
|
214
|
+
* @param {object[]} [input.flows] - Allowlisted changed-flow definitions.
|
|
215
|
+
* @param {object[]} [input.interstitials] - Config-declared dismiss selectors.
|
|
216
|
+
* @param {string[]} [input.changedPaths] - Changed file paths (drives selection).
|
|
217
|
+
* @param {string} [input.serverLogExceptionPattern] - regex source for log-tail classification.
|
|
218
|
+
* @param {object} [input.caps] - Project cap overrides (clamped to the ceilings).
|
|
219
|
+
* @param {object} seams - Injected IO.
|
|
220
|
+
* @param {(a:{appUrl:string,login:object})=>Promise<{ok:boolean,detail:string}>} seams.authenticate
|
|
221
|
+
* @param {(a:{interstitials:object[]})=>Promise<{dismissed:string[]}>} [seams.dismissInterstitials]
|
|
222
|
+
* @param {(a:{appUrl:string,flow:object,step:object,index:number})=>Promise<{screenshotPath?:string,statePath?:string,ok?:boolean,detail?:string}>} seams.runStep
|
|
223
|
+
* @param {()=>{responses?:object[],requestFailures?:object[],pageErrors?:object[]}} seams.getCapturedEvents
|
|
224
|
+
* @param {()=>Promise<string>} [seams.readServerLogTail]
|
|
225
|
+
* @param {(msg:string)=>void} [seams.log]
|
|
226
|
+
* @returns {Promise<object>} Result envelope (steps, captures, failures, caps, logs).
|
|
227
|
+
*/
|
|
228
|
+
export async function driveUiReview(
|
|
229
|
+
{ appUrl, login, flows = [], interstitials = [], changedPaths = [], serverLogExceptionPattern, caps = {} },
|
|
230
|
+
{
|
|
231
|
+
authenticate,
|
|
232
|
+
dismissInterstitials = async () => ({ dismissed: [] }),
|
|
233
|
+
runStep,
|
|
234
|
+
getCapturedEvents,
|
|
235
|
+
readServerLogTail = async () => "",
|
|
236
|
+
log = () => {},
|
|
237
|
+
} = {},
|
|
238
|
+
) {
|
|
239
|
+
const logs = [];
|
|
240
|
+
const record = (msg) => {
|
|
241
|
+
logs.push(msg);
|
|
242
|
+
log(msg);
|
|
243
|
+
};
|
|
244
|
+
const resolvedCaps = resolveCaps(caps);
|
|
245
|
+
// No-retry is a fixed policy — log it every run so the bound is never implicit.
|
|
246
|
+
record(`caps: maxScreenshots=${resolvedCaps.maxScreenshots}, maxFlows=${resolvedCaps.maxFlows}, maxStepsPerFlow=${resolvedCaps.maxStepsPerFlow}, retries=${resolvedCaps.retries} (no-retry)`);
|
|
247
|
+
|
|
248
|
+
const base = () => ({ appUrl: appUrl ?? null, logs });
|
|
249
|
+
|
|
250
|
+
// 1. Authenticate as the target role. Fail closed: no session -> STOP, drive
|
|
251
|
+
// nothing (a review that never reached the app is worthless, not empty).
|
|
252
|
+
const auth = await authenticate({ appUrl, login });
|
|
253
|
+
if (!auth.ok) {
|
|
254
|
+
const stopReason = `cannot authenticate: ${auth.detail ?? "dev-login recipe did not yield a session"}`;
|
|
255
|
+
record(`STOP: ${stopReason}`);
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
stopped: true,
|
|
259
|
+
stopReason,
|
|
260
|
+
steps: [],
|
|
261
|
+
captures: [],
|
|
262
|
+
failures: [{ kind: "auth-failed", severity: MUST_FIX, message: stopReason }],
|
|
263
|
+
caps: resolvedCaps,
|
|
264
|
+
...base(),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
record(`authenticated: ${auth.detail ?? "session established"}`);
|
|
268
|
+
|
|
269
|
+
// 2. Dismiss known interstitials ONCE per browser context (config-declared).
|
|
270
|
+
const dismiss = await dismissInterstitials({ interstitials });
|
|
271
|
+
if (dismiss.dismissed?.length) record(`interstitials dismissed: ${dismiss.dismissed.join(", ")}`);
|
|
272
|
+
|
|
273
|
+
// 3. Select the changed flows (bounded heuristic over the explicit allowlist).
|
|
274
|
+
const { selected, skipped } = selectFlows({ flows, changedPaths, caps: resolvedCaps });
|
|
275
|
+
for (const s of skipped) record(`flow skipped: ${s.name} (${s.reason})`);
|
|
276
|
+
record(`driving ${selected.length} flow(s)`);
|
|
277
|
+
|
|
278
|
+
// 4. Walk each flow's steps, capturing every step, until the screenshot cap.
|
|
279
|
+
// No retry: a step that throws is recorded as a step failure and the walk
|
|
280
|
+
// moves on — deterministic, bounded, never re-run.
|
|
281
|
+
const steps = [];
|
|
282
|
+
const captures = [];
|
|
283
|
+
let screenshots = 0;
|
|
284
|
+
let screensSkipped = 0;
|
|
285
|
+
for (const flow of selected) {
|
|
286
|
+
const declaredSteps = Array.isArray(flow.steps) ? flow.steps : [];
|
|
287
|
+
const flowSteps = declaredSteps.slice(0, resolvedCaps.maxStepsPerFlow);
|
|
288
|
+
if (declaredSteps.length > flowSteps.length) {
|
|
289
|
+
record(`steps skipped: ${flow.name} truncated to ${flowSteps.length} step(s) at the maxStepsPerFlow cap (${resolvedCaps.maxStepsPerFlow})`);
|
|
290
|
+
}
|
|
291
|
+
for (let i = 0; i < flowSteps.length; i += 1) {
|
|
292
|
+
const step = flowSteps[i];
|
|
293
|
+
if (screenshots >= resolvedCaps.maxScreenshots) {
|
|
294
|
+
screensSkipped += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
let outcome;
|
|
298
|
+
try {
|
|
299
|
+
outcome = await runStep({ appUrl, flow, step, index: screenshots });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
outcome = { ok: false, detail: (err?.message ?? String(err)).slice(0, 500) };
|
|
302
|
+
}
|
|
303
|
+
const ok = outcome?.ok !== false;
|
|
304
|
+
screenshots += 1;
|
|
305
|
+
const entry = {
|
|
306
|
+
flow: flow.name,
|
|
307
|
+
step: step.name ?? step.action ?? `step-${i}`,
|
|
308
|
+
order: screenshots,
|
|
309
|
+
ok,
|
|
310
|
+
screenshotPath: outcome?.screenshotPath ?? null,
|
|
311
|
+
statePath: outcome?.statePath ?? null,
|
|
312
|
+
detail: outcome?.detail ?? null,
|
|
313
|
+
};
|
|
314
|
+
steps.push(entry);
|
|
315
|
+
if (entry.screenshotPath) captures.push({ flow: flow.name, step: entry.step, screenshotPath: entry.screenshotPath, statePath: entry.statePath });
|
|
316
|
+
if (!ok) record(`step failed (no retry): ${flow.name} / ${entry.step}: ${entry.detail ?? "unknown"}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (screensSkipped > 0) record(`screens skipped: ${screensSkipped} step(s) past the maxScreenshots cap (${resolvedCaps.maxScreenshots})`);
|
|
320
|
+
|
|
321
|
+
// 5. Collate the out-of-band captures: listener events + the server-log tail.
|
|
322
|
+
const events = getCapturedEvents() ?? {};
|
|
323
|
+
const serverLogTail = await readServerLogTail();
|
|
324
|
+
const failures = classifyFailures({
|
|
325
|
+
responses: events.responses ?? [],
|
|
326
|
+
requestFailures: events.requestFailures ?? [],
|
|
327
|
+
pageErrors: events.pageErrors ?? [],
|
|
328
|
+
serverLogTail,
|
|
329
|
+
serverLogExceptionPattern,
|
|
330
|
+
});
|
|
331
|
+
// A step that threw is a drive failure too — surface it in the structured list.
|
|
332
|
+
for (const s of steps) {
|
|
333
|
+
if (!s.ok) failures.push({ kind: "step-failure", severity: MUST_FIX, message: `step failed: ${s.flow} / ${s.step}${s.detail ? `: ${s.detail}` : ""}` });
|
|
334
|
+
}
|
|
335
|
+
record(`captured ${failures.length} failure(s) across ${steps.length} step(s)`);
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
ok: failures.length === 0,
|
|
339
|
+
stopped: false,
|
|
340
|
+
stopReason: null,
|
|
341
|
+
steps,
|
|
342
|
+
captures,
|
|
343
|
+
failures,
|
|
344
|
+
caps: resolvedCaps,
|
|
345
|
+
screensSkipped,
|
|
346
|
+
...base(),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provision + boot orchestrator for the ui_review route (Stage 1).
|
|
3
|
+
*
|
|
4
|
+
* Provisions an isolated worktree for a PR head and boots the branch's app to a
|
|
5
|
+
* ready state, then hands off a booted app for the running-app review stages.
|
|
6
|
+
* The orchestration is a fail-closed sequence:
|
|
7
|
+
*
|
|
8
|
+
* 1. create-or-reuse the PR worktree (fetch before) and provision it
|
|
9
|
+
* 2. refuse to operate in the primary checkout (worktree guard)
|
|
10
|
+
* 3. install ONLY the dependency-lock delta vs. the primary checkout
|
|
11
|
+
* 4. run pending dev-DB migrations; a destructive one stops for explicit ack
|
|
12
|
+
* 5. resolve a per-project run recipe (no app is ever guessed)
|
|
13
|
+
* 6. boot the app and poll an HTTP readiness probe (never a fixed sleep)
|
|
14
|
+
*
|
|
15
|
+
* Every bounded cap (install skipped, migration ack required, boot timeout) is
|
|
16
|
+
* logged — no silent truncation. This module is pure orchestration: all IO
|
|
17
|
+
* (git/worktree, config, spawn, HTTP probe, clock) is injected so it is fully
|
|
18
|
+
* testable against a fixture project. The thin CLI wires the real seams.
|
|
19
|
+
*
|
|
20
|
+
* Out of scope (later stages): browser driving, auth, screenshots, review
|
|
21
|
+
* posting, production DB.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
const MUST_FIX = "must-fix";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run the provision+boot sequence.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} input
|
|
32
|
+
* @param {string} input.repoRoot - Absolute path to the primary checkout.
|
|
33
|
+
* @param {number} input.pr - PR number whose head is provisioned.
|
|
34
|
+
* @param {string} [input.branch] - Branch to check out (default: pr-<n>).
|
|
35
|
+
* @param {boolean} [input.ackDestructiveMigration] - Explicit ack unblocking a
|
|
36
|
+
* destructive/blocked migration. Fail-closed: absent means "not acknowledged".
|
|
37
|
+
* @param {object} seams - Injected IO (all required except clock/log defaults).
|
|
38
|
+
* @param {(a:{repoRoot:string,pr:number,branch?:string})=>Promise<{path:string,created:boolean,reused:boolean}>} seams.ensureWorktree
|
|
39
|
+
* @param {(a:{worktreePath:string,repoRoot:string})=>{ok:boolean,message?:string,mainWorktreePath?:string|null}} seams.assertNotPrimary
|
|
40
|
+
* @param {(a:{repoRoot:string,worktreePath:string})=>Promise<{changed:boolean,detail:string}>} seams.detectDepDelta
|
|
41
|
+
* @param {(a:{worktreePath:string})=>Promise<{ok:boolean,detail:string}>} seams.installDeps
|
|
42
|
+
* @param {(worktreePath:string)=>Promise<object|null>} seams.resolveRunRecipe
|
|
43
|
+
* @param {(a:{worktreePath:string,recipe:object,runCwd:string})=>Promise<{pending:string[],destructive:string[],detail:string}>} seams.inspectMigrations — MUST run in `runCwd` (the guard-validated absolute cwd), never re-derive it
|
|
44
|
+
* @param {(a:{worktreePath:string,recipe:object,runCwd:string})=>Promise<{ok:boolean,applied:number,detail:string}>} seams.applyMigrations — MUST run in `runCwd`
|
|
45
|
+
* @param {(a:{worktreePath:string,recipe:object,runCwd:string})=>Promise<{pid:number|null,detail:string}>} seams.bootApp — MUST run in `runCwd`
|
|
46
|
+
* @param {(url:string)=>Promise<boolean>} seams.probe
|
|
47
|
+
* @param {(ms:number)=>Promise<false> & {clear?:()=>void}} [seams.probeTimeout] -
|
|
48
|
+
* Per-attempt cap: resolves false once `ms` elapses, so a hung probe can't
|
|
49
|
+
* outlive the budget. May expose `clear()`; the poll calls it after each race
|
|
50
|
+
* resolves so a pending timer is cancelled rather than left to fire.
|
|
51
|
+
* @param {(ms:number)=>Promise<void>} [seams.delay]
|
|
52
|
+
* @param {()=>number} [seams.now]
|
|
53
|
+
* @param {(msg:string)=>void} [seams.log]
|
|
54
|
+
* @returns {Promise<object>} A result envelope (see fields assembled below).
|
|
55
|
+
*/
|
|
56
|
+
export async function provisionAndBoot(
|
|
57
|
+
{ repoRoot, pr, branch, ackDestructiveMigration = false },
|
|
58
|
+
{
|
|
59
|
+
ensureWorktree,
|
|
60
|
+
assertNotPrimary,
|
|
61
|
+
detectDepDelta,
|
|
62
|
+
installDeps,
|
|
63
|
+
resolveRunRecipe,
|
|
64
|
+
inspectMigrations,
|
|
65
|
+
applyMigrations,
|
|
66
|
+
bootApp,
|
|
67
|
+
probe,
|
|
68
|
+
probeTimeout = (ms) => {
|
|
69
|
+
let t;
|
|
70
|
+
const p = /** @type {Promise<false> & {clear?:()=>void}} */ (
|
|
71
|
+
new Promise((resolve) => {
|
|
72
|
+
t = setTimeout(() => resolve(false), ms);
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
// Self-clearing: the poll calls clear() after the race resolves, so a
|
|
76
|
+
// still-pending timer is cancelled outright (never left to fire, never
|
|
77
|
+
// holds the process open) — bounding any injected probe seam.
|
|
78
|
+
p.clear = () => clearTimeout(t);
|
|
79
|
+
return p;
|
|
80
|
+
},
|
|
81
|
+
delay = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
82
|
+
now = () => Date.now(),
|
|
83
|
+
log = () => {},
|
|
84
|
+
} = {},
|
|
85
|
+
) {
|
|
86
|
+
const logs = [];
|
|
87
|
+
const findings = [];
|
|
88
|
+
const record = (msg) => {
|
|
89
|
+
logs.push(msg);
|
|
90
|
+
log(msg);
|
|
91
|
+
};
|
|
92
|
+
const base = () => ({ pr, branch: branch ?? null, findings, logs });
|
|
93
|
+
const stop = (stopReason, finding, extra = {}) => {
|
|
94
|
+
if (finding) findings.push(finding);
|
|
95
|
+
record(`STOP: ${stopReason}`);
|
|
96
|
+
return { ok: false, stopped: true, stopReason, ...base(), ...extra };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// 1. Create-or-reuse the PR worktree (ensureWorktree fetches + provisions).
|
|
100
|
+
const wt = await ensureWorktree({ repoRoot, pr, branch });
|
|
101
|
+
const worktreePath = wt.path;
|
|
102
|
+
record(`worktree ${wt.created ? "created" : "reused"}: ${worktreePath}`);
|
|
103
|
+
|
|
104
|
+
// 2. Fail closed if that path is the primary checkout — never operate there.
|
|
105
|
+
const guard = assertNotPrimary({ worktreePath, repoRoot });
|
|
106
|
+
if (!guard.ok) {
|
|
107
|
+
return stop(
|
|
108
|
+
"worktree guard: refusing to operate in the primary checkout",
|
|
109
|
+
{ kind: "worktree-guard", severity: MUST_FIX, message: guard.message ?? "resolved worktree is the primary checkout" },
|
|
110
|
+
{ worktreePath },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Install only the dependency-lock delta vs. the primary checkout. No delta
|
|
115
|
+
// => deps are shared; installing anything would be a blind re-install.
|
|
116
|
+
const delta = await detectDepDelta({ repoRoot, worktreePath });
|
|
117
|
+
let depInstall = { installed: false, detail: delta.detail };
|
|
118
|
+
if (delta.changed) {
|
|
119
|
+
record(`dependency-lock delta detected (${delta.detail}); installing branch deps`);
|
|
120
|
+
const inst = await installDeps({ worktreePath });
|
|
121
|
+
depInstall = { installed: inst.ok, detail: inst.detail };
|
|
122
|
+
record(`dependency install ${inst.ok ? "ok" : "FAILED"}: ${inst.detail}`);
|
|
123
|
+
if (!inst.ok) {
|
|
124
|
+
return stop(
|
|
125
|
+
"dependency install failed",
|
|
126
|
+
{ kind: "dep-install", severity: MUST_FIX, message: inst.detail },
|
|
127
|
+
{ worktreePath, depInstall },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
record(`dependency install skipped: no lock delta vs primary checkout (${delta.detail})`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 4. Resolve the per-project run recipe before migrate/boot (no app guessed).
|
|
135
|
+
const recipe = await resolveRunRecipe(worktreePath);
|
|
136
|
+
if (!recipe) {
|
|
137
|
+
return stop(
|
|
138
|
+
"no run recipe: the branch declares no uiReview.run recipe (cannot boot the app)",
|
|
139
|
+
{ kind: "run-recipe-missing", severity: MUST_FIX, message: "declare uiReview.run.command + readyUrl in .devloops" },
|
|
140
|
+
{ worktreePath, depInstall },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 4b. Resolve + validate the run recipe's cwd ONCE, here — the single source
|
|
145
|
+
// of cwd truth. The recipe's cwd is worktree-relative; a recipe that
|
|
146
|
+
// escapes the worktree (e.g. cwd "../..") would run migrate/boot in
|
|
147
|
+
// another checkout, so fail closed unless the resolved cwd stays inside
|
|
148
|
+
// the provisioned tree. The validated ABSOLUTE path (`runCwd`) is what the
|
|
149
|
+
// migrate/boot seams execute in — they consume it verbatim and never
|
|
150
|
+
// re-derive it, so the guarded path and the executed path cannot drift.
|
|
151
|
+
let runCwd = worktreePath;
|
|
152
|
+
if (recipe.cwd) {
|
|
153
|
+
const resolvedCwd = path.resolve(worktreePath, recipe.cwd);
|
|
154
|
+
const insideWorktree = resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + path.sep);
|
|
155
|
+
if (!insideWorktree) {
|
|
156
|
+
return stop(
|
|
157
|
+
"run recipe cwd escapes the provisioned worktree",
|
|
158
|
+
{ kind: "cwd-traversal", severity: MUST_FIX, message: `uiReview.run.cwd (${recipe.cwd}) resolves outside the worktree: ${resolvedCwd}` },
|
|
159
|
+
{ worktreePath, depInstall },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
runCwd = resolvedCwd;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 5. Dev-DB migrations. A destructive/blocked migration fails closed to a
|
|
166
|
+
// finding requiring explicit ack; nothing is applied until acknowledged.
|
|
167
|
+
let migrations = { pending: 0, applied: 0, destructive: [], detail: "no migrate recipe" };
|
|
168
|
+
if (recipe.migrate) {
|
|
169
|
+
const mig = await inspectMigrations({ worktreePath, recipe, runCwd });
|
|
170
|
+
record(`migration status: ${mig.pending.length} pending, ${mig.destructive.length} destructive (${mig.detail})`);
|
|
171
|
+
if (mig.destructive.length > 0 && !ackDestructiveMigration) {
|
|
172
|
+
return stop(
|
|
173
|
+
"destructive migration requires explicit acknowledgement",
|
|
174
|
+
{
|
|
175
|
+
kind: "destructive-migration",
|
|
176
|
+
severity: MUST_FIX,
|
|
177
|
+
requiresAck: true,
|
|
178
|
+
message: `${mig.destructive.length} destructive migration(s) blocked pending ack`,
|
|
179
|
+
destructive: mig.destructive,
|
|
180
|
+
},
|
|
181
|
+
{ worktreePath, depInstall, migrations: { pending: mig.pending.length, applied: 0, destructive: mig.destructive, detail: mig.detail } },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (mig.pending.length > 0) {
|
|
185
|
+
if (mig.destructive.length > 0) {
|
|
186
|
+
record(`destructive migration(s) acknowledged; applying ${mig.pending.length} pending migration(s)`);
|
|
187
|
+
}
|
|
188
|
+
const applied = await applyMigrations({ worktreePath, recipe, runCwd });
|
|
189
|
+
if (!applied.ok) {
|
|
190
|
+
return stop(
|
|
191
|
+
"migration apply failed",
|
|
192
|
+
{ kind: "migration-apply", severity: MUST_FIX, message: applied.detail },
|
|
193
|
+
{ worktreePath, depInstall, migrations: { pending: mig.pending.length, applied: 0, destructive: mig.destructive, detail: applied.detail } },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
migrations = { pending: mig.pending.length, applied: applied.applied, destructive: mig.destructive, detail: applied.detail };
|
|
197
|
+
record(`migrations applied: ${applied.applied} (${applied.detail})`);
|
|
198
|
+
} else {
|
|
199
|
+
migrations = { pending: 0, applied: 0, destructive: mig.destructive, detail: "no pending migrations" };
|
|
200
|
+
record("no pending migrations");
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
record("migrations skipped: branch declares no migrate recipe");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 6. Boot the app, then poll the readiness probe against an explicit deadline.
|
|
207
|
+
const boot = await bootApp({ worktreePath, recipe, runCwd });
|
|
208
|
+
record(`app booting (pid ${boot.pid ?? "n/a"}): ${boot.detail}`);
|
|
209
|
+
|
|
210
|
+
const timeoutMs = recipe.readyTimeoutMs;
|
|
211
|
+
const intervalMs = recipe.readyIntervalMs;
|
|
212
|
+
const deadline = now() + timeoutMs;
|
|
213
|
+
let ready = false;
|
|
214
|
+
let attempts = 0;
|
|
215
|
+
// Bounded poll (never a fixed sleep): probe, then wait one interval, until the
|
|
216
|
+
// deadline. The injected clock/delay make the timeout deterministic in tests.
|
|
217
|
+
while (now() <= deadline) {
|
|
218
|
+
attempts += 1;
|
|
219
|
+
// Fail closed: a probe that throws/rejects counts as "not ready yet" so the
|
|
220
|
+
// deadline path produces the clean boot-timeout stop instead of crashing.
|
|
221
|
+
// Bound each attempt by the remaining budget (raced against probeTimeout) so
|
|
222
|
+
// a slow/hung probe can't exceed the deadline or block forever — a per-attempt
|
|
223
|
+
// timeout is just "not ready yet", keeping boot-timeout deterministic.
|
|
224
|
+
let probeOk = false;
|
|
225
|
+
const timeout = probeTimeout(Math.max(0, deadline - now()));
|
|
226
|
+
try {
|
|
227
|
+
probeOk = await Promise.race([Promise.resolve(probe(recipe.readyUrl)), timeout]);
|
|
228
|
+
} catch {
|
|
229
|
+
probeOk = false;
|
|
230
|
+
} finally {
|
|
231
|
+
timeout.clear?.(); // cancel the pending per-attempt timer once the race is decided
|
|
232
|
+
}
|
|
233
|
+
if (probeOk) {
|
|
234
|
+
ready = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
if (now() + intervalMs > deadline) break; // would overshoot the deadline
|
|
238
|
+
await delay(intervalMs);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const bootResult = { pid: boot.pid ?? null, ready, attempts, readyUrl: recipe.readyUrl, timeoutMs, intervalMs };
|
|
242
|
+
if (!ready) {
|
|
243
|
+
record(`boot timeout: not ready after ${timeoutMs}ms (${attempts} probe attempt(s)) at ${recipe.readyUrl}`);
|
|
244
|
+
return stop(
|
|
245
|
+
`readiness probe timed out after ${timeoutMs}ms (${attempts} attempt(s) at ${recipe.readyUrl})`,
|
|
246
|
+
{ kind: "boot-timeout", severity: MUST_FIX, message: `app never became ready within ${timeoutMs}ms` },
|
|
247
|
+
{ worktreePath, depInstall, migrations, boot: bootResult },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
record(`app ready after ${attempts} probe attempt(s) at ${recipe.readyUrl}`);
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
stopped: false,
|
|
255
|
+
stopReason: null,
|
|
256
|
+
worktreePath,
|
|
257
|
+
created: wt.created,
|
|
258
|
+
reused: wt.reused,
|
|
259
|
+
depInstall,
|
|
260
|
+
migrations,
|
|
261
|
+
boot: bootResult,
|
|
262
|
+
...base(),
|
|
263
|
+
};
|
|
264
|
+
}
|