@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.
@@ -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 };