@dev-loops/core 0.8.0 → 1.0.0-rc.1

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,292 @@
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 tagged, 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
+ * Row-drop model: Stage 2 stamps each mutating step with a drive-session id and
32
+ * emits a session-tagged row manifest. Given that manifest (and confirmation),
33
+ * this stage drops exactly the rows tagged with that session. Only the fallback
34
+ * case — a drive that mutated but handed in no manifest — CANNOT know which rows
35
+ * to drop and MUST NOT guess: the ledger reports rows "may remain (untagged)"
36
+ * rather than dropping anything.
37
+ */
38
+
39
+ const ROW_STATUS = Object.freeze({
40
+ DROPPED: "dropped",
41
+ DROP_FAILED: "drop-failed",
42
+ MAY_REMAIN_UNTAGGED: "may-remain-untagged",
43
+ SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
44
+ NONE: "none",
45
+ });
46
+
47
+ const WORKTREE_STATUS = Object.freeze({
48
+ REMOVED: "removed",
49
+ REMOVE_FAILED: "remove-failed",
50
+ SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
51
+ // No worktree path in the provision result at all. Distinct from
52
+ // SKIPPED_UNCONFIRMED (which is the confirmation gate) so the ledger says WHY
53
+ // removal did not run — a missing path, not a withheld confirmation.
54
+ MISSING_PATH: "missing-path",
55
+ });
56
+
57
+ const PROCESS_STATUS = Object.freeze({
58
+ STOPPED: "stopped",
59
+ KILL_FAILED: "kill-failed",
60
+ MAY_BE_RUNNING: "may-be-running",
61
+ SKIPPED: "skipped",
62
+ });
63
+
64
+ const GIST_STATUS = Object.freeze({
65
+ DELETED: "deleted",
66
+ DELETE_FAILED: "delete-failed",
67
+ SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
68
+ NONE: "none",
69
+ });
70
+
71
+ /**
72
+ * Did the Stage-2 drive potentially create dev-DB rows? Without row tagging this
73
+ * is a coarse but honest signal: a drive that actually walked steps exercised
74
+ * create/edit/upload interactions, so rows may have been created. A drive that
75
+ * stopped before driving anything (e.g. auth failure) created nothing.
76
+ */
77
+ function driveMayHaveCreatedRows(driveResult) {
78
+ if (!driveResult || driveResult.stopped) return false;
79
+ return Array.isArray(driveResult.steps) && driveResult.steps.length > 0;
80
+ }
81
+
82
+ /**
83
+ * Run the teardown sequence and always return a result carrying the side-effect
84
+ * ledger.
85
+ *
86
+ * @param {object} input
87
+ * @param {object} input.provisionResult - Stage-1 result: `boot.pid` (app PID),
88
+ * `migrations` (applied count/detail), and `worktreePath`.
89
+ * @param {object|null} [input.driveResult] - Stage-2 result: the rows-created
90
+ * signal (whether the drive walked mutating steps). Null when no drive ran.
91
+ * @param {Array<object>|null} [input.rowManifest] - Explicit rows to drop, when
92
+ * a session tag/manifest is available. Absent/empty => untagged fallback.
93
+ * @param {{id?:string|null,url?:string|null}|null} [input.gist] - The Stage-4
94
+ * GitHub-native hosting artifact (a secret gist) to prune, when one was
95
+ * published off-Claude. Absent => nothing to prune.
96
+ * @param {boolean} [input.confirm] - Explicit authorization for the destructive
97
+ * steps (row drop, worktree removal, gist deletion). Fail-safe: absent means
98
+ * NOT confirmed.
99
+ * @param {boolean} [input.stopApp] - Stop the Stage-1 app (default true). This is
100
+ * a clean shutdown, NOT gated on confirmation.
101
+ * @param {object} seams
102
+ * @param {(a:{pid:number})=>Promise<{stopped:boolean,forced:boolean,detail:string,mayBeRunning?:boolean}>} seams.killProcess
103
+ * `mayBeRunning:true` marks a NOT-ATTEMPTED outcome (e.g. win32, where process-group
104
+ * signalling is unsupported) — mapped to MAY_BE_RUNNING (non-fatal), not KILL_FAILED.
105
+ * @param {(a:{rows:Array<object>})=>Promise<{ok:boolean,dropped:number,detail:string}>} seams.dropRows
106
+ * @param {(a:{worktreePath:string})=>Promise<{removed:string|null,ok:boolean,detail:string}>} seams.removeWorktree
107
+ * @param {(a:{id:string})=>Promise<{ok:boolean,detail:string}>} [seams.deleteGist] - Prune the
108
+ * Stage-4 hosting gist. Only invoked when a gist id is present AND confirmed.
109
+ * @param {(msg:string)=>void} [seams.log]
110
+ * @returns {Promise<{ok:boolean,confirmed:boolean,ledger:object,errors:string[],logs:string[]}>}
111
+ */
112
+ export async function teardown(
113
+ { provisionResult, driveResult = null, rowManifest = null, gist = null, confirm = false, stopApp = true },
114
+ { killProcess, dropRows, removeWorktree, deleteGist, log = () => {} } = {},
115
+ ) {
116
+ const logs = [];
117
+ const errors = [];
118
+ const record = (msg) => {
119
+ logs.push(msg);
120
+ log(msg);
121
+ };
122
+ const fail = (msg) => {
123
+ errors.push(msg);
124
+ record(msg);
125
+ };
126
+
127
+ const provision = provisionResult ?? {};
128
+ // `worktreePath` is read from disk (trust boundary). A non-string (number,
129
+ // object, array) must never reach `removeWorktree` — the CLI's cleanup path
130
+ // calls `path.resolve(worktreePath)`, which throws on a non-string, breaking
131
+ // the always-emit-ledger invariant. Coerce to a usable string or null here.
132
+ const rawWorktreePath = provision.worktreePath ?? null;
133
+ const worktreePath = typeof rawWorktreePath === "string" ? rawWorktreePath : null;
134
+ const worktreePathMalformed = rawWorktreePath != null && typeof rawWorktreePath !== "string";
135
+ const pid = provision.boot?.pid ?? null;
136
+ const migrations = provision.migrations ?? { applied: 0, pending: 0, destructive: [], detail: "no provision migrations" };
137
+
138
+ // 1. Stop the app (clean shutdown; NOT confirmation-gated). Uses the Stage-1
139
+ // boot PID. A missing OR unusable PID means we cannot stop it — the ledger
140
+ // reports it may still be running rather than guessing. `boot.pid` is read
141
+ // from disk (trust boundary), so anything that is not a positive integer
142
+ // (0, negative, NaN, float, string) is rejected here: passing it to the
143
+ // kill seam would let the CLI's process-group kill signal `process.kill(0)`
144
+ // (this loop's OWN group) or `process.kill(-1)` (every process).
145
+ const usablePid = Number.isInteger(pid) && pid > 0;
146
+ let processLedger;
147
+ if (!stopApp) {
148
+ processLedger = { pid: usablePid ? pid : null, status: PROCESS_STATUS.SKIPPED, forced: false, detail: "app stop skipped by request" };
149
+ record(`app stop skipped (pid ${usablePid ? pid : "n/a"})`);
150
+ } else if (!usablePid) {
151
+ const detail = pid == null
152
+ ? "no PID captured from Stage 1; process may still be running"
153
+ : "no usable PID from Stage 1 (not a positive integer); process may still be running";
154
+ processLedger = { pid: null, status: PROCESS_STATUS.MAY_BE_RUNNING, forced: false, detail };
155
+ record(`app stop: ${detail}`);
156
+ } else {
157
+ // A seam that THROWS must still yield a fully-emitted ledger: catch it,
158
+ // record KILL_FAILED, and press on. The always-emit invariant holds even
159
+ // when a real IO seam rejects.
160
+ try {
161
+ const kill = await killProcess({ pid });
162
+ if (kill.stopped) {
163
+ processLedger = { pid, status: PROCESS_STATUS.STOPPED, forced: !!kill.forced, detail: kill.detail };
164
+ record(`app stopped (pid ${pid})${kill.forced ? " [force-killed: SIGKILL fallback]" : ""}: ${kill.detail}`);
165
+ } else if (kill.mayBeRunning) {
166
+ // The kill was NOT ATTEMPTED (e.g. win32 process-group signalling is
167
+ // unsupported) — this is a "couldn't stop", not a failed attempt, so it
168
+ // is non-fatal (matches the null-PID may-be-running treatment): the
169
+ // ledger reports the app may still be running and `ok` is left intact.
170
+ processLedger = { pid, status: PROCESS_STATUS.MAY_BE_RUNNING, forced: !!kill.forced, detail: kill.detail };
171
+ record(`app stop: ${kill.detail}`);
172
+ } else {
173
+ processLedger = { pid, status: PROCESS_STATUS.KILL_FAILED, forced: !!kill.forced, detail: kill.detail };
174
+ fail(`app stop FAILED (pid ${pid}): ${kill.detail}`);
175
+ }
176
+ } catch (err) {
177
+ processLedger = { pid, status: PROCESS_STATUS.KILL_FAILED, forced: false, detail: `kill seam threw: ${err?.message ?? err}` };
178
+ fail(`app stop FAILED (pid ${pid}): kill seam threw: ${err?.message ?? err}`);
179
+ }
180
+ }
181
+
182
+ // 2. Drop dev-DB rows — DESTRUCTIVE, confirmation-gated, dev DB only. Only ever
183
+ // drops an explicit manifest; never guesses untagged rows (see file header).
184
+ const hasManifest = Array.isArray(rowManifest) && rowManifest.length > 0;
185
+ let rowsLedger;
186
+ if (!confirm) {
187
+ if (hasManifest) {
188
+ rowsLedger = { status: ROW_STATUS.SKIPPED_UNCONFIRMED, dropped: 0, candidates: rowManifest.length, detail: `${rowManifest.length} row(s) NOT dropped: teardown not confirmed` };
189
+ record(`row drop skipped (not confirmed): ${rowManifest.length} manifest row(s) remain`);
190
+ } else if (driveMayHaveCreatedRows(driveResult)) {
191
+ 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" };
192
+ record("row drop skipped: rows may remain (untagged)");
193
+ } else {
194
+ rowsLedger = { status: ROW_STATUS.NONE, dropped: 0, candidates: 0, detail: "no rows created (drive drove no mutating steps)" };
195
+ }
196
+ } else if (hasManifest) {
197
+ try {
198
+ const drop = await dropRows({ rows: rowManifest });
199
+ if (drop.ok) {
200
+ rowsLedger = { status: ROW_STATUS.DROPPED, dropped: drop.dropped ?? rowManifest.length, candidates: rowManifest.length, detail: drop.detail };
201
+ record(`dev-DB rows dropped: ${drop.dropped ?? rowManifest.length} (${drop.detail})`);
202
+ } else {
203
+ rowsLedger = { status: ROW_STATUS.DROP_FAILED, dropped: drop.dropped ?? 0, candidates: rowManifest.length, detail: drop.detail };
204
+ fail(`dev-DB row drop FAILED: ${drop.detail}`);
205
+ }
206
+ } catch (err) {
207
+ rowsLedger = { status: ROW_STATUS.DROP_FAILED, dropped: 0, candidates: rowManifest.length, detail: `drop seam threw: ${err?.message ?? err}` };
208
+ fail(`dev-DB row drop FAILED: drop seam threw: ${err?.message ?? err}`);
209
+ }
210
+ } else if (driveMayHaveCreatedRows(driveResult)) {
211
+ // Confirmed, but nothing to target: honesty over a guess-drop.
212
+ 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" };
213
+ record("row drop: rows may remain (untagged) — no manifest to target, not guessing");
214
+ } else {
215
+ rowsLedger = { status: ROW_STATUS.NONE, dropped: 0, candidates: 0, detail: "no rows created (drive drove no mutating steps)" };
216
+ }
217
+
218
+ // 3. Remove the worktree — DESTRUCTIVE, confirmation-gated. Delegated to the
219
+ // shared cleanup path, which refuses anything outside the loop namespace.
220
+ let worktreeLedger;
221
+ if (worktreePathMalformed) {
222
+ worktreeLedger = { path: null, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: `malformed worktree path in provision result (not a string): ${typeof rawWorktreePath}` };
223
+ fail(`worktree removal FAILED: malformed worktree path in provision result (not a string): ${typeof rawWorktreePath}`);
224
+ } else if (!worktreePath) {
225
+ worktreeLedger = { path: null, removed: false, status: WORKTREE_STATUS.MISSING_PATH, detail: "no worktree path in provision result" };
226
+ record("worktree removal skipped: no worktree path in provision result");
227
+ } else if (!confirm) {
228
+ worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.SKIPPED_UNCONFIRMED, detail: "worktree retained: teardown not confirmed" };
229
+ record(`worktree removal skipped (not confirmed): ${worktreePath} retained`);
230
+ } else {
231
+ try {
232
+ const rm = await removeWorktree({ worktreePath });
233
+ if (rm.removed) {
234
+ worktreeLedger = { path: worktreePath, removed: true, status: WORKTREE_STATUS.REMOVED, detail: rm.detail };
235
+ record(`worktree removed: ${worktreePath} (${rm.detail})`);
236
+ } else {
237
+ worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: rm.detail };
238
+ fail(`worktree removal FAILED: ${worktreePath} (${rm.detail})`);
239
+ }
240
+ } catch (err) {
241
+ worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: `removeWorktree seam threw: ${err?.message ?? err}` };
242
+ fail(`worktree removal FAILED: ${worktreePath} (removeWorktree seam threw: ${err?.message ?? err})`);
243
+ }
244
+ }
245
+
246
+ // 4. Prune the Stage-4 hosting gist — DESTRUCTIVE, confirmation-gated. A gist
247
+ // accretes one secret entry per run; deleting it keeps the hosting target
248
+ // from piling up. Only ever acts on an explicit gist id from the report
249
+ // result; a missing id is NONE (nothing published, or Claude-hosted).
250
+ const gistId = typeof gist?.id === "string" && gist.id.trim().length > 0 ? gist.id.trim() : null;
251
+ let gistLedger;
252
+ if (!gistId) {
253
+ gistLedger = { id: null, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.NONE, detail: "no hosting gist to prune" };
254
+ } else if (!confirm) {
255
+ gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.SKIPPED_UNCONFIRMED, detail: "hosting gist retained: teardown not confirmed" };
256
+ record(`gist prune skipped (not confirmed): ${gistId} retained`);
257
+ } else {
258
+ try {
259
+ const del = await deleteGist({ id: gistId });
260
+ if (del.ok) {
261
+ gistLedger = { id: gistId, url: gist?.url ?? null, deleted: true, status: GIST_STATUS.DELETED, detail: del.detail };
262
+ record(`hosting gist deleted: ${gistId} (${del.detail})`);
263
+ } else {
264
+ gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.DELETE_FAILED, detail: del.detail };
265
+ fail(`hosting gist delete FAILED: ${gistId} (${del.detail})`);
266
+ }
267
+ } catch (err) {
268
+ gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.DELETE_FAILED, detail: `deleteGist seam threw: ${err?.message ?? err}` };
269
+ fail(`hosting gist delete FAILED: ${gistId} (deleteGist seam threw: ${err?.message ?? err})`);
270
+ }
271
+ }
272
+
273
+ // The ledger is ALWAYS emitted (every case), enumerating every known side
274
+ // effect. Migrations are recorded as applied-not-reverted by design.
275
+ const ledger = {
276
+ confirmed: confirm,
277
+ migrations: {
278
+ applied: migrations.applied ?? 0,
279
+ reverted: false,
280
+ detail: migrations.detail ?? null,
281
+ note: "not reverted (dev DB; migration reversal is a separate explicit action)",
282
+ },
283
+ rows: rowsLedger,
284
+ worktree: worktreeLedger,
285
+ gist: gistLedger,
286
+ process: processLedger,
287
+ };
288
+
289
+ return { ok: errors.length === 0, confirmed: confirm, ledger, errors, logs };
290
+ }
291
+
292
+ export { ROW_STATUS, WORKTREE_STATUS, PROCESS_STATUS, GIST_STATUS };