@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,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report for the ui_review route (Stage 4, terminal reporting stage).
|
|
3
|
+
*
|
|
4
|
+
* Pure decision layer. Maps the Stage-3 ranked findings into:
|
|
5
|
+
* - a pending draft-review input (consumed by buildDraftReviewPayload — the
|
|
6
|
+
* shared poster's contract: {path,line,body,side:RIGHT} inline comments, no
|
|
7
|
+
* `event`), with anchorable findings inlined on their exact diff anchors and
|
|
8
|
+
* non-anchorable findings retained in the review body,
|
|
9
|
+
* - a severity->event policy (a confirmed user-facing server error maps to
|
|
10
|
+
* REQUEST_CHANGES ONLY when the caller authorizes submit; otherwise the
|
|
11
|
+
* review stays pending with the severity recorded — never auto-submit),
|
|
12
|
+
* - a self-contained, CSP-safe HTML artifact string (ranked findings + inline
|
|
13
|
+
* screenshot evidence), and
|
|
14
|
+
* - a harness-aware hosting directive (Claude Code -> a publishable Artifacts
|
|
15
|
+
* directive for the orchestrator; any other harness -> fail closed with a
|
|
16
|
+
* stated reason and a follow-up marker — no hosted link this stage).
|
|
17
|
+
*
|
|
18
|
+
* All IO (reading the diagnose output + the screenshot bytes, writing the HTML,
|
|
19
|
+
* invoking the poster) lives in the thin CLI. This module reads only its inputs.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { isClaudeHarness } from "./run-context.mjs";
|
|
23
|
+
import { sanitizeCopilotSummonTokens } from "../github/copilot-helpers.mjs";
|
|
24
|
+
|
|
25
|
+
/** Follow-up marker for the descoped GitHub-native hosting fallback. */
|
|
26
|
+
export const HOSTING_FOLLOWUP = "#1285";
|
|
27
|
+
|
|
28
|
+
/** Findings past this cap are dropped from the artifact and the drop is logged. */
|
|
29
|
+
export const ARTIFACT_MAX_FINDINGS = 100;
|
|
30
|
+
|
|
31
|
+
/** A screenshot whose data URI exceeds this is omitted from the artifact (logged). */
|
|
32
|
+
export const ARTIFACT_MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** The drive kinds that count as a confirmed user-facing server error. Both are
|
|
35
|
+
* must-fix in the drive's classification: an error response the app returned and
|
|
36
|
+
* a server-log exception the request raised. */
|
|
37
|
+
const SERVER_ERROR_KINDS = new Set(["error-response", "server-log-exception"]);
|
|
38
|
+
|
|
39
|
+
function normalizeSha(value) {
|
|
40
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A confirmed user-facing server error: a must-fix error-response / server-log
|
|
44
|
+
* exception. This is the single-source predicate the severity policy keys off. */
|
|
45
|
+
function isBlockingFinding(finding) {
|
|
46
|
+
return finding?.severity === "must-fix" && SERVER_ERROR_KINDS.has(finding?.kind);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render a finding's reproduced exception as one line, falling back to its
|
|
50
|
+
* message when the drive captured no parseable exception. */
|
|
51
|
+
function reproducedLine(finding) {
|
|
52
|
+
const type = finding?.exception?.type;
|
|
53
|
+
const message = finding?.exception?.message;
|
|
54
|
+
if (typeof type === "string" && type.length > 0) {
|
|
55
|
+
return message ? `${type}: ${message}` : type;
|
|
56
|
+
}
|
|
57
|
+
return typeof finding?.message === "string" && finding.message.length > 0
|
|
58
|
+
? finding.message
|
|
59
|
+
: "Captured failure (no exception detail)";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A short, kind-specific fix direction. Deliberately generic — the goal is to
|
|
63
|
+
* point the author at the changed line, not to prescribe the patch. */
|
|
64
|
+
function fixDirection(kind) {
|
|
65
|
+
switch (kind) {
|
|
66
|
+
case "error-response":
|
|
67
|
+
return "Fix the request path so it no longer returns an error response.";
|
|
68
|
+
case "server-log-exception":
|
|
69
|
+
case "page-error":
|
|
70
|
+
return "Guard the throwing code path; the exception above was raised here.";
|
|
71
|
+
case "request-failed":
|
|
72
|
+
return "The request from this change failed at the wire; verify the endpoint/URL.";
|
|
73
|
+
default:
|
|
74
|
+
return "Address the reproduced failure on this changed line.";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Inline-comment body for an anchorable finding: reproduced exception + fix direction. */
|
|
79
|
+
export function formatInlineBody(finding) {
|
|
80
|
+
return `Reproduced in the running app: ${reproducedLine(finding)}\nFix direction: ${fixDirection(finding?.kind)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Severity->event policy (pure). A confirmed user-facing server error maps to
|
|
85
|
+
* REQUEST_CHANGES ONLY when submit is authorized; otherwise the review stays
|
|
86
|
+
* pending (event null) with the severity recorded. Never auto-submits.
|
|
87
|
+
*
|
|
88
|
+
* @param {{findings?: object[], submitAuthorized?: boolean}} [input]
|
|
89
|
+
* @returns {{event: "REQUEST_CHANGES"|null, blocking: boolean, submitAuthorized: boolean, severity: string}}
|
|
90
|
+
*/
|
|
91
|
+
export function severityToEvent({ findings = [], submitAuthorized = false } = {}) {
|
|
92
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
93
|
+
const blocking = list.some(isBlockingFinding);
|
|
94
|
+
const severity = blocking
|
|
95
|
+
? "must-fix"
|
|
96
|
+
: (list.some((f) => f?.severity === "must-fix") ? "must-fix" : (list.length > 0 ? "note" : "none"));
|
|
97
|
+
return {
|
|
98
|
+
event: submitAuthorized && blocking ? "REQUEST_CHANGES" : null,
|
|
99
|
+
blocking,
|
|
100
|
+
submitAuthorized: Boolean(submitAuthorized),
|
|
101
|
+
severity,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Harness-aware hosting directive (pure). Claude Code -> a publishable Artifacts
|
|
107
|
+
* directive for the orchestrator to host (this module never calls an agent tool
|
|
108
|
+
* itself). Any other harness / Artifacts unavailable -> fail closed with a
|
|
109
|
+
* stated reason and the follow-up marker. The self-contained HTML is produced
|
|
110
|
+
* regardless; only this link step is harness-aware.
|
|
111
|
+
*
|
|
112
|
+
* @param {{htmlPath: string, env?: Record<string,string|undefined>}} input
|
|
113
|
+
*/
|
|
114
|
+
export function decideHosting({ htmlPath, env = process.env } = {}) {
|
|
115
|
+
if (isClaudeHarness(env)) {
|
|
116
|
+
return { hosting: "claude-artifact", publishable: true, htmlPath: htmlPath ?? null };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
hosting: "unavailable",
|
|
120
|
+
publishable: false,
|
|
121
|
+
htmlPath: htmlPath ?? null,
|
|
122
|
+
reason: "no hosted-artifact publisher on this harness; GitHub-native fallback is deferred",
|
|
123
|
+
followup: HOSTING_FOLLOWUP,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One review-body line describing where the screenshot artifact lives. Links a
|
|
128
|
+
* real hosted URL when one exists; otherwise states the harness-aware status so
|
|
129
|
+
* the review never blocks on hosting. */
|
|
130
|
+
function artifactBodyLine({ hosting, hostedUrl }) {
|
|
131
|
+
if (typeof hostedUrl === "string" && hostedUrl.length > 0) {
|
|
132
|
+
return `Screenshot artifact: ${hostedUrl}`;
|
|
133
|
+
}
|
|
134
|
+
if (hosting?.hosting === "claude-artifact") {
|
|
135
|
+
return "Screenshot artifact prepared for Claude Artifacts hosting (published by the harness; see run output).";
|
|
136
|
+
}
|
|
137
|
+
const reason = hosting?.reason ? ` (${hosting.reason})` : "";
|
|
138
|
+
const followup = hosting?.followup ? ` [follow-up ${hosting.followup}]` : "";
|
|
139
|
+
return `Screenshot artifact is unhosted this stage${reason}${followup}. Findings are included below.`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A finding is inlineable ONLY with a complete anchor buildDraftReviewPayload
|
|
143
|
+
* will keep: a non-blank path and a line > 0 (exactly the payload's inline
|
|
144
|
+
* filter), side RIGHT. A finding flagged anchorable but carrying a
|
|
145
|
+
* malformed/incomplete anchor (blank path, line <= 0) falls back to the body
|
|
146
|
+
* (summaryFindings) instead of being dropped by the payload's inline filter. */
|
|
147
|
+
function hasValidAnchor(finding) {
|
|
148
|
+
const a = finding?.anchor;
|
|
149
|
+
return Boolean(
|
|
150
|
+
finding?.anchorable &&
|
|
151
|
+
a &&
|
|
152
|
+
typeof a.path === "string" && a.path.trim().length > 0 &&
|
|
153
|
+
typeof a.line === "number" && a.line > 0 &&
|
|
154
|
+
a.side === "RIGHT"
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Body line for a non-anchorable finding: it is kept, never dropped. */
|
|
159
|
+
function nonAnchorableBodyMessage(finding) {
|
|
160
|
+
const reason = finding?.nonAnchorableReason ? ` — not inlined: ${finding.nonAnchorableReason}` : "";
|
|
161
|
+
return `${reproducedLine(finding)}${reason}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Map Stage-3 findings + hosting status into the merged-result input that the
|
|
166
|
+
* shared buildDraftReviewPayload consumes. Anchorable findings become inline
|
|
167
|
+
* comments on their exact {path,line,side:RIGHT} anchors; the artifact line and
|
|
168
|
+
* every non-anchorable finding are retained as summary (body) findings.
|
|
169
|
+
*
|
|
170
|
+
* @param {{findings?: object[], headSha?: string|null, hosting?: object, hostedUrl?: string}} input
|
|
171
|
+
*/
|
|
172
|
+
export function buildReviewInput({ findings = [], headSha = null, hosting = null, hostedUrl = null } = {}) {
|
|
173
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
174
|
+
const anchorable = list.filter(hasValidAnchor);
|
|
175
|
+
const nonAnchorable = list.filter((f) => !hasValidAnchor(f));
|
|
176
|
+
|
|
177
|
+
// Untrusted target-app text (exception type/message, log lines, nonAnchorableReason)
|
|
178
|
+
// flows into these bodies. Sanitize copilot-summon tokens before they enter the
|
|
179
|
+
// payload buildDraftReviewPayload posts verbatim — every sibling posting path does.
|
|
180
|
+
const inlineComments = anchorable.map((f) => ({
|
|
181
|
+
path: f.anchor.path,
|
|
182
|
+
line: f.anchor.line,
|
|
183
|
+
message: sanitizeCopilotSummonTokens(formatInlineBody(f)),
|
|
184
|
+
severity: f.severity ?? "note",
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
const summaryFindings = [
|
|
188
|
+
{ message: sanitizeCopilotSummonTokens(artifactBodyLine({ hosting, hostedUrl })), severity: "note" },
|
|
189
|
+
...nonAnchorable.map((f) => ({ message: sanitizeCopilotSummonTokens(nonAnchorableBodyMessage(f)), severity: f.severity ?? "note" })),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const blocking = list.some(isBlockingFinding);
|
|
193
|
+
const verdict = list.length === 0 ? "APPROVE" : (blocking ? "REQUEST_CHANGES" : "COMMENT");
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
headSha: normalizeSha(headSha),
|
|
197
|
+
verdict,
|
|
198
|
+
inlineComments,
|
|
199
|
+
summaryFindings,
|
|
200
|
+
totalFindings: list.length,
|
|
201
|
+
runsMerged: 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function escapeHtml(value) {
|
|
206
|
+
return String(value).replace(/[&<>"']/gu, (c) => (
|
|
207
|
+
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
|
208
|
+
));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const ARTIFACT_STYLE = [
|
|
212
|
+
"body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;color:#1a1a1a;background:#fafafa}",
|
|
213
|
+
"h1{font-size:20px;margin:0 0 4px}",
|
|
214
|
+
".meta{color:#666;margin-bottom:20px}",
|
|
215
|
+
".finding{border:1px solid #ddd;border-radius:6px;padding:12px 16px;margin:0 0 12px;background:#fff}",
|
|
216
|
+
".finding.blocking{border-left:4px solid #c0392b}",
|
|
217
|
+
".finding.note{border-left:4px solid #999}",
|
|
218
|
+
".sev{font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}",
|
|
219
|
+
".anchor{color:#2c3e50;font-family:ui-monospace,monospace;font-size:12px}",
|
|
220
|
+
".exc{font-family:ui-monospace,monospace;white-space:pre-wrap;margin:6px 0}",
|
|
221
|
+
".reason{color:#c0392b;font-size:12px}",
|
|
222
|
+
"img{max-width:100%;border:1px solid #ddd;border-radius:4px;margin-top:12px}",
|
|
223
|
+
].join("");
|
|
224
|
+
|
|
225
|
+
const ARTIFACT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'";
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build the self-contained, CSP-safe HTML artifact (ranked findings + inline
|
|
229
|
+
* screenshot evidence). Fully inlined: no external scripts/styles/fonts/images.
|
|
230
|
+
* Bounded caps (findings past ARTIFACT_MAX_FINDINGS, an oversized screenshot)
|
|
231
|
+
* are applied here and returned in `caps` so the CLI can log them — never a
|
|
232
|
+
* silent truncation.
|
|
233
|
+
*
|
|
234
|
+
* @param {{findings?: object[], counts?: object, pr?: object, screenshot?: {path:string,dataUri:string}|null, generatedAt?: string}} input
|
|
235
|
+
* @returns {{html: string, caps: string[]}}
|
|
236
|
+
*/
|
|
237
|
+
export function buildArtifactHtml({ findings = [], counts = {}, pr = {}, screenshot = null, generatedAt } = {}) {
|
|
238
|
+
const caps = [];
|
|
239
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
240
|
+
|
|
241
|
+
let shown = list;
|
|
242
|
+
if (list.length > ARTIFACT_MAX_FINDINGS) {
|
|
243
|
+
shown = list.slice(0, ARTIFACT_MAX_FINDINGS);
|
|
244
|
+
caps.push(`artifact: findings truncated to ${ARTIFACT_MAX_FINDINGS} of ${list.length} (${list.length - ARTIFACT_MAX_FINDINGS} not rendered)`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let screenshotHtml = "";
|
|
248
|
+
if (screenshot && typeof screenshot.dataUri === "string") {
|
|
249
|
+
if (screenshot.dataUri.length > ARTIFACT_MAX_SCREENSHOT_BYTES) {
|
|
250
|
+
caps.push(`artifact: screenshot omitted (${screenshot.dataUri.length} bytes > ${ARTIFACT_MAX_SCREENSHOT_BYTES} cap): ${screenshot.path ?? "unknown"}`);
|
|
251
|
+
} else {
|
|
252
|
+
screenshotHtml = `<h2>Reproduced evidence</h2><img alt="reproduced state" src="${escapeHtml(screenshot.dataUri)}" />`;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const findingsHtml = shown.map((f) => {
|
|
257
|
+
const blocking = isBlockingFinding(f);
|
|
258
|
+
const cls = blocking ? "blocking" : "note";
|
|
259
|
+
const anchor = f?.anchor
|
|
260
|
+
? `<div class="anchor">${escapeHtml(f.anchor.path)}:${escapeHtml(f.anchor.line)} (${escapeHtml(f.anchor.side)})</div>`
|
|
261
|
+
: `<div class="reason">not inlined: ${escapeHtml(f?.nonAnchorableReason ?? "no anchor")}</div>`;
|
|
262
|
+
return [
|
|
263
|
+
`<div class="finding ${cls}">`,
|
|
264
|
+
`<div class="sev">${escapeHtml(f?.severity ?? "note")} · ${escapeHtml(f?.kind ?? "finding")}</div>`,
|
|
265
|
+
`<div class="exc">${escapeHtml(reproducedLine(f))}</div>`,
|
|
266
|
+
anchor,
|
|
267
|
+
"</div>",
|
|
268
|
+
].join("");
|
|
269
|
+
}).join("");
|
|
270
|
+
|
|
271
|
+
const total = Number.isFinite(counts?.total) ? counts.total : list.length;
|
|
272
|
+
const html = [
|
|
273
|
+
"<!doctype html>",
|
|
274
|
+
'<html lang="en"><head><meta charset="utf-8" />',
|
|
275
|
+
`<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CSP}" />`,
|
|
276
|
+
"<title>UI review findings</title>",
|
|
277
|
+
`<style>${ARTIFACT_STYLE}</style>`,
|
|
278
|
+
"</head><body>",
|
|
279
|
+
`<h1>UI review findings — PR #${escapeHtml(pr?.number ?? "?")}</h1>`,
|
|
280
|
+
`<div class="meta">head ${escapeHtml(pr?.headSha ?? "?")} · ${escapeHtml(total)} finding(s) · ${escapeHtml(counts?.anchorable ?? 0)} anchorable · generated ${escapeHtml(generatedAt ?? "")}</div>`,
|
|
281
|
+
findingsHtml,
|
|
282
|
+
screenshotHtml,
|
|
283
|
+
"</body></html>",
|
|
284
|
+
].join("");
|
|
285
|
+
|
|
286
|
+
return { html, caps };
|
|
287
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teardown + side-effect ledger orchestrator for the ui_review route (Stage 5).
|
|
3
|
+
*
|
|
4
|
+
* Terminal cleanup for a running-app review: stop the app booted in Stage 1,
|
|
5
|
+
* drop the dev-DB rows the Stage-2 drive created, and remove the provisioned
|
|
6
|
+
* worktree. The core safety property of this stage is that a side-effect ledger
|
|
7
|
+
* is ALWAYS emitted — enumerating every migration applied, row created/dropped,
|
|
8
|
+
* the worktree path, and any process left running — so nothing the loop touched
|
|
9
|
+
* is ever silently orphaned, whether teardown succeeds, is skipped, or partially
|
|
10
|
+
* fails.
|
|
11
|
+
*
|
|
12
|
+
* Two safety rails:
|
|
13
|
+
* - Destructive steps (row drops, worktree removal) run ONLY on explicit
|
|
14
|
+
* confirmation. Without it the destructive steps are skipped and the ledger
|
|
15
|
+
* records what remains. Stopping the app is a clean shutdown of a process
|
|
16
|
+
* the loop itself started, not a destructive mutation of persisted state, so
|
|
17
|
+
* it runs regardless of confirmation.
|
|
18
|
+
* - A failed kill/drop/removal is REPORTED in the ledger and the result's
|
|
19
|
+
* errors list, never swallowed.
|
|
20
|
+
*
|
|
21
|
+
* This module is PURE orchestration: the process kill, the row drop, and the
|
|
22
|
+
* worktree removal are injected seams so it is fully testable without real side
|
|
23
|
+
* effects. The thin CLI wires the real ones.
|
|
24
|
+
*
|
|
25
|
+
* Non-goals (explicit): NO rollback of the branch's dev-DB migrations by default
|
|
26
|
+
* (they were applied to a dev DB; reversal is a separate explicit action — the
|
|
27
|
+
* ledger records they were applied, not reverted). NO production teardown.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The honest row-drop reality: Stage 2 does NOT tag the dev-DB rows it creates
|
|
32
|
+
* with a session id or row manifest. So unless an explicit row manifest is
|
|
33
|
+
* handed in (and confirmed), this stage CANNOT know which rows to drop and MUST
|
|
34
|
+
* NOT guess. When the drive ran mutating flows without a manifest, the ledger
|
|
35
|
+
* reports rows "may remain (untagged)" rather than dropping anything.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const ROW_STATUS = Object.freeze({
|
|
39
|
+
DROPPED: "dropped",
|
|
40
|
+
DROP_FAILED: "drop-failed",
|
|
41
|
+
MAY_REMAIN_UNTAGGED: "may-remain-untagged",
|
|
42
|
+
SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
|
|
43
|
+
NONE: "none",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const WORKTREE_STATUS = Object.freeze({
|
|
47
|
+
REMOVED: "removed",
|
|
48
|
+
REMOVE_FAILED: "remove-failed",
|
|
49
|
+
SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
|
|
50
|
+
// No worktree path in the provision result at all. Distinct from
|
|
51
|
+
// SKIPPED_UNCONFIRMED (which is the confirmation gate) so the ledger says WHY
|
|
52
|
+
// removal did not run — a missing path, not a withheld confirmation.
|
|
53
|
+
MISSING_PATH: "missing-path",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const PROCESS_STATUS = Object.freeze({
|
|
57
|
+
STOPPED: "stopped",
|
|
58
|
+
KILL_FAILED: "kill-failed",
|
|
59
|
+
MAY_BE_RUNNING: "may-be-running",
|
|
60
|
+
SKIPPED: "skipped",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Did the Stage-2 drive potentially create dev-DB rows? Without row tagging this
|
|
65
|
+
* is a coarse but honest signal: a drive that actually walked steps exercised
|
|
66
|
+
* create/edit/upload interactions, so rows may have been created. A drive that
|
|
67
|
+
* stopped before driving anything (e.g. auth failure) created nothing.
|
|
68
|
+
*/
|
|
69
|
+
function driveMayHaveCreatedRows(driveResult) {
|
|
70
|
+
if (!driveResult || driveResult.stopped) return false;
|
|
71
|
+
return Array.isArray(driveResult.steps) && driveResult.steps.length > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run the teardown sequence and always return a result carrying the side-effect
|
|
76
|
+
* ledger.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} input
|
|
79
|
+
* @param {object} input.provisionResult - Stage-1 result: `boot.pid` (app PID),
|
|
80
|
+
* `migrations` (applied count/detail), and `worktreePath`.
|
|
81
|
+
* @param {object|null} [input.driveResult] - Stage-2 result: the rows-created
|
|
82
|
+
* signal (whether the drive walked mutating steps). Null when no drive ran.
|
|
83
|
+
* @param {Array<object>|null} [input.rowManifest] - Explicit rows to drop, when
|
|
84
|
+
* a session tag/manifest is available. Absent/empty => untagged fallback.
|
|
85
|
+
* @param {boolean} [input.confirm] - Explicit authorization for the destructive
|
|
86
|
+
* steps (row drop, worktree removal). Fail-safe: absent means NOT confirmed.
|
|
87
|
+
* @param {boolean} [input.stopApp] - Stop the Stage-1 app (default true). This is
|
|
88
|
+
* a clean shutdown, NOT gated on confirmation.
|
|
89
|
+
* @param {object} seams
|
|
90
|
+
* @param {(a:{pid:number})=>Promise<{stopped:boolean,forced:boolean,detail:string,mayBeRunning?:boolean}>} seams.killProcess
|
|
91
|
+
* `mayBeRunning:true` marks a NOT-ATTEMPTED outcome (e.g. win32, where process-group
|
|
92
|
+
* signalling is unsupported) — mapped to MAY_BE_RUNNING (non-fatal), not KILL_FAILED.
|
|
93
|
+
* @param {(a:{rows:Array<object>})=>Promise<{ok:boolean,dropped:number,detail:string}>} seams.dropRows
|
|
94
|
+
* @param {(a:{worktreePath:string})=>Promise<{removed:string|null,ok:boolean,detail:string}>} seams.removeWorktree
|
|
95
|
+
* @param {(msg:string)=>void} [seams.log]
|
|
96
|
+
* @returns {Promise<{ok:boolean,confirmed:boolean,ledger:object,errors:string[],logs:string[]}>}
|
|
97
|
+
*/
|
|
98
|
+
export async function teardown(
|
|
99
|
+
{ provisionResult, driveResult = null, rowManifest = null, confirm = false, stopApp = true },
|
|
100
|
+
{ killProcess, dropRows, removeWorktree, log = () => {} } = {},
|
|
101
|
+
) {
|
|
102
|
+
const logs = [];
|
|
103
|
+
const errors = [];
|
|
104
|
+
const record = (msg) => {
|
|
105
|
+
logs.push(msg);
|
|
106
|
+
log(msg);
|
|
107
|
+
};
|
|
108
|
+
const fail = (msg) => {
|
|
109
|
+
errors.push(msg);
|
|
110
|
+
record(msg);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const provision = provisionResult ?? {};
|
|
114
|
+
// `worktreePath` is read from disk (trust boundary). A non-string (number,
|
|
115
|
+
// object, array) must never reach `removeWorktree` — the CLI's cleanup path
|
|
116
|
+
// calls `path.resolve(worktreePath)`, which throws on a non-string, breaking
|
|
117
|
+
// the always-emit-ledger invariant. Coerce to a usable string or null here.
|
|
118
|
+
const rawWorktreePath = provision.worktreePath ?? null;
|
|
119
|
+
const worktreePath = typeof rawWorktreePath === "string" ? rawWorktreePath : null;
|
|
120
|
+
const worktreePathMalformed = rawWorktreePath != null && typeof rawWorktreePath !== "string";
|
|
121
|
+
const pid = provision.boot?.pid ?? null;
|
|
122
|
+
const migrations = provision.migrations ?? { applied: 0, pending: 0, destructive: [], detail: "no provision migrations" };
|
|
123
|
+
|
|
124
|
+
// 1. Stop the app (clean shutdown; NOT confirmation-gated). Uses the Stage-1
|
|
125
|
+
// boot PID. A missing OR unusable PID means we cannot stop it — the ledger
|
|
126
|
+
// reports it may still be running rather than guessing. `boot.pid` is read
|
|
127
|
+
// from disk (trust boundary), so anything that is not a positive integer
|
|
128
|
+
// (0, negative, NaN, float, string) is rejected here: passing it to the
|
|
129
|
+
// kill seam would let the CLI's process-group kill signal `process.kill(0)`
|
|
130
|
+
// (this loop's OWN group) or `process.kill(-1)` (every process).
|
|
131
|
+
const usablePid = Number.isInteger(pid) && pid > 0;
|
|
132
|
+
let processLedger;
|
|
133
|
+
if (!stopApp) {
|
|
134
|
+
processLedger = { pid: usablePid ? pid : null, status: PROCESS_STATUS.SKIPPED, forced: false, detail: "app stop skipped by request" };
|
|
135
|
+
record(`app stop skipped (pid ${usablePid ? pid : "n/a"})`);
|
|
136
|
+
} else if (!usablePid) {
|
|
137
|
+
const detail = pid == null
|
|
138
|
+
? "no PID captured from Stage 1; process may still be running"
|
|
139
|
+
: "no usable PID from Stage 1 (not a positive integer); process may still be running";
|
|
140
|
+
processLedger = { pid: null, status: PROCESS_STATUS.MAY_BE_RUNNING, forced: false, detail };
|
|
141
|
+
record(`app stop: ${detail}`);
|
|
142
|
+
} else {
|
|
143
|
+
// A seam that THROWS must still yield a fully-emitted ledger: catch it,
|
|
144
|
+
// record KILL_FAILED, and press on. The always-emit invariant holds even
|
|
145
|
+
// when a real IO seam rejects.
|
|
146
|
+
try {
|
|
147
|
+
const kill = await killProcess({ pid });
|
|
148
|
+
if (kill.stopped) {
|
|
149
|
+
processLedger = { pid, status: PROCESS_STATUS.STOPPED, forced: !!kill.forced, detail: kill.detail };
|
|
150
|
+
record(`app stopped (pid ${pid})${kill.forced ? " [force-killed: SIGKILL fallback]" : ""}: ${kill.detail}`);
|
|
151
|
+
} else if (kill.mayBeRunning) {
|
|
152
|
+
// The kill was NOT ATTEMPTED (e.g. win32 process-group signalling is
|
|
153
|
+
// unsupported) — this is a "couldn't stop", not a failed attempt, so it
|
|
154
|
+
// is non-fatal (matches the null-PID may-be-running treatment): the
|
|
155
|
+
// ledger reports the app may still be running and `ok` is left intact.
|
|
156
|
+
processLedger = { pid, status: PROCESS_STATUS.MAY_BE_RUNNING, forced: !!kill.forced, detail: kill.detail };
|
|
157
|
+
record(`app stop: ${kill.detail}`);
|
|
158
|
+
} else {
|
|
159
|
+
processLedger = { pid, status: PROCESS_STATUS.KILL_FAILED, forced: !!kill.forced, detail: kill.detail };
|
|
160
|
+
fail(`app stop FAILED (pid ${pid}): ${kill.detail}`);
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
processLedger = { pid, status: PROCESS_STATUS.KILL_FAILED, forced: false, detail: `kill seam threw: ${err?.message ?? err}` };
|
|
164
|
+
fail(`app stop FAILED (pid ${pid}): kill seam threw: ${err?.message ?? err}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 2. Drop dev-DB rows — DESTRUCTIVE, confirmation-gated, dev DB only. Only ever
|
|
169
|
+
// drops an explicit manifest; never guesses untagged rows (see file header).
|
|
170
|
+
const hasManifest = Array.isArray(rowManifest) && rowManifest.length > 0;
|
|
171
|
+
let rowsLedger;
|
|
172
|
+
if (!confirm) {
|
|
173
|
+
if (hasManifest) {
|
|
174
|
+
rowsLedger = { status: ROW_STATUS.SKIPPED_UNCONFIRMED, dropped: 0, candidates: rowManifest.length, detail: `${rowManifest.length} row(s) NOT dropped: teardown not confirmed` };
|
|
175
|
+
record(`row drop skipped (not confirmed): ${rowManifest.length} manifest row(s) remain`);
|
|
176
|
+
} else if (driveMayHaveCreatedRows(driveResult)) {
|
|
177
|
+
rowsLedger = { status: ROW_STATUS.MAY_REMAIN_UNTAGGED, dropped: 0, candidates: 0, detail: "rows may remain (untagged): drive created rows but no session tag/manifest to target them, and teardown not confirmed" };
|
|
178
|
+
record("row drop skipped: rows may remain (untagged)");
|
|
179
|
+
} else {
|
|
180
|
+
rowsLedger = { status: ROW_STATUS.NONE, dropped: 0, candidates: 0, detail: "no rows created (drive drove no mutating steps)" };
|
|
181
|
+
}
|
|
182
|
+
} else if (hasManifest) {
|
|
183
|
+
try {
|
|
184
|
+
const drop = await dropRows({ rows: rowManifest });
|
|
185
|
+
if (drop.ok) {
|
|
186
|
+
rowsLedger = { status: ROW_STATUS.DROPPED, dropped: drop.dropped ?? rowManifest.length, candidates: rowManifest.length, detail: drop.detail };
|
|
187
|
+
record(`dev-DB rows dropped: ${drop.dropped ?? rowManifest.length} (${drop.detail})`);
|
|
188
|
+
} else {
|
|
189
|
+
rowsLedger = { status: ROW_STATUS.DROP_FAILED, dropped: drop.dropped ?? 0, candidates: rowManifest.length, detail: drop.detail };
|
|
190
|
+
fail(`dev-DB row drop FAILED: ${drop.detail}`);
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
rowsLedger = { status: ROW_STATUS.DROP_FAILED, dropped: 0, candidates: rowManifest.length, detail: `drop seam threw: ${err?.message ?? err}` };
|
|
194
|
+
fail(`dev-DB row drop FAILED: drop seam threw: ${err?.message ?? err}`);
|
|
195
|
+
}
|
|
196
|
+
} else if (driveMayHaveCreatedRows(driveResult)) {
|
|
197
|
+
// Confirmed, but nothing to target: honesty over a guess-drop.
|
|
198
|
+
rowsLedger = { status: ROW_STATUS.MAY_REMAIN_UNTAGGED, dropped: 0, candidates: 0, detail: "rows may remain (untagged): drive created rows but no session tag/manifest to target them; refusing to guess which rows to drop" };
|
|
199
|
+
record("row drop: rows may remain (untagged) — no manifest to target, not guessing");
|
|
200
|
+
} else {
|
|
201
|
+
rowsLedger = { status: ROW_STATUS.NONE, dropped: 0, candidates: 0, detail: "no rows created (drive drove no mutating steps)" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 3. Remove the worktree — DESTRUCTIVE, confirmation-gated. Delegated to the
|
|
205
|
+
// shared cleanup path, which refuses anything outside the loop namespace.
|
|
206
|
+
let worktreeLedger;
|
|
207
|
+
if (worktreePathMalformed) {
|
|
208
|
+
worktreeLedger = { path: null, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: `malformed worktree path in provision result (not a string): ${typeof rawWorktreePath}` };
|
|
209
|
+
fail(`worktree removal FAILED: malformed worktree path in provision result (not a string): ${typeof rawWorktreePath}`);
|
|
210
|
+
} else if (!worktreePath) {
|
|
211
|
+
worktreeLedger = { path: null, removed: false, status: WORKTREE_STATUS.MISSING_PATH, detail: "no worktree path in provision result" };
|
|
212
|
+
record("worktree removal skipped: no worktree path in provision result");
|
|
213
|
+
} else if (!confirm) {
|
|
214
|
+
worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.SKIPPED_UNCONFIRMED, detail: "worktree retained: teardown not confirmed" };
|
|
215
|
+
record(`worktree removal skipped (not confirmed): ${worktreePath} retained`);
|
|
216
|
+
} else {
|
|
217
|
+
try {
|
|
218
|
+
const rm = await removeWorktree({ worktreePath });
|
|
219
|
+
if (rm.removed) {
|
|
220
|
+
worktreeLedger = { path: worktreePath, removed: true, status: WORKTREE_STATUS.REMOVED, detail: rm.detail };
|
|
221
|
+
record(`worktree removed: ${worktreePath} (${rm.detail})`);
|
|
222
|
+
} else {
|
|
223
|
+
worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: rm.detail };
|
|
224
|
+
fail(`worktree removal FAILED: ${worktreePath} (${rm.detail})`);
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: `removeWorktree seam threw: ${err?.message ?? err}` };
|
|
228
|
+
fail(`worktree removal FAILED: ${worktreePath} (removeWorktree seam threw: ${err?.message ?? err})`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The ledger is ALWAYS emitted (every case), enumerating every known side
|
|
233
|
+
// effect. Migrations are recorded as applied-not-reverted by design.
|
|
234
|
+
const ledger = {
|
|
235
|
+
confirmed: confirm,
|
|
236
|
+
migrations: {
|
|
237
|
+
applied: migrations.applied ?? 0,
|
|
238
|
+
reverted: false,
|
|
239
|
+
detail: migrations.detail ?? null,
|
|
240
|
+
note: "not reverted (dev DB; migration reversal is a separate explicit action)",
|
|
241
|
+
},
|
|
242
|
+
rows: rowsLedger,
|
|
243
|
+
worktree: worktreeLedger,
|
|
244
|
+
process: processLedger,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return { ok: errors.length === 0, confirmed: confirm, ledger, errors, logs };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { ROW_STATUS, WORKTREE_STATUS, PROCESS_STATUS };
|