agent-dealer 0.2.0 → 1.0.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.
Files changed (276) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
  3. package/bundle/server/dist/adapters/agent-deck.js +38 -50
  4. package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
  5. package/bundle/server/dist/adapters/agent-health.js +230 -33
  6. package/bundle/server/dist/adapters/agent-health.test.js +237 -0
  7. package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
  8. package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
  9. package/bundle/server/dist/adapters/execution-authority.js +103 -0
  10. package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
  11. package/bundle/server/dist/adapters/external.js +0 -1
  12. package/bundle/server/dist/adapters/git-worktree.js +514 -0
  13. package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
  14. package/bundle/server/dist/adapters/github.js +170 -0
  15. package/bundle/server/dist/adapters/github.test.js +205 -0
  16. package/bundle/server/dist/adapters/linear-inbox.js +199 -30
  17. package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
  18. package/bundle/server/dist/adapters/linear-sync.js +3 -101
  19. package/bundle/server/dist/adapters/managed-repo.js +168 -0
  20. package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
  21. package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
  22. package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
  23. package/bundle/server/dist/adapters/reflect-authority.js +43 -0
  24. package/bundle/server/dist/cli-env.js +26 -0
  25. package/bundle/server/dist/cli-env.test.js +14 -1
  26. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  27. package/bundle/server/dist/coordinator/admission.js +330 -0
  28. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  29. package/bundle/server/dist/coordinator/args.js +128 -0
  30. package/bundle/server/dist/coordinator/args.test.js +132 -0
  31. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  32. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
  33. package/bundle/server/dist/coordinator/auto-merge.js +286 -0
  34. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
  35. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  36. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  37. package/bundle/server/dist/coordinator/commands.js +1088 -0
  38. package/bundle/server/dist/coordinator/commands.test.js +554 -0
  39. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  40. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  41. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  42. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  43. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  44. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  45. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  46. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  47. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  48. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  49. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  50. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  51. package/bundle/server/dist/coordinator/guidance.js +33 -0
  52. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  53. package/bundle/server/dist/coordinator/human-resolution.js +88 -0
  54. package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  56. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  57. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  58. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  59. package/bundle/server/dist/coordinator/metrics.js +35 -0
  60. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  61. package/bundle/server/dist/coordinator/permissions.js +127 -0
  62. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  63. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  64. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  66. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  67. package/bundle/server/dist/coordinator/projection.js +147 -0
  68. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  69. package/bundle/server/dist/coordinator/prompts.js +164 -0
  70. package/bundle/server/dist/coordinator/prompts.test.js +177 -0
  71. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  72. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  73. package/bundle/server/dist/coordinator/recovery.js +466 -0
  74. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  76. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  77. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  78. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
  80. package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
  82. package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
  83. package/bundle/server/dist/coordinator/routing.js +160 -0
  84. package/bundle/server/dist/coordinator/routing.test.js +336 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  87. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  88. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  89. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  90. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  91. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  92. package/bundle/server/dist/coordinator/spawn.js +49 -0
  93. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  94. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  95. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  96. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  97. package/bundle/server/dist/coordinator/usage.js +40 -0
  98. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  100. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  101. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  102. package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
  103. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  105. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  106. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  107. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  108. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  109. package/bundle/server/dist/db/index.js +359 -7
  110. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  111. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  112. package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
  113. package/bundle/server/dist/db/schema.sql +311 -1
  114. package/bundle/server/dist/db/schema.test.js +66 -0
  115. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  116. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
  117. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  118. package/bundle/server/dist/index.js +58 -5
  119. package/bundle/server/dist/paths.js +40 -4
  120. package/bundle/server/dist/paths.test.js +42 -0
  121. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  122. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  123. package/bundle/server/dist/pid-marker.js +141 -0
  124. package/bundle/server/dist/pid-marker.test.js +208 -0
  125. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  126. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  127. package/bundle/server/dist/queue/result-qa.js +1 -1
  128. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  129. package/bundle/server/dist/repository/agents.js +55 -18
  130. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  131. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  132. package/bundle/server/dist/repository/artifacts.js +47 -0
  133. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  134. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  135. package/bundle/server/dist/repository/findings.js +70 -0
  136. package/bundle/server/dist/repository/findings.test.js +68 -0
  137. package/bundle/server/dist/repository/human-actions.js +142 -0
  138. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  139. package/bundle/server/dist/repository/intake-settings.js +5 -59
  140. package/bundle/server/dist/repository/issues.js +305 -0
  141. package/bundle/server/dist/repository/issues.test.js +69 -0
  142. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  143. package/bundle/server/dist/repository/queue-entries.js +162 -0
  144. package/bundle/server/dist/repository/review-publications.js +153 -0
  145. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  146. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  147. package/bundle/server/dist/repository/runs.js +4 -211
  148. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  149. package/bundle/server/dist/repository/usage-events.js +60 -0
  150. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  151. package/bundle/server/dist/repository/work-items.js +305 -0
  152. package/bundle/server/dist/repository/work-items.test.js +142 -0
  153. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  154. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  155. package/bundle/server/dist/repository/workflow-events.js +193 -0
  156. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  157. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  158. package/bundle/server/dist/routes/human-actions.js +68 -0
  159. package/bundle/server/dist/routes/human-actions.test.js +284 -0
  160. package/bundle/server/dist/routes/index.js +29 -496
  161. package/bundle/server/dist/routes/issues.js +240 -0
  162. package/bundle/server/dist/routes/issues.test.js +451 -0
  163. package/bundle/server/dist/routes/queue.js +41 -0
  164. package/bundle/server/dist/routes/queue.test.js +78 -0
  165. package/bundle/server/dist/runners/claude-args.js +18 -30
  166. package/bundle/server/dist/runners/claude.js +19 -163
  167. package/bundle/server/dist/runners/claude.test.js +15 -70
  168. package/bundle/server/dist/runners/codex-args.js +59 -0
  169. package/bundle/server/dist/runners/codex-args.test.js +72 -0
  170. package/bundle/server/dist/runners/codex-jsonl.js +150 -0
  171. package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
  172. package/bundle/server/dist/runners/codex-resume.test.js +19 -0
  173. package/bundle/server/dist/runners/codex.js +46 -0
  174. package/bundle/server/dist/runners/models-codex.test.js +14 -0
  175. package/bundle/server/dist/runners/models.js +53 -1
  176. package/bundle/server/dist/runners/persist.js +4 -1
  177. package/bundle/server/dist/runners/process-registry.js +11 -0
  178. package/bundle/server/dist/runners/prompts.js +13 -280
  179. package/bundle/server/dist/runners/prompts.test.js +22 -153
  180. package/bundle/server/dist/runners/qa.js +17 -1
  181. package/bundle/server/dist/runners/reflect.js +4 -16
  182. package/bundle/server/dist/runners/spawn-cli.js +197 -0
  183. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  184. package/bundle/server/dist/runners/stream-json.js +0 -141
  185. package/bundle/server/dist/runners/usage-cap.js +242 -0
  186. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  187. package/bundle/server/dist/server-liveness.js +37 -0
  188. package/bundle/server/dist/server-liveness.test.js +82 -0
  189. package/bundle/server/dist/static-ui.test.js +44 -0
  190. package/bundle/server/package.json +4 -2
  191. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  192. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  193. package/bundle/server/static-ui/index.html +2 -2
  194. package/bundle/shared/dist/agents.d.ts +262 -133
  195. package/bundle/shared/dist/agents.js +85 -19
  196. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  197. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  198. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  199. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  200. package/bundle/shared/dist/findings.d.ts +46 -0
  201. package/bundle/shared/dist/findings.js +17 -0
  202. package/bundle/shared/dist/findings.test.d.ts +1 -0
  203. package/bundle/shared/dist/findings.test.js +20 -0
  204. package/bundle/shared/dist/github-repo.d.ts +28 -0
  205. package/bundle/shared/dist/github-repo.js +98 -0
  206. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  207. package/bundle/shared/dist/github-repo.test.js +32 -0
  208. package/bundle/shared/dist/human-actions.d.ts +66 -0
  209. package/bundle/shared/dist/human-actions.js +51 -0
  210. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  211. package/bundle/shared/dist/human-actions.test.js +57 -0
  212. package/bundle/shared/dist/index.d.ts +567 -757
  213. package/bundle/shared/dist/index.js +22 -36
  214. package/bundle/shared/dist/issues.d.ts +385 -0
  215. package/bundle/shared/dist/issues.js +169 -0
  216. package/bundle/shared/dist/issues.test.d.ts +1 -0
  217. package/bundle/shared/dist/issues.test.js +72 -0
  218. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  219. package/bundle/shared/dist/outbound-draft.js +4 -0
  220. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  221. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  222. package/bundle/shared/dist/profile-snapshot.js +145 -0
  223. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  224. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  225. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  226. package/bundle/shared/dist/queue-entries.js +19 -0
  227. package/bundle/shared/dist/result-qa.d.ts +2 -2
  228. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  229. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  230. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  231. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  232. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  233. package/bundle/shared/dist/runtime-availability.js +13 -0
  234. package/bundle/shared/dist/runtime.d.ts +1 -1
  235. package/bundle/shared/dist/runtime.js +1 -1
  236. package/bundle/shared/dist/usage-events.d.ts +36 -0
  237. package/bundle/shared/dist/usage-events.js +13 -0
  238. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  239. package/bundle/shared/dist/worker-sessions.js +70 -0
  240. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  241. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  242. package/bundle/shared/dist/workflow.d.ts +78 -0
  243. package/bundle/shared/dist/workflow.js +59 -0
  244. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  245. package/bundle/shared/dist/workflow.test.js +61 -0
  246. package/bundle/shared/package.json +1 -1
  247. package/dist/action.d.ts +1 -0
  248. package/dist/action.js +50 -0
  249. package/dist/action.test.d.ts +1 -0
  250. package/dist/action.test.js +70 -0
  251. package/dist/agent.d.ts +1 -0
  252. package/dist/agent.js +17 -0
  253. package/dist/agent.test.d.ts +1 -0
  254. package/dist/agent.test.js +19 -0
  255. package/dist/cli-check.d.ts +7 -0
  256. package/dist/cli-check.js +40 -0
  257. package/dist/doctor.js +59 -1
  258. package/dist/http.d.ts +5 -0
  259. package/dist/http.js +19 -0
  260. package/dist/index.js +28 -0
  261. package/dist/issue.d.ts +35 -0
  262. package/dist/issue.js +143 -0
  263. package/dist/issue.test.d.ts +1 -0
  264. package/dist/issue.test.js +240 -0
  265. package/dist/lifecycle.contract.test.d.ts +1 -0
  266. package/dist/lifecycle.contract.test.js +135 -0
  267. package/dist/queue.d.ts +11 -0
  268. package/dist/queue.js +60 -0
  269. package/dist/queue.test.d.ts +1 -0
  270. package/dist/queue.test.js +46 -0
  271. package/dist/test-fetch-stub.d.ts +6 -0
  272. package/dist/test-fetch-stub.js +31 -0
  273. package/package.json +2 -1
  274. package/templates/prod.env.example +1 -1
  275. package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
  276. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -0,0 +1,824 @@
1
+ #!/usr/bin/env tsx
2
+ // packages/server/src/db/migrate-to-issues.ts
3
+ //
4
+ // NOT-66: the one-time, explicit cutover from the legacy single-agent `runs` model to the
5
+ // issue-centric developer/reviewer model (design doc §"Migration and cutover"). This is
6
+ // NOT the additive, idempotent-at-every-startup `migrate()` in ./index.ts — it is an
7
+ // operator-invoked script that renames the legacy tables away, so it must refuse to run
8
+ // twice and must refuse to run against a live service.
9
+ //
10
+ // Usage:
11
+ // tsx src/db/migrate-to-issues.ts <path-to-dealer.db> # run the cutover
12
+ // tsx src/db/migrate-to-issues.ts <path-to-dealer.db> --rollback # undo it
13
+ //
14
+ // Rollback procedure (documented here, exercised by an automated test below):
15
+ // The cutover's first act is an exclusive copy of the database to
16
+ // `<dbPath>.pre-issue-migration-backup` *before* any table is touched. To undo a
17
+ // completed migration, stop the service and run this script again with `--rollback`:
18
+ // it restores that backup file over the live database, verbatim. Rollback itself
19
+ // refuses to run against a live service, for the same reason the forward migration does.
20
+ //
21
+ // Scope note: this script only migrates data and creates the `legacy_v0_*` audit tables.
22
+ // It does not touch any runtime route, the dispatcher, or the frontend nav — the ticket
23
+ // explicitly scopes "remove legacy writers" as a separate, later action. The legacy
24
+ // Operations/Inbox/Done UI and its routes keep working against the *renamed* legacy
25
+ // tables' data having already moved into the issue model; the legacy runtime writers
26
+ // themselves are untouched by this script and keep writing to a fresh, empty `runs` table
27
+ // (recreated by schema.sql on next startup) until that follow-up work removes them.
28
+ import Database from "better-sqlite3";
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import { v4 as uuid } from "uuid";
32
+ import { RunStatus } from "@agent-dealer/shared";
33
+ import { claimPidMarker, readPidMarkerOwner, releasePidMarker } from "../pid-marker.js";
34
+ import { responseOptionsFor } from "../coordinator/commands.js";
35
+ function emptyReport() {
36
+ return {
37
+ issuesCreated: 0,
38
+ legacySessionsCreated: 0,
39
+ artifactsRepointed: 0,
40
+ eventsRepointed: 0,
41
+ mismatches: [],
42
+ alreadyMigrated: false,
43
+ };
44
+ }
45
+ /** Groups every run by COALESCE(lineage_id, id) — one bucket per historical lineage. */
46
+ function groupByLineage(runs) {
47
+ const groups = new Map();
48
+ for (const run of runs) {
49
+ const key = run.lineage_id ?? run.id;
50
+ const bucket = groups.get(key) ?? [];
51
+ bucket.push(run);
52
+ groups.set(key, bucket);
53
+ }
54
+ return groups;
55
+ }
56
+ function latestRun(runs) {
57
+ return [...runs].sort((a, b) => a.created_at.localeCompare(b.created_at))[runs.length - 1];
58
+ }
59
+ /** Legacy worker_session.status follows the same bucket as the issue-status mapping below. */
60
+ function legacySessionStatus(runStatus) {
61
+ if (runStatus === "done" || runStatus === "review")
62
+ return "done";
63
+ if (runStatus === "failed")
64
+ return "failed";
65
+ return "cancelled"; // cancelled, queued, plan_pending, plan_approved, running
66
+ }
67
+ function issueStatusForLatest(runStatus) {
68
+ switch (runStatus) {
69
+ case "done":
70
+ return { status: "done", owner: "system" };
71
+ case "cancelled":
72
+ return { status: "closed", owner: "system" };
73
+ case "review":
74
+ return {
75
+ status: "final_review",
76
+ owner: "human",
77
+ humanAction: {
78
+ actionType: "final_review",
79
+ reason: "Migrated from a legacy run awaiting result review",
80
+ question: "Accept this legacy work?",
81
+ },
82
+ };
83
+ case "failed":
84
+ return {
85
+ status: "needs_human",
86
+ owner: "human",
87
+ humanAction: {
88
+ actionType: "attempts_exhausted",
89
+ reason: "Migrated from a legacy failed run",
90
+ question: "This legacy run failed — how should it be resolved?",
91
+ },
92
+ };
93
+ default:
94
+ // queued, plan_pending, plan_approved, running
95
+ return { status: "ready", owner: "system" };
96
+ }
97
+ }
98
+ /**
99
+ * The live app runs in WAL mode (getDb() in ./index.ts), which can leave recently
100
+ * committed data sitting only in the `<dbPath>-wal` sidecar file, not yet folded into the
101
+ * main database file. A plain `fs.copyFileSync(dbPath, ...)` of the main file alone would
102
+ * then silently miss that data (or, worse, produce a backup that is missing tables
103
+ * entirely if the schema itself was created since the last checkpoint). Checkpointing
104
+ * with TRUNCATE folds everything into the main file and empties the WAL, so a bare file
105
+ * copy of `dbPath` alone is always a complete, self-contained snapshot.
106
+ */
107
+ function checkpointAndTruncate(dbPath) {
108
+ const db = new Database(dbPath);
109
+ try {
110
+ // No point waiting on SQLite's default busy retry loop — a conflicting reader isn't
111
+ // expected to release its snapshot imminently, and this function's job is to fail
112
+ // fast and clearly rather than block the whole migration on an indeterminate wait.
113
+ db.pragma("busy_timeout = 0");
114
+ // wal_checkpoint(TRUNCATE) reports failure via its result row, not by throwing: `busy`
115
+ // is nonzero when some other connection's open read transaction pinned WAL frames the
116
+ // checkpoint could not fold into the main file. Copying the main file in that state
117
+ // produces a backup silently missing whatever those unfoldable frames contained —
118
+ // exactly the rollback guarantee this function exists to protect. Refuse rather than
119
+ // guess; the caller can retry once nothing else holds the database open.
120
+ const [result] = db.pragma("wal_checkpoint(TRUNCATE)");
121
+ if (result.busy !== 0) {
122
+ throw new Error(`refusing to back up: WAL checkpoint could not complete (busy=${result.busy}, ` +
123
+ `log=${result.log} frames, checkpointed=${result.checkpointed}) — another connection ` +
124
+ `is holding a conflicting read lock on ${dbPath}. A backup taken now could silently ` +
125
+ `miss recent commits. Retry once nothing else is reading the database.`);
126
+ }
127
+ }
128
+ finally {
129
+ db.close();
130
+ }
131
+ }
132
+ /** Removes a possibly-stale WAL/SHM sidecar so a later connection never replays frames
133
+ * left over from before a file at this path was overwritten (e.g. by rollback). */
134
+ function removeWalSidecars(dbPath) {
135
+ for (const suffix of ["-wal", "-shm"]) {
136
+ try {
137
+ fs.unlinkSync(`${dbPath}${suffix}`);
138
+ }
139
+ catch {
140
+ /* fine if absent */
141
+ }
142
+ }
143
+ }
144
+ function tableExists(db, name) {
145
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
146
+ }
147
+ function columnsOf(db, table) {
148
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
149
+ return new Set(rows.map((r) => r.name));
150
+ }
151
+ const REQUIRED_LEGACY_COLUMNS = {
152
+ runs: [
153
+ "id",
154
+ "source",
155
+ "external_id",
156
+ "external_label",
157
+ "title",
158
+ "description",
159
+ "repo",
160
+ "agent_id",
161
+ "status",
162
+ "lineage_id",
163
+ "acceptance_criteria",
164
+ "runtime",
165
+ "plan_model",
166
+ "execute_model",
167
+ "budget_json",
168
+ "created_at",
169
+ "updated_at",
170
+ ],
171
+ artifacts: ["id", "run_id", "kind", "content_json", "blob_path", "author", "created_at", "issue_id", "worker_session_id"],
172
+ events: ["id", "run_id", "type", "payload_json", "ts"],
173
+ };
174
+ const REQUIRED_TARGET_TABLES = [
175
+ "issues",
176
+ "worker_sessions",
177
+ "workflow_instances",
178
+ "workflow_events",
179
+ "human_actions",
180
+ "agents",
181
+ ];
182
+ /**
183
+ * Refuses to guess: validates the legacy `runs`/`artifacts`/`events` shape this script
184
+ * depends on, and that the issue-centric target tables already exist (created by the
185
+ * ordinary additive `migrate()` in ./index.ts, which every server startup already runs).
186
+ * Returns a description of the first problem found, or null when the schema is safe to
187
+ * migrate. Called *before* the backup is created — a bad schema should never even cost a
188
+ * backup copy.
189
+ */
190
+ function validateLegacySchema(db) {
191
+ for (const table of ["runs", "artifacts", "events", "approval_gates"]) {
192
+ if (!tableExists(db, table)) {
193
+ return `expected legacy table "${table}" not found — this database may already be migrated or is not an agent-dealer database`;
194
+ }
195
+ }
196
+ for (const table of REQUIRED_TARGET_TABLES) {
197
+ if (!tableExists(db, table)) {
198
+ return `expected issue-centric table "${table}" not found — run the ordinary server migration (npm run db:migrate) first`;
199
+ }
200
+ }
201
+ for (const [table, cols] of Object.entries(REQUIRED_LEGACY_COLUMNS)) {
202
+ const present = columnsOf(db, table);
203
+ const missing = cols.filter((c) => !present.has(c));
204
+ if (missing.length > 0) {
205
+ return `table "${table}" is missing expected column(s): ${missing.join(", ")}`;
206
+ }
207
+ }
208
+ // issueStatusForLatest()/legacySessionStatus() have a `default:` branch that folds any
209
+ // unrecognized status into "ready"/"cancelled" — appropriate for the *known* pre-terminal
210
+ // statuses that fall into it (queued/plan_pending/plan_approved/running), but silently
211
+ // wrong for a genuinely unexpected value (a future/older/corrupt status this script has
212
+ // never seen), which would otherwise be converted into ordinary runnable work instead of
213
+ // being flagged. Validate every distinct value up front against the full closed set.
214
+ const distinctStatuses = db.prepare("SELECT DISTINCT status FROM runs").all().map((r) => r.status);
215
+ const unknown = distinctStatuses.filter((s) => !RunStatus.safeParse(s).success);
216
+ if (unknown.length > 0) {
217
+ const offendingIds = db
218
+ .prepare(`SELECT id FROM runs WHERE status IN (${unknown.map(() => "?").join(",")})`)
219
+ .all(...unknown).map((r) => r.id);
220
+ return `runs table contains unrecognized status value(s) ${unknown.map((s) => `"${s}"`).join(", ")} (run ids: ${offendingIds.join(", ")}) — refusing to guess how to migrate them`;
221
+ }
222
+ return null;
223
+ }
224
+ /**
225
+ * Resolves symlinks so every derived path (server.pid, run.json, the backup, WAL
226
+ * sidecars) is anchored to the database's *real* directory, not wherever a symlink to it
227
+ * happens to live. Without this, `tsx src/db/migrate-to-issues.ts /some/alias/dealer.db`
228
+ * (a symlink to the real, live dealer.db elsewhere) would derive server.pid next to the
229
+ * alias — a path the running server never wrote to — so both the live-service check and
230
+ * the exclusive claim would incorrectly see nothing there and proceed to mutate the very
231
+ * database the live server is using. Falls back to a plain absolute-path resolution when
232
+ * the target doesn't exist (rollbackMigration may be pointed at a path that hasn't been
233
+ * created yet in some caller), matching the old, unresolved-path behavior for that case
234
+ * rather than throwing.
235
+ */
236
+ function canonicalDbPath(dbPath) {
237
+ try {
238
+ return fs.realpathSync(dbPath);
239
+ }
240
+ catch {
241
+ return path.resolve(dbPath);
242
+ }
243
+ }
244
+ function serverPidPathFor(dbPath) {
245
+ return path.join(path.dirname(dbPath), "server.pid");
246
+ }
247
+ function runStatePathFor(dbPath) {
248
+ return path.join(path.dirname(dbPath), "run.json");
249
+ }
250
+ /** run.json uses `serverPid`, not `pid` — packages/cli/src/runtime-state.ts's RunState
251
+ * shape, kept as its own reader since it is a different file this script never claims. */
252
+ function readRunStateOwner(dbPath) {
253
+ const owner = readPidMarkerOwner(runStatePathFor(dbPath));
254
+ if (owner)
255
+ return owner;
256
+ // readPidMarkerOwner() only understands the `pid` field; re-check run.json's own
257
+ // `serverPid` field directly rather than duplicating the alive-probe logic here.
258
+ const filePath = runStatePathFor(dbPath);
259
+ if (!fs.existsSync(filePath))
260
+ return null;
261
+ try {
262
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
263
+ const pid = raw.serverPid;
264
+ if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0)
265
+ return null;
266
+ try {
267
+ process.kill(pid, 0);
268
+ return { pid, alive: true };
269
+ }
270
+ catch {
271
+ return { pid, alive: false };
272
+ }
273
+ }
274
+ catch {
275
+ return null;
276
+ }
277
+ }
278
+ /**
279
+ * Real liveness check, not a placeholder: the running service is tracked via
280
+ * `server.pid` (packages/server/src/server-liveness.ts — written directly by the server's
281
+ * own entrypoint on every launch mode: `npm run dev`, `npm run start`, and the CLI daemon,
282
+ * which spawns that exact same entrypoint as a child) and, as a fallback,
283
+ * `run.json` (packages/cli/src/runtime-state.ts's RunState, written only by the CLI
284
+ * daemon supervisor). Checking server.pid first is what makes this guard hold for a
285
+ * directly-launched dev/start server, which never writes run.json at all.
286
+ *
287
+ * This is a read-only inspection, useful on its own (e.g. reporting), but runMigration()
288
+ * below does not rely on it alone for its own safety: between this check returning "not
289
+ * running" and the migration actually acting, a server could start. runMigration()
290
+ * instead atomically *claims* server.pid via pid-marker.ts for the duration of the
291
+ * migration, which is what actually closes that race (a server whose own claim then fails
292
+ * is required — see index.ts — to refuse to start, rather than run unmonitored).
293
+ */
294
+ export function isServiceRunning(rawDbPath) {
295
+ const dbPath = canonicalDbPath(rawDbPath);
296
+ const serverOwner = readPidMarkerOwner(serverPidPathFor(dbPath));
297
+ if (serverOwner?.alive) {
298
+ return { running: true, detail: `pid ${serverOwner.pid} (from ${serverPidPathFor(dbPath)})` };
299
+ }
300
+ const cliOwner = readRunStateOwner(dbPath);
301
+ if (cliOwner?.alive) {
302
+ return { running: true, detail: `serverPid ${cliOwner.pid} (from ${runStatePathFor(dbPath)})` };
303
+ }
304
+ return { running: false };
305
+ }
306
+ export function runMigration(rawDbPath, opts) {
307
+ // Canonicalize once, up front — every path derived below (server.pid, run.json, the
308
+ // backup, WAL sidecars) must be anchored to the database's real location. See
309
+ // canonicalDbPath's doc comment.
310
+ const dbPath = canonicalDbPath(rawDbPath);
311
+ // Phase 0: read-only probe — idempotent no-op check + schema validation, before anything
312
+ // else touches the filesystem or the database.
313
+ const probe = new Database(dbPath, { readonly: true, fileMustExist: true });
314
+ try {
315
+ if (tableExists(probe, "legacy_v0_runs")) {
316
+ return { ...emptyReport(), alreadyMigrated: true };
317
+ }
318
+ const schemaError = validateLegacySchema(probe);
319
+ if (schemaError) {
320
+ return { ...emptyReport(), mismatches: [schemaError] };
321
+ }
322
+ }
323
+ finally {
324
+ probe.close();
325
+ }
326
+ // run.json (the CLI daemon's own marker) is a read-only signal this script never
327
+ // claims — a live entry there blocks unconditionally, the same as before.
328
+ if (!opts?.skipServiceCheck) {
329
+ const cliOwner = readRunStateOwner(dbPath);
330
+ if (cliOwner?.alive) {
331
+ return {
332
+ ...emptyReport(),
333
+ mismatches: [
334
+ `refusing to migrate while the service is running (serverPid ${cliOwner.pid} from ${runStatePathFor(dbPath)}) — stop it first`,
335
+ ],
336
+ };
337
+ }
338
+ }
339
+ // Atomically claim server.pid for the duration of this migration (see pid-marker.ts).
340
+ // This is what actually closes the TOCTOU window a plain read-then-act check could
341
+ // not: a server trying to start concurrently sees this migration's own live pid as the
342
+ // current owner and — index.ts treats a failed claim as fatal — refuses to start, while
343
+ // this claim itself fails if a server won the race first.
344
+ const serverPidPath = serverPidPathFor(dbPath);
345
+ let claimedLock = false;
346
+ if (!opts?.skipServiceCheck) {
347
+ claimedLock = claimPidMarker(serverPidPath, { role: "migration" });
348
+ if (!claimedLock) {
349
+ const owner = readPidMarkerOwner(serverPidPath);
350
+ return {
351
+ ...emptyReport(),
352
+ mismatches: [
353
+ `refusing to migrate while the service is running (pid ${owner?.pid} from ${serverPidPath}) — stop it first`,
354
+ ],
355
+ };
356
+ }
357
+ }
358
+ try {
359
+ return runMigrationBody(dbPath, opts);
360
+ }
361
+ finally {
362
+ if (claimedLock)
363
+ releasePidMarker(serverPidPath);
364
+ }
365
+ }
366
+ function runMigrationBody(dbPath, opts) {
367
+ if (opts?.injectFailureAt === "before-backup") {
368
+ throw new Error("injected failure: before-backup");
369
+ }
370
+ // Fold any WAL-resident commits into the main file before copying it — see
371
+ // checkpointAndTruncate's doc comment.
372
+ checkpointAndTruncate(dbPath);
373
+ // Exclusive, unique backup: COPYFILE_EXCL fails (EEXIST) rather than silently overwriting
374
+ // a backup from a prior attempt — a second invocation is a safe refusal, never a second
375
+ // (possibly different) backup replacing the first.
376
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
377
+ try {
378
+ fs.copyFileSync(dbPath, backupPath, fs.constants.COPYFILE_EXCL);
379
+ }
380
+ catch (err) {
381
+ if (err.code === "EEXIST") {
382
+ return {
383
+ ...emptyReport(),
384
+ mismatches: [
385
+ `backup already exists at ${backupPath} — refusing to run again. If this is a stale backup ` +
386
+ `from a previous failed attempt, inspect and remove it manually before retrying.`,
387
+ ],
388
+ };
389
+ }
390
+ throw err;
391
+ }
392
+ if (opts?.injectFailureAt === "after-backup") {
393
+ throw new Error("injected failure: after-backup");
394
+ }
395
+ const db = new Database(dbPath);
396
+ const report = emptyReport();
397
+ const tx = db.transaction(() => {
398
+ const runs = db.prepare("SELECT * FROM runs").all();
399
+ const runIds = new Set(runs.map((r) => r.id));
400
+ // Refuse malformed input rather than guessing: a lineage_id that points nowhere.
401
+ for (const run of runs) {
402
+ if (run.lineage_id && !runIds.has(run.lineage_id) && run.lineage_id !== run.id) {
403
+ report.mismatches.push(`run ${run.id} has lineage_id ${run.lineage_id} which does not exist`);
404
+ }
405
+ }
406
+ if (report.mismatches.length > 0)
407
+ return;
408
+ const agentExists = (id) => !!id && !!db.prepare("SELECT 1 FROM agents WHERE id = ?").get(id);
409
+ const issuesBefore = db.prepare("SELECT COUNT(*) AS c FROM issues").get().c;
410
+ const groups = groupByLineage(runs);
411
+ for (const [, lineageRuns] of groups) {
412
+ const issueId = uuid();
413
+ const latest = latestRun(lineageRuns);
414
+ const mapped = issueStatusForLatest(latest.status);
415
+ const resolvedAgentId = agentExists(latest.agent_id) ? latest.agent_id : null;
416
+ db.prepare(`
417
+ INSERT INTO issues (
418
+ id, source, external_id, external_label, external_url, title, description,
419
+ acceptance_criteria, repo, base_branch, status, current_owner, current_intent,
420
+ developer_agent_id, reviewer_agent_id, max_review_rounds, current_round,
421
+ max_infra_attempts, infra_attempts,
422
+ branch, base_sha, head_sha, pr_number, pr_url, created_at, updated_at
423
+ ) VALUES (
424
+ @id, @source, @external_id, @external_label, NULL, @title, @description,
425
+ @acceptance_criteria, @repo, 'main', @status, @current_owner, NULL,
426
+ @agent_id, @agent_id, 3, 1,
427
+ 3, 0,
428
+ NULL, NULL, NULL, NULL, NULL, @created_at, @updated_at
429
+ )
430
+ `).run({
431
+ id: issueId,
432
+ source: latest.source,
433
+ external_id: latest.external_id,
434
+ external_label: latest.external_label,
435
+ title: latest.title,
436
+ description: latest.description,
437
+ acceptance_criteria: latest.acceptance_criteria,
438
+ repo: latest.repo ?? "unknown",
439
+ status: mapped.status,
440
+ current_owner: mapped.owner,
441
+ agent_id: resolvedAgentId,
442
+ created_at: latest.created_at,
443
+ updated_at: latest.updated_at,
444
+ });
445
+ report.issuesCreated += 1;
446
+ // One completed legacy_v0 workflow instance per lineage (audit only, never active).
447
+ // Inserted directly rather than through startWorkflowInstance(), which always creates
448
+ // an *active* (completed_at=null) instance — this one is born already complete.
449
+ const instanceId = uuid();
450
+ db.prepare(`
451
+ INSERT INTO workflow_instances (id, issue_id, workflow_version, started_at, completed_at, outcome)
452
+ VALUES (?, ?, 'legacy_v0', ?, ?, 'migrated')
453
+ `).run(instanceId, issueId, latest.created_at, latest.updated_at);
454
+ if (mapped.humanAction) {
455
+ // response_options_json populated from the exact same table the live coordinator
456
+ // uses (commands.ts's responseOptionsFor) — left NULL, the production UI parses it
457
+ // as no options and renders nothing to resolve the action with.
458
+ const responseOptionsJson = JSON.stringify(responseOptionsFor(mapped.humanAction.actionType));
459
+ db.prepare(`
460
+ INSERT INTO human_actions (
461
+ id, issue_id, workflow_instance_id, action_type, reason, question, evidence_json,
462
+ response_options_json, continuation_preview_json, status, resolution_json, resolved_by,
463
+ requested_at, resolved_at
464
+ ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL, 'open', NULL, NULL, ?, NULL)
465
+ `).run(uuid(), issueId, instanceId, mapped.humanAction.actionType, mapped.humanAction.reason, mapped.humanAction.question, responseOptionsJson, latest.updated_at);
466
+ }
467
+ for (const run of lineageRuns) {
468
+ const sessionId = uuid();
469
+ const sessionStatus = legacySessionStatus(run.status);
470
+ const sessionAgentId = agentExists(run.agent_id) ? run.agent_id : null;
471
+ // Design §"worker_sessions": runtime/model/budget "moved from runs" — preserve them
472
+ // on the migrated session rather than dropping them, since legacy_v0_runs (where
473
+ // they'd otherwise still live) is only retained for one release. execute_model
474
+ // wins over plan_model when both are set, matching the same execute-first
475
+ // precedence migrate() already uses when backfilling agents.default_model.
476
+ const model = run.execute_model ?? run.plan_model;
477
+ db.prepare(`
478
+ INSERT INTO worker_sessions (
479
+ id, issue_id, role, round, agent_id, runtime, model, budget_json, worktree_path,
480
+ input_sha, status, session_ref, log_path, exit_code, error_json, metadata_json,
481
+ profile_snapshot_json, created_at, started_at, heartbeat_at, completed_at, updated_at
482
+ ) VALUES (
483
+ ?, ?, 'legacy', 1, ?, ?, ?, ?, NULL,
484
+ NULL, ?, NULL, NULL, NULL, NULL, ?,
485
+ NULL, ?, NULL, NULL, ?, ?
486
+ )
487
+ `).run(sessionId, issueId, sessionAgentId, run.runtime, model, run.budget_json, sessionStatus, JSON.stringify({
488
+ legacyRunId: run.id,
489
+ legacyStatus: run.status,
490
+ legacyLineageId: run.lineage_id,
491
+ legacyPlanModel: run.plan_model,
492
+ legacyExecuteModel: run.execute_model,
493
+ }), run.created_at, run.updated_at, run.updated_at);
494
+ report.legacySessionsCreated += 1;
495
+ // Repoint in place — artifacts is the SAME table the live coordinator writes to
496
+ // (issue_id/worker_session_id were added to it additively, see ./index.ts), never a
497
+ // renamed legacy table. Updating in place, rather than copying into a staging table
498
+ // and swapping the whole table, is what keeps any artifact rows a real (non-legacy)
499
+ // issue already wrote — this migration is not the first runtime activity against
500
+ // this database — completely untouched.
501
+ const repointed = db
502
+ .prepare("UPDATE artifacts SET issue_id = ?, worker_session_id = ? WHERE run_id = ?")
503
+ .run(issueId, sessionId, run.id);
504
+ report.artifactsRepointed += repointed.changes;
505
+ const events = db.prepare("SELECT * FROM events WHERE run_id = ?").all(run.id);
506
+ for (const e of events) {
507
+ let legacyPayload = null;
508
+ if (e.payload_json) {
509
+ try {
510
+ legacyPayload = JSON.parse(e.payload_json);
511
+ }
512
+ catch {
513
+ legacyPayload = e.payload_json;
514
+ }
515
+ }
516
+ db.prepare(`
517
+ INSERT INTO workflow_events (
518
+ id, issue_id, workflow_instance_id, worker_session_id, type, actor_type, actor_ref,
519
+ stage, round, payload_json, artifact_ref, idempotency_key, causation_event_id, ts
520
+ ) VALUES (?, ?, ?, ?, 'legacy.imported', 'system', NULL, ?, 1, ?, NULL, NULL, NULL, ?)
521
+ `).run(uuid(), issueId, instanceId, sessionId, mapped.status, JSON.stringify({ legacyType: e.type, legacyPayload }), e.ts);
522
+ report.eventsRepointed += 1;
523
+ }
524
+ }
525
+ }
526
+ if (opts?.injectFailureAt === "after-insert") {
527
+ throw new Error("injected failure: after-insert");
528
+ }
529
+ // Verification gate — any mismatch aborts the whole transaction (rollback).
530
+ const issuesAfter = db.prepare("SELECT COUNT(*) AS c FROM issues").get().c;
531
+ if (issuesAfter - issuesBefore !== groups.size) {
532
+ report.mismatches.push(`expected ${groups.size} new issues, found ${issuesAfter - issuesBefore} (database may already contain unrelated issues — compared as a delta)`);
533
+ }
534
+ const sessionCount = db.prepare("SELECT COUNT(*) AS c FROM worker_sessions WHERE role = 'legacy'").get().c;
535
+ if (sessionCount !== runs.length) {
536
+ report.mismatches.push(`expected ${runs.length} legacy sessions, found ${sessionCount}`);
537
+ }
538
+ // Scoped to the tables this migration writes to: a legacy `runs`/`events`/
539
+ // `approval_gates` row can already carry a dangling reference from before this script
540
+ // ever ran (e.g. an agent deleted outside deleteAgent()'s normal path) — that
541
+ // pre-existing legacy data quality issue is not this migration's to fix, and is kept
542
+ // as-is under legacy_v0_*. Only a violation in a table *this migration wrote to* means
543
+ // the migration itself produced a bad reference.
544
+ const NEW_TABLES = new Set(["issues", "worker_sessions", "workflow_instances", "workflow_events", "human_actions"]);
545
+ const fkViolations = db.prepare("PRAGMA foreign_key_check").all().filter((v) => NEW_TABLES.has(v.table));
546
+ if (fkViolations.length > 0) {
547
+ report.mismatches.push(`foreign key check failed: ${JSON.stringify(fkViolations)}`);
548
+ }
549
+ if (opts?.injectFailureAt === "after-verify") {
550
+ throw new Error("injected failure: after-verify");
551
+ }
552
+ if (report.mismatches.length > 0) {
553
+ throw new Error("migration verification failed — rolling back");
554
+ }
555
+ // Rename the legacy-only tables so the running app's fresh, empty versions (recreated
556
+ // by schema.sql on next startup) are the only ones the *new* coordinator ever writes —
557
+ // "artifacts" is deliberately excluded; see the repoint comment above.
558
+ for (const table of ["runs", "events", "approval_gates"]) {
559
+ db.exec(`ALTER TABLE ${table} RENAME TO legacy_v0_${table}`);
560
+ }
561
+ if (opts?.injectFailureAt === "after-rename") {
562
+ throw new Error("injected failure: after-rename");
563
+ }
564
+ // The rename above makes SQLite rewrite artifacts.run_id's FK target to
565
+ // "legacy_v0_runs" (a foreign key follows a RENAME of the table it references).
566
+ // schema.sql recreates a *fresh*, empty "runs" table on the very next startup — but
567
+ // the legacy `/api/runs` writers keep running per this ticket's scope, and the first
568
+ // fresh run's addArtifact() would then fail with "FOREIGN KEY constraint failed"
569
+ // because the live artifacts table's declared FK target no longer matches the table
570
+ // schema.sql just (re)created. Rebuild `artifacts` with run_id as a plain, unconstrained
571
+ // column instead — re-declaring `REFERENCES runs(id)` here does not work: SQLite
572
+ // requires the referenced table to exist by the time a row is *inserted* into the
573
+ // child (not merely at CREATE TABLE time), and this same transaction has to copy rows
574
+ // into the rebuilt table before schema.sql ever gets a chance to recreate "runs".
575
+ // Given "runs" itself now cycles between the renamed legacy table and a freshly
576
+ // recreated empty one across every cutover, a declared FK into it is unenforceable by
577
+ // construction; dropping the constraint (the column and its data are untouched) is the
578
+ // correct fix, not a workaround. Same rebuild-in-place technique migrate() already uses
579
+ // in ./index.ts for the analogous "SQLite can't ALTER a column's FK target" limitation.
580
+ db.exec(`
581
+ CREATE TABLE artifacts_new (
582
+ id TEXT PRIMARY KEY,
583
+ run_id TEXT,
584
+ kind TEXT NOT NULL,
585
+ content_json TEXT,
586
+ blob_path TEXT,
587
+ author TEXT NOT NULL,
588
+ created_at TEXT NOT NULL,
589
+ issue_id TEXT REFERENCES issues(id),
590
+ worker_session_id TEXT REFERENCES worker_sessions(id)
591
+ );
592
+ INSERT INTO artifacts_new (id, run_id, kind, content_json, blob_path, author, created_at, issue_id, worker_session_id)
593
+ SELECT id, run_id, kind, content_json, blob_path, author, created_at, issue_id, worker_session_id FROM artifacts;
594
+ DROP TABLE artifacts;
595
+ ALTER TABLE artifacts_new RENAME TO artifacts;
596
+ CREATE INDEX IF NOT EXISTS idx_artifacts_run ON artifacts(run_id);
597
+ `);
598
+ // The same rename-follows-reference behavior applies to each legacy table's own
599
+ // secondary indexes: SQLite keeps an index's original name attached through a table
600
+ // rename, so schema.sql's `CREATE INDEX IF NOT EXISTS idx_runs_status ...` (index
601
+ // names are unique database-wide, not per-table) finds that name already taken and
602
+ // silently skips creating it against the fresh "runs" table — leaving the live legacy
603
+ // write path with only its primary-key autoindex. Free the original names by giving
604
+ // the legacy copies new ones.
605
+ const LEGACY_INDEXES = [
606
+ { name: "idx_runs_status", table: "runs", columns: "status" },
607
+ { name: "idx_runs_external", table: "runs", columns: "source, external_id" },
608
+ { name: "idx_events_run", table: "events", columns: "run_id" },
609
+ { name: "idx_gates_run", table: "approval_gates", columns: "run_id" },
610
+ ];
611
+ for (const idx of LEGACY_INDEXES) {
612
+ db.exec(`DROP INDEX IF EXISTS ${idx.name}`);
613
+ db.exec(`CREATE INDEX legacy_v0_${idx.name} ON legacy_v0_${idx.table}(${idx.columns})`);
614
+ }
615
+ // Post-rename assertion: query "artifacts" exactly the way the live API does (by
616
+ // issue_id) and confirm every repointed artifact is actually reachable there.
617
+ const reachableForRun = db
618
+ .prepare(`SELECT COUNT(*) AS c FROM artifacts WHERE issue_id IS NOT NULL AND run_id IN (SELECT id FROM legacy_v0_runs)`)
619
+ .get();
620
+ if (reachableForRun.c !== report.artifactsRepointed) {
621
+ report.mismatches.push(`expected ${report.artifactsRepointed} migrated artifacts reachable via the post-cutover "artifacts" table, found ${reachableForRun.c}`);
622
+ throw new Error("migration verification failed — rolling back");
623
+ }
624
+ });
625
+ try {
626
+ tx();
627
+ }
628
+ catch (err) {
629
+ // better-sqlite3 already rolled back the transaction; report.mismatches carries the
630
+ // reason when it's one this script raised. An injected/unexpected error still needs a
631
+ // message so the caller can see *why* — mismatches is the one channel both use.
632
+ if (report.mismatches.length === 0) {
633
+ report.mismatches.push(err instanceof Error ? err.message : String(err));
634
+ }
635
+ }
636
+ finally {
637
+ // Whether committed or rolled back, leave the file self-contained on disk — the same
638
+ // reason the backup is checkpointed before copy (see checkpointAndTruncate).
639
+ db.pragma("wal_checkpoint(TRUNCATE)");
640
+ db.close();
641
+ }
642
+ return report;
643
+ }
644
+ /**
645
+ * Restores the pre-migration backup over the live database, undoing a completed cutover.
646
+ * Refuses while the service is running, for the same reason the forward migration does.
647
+ * Refuses when no backup exists rather than silently doing nothing that looks like success.
648
+ */
649
+ /** Opens `path` read-only and runs an integrity check plus a minimal legacy-schema sanity
650
+ * check (it must look like a pre-migration snapshot, i.e. still have "runs" — a backup is
651
+ * only ever taken *before* the rename). Returns null when the file is a usable rollback
652
+ * source, or a description of the first problem found. */
653
+ function validateRollbackSource(path_) {
654
+ // A malformed file (not a SQLite database at all, or truncated/corrupt) does not
655
+ // necessarily fail at open time — better-sqlite3/SQLite reads the header lazily, so the
656
+ // actual SQLITE_NOTADB/SQLITE_CORRUPT throw can happen on the first real statement
657
+ // (integrity_check below) just as easily as on the constructor. Both must be caught the
658
+ // same way: this is a "not a usable rollback source" verdict either way, not a crash.
659
+ let db = null;
660
+ try {
661
+ db = new Database(path_, { readonly: true, fileMustExist: true });
662
+ const rows = db.pragma("integrity_check");
663
+ const problems = rows.filter((r) => r.integrity_check !== "ok").map((r) => r.integrity_check);
664
+ if (problems.length > 0) {
665
+ return `${path_} failed SQLite's integrity check: ${problems.join("; ")}`;
666
+ }
667
+ // Reuse the exact same validation that gates creating a backup in the first place — a
668
+ // genuine pre-cutover snapshot must satisfy every bit of it too (all legacy tables and
669
+ // columns, all issue-centric target tables, no unrecognized run status), not a
670
+ // narrower ad hoc subset that a post-restart or unrelated database could pass by
671
+ // accident (e.g. one with only "runs"/"artifacts"/"events" but no "approval_gates" or
672
+ // target tables — a near-miss the previous, narrower check here did not catch).
673
+ const schemaProblem = validateLegacySchema(db);
674
+ if (schemaProblem) {
675
+ return `${path_} does not look like a pre-migration backup — ${schemaProblem}`;
676
+ }
677
+ // validateLegacySchema() doesn't check this — it runs on a live, not-yet-migrated
678
+ // database in the forward direction, where legacy_v0_* tables couldn't exist yet
679
+ // anyway. A rollback source additionally must not carry them: a post-restart database
680
+ // (schema.sql recreates a fresh "runs" after a real cutover) would otherwise pass the
681
+ // shape check above and be silently accepted as if it were the original pre-cutover
682
+ // snapshot.
683
+ const cutoverTables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'legacy_v0_%'").all().map((r) => r.name);
684
+ if (cutoverTables.length > 0) {
685
+ return `${path_} does not look like a pre-migration backup — carries cutover-only table(s) ${cutoverTables.join(", ")}, meaning this looks like a post-migration database, not a pre-cutover snapshot`;
686
+ }
687
+ return null;
688
+ }
689
+ catch (err) {
690
+ return `cannot open ${path_} as a SQLite database: ${err instanceof Error ? err.message : String(err)}`;
691
+ }
692
+ finally {
693
+ db?.close();
694
+ }
695
+ }
696
+ /**
697
+ * Restores the pre-migration backup over the live database, undoing a completed cutover.
698
+ * Refuses while the service is running, for the same reason the forward migration does.
699
+ * Refuses when no backup exists rather than silently doing nothing that looks like success.
700
+ *
701
+ * Never trusts the backup file blindly: a truncated, corrupted, or unrelated file at that
702
+ * path (a bad disk during the original backup, or manual tampering) is validated — opened
703
+ * read-only, integrity-checked, and schema-sanity-checked — *before* it ever touches the
704
+ * live database. The restore itself copies to a temporary file in the same directory,
705
+ * validates that copy too (protects against a corrupted copy, e.g. a disk failing
706
+ * mid-write), and only then atomically renames it over the live path — an interrupted
707
+ * copy leaves the original live database untouched rather than a half-written file in
708
+ * its place.
709
+ */
710
+ export function rollbackMigration(rawDbPath, opts) {
711
+ const dbPath = canonicalDbPath(rawDbPath);
712
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
713
+ if (!fs.existsSync(backupPath)) {
714
+ return { rolledBack: false, detail: `no backup found at ${backupPath} — nothing to roll back to` };
715
+ }
716
+ // run.json (CLI daemon) is a read-only signal this script never claims — same as
717
+ // runMigration(), a live entry there blocks unconditionally.
718
+ if (!opts?.skipServiceCheck) {
719
+ const cliOwner = readRunStateOwner(dbPath);
720
+ if (cliOwner?.alive) {
721
+ return {
722
+ rolledBack: false,
723
+ detail: `refusing rollback while the service is running (serverPid ${cliOwner.pid} from ${runStatePathFor(dbPath)}) — stop it first`,
724
+ };
725
+ }
726
+ }
727
+ // Atomically claim server.pid for the duration of the restore, exactly as
728
+ // runMigration() does — a plain read-then-act liveness check leaves the same TOCTOU
729
+ // window a forward migration would: a server could start between the check and the
730
+ // rename below and have the file replaced out from under it mid-run.
731
+ const serverPidPath = serverPidPathFor(dbPath);
732
+ let claimedLock = false;
733
+ if (!opts?.skipServiceCheck) {
734
+ claimedLock = claimPidMarker(serverPidPath, { role: "rollback" });
735
+ if (!claimedLock) {
736
+ const owner = readPidMarkerOwner(serverPidPath);
737
+ return {
738
+ rolledBack: false,
739
+ detail: `refusing rollback while the service is running (pid ${owner?.pid} from ${serverPidPath}) — stop it first`,
740
+ };
741
+ }
742
+ }
743
+ try {
744
+ const sourceProblem = validateRollbackSource(backupPath);
745
+ // Opening the backup read-only for validation can itself create -wal/-shm sidecars
746
+ // next to it; tidy those up regardless of outcome — the backup file itself is untouched.
747
+ removeWalSidecars(backupPath);
748
+ if (sourceProblem) {
749
+ return { rolledBack: false, detail: `refusing rollback — ${sourceProblem}` };
750
+ }
751
+ const stagedPath = `${dbPath}.rollback-staging-${process.pid}-${Date.now()}`;
752
+ try {
753
+ fs.copyFileSync(backupPath, stagedPath);
754
+ const stagedProblem = validateRollbackSource(stagedPath);
755
+ if (stagedProblem) {
756
+ return { rolledBack: false, detail: `refusing rollback — staged copy is unusable: ${stagedProblem}` };
757
+ }
758
+ // Same-directory rename is atomic on the same filesystem — dbPath is either the
759
+ // fully restored file or (on any failure above) untouched; never a partial write.
760
+ fs.renameSync(stagedPath, dbPath);
761
+ }
762
+ finally {
763
+ // validateRollbackSource() opens stagedPath (even read-only), which can create its
764
+ // own -wal/-shm sidecars — clean those up alongside the main staged file too.
765
+ try {
766
+ fs.unlinkSync(stagedPath);
767
+ }
768
+ catch {
769
+ // already renamed away on success, or never created on an early failure — fine
770
+ }
771
+ removeWalSidecars(stagedPath);
772
+ }
773
+ // The restored main file is a complete snapshot (the backup was made from a
774
+ // checkpointed, WAL-truncated database) — any WAL/SHM sidecar still sitting next to
775
+ // dbPath from the post-migration state describes pages that no longer match this
776
+ // now-older main file, so it must not be replayed against it.
777
+ removeWalSidecars(dbPath);
778
+ return { rolledBack: true, detail: `restored ${dbPath} from ${backupPath}` };
779
+ }
780
+ finally {
781
+ if (claimedLock)
782
+ releasePidMarker(serverPidPath);
783
+ }
784
+ }
785
+ function printUsage() {
786
+ console.error("Usage:");
787
+ console.error(" tsx src/db/migrate-to-issues.ts <path-to-dealer.db> # run the cutover");
788
+ console.error(" tsx src/db/migrate-to-issues.ts <path-to-dealer.db> --rollback # undo it");
789
+ }
790
+ async function main() {
791
+ const args = process.argv.slice(2);
792
+ const dbPath = args.find((a) => !a.startsWith("--"));
793
+ const rollback = args.includes("--rollback");
794
+ if (!dbPath) {
795
+ printUsage();
796
+ process.exit(1);
797
+ }
798
+ if (!fs.existsSync(dbPath)) {
799
+ console.error(`Database not found: ${dbPath}`);
800
+ process.exit(1);
801
+ }
802
+ if (rollback) {
803
+ const result = rollbackMigration(dbPath);
804
+ console.log(JSON.stringify(result, null, 2));
805
+ process.exit(result.rolledBack ? 0 : 1);
806
+ }
807
+ const report = runMigration(dbPath);
808
+ console.log(JSON.stringify(report, null, 2));
809
+ if (report.alreadyMigrated) {
810
+ console.log("Already migrated — no-op.");
811
+ return;
812
+ }
813
+ if (report.mismatches.length > 0) {
814
+ console.error("Migration refused or rolled back — see mismatches above. Original data untouched.");
815
+ process.exit(1);
816
+ }
817
+ console.log(`Migration complete. Roll back with: tsx src/db/migrate-to-issues.ts ${dbPath} --rollback`);
818
+ }
819
+ if (import.meta.url === `file://${process.argv[1]}`) {
820
+ main().catch((e) => {
821
+ console.error(e);
822
+ process.exit(1);
823
+ });
824
+ }