@dev-loops/core 0.8.0 → 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 +6 -1
- package/src/claude/asset-generation.mjs +23 -1
- package/src/config/config.mjs +277 -0
- package/src/config/extension-defaults.yaml +0 -1
- package/src/loop/handoff-envelope.mjs +27 -0
- package/src/loop/issue-refinement-artifact.mjs +10 -5
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- 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,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 };
|