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,842 @@
1
+ // packages/server/src/db/migrate-to-issues.test.ts
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import Database from "better-sqlite3";
8
+ import { spawn } from "node:child_process";
9
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-issue-migration-"));
10
+ const { migrate, getDb, getDbPath, closeDb } = await import("./index.js");
11
+ const { runMigration, rollbackMigration, isServiceRunning } = await import("./migrate-to-issues.js");
12
+ const { BUILTIN_AGENT_CLAUDE_ID, Issue, WorkerSession, WorkflowEvent, WorkflowInstance, HumanAction, IssueArtifact } = await import("@agent-dealer/shared");
13
+ const { createIssue, getIssue, listIssues } = await import("../repository/issues.js");
14
+ const { createIssueArtifact } = await import("../repository/artifacts.js");
15
+ const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
16
+ const { listWorkflowEventsForIssue, listWorkflowInstancesForIssue } = await import("../repository/workflow-events.js");
17
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
18
+ const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
19
+ const { createRun, addArtifact } = await import("../repository/runs.js");
20
+ const { resolveHumanActionAndAdvance, responseOptionsFor, startWorkflow } = await import("../coordinator/commands.js");
21
+ const NOW = "2026-01-01T00:00:00.000Z";
22
+ /** A genuinely alive, same-user child pid — safe to signal, unlike e.g. pid 1 (which is
23
+ * alive but throws EPERM rather than ESRCH for an unprivileged process.kill probe). */
24
+ function spawnLiveChild() {
25
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
26
+ return { pid: child.pid, kill: () => child.kill("SIGKILL") };
27
+ }
28
+ /** Every test gets its own AGENT_DEALER_HOME + a freshly migrated (current schema) db,
29
+ * matching how the rest of this repo's repository tests isolate state — but reopened per
30
+ * test here (via closeDb()) since, unlike a single-database test file, this suite needs a
31
+ * clean legacy+issue-model database for every scenario. */
32
+ function freshHome() {
33
+ closeDb();
34
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-issue-migration-"));
35
+ migrate();
36
+ }
37
+ /** Seeds two legacy lineages directly into the current test's database and returns its path. */
38
+ function seedLegacyLineages() {
39
+ const db = getDb();
40
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
41
+ lineage_id, created_at, updated_at)
42
+ VALUES ('run-a1', 'manual', 'run-a1', 'code', 'Done task', '/repo', ?, 'done', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
43
+ db.prepare(`INSERT INTO artifacts (id, run_id, kind, content_json, author, created_at)
44
+ VALUES ('art-a1', 'run-a1', 'execution_result', '{"exitCode":0}', 'agent', ?)`).run(NOW);
45
+ db.prepare(`INSERT INTO events (id, run_id, type, payload_json, ts) VALUES ('evt-a1', 'run-a1', 'run.created', NULL, ?)`).run(NOW);
46
+ // Lineage B: two runs sharing a lineage_id, latest is plan_pending — one issue, status
47
+ // 'ready', legacy session status 'cancelled'.
48
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
49
+ lineage_id, created_at, updated_at)
50
+ VALUES ('run-b1', 'manual', 'run-b1', 'code', 'Retried task', '/repo', ?, 'failed', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
51
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
52
+ lineage_id, created_at, updated_at)
53
+ VALUES ('run-b2', 'manual', 'run-b2', 'code', 'Retried task', '/repo', ?, 'plan_pending', 'run-b1', ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
54
+ return getDbPath();
55
+ }
56
+ test("migrates lineages into issues, is readable through production repositories, and renames legacy tables", () => {
57
+ freshHome();
58
+ const dbPath = seedLegacyLineages();
59
+ const report = runMigration(dbPath, { skipServiceCheck: true });
60
+ assert.deepStrictEqual(report.mismatches, []);
61
+ assert.equal(report.alreadyMigrated, false);
62
+ assert.equal(report.issuesCreated, 2);
63
+ assert.equal(report.legacySessionsCreated, 3);
64
+ assert.equal(report.artifactsRepointed, 1);
65
+ assert.equal(report.eventsRepointed, 1);
66
+ // Read back through the real production repositories (getDb() reopened against this same
67
+ // AGENT_DEALER_HOME/dealer.db, the exact file runMigration operated on) and zod-parse
68
+ // every returned shape — the "parse through shared schemas and are returned by
69
+ // production repositories" acceptance criterion, not just a raw SQL check.
70
+ const issues = listIssues();
71
+ assert.equal(issues.length, 2);
72
+ for (const issue of issues)
73
+ Issue.parse(issue);
74
+ const doneIssue = issues.find((i) => i.title === "Done task");
75
+ const readyIssue = issues.find((i) => i.title === "Retried task");
76
+ assert.equal(doneIssue.status, "done");
77
+ assert.equal(readyIssue.status, "ready");
78
+ const doneSessions = listWorkerSessionsForIssue(doneIssue.id);
79
+ assert.equal(doneSessions.length, 1);
80
+ assert.equal(doneSessions[0].role, "legacy");
81
+ assert.equal(doneSessions[0].status, "done");
82
+ for (const s of doneSessions)
83
+ WorkerSession.parse(s);
84
+ const readySessions = listWorkerSessionsForIssue(readyIssue.id);
85
+ assert.equal(readySessions.length, 2);
86
+ // run-b1 was 'failed' (→ session 'failed'), run-b2 (the latest) was 'plan_pending' (→
87
+ // session 'cancelled' — nothing survives as a completed attempt under the new model).
88
+ assert.deepStrictEqual(readySessions.map((s) => s.status).sort(), ["cancelled", "failed"]);
89
+ for (const s of readySessions)
90
+ WorkerSession.parse(s);
91
+ const doneInstances = listWorkflowInstancesForIssue(doneIssue.id);
92
+ assert.equal(doneInstances.length, 1);
93
+ assert.equal(doneInstances[0].outcome, "migrated");
94
+ for (const inst of doneInstances)
95
+ WorkflowInstance.parse(inst);
96
+ const doneEvents = listWorkflowEventsForIssue(doneIssue.id);
97
+ assert.equal(doneEvents.length, 1);
98
+ assert.equal(doneEvents[0].type, "legacy.imported");
99
+ for (const e of doneEvents)
100
+ WorkflowEvent.parse(e);
101
+ const readyActions = listHumanActionsForIssue(readyIssue.id);
102
+ assert.equal(readyActions.length, 0); // 'ready' mapping has no human action
103
+ const doneArtifacts = listArtifactsForIssue(doneIssue.id);
104
+ assert.equal(doneArtifacts.length, 1);
105
+ assert.equal(doneArtifacts[0].kind, "execution_result");
106
+ for (const a of doneArtifacts)
107
+ IssueArtifact.parse(a);
108
+ // Legacy tables renamed; "artifacts" (shared table) is NOT renamed.
109
+ const tableNames = getDb().prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
110
+ assert.ok(tableNames.includes("legacy_v0_runs"));
111
+ assert.ok(tableNames.includes("legacy_v0_events"));
112
+ assert.ok(tableNames.includes("legacy_v0_approval_gates"));
113
+ assert.equal(tableNames.includes("runs"), false);
114
+ assert.equal(tableNames.includes("artifacts_migrated"), false);
115
+ assert.equal(tableNames.includes("artifacts"), true);
116
+ });
117
+ test("a legacy run left in 'review' seeds an open final_review human action", () => {
118
+ freshHome();
119
+ const db = getDb();
120
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
121
+ lineage_id, created_at, updated_at)
122
+ VALUES ('run-r1', 'manual', 'run-r1', 'code', 'Review task', '/repo', ?, 'review', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
123
+ const dbPath = getDbPath();
124
+ const report = runMigration(dbPath, { skipServiceCheck: true });
125
+ assert.deepStrictEqual(report.mismatches, []);
126
+ const issue = listIssues().find((i) => i.title === "Review task");
127
+ assert.equal(issue.status, "final_review");
128
+ const actions = listHumanActionsForIssue(issue.id);
129
+ assert.equal(actions.length, 1);
130
+ assert.equal(actions[0].actionType, "final_review");
131
+ HumanAction.parse(actions[0]);
132
+ // Populated from the exact same table the live coordinator uses — a null here means the
133
+ // production UI parses it as no options and renders nothing to resolve the action with.
134
+ assert.deepStrictEqual(JSON.parse(actions[0].responseOptionsJson), responseOptionsFor("final_review"));
135
+ });
136
+ test("a migrated final_review action is resolvable end to end through the real coordinator function, for every choice", () => {
137
+ for (const [choice, expectedStatus] of [
138
+ ["complete", "done"],
139
+ ["repair", "needs_human"],
140
+ ["close", "closed"],
141
+ ]) {
142
+ freshHome();
143
+ const db = getDb();
144
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
145
+ lineage_id, created_at, updated_at)
146
+ VALUES ('run-r1', 'manual', 'run-r1', 'code', 'Review task', '/repo', ?, 'review', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
147
+ const dbPath = getDbPath();
148
+ runMigration(dbPath, { skipServiceCheck: true });
149
+ const issue = listIssues().find((i) => i.title === "Review task");
150
+ const action = listHumanActionsForIssue(issue.id)[0];
151
+ const result = resolveHumanActionAndAdvance(action.id, "tester@example.com", choice);
152
+ assert.ok(result.ok, `choice "${choice}" should resolve successfully: ${JSON.stringify(result)}`);
153
+ if (result.ok) {
154
+ assert.equal(result.issueStatus, expectedStatus, `choice "${choice}"`);
155
+ assert.equal(result.nextWorkItemId, null, `choice "${choice}" must never queue a work item`);
156
+ }
157
+ const resolvedAction = listHumanActionsForIssue(issue.id)[0];
158
+ assert.equal(resolvedAction.status, "resolved");
159
+ const resolvedIssue = getIssue(issue.id);
160
+ assert.equal(resolvedIssue.status, expectedStatus);
161
+ // needs_human still means a human owns getting this moving again — never "system",
162
+ // which would wrongly claim nothing is waiting on a person.
163
+ if (expectedStatus === "needs_human") {
164
+ assert.equal(resolvedIssue.currentOwner, "human");
165
+ }
166
+ // The legacy_v0 instance itself is never touched by this resolution — it was already
167
+ // terminal ('migrated') when the migration created it.
168
+ const instances = listWorkflowInstancesForIssue(issue.id);
169
+ assert.equal(instances.length, 1);
170
+ assert.equal(instances[0].outcome, "migrated");
171
+ }
172
+ });
173
+ test("'repair' on a migrated final_review action leaves the issue genuinely startable — not a dead end", () => {
174
+ // Exercises the exact path the "Start" button (now shown for needs_human with no open
175
+ // actions — see IssueDetailPage.tsx) relies on: startWorkflow()'s own preStart gate,
176
+ // not just that the issue *looks* like it's in the right status.
177
+ freshHome();
178
+ const db = getDb();
179
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
180
+ lineage_id, acceptance_criteria, created_at, updated_at)
181
+ VALUES ('run-r1', 'manual', 'run-r1', 'code', 'Review task', '/repo', ?, 'review', NULL,
182
+ 'Must still pass CI', ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
183
+ const dbPath = getDbPath();
184
+ runMigration(dbPath, { skipServiceCheck: true });
185
+ const issue = listIssues().find((i) => i.title === "Review task");
186
+ const action = listHumanActionsForIssue(issue.id)[0];
187
+ resolveHumanActionAndAdvance(action.id, "tester@example.com", "repair");
188
+ assert.equal(listHumanActionsForIssue(issue.id).filter((a) => a.status === "open").length, 0);
189
+ assert.equal(getIssue(issue.id).status, "needs_human");
190
+ const started = startWorkflow(issue.id);
191
+ assert.ok(started.ok === true, `expected startWorkflow to succeed, got: ${JSON.stringify(started)}`);
192
+ });
193
+ test("after a restart, a fresh legacy run can still write an artifact without a foreign-key violation", () => {
194
+ // Reproduces the exact reviewer-reported regression: runMigration -> migrate() (the
195
+ // ordinary additive migration a real restart runs, which recreates a fresh, empty
196
+ // "runs" table via CREATE TABLE IF NOT EXISTS) -> createRun -> addArtifact. Before the
197
+ // artifacts-rebuild fix, the rename of runs -> legacy_v0_runs left artifacts.run_id's FK
198
+ // declaration pointed at legacy_v0_runs, so this exact sequence failed with
199
+ // "FOREIGN KEY constraint failed".
200
+ freshHome();
201
+ const dbPath = seedLegacyLineages();
202
+ const report = runMigration(dbPath, { skipServiceCheck: true });
203
+ assert.deepStrictEqual(report.mismatches, []);
204
+ migrate(); // the restart every real cutover is followed by
205
+ const freshRun = createRun({
206
+ title: "Fresh post-cutover run",
207
+ taskCategory: "code",
208
+ status: "plan_pending",
209
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
210
+ repo: "acme/app"
211
+ });
212
+ assert.doesNotThrow(() => addArtifact(freshRun.id, "task_snapshot", { hello: "world" }, "system"));
213
+ });
214
+ test("after a restart, the fresh runs/events/approval_gates tables have their secondary indexes back", () => {
215
+ freshHome();
216
+ const dbPath = seedLegacyLineages();
217
+ const report = runMigration(dbPath, { skipServiceCheck: true });
218
+ assert.deepStrictEqual(report.mismatches, []);
219
+ migrate();
220
+ const db = getDb();
221
+ const indexNames = (table) => db.prepare(`PRAGMA index_list(${table})`).all().map((i) => i.name);
222
+ assert.ok(indexNames("runs").includes("idx_runs_status"), "fresh runs table should have idx_runs_status");
223
+ assert.ok(indexNames("runs").includes("idx_runs_external"), "fresh runs table should have idx_runs_external");
224
+ assert.ok(indexNames("events").includes("idx_events_run"), "fresh events table should have idx_events_run");
225
+ assert.ok(indexNames("approval_gates").includes("idx_gates_run"), "fresh approval_gates table should have idx_gates_run");
226
+ // And the legacy data is still indexed too, just under a renamed index.
227
+ assert.ok(indexNames("legacy_v0_runs").includes("legacy_v0_idx_runs_status"));
228
+ });
229
+ test("a legacy run left in 'failed' seeds an open attempts_exhausted human action", () => {
230
+ freshHome();
231
+ const db = getDb();
232
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
233
+ lineage_id, created_at, updated_at)
234
+ VALUES ('run-f1', 'manual', 'run-f1', 'code', 'Failed task', '/repo', ?, 'failed', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
235
+ const dbPath = getDbPath();
236
+ const report = runMigration(dbPath, { skipServiceCheck: true });
237
+ assert.deepStrictEqual(report.mismatches, []);
238
+ const issue = listIssues().find((i) => i.title === "Failed task");
239
+ assert.equal(issue.status, "needs_human");
240
+ Issue.parse(issue);
241
+ const actions = listHumanActionsForIssue(issue.id);
242
+ assert.equal(actions.length, 1);
243
+ assert.equal(actions[0].actionType, "attempts_exhausted");
244
+ HumanAction.parse(actions[0]);
245
+ assert.deepStrictEqual(JSON.parse(actions[0].responseOptionsJson), responseOptionsFor("attempts_exhausted"));
246
+ const sessions = listWorkerSessionsForIssue(issue.id);
247
+ assert.equal(sessions.length, 1);
248
+ assert.equal(sessions[0].status, "failed");
249
+ });
250
+ test("a migrated attempts_exhausted action is resolvable end to end through the real coordinator function, for every choice", () => {
251
+ for (const [choice, expectedStatus] of [
252
+ ["retry", "needs_human"],
253
+ ["close", "closed"],
254
+ ]) {
255
+ freshHome();
256
+ const db = getDb();
257
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
258
+ lineage_id, created_at, updated_at)
259
+ VALUES ('run-f1', 'manual', 'run-f1', 'code', 'Failed task', '/repo', ?, 'failed', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
260
+ const dbPath = getDbPath();
261
+ runMigration(dbPath, { skipServiceCheck: true });
262
+ const issue = listIssues().find((i) => i.title === "Failed task");
263
+ assert.equal(issue.status, "needs_human");
264
+ const action = listHumanActionsForIssue(issue.id)[0];
265
+ const result = resolveHumanActionAndAdvance(action.id, "tester@example.com", choice);
266
+ assert.ok(result.ok, `choice "${choice}" should resolve successfully: ${JSON.stringify(result)}`);
267
+ if (result.ok) {
268
+ assert.equal(result.issueStatus, expectedStatus, `choice "${choice}"`);
269
+ assert.equal(result.nextWorkItemId, null, `choice "${choice}" must never queue a work item`);
270
+ }
271
+ assert.equal(listHumanActionsForIssue(issue.id)[0].status, "resolved");
272
+ assert.equal(getIssue(issue.id).status, expectedStatus);
273
+ }
274
+ });
275
+ test("resolving a second time returns 409, and an invalid choice returns 400, for a migrated action", () => {
276
+ freshHome();
277
+ const db = getDb();
278
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
279
+ lineage_id, created_at, updated_at)
280
+ VALUES ('run-r1', 'manual', 'run-r1', 'code', 'Review task', '/repo', ?, 'review', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
281
+ const dbPath = getDbPath();
282
+ runMigration(dbPath, { skipServiceCheck: true });
283
+ const issue = listIssues().find((i) => i.title === "Review task");
284
+ const action = listHumanActionsForIssue(issue.id)[0];
285
+ const badChoice = resolveHumanActionAndAdvance(action.id, "tester@example.com", "not-a-real-choice");
286
+ assert.equal(badChoice.ok, false);
287
+ if (!badChoice.ok)
288
+ assert.equal(badChoice.code, 400);
289
+ const first = resolveHumanActionAndAdvance(action.id, "tester@example.com", "close");
290
+ assert.ok(first.ok);
291
+ const second = resolveHumanActionAndAdvance(action.id, "tester@example.com", "close");
292
+ assert.equal(second.ok, false);
293
+ if (!second.ok)
294
+ assert.equal(second.code, 409);
295
+ });
296
+ test("a legacy run left in 'cancelled' maps to a closed issue with no human action", () => {
297
+ freshHome();
298
+ const db = getDb();
299
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
300
+ lineage_id, created_at, updated_at)
301
+ VALUES ('run-x1', 'manual', 'run-x1', 'code', 'Cancelled task', '/repo', ?, 'cancelled', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
302
+ const dbPath = getDbPath();
303
+ const report = runMigration(dbPath, { skipServiceCheck: true });
304
+ assert.deepStrictEqual(report.mismatches, []);
305
+ const issue = listIssues().find((i) => i.title === "Cancelled task");
306
+ assert.equal(issue.status, "closed");
307
+ assert.equal(issue.currentOwner, "system");
308
+ Issue.parse(issue);
309
+ assert.equal(listHumanActionsForIssue(issue.id).length, 0);
310
+ const sessions = listWorkerSessionsForIssue(issue.id);
311
+ assert.equal(sessions.length, 1);
312
+ assert.equal(sessions[0].status, "cancelled");
313
+ });
314
+ test("a legacy run whose agent was since deleted migrates with a null agent reference instead of violating the foreign key", () => {
315
+ freshHome();
316
+ const dbPath = getDbPath();
317
+ // getDb()'s connection runs with foreign_keys = ON, so seeding a dangling agent_id
318
+ // through it would fail at INSERT time — exactly what the app itself would enforce via
319
+ // deleteAgent(). A real dangling reference like this can still reach a production
320
+ // database (an older app version, or direct DB surgery), so seed it here through a
321
+ // separate raw connection with FK enforcement off, purely to reach that state.
322
+ const raw = new Database(dbPath);
323
+ raw.pragma("foreign_keys = OFF");
324
+ raw.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
325
+ lineage_id, created_at, updated_at)
326
+ VALUES ('run-deleted-agent', 'manual', 'run-deleted-agent', 'code', 'Orphaned agent task', '/repo',
327
+ 'agent-does-not-exist-anymore', 'done', NULL, ?, ?)`).run(NOW, NOW);
328
+ raw.close();
329
+ const report = runMigration(dbPath, { skipServiceCheck: true });
330
+ assert.deepStrictEqual(report.mismatches, []);
331
+ const issue = listIssues().find((i) => i.title === "Orphaned agent task");
332
+ Issue.parse(issue); // developerAgentId/reviewerAgentId are nullable — this must still parse
333
+ assert.equal(issue.developerAgentId, null);
334
+ assert.equal(issue.reviewerAgentId, null);
335
+ const sessions = listWorkerSessionsForIssue(issue.id);
336
+ assert.equal(sessions.length, 1);
337
+ assert.equal(sessions[0].agentId, null);
338
+ WorkerSession.parse(sessions[0]);
339
+ });
340
+ test("preserves the legacy execution snapshot (runtime, model, budget, lineage) on the migrated session", () => {
341
+ freshHome();
342
+ const db = getDb();
343
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
344
+ lineage_id, runtime, plan_model, execute_model, budget_json, created_at, updated_at)
345
+ VALUES ('run-snap1', 'manual', 'run-snap1', 'code', 'Snapshot task', '/repo', ?, 'done',
346
+ 'run-snap1', 'claude_code', 'claude-plan-1', 'claude-execute-1', '{"maxUsd":5}', ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
347
+ const dbPath = getDbPath();
348
+ const report = runMigration(dbPath, { skipServiceCheck: true });
349
+ assert.deepStrictEqual(report.mismatches, []);
350
+ const issue = listIssues().find((i) => i.title === "Snapshot task");
351
+ const session = listWorkerSessionsForIssue(issue.id)[0];
352
+ WorkerSession.parse(session);
353
+ assert.equal(session.runtime, "claude_code");
354
+ assert.equal(session.model, "claude-execute-1"); // execute_model wins over plan_model
355
+ assert.equal(session.budgetJson, '{"maxUsd":5}');
356
+ const metadata = JSON.parse(session.metadataJson);
357
+ assert.equal(metadata.legacyLineageId, "run-snap1");
358
+ assert.equal(metadata.legacyPlanModel, "claude-plan-1");
359
+ assert.equal(metadata.legacyExecuteModel, "claude-execute-1");
360
+ });
361
+ test("falls back to plan_model when execute_model is absent", () => {
362
+ freshHome();
363
+ const db = getDb();
364
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
365
+ lineage_id, runtime, plan_model, execute_model, budget_json, created_at, updated_at)
366
+ VALUES ('run-snap2', 'manual', 'run-snap2', 'code', 'Plan-only task', '/repo', ?, 'done',
367
+ NULL, 'cursor_local', 'cursor-plan-1', NULL, NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
368
+ const dbPath = getDbPath();
369
+ runMigration(dbPath, { skipServiceCheck: true });
370
+ const issue = listIssues().find((i) => i.title === "Plan-only task");
371
+ const session = listWorkerSessionsForIssue(issue.id)[0];
372
+ assert.equal(session.model, "cursor-plan-1");
373
+ });
374
+ test("refuses via schema validation, before creating a backup, when a run has an unrecognized status", () => {
375
+ freshHome();
376
+ const db = getDb();
377
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
378
+ lineage_id, created_at, updated_at)
379
+ VALUES ('run-weird', 'manual', 'run-weird', 'code', 'Weird task', '/repo', ?, 'archived_v0', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
380
+ const dbPath = getDbPath();
381
+ const report = runMigration(dbPath, { skipServiceCheck: true });
382
+ assert.ok(report.mismatches.length > 0);
383
+ assert.match(report.mismatches[0], /unrecognized status/);
384
+ assert.match(report.mismatches[0], /archived_v0/);
385
+ assert.match(report.mismatches[0], /run-weird/);
386
+ assert.equal(fs.existsSync(`${dbPath}.pre-issue-migration-backup`), false);
387
+ });
388
+ test("refuses to back up when the WAL checkpoint reports busy (a reader holds an older snapshot)", () => {
389
+ freshHome();
390
+ const dbPath = seedLegacyLineages();
391
+ // Open a second connection and start a read transaction without committing — this pins
392
+ // the reader's snapshot, so wal_checkpoint(TRUNCATE) cannot fold everything into the
393
+ // main file and reports busy.
394
+ const reader = new Database(dbPath);
395
+ reader.pragma("journal_mode = WAL");
396
+ reader.prepare("BEGIN").run();
397
+ reader.prepare("SELECT * FROM runs").all();
398
+ try {
399
+ assert.throws(() => runMigration(dbPath, { skipServiceCheck: true }), /WAL checkpoint could not complete/);
400
+ assert.equal(fs.existsSync(`${dbPath}.pre-issue-migration-backup`), false);
401
+ }
402
+ finally {
403
+ reader.prepare("COMMIT").run();
404
+ reader.close();
405
+ }
406
+ });
407
+ test("does not touch a pre-existing genuine issue and its artifacts created before the migration ran", () => {
408
+ freshHome();
409
+ const preexisting = createIssue({
410
+ title: "Already using the new model",
411
+ repo: "acme/app",
412
+ baseBranch: "main",
413
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
414
+ reviewerAgentId: BUILTIN_AGENT_CLAUDE_ID,
415
+ maxReviewRounds: 3,
416
+ maxInfraAttempts: 3,
417
+ source: "manual"
418
+ });
419
+ const preexistingArtifact = createIssueArtifact({
420
+ issueId: preexisting.id,
421
+ kind: "task_snapshot",
422
+ content: { hello: "world" },
423
+ author: "system"
424
+ });
425
+ const db = getDb();
426
+ db.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
427
+ lineage_id, created_at, updated_at)
428
+ VALUES ('run-c1', 'manual', 'run-c1', 'code', 'Legacy task', '/repo', ?, 'done', NULL, ?, ?)`).run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
429
+ const dbPath = getDbPath();
430
+ const report = runMigration(dbPath, { skipServiceCheck: true });
431
+ assert.deepStrictEqual(report.mismatches, []);
432
+ assert.equal(report.issuesCreated, 1); // only the legacy lineage, not the pre-existing issue
433
+ const stillThere = getIssue(preexisting.id);
434
+ assert.ok(stillThere);
435
+ Issue.parse(stillThere);
436
+ assert.equal(stillThere.title, "Already using the new model");
437
+ const artifacts = listArtifactsForIssue(preexisting.id);
438
+ assert.equal(artifacts.length, 1);
439
+ assert.equal(artifacts[0].id, preexistingArtifact.id);
440
+ });
441
+ test("refuses via schema validation and never creates a backup when a legacy table is missing", () => {
442
+ freshHome();
443
+ getDb().exec("DROP TABLE approval_gates");
444
+ const dbPath = getDbPath();
445
+ const report = runMigration(dbPath, { skipServiceCheck: true });
446
+ assert.ok(report.mismatches.length > 0);
447
+ assert.match(report.mismatches[0], /approval_gates/);
448
+ assert.equal(fs.existsSync(`${dbPath}.pre-issue-migration-backup`), false);
449
+ });
450
+ test("rolls back and leaves the original tables + a usable backup when a lineage_id points nowhere", () => {
451
+ freshHome();
452
+ const dbPath = seedLegacyLineages();
453
+ getDb()
454
+ .prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
455
+ lineage_id, created_at, updated_at)
456
+ VALUES ('run-orphan', 'manual', 'run-orphan', 'code', 'Orphan', '/repo', ?, 'done', 'does-not-exist', ?, ?)`)
457
+ .run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
458
+ const report = runMigration(dbPath, { skipServiceCheck: true });
459
+ assert.ok(report.mismatches.length > 0);
460
+ const tables = new Database(dbPath, { readonly: true }).prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
461
+ assert.ok(tables.includes("runs")); // untouched — rollback happened
462
+ assert.equal(tables.includes("legacy_v0_runs"), false);
463
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
464
+ assert.ok(fs.existsSync(backupPath));
465
+ const backupDb = new Database(backupPath, { readonly: true });
466
+ const backupRunCount = backupDb.prepare("SELECT COUNT(*) AS c FROM runs").get().c;
467
+ assert.ok(backupRunCount > 0);
468
+ backupDb.close();
469
+ });
470
+ test("second invocation after a completed migration is a safe no-op and does not replace the backup", () => {
471
+ freshHome();
472
+ const dbPath = seedLegacyLineages();
473
+ const first = runMigration(dbPath, { skipServiceCheck: true });
474
+ assert.deepStrictEqual(first.mismatches, []);
475
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
476
+ const backupBytesBefore = fs.readFileSync(backupPath);
477
+ const second = runMigration(dbPath, { skipServiceCheck: true });
478
+ assert.equal(second.alreadyMigrated, true);
479
+ assert.deepStrictEqual(second.mismatches, []);
480
+ assert.equal(second.issuesCreated, 0);
481
+ const backupBytesAfter = fs.readFileSync(backupPath);
482
+ assert.deepStrictEqual(backupBytesBefore, backupBytesAfter);
483
+ });
484
+ test("refuses without touching data when a backup already exists from a prior attempt", () => {
485
+ freshHome();
486
+ const dbPath = seedLegacyLineages();
487
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
488
+ fs.writeFileSync(backupPath, "stale-backup-marker");
489
+ const report = runMigration(dbPath, { skipServiceCheck: true });
490
+ assert.ok(report.mismatches.length > 0);
491
+ assert.match(report.mismatches[0], /backup already exists/);
492
+ // The stale backup is untouched — never replaced.
493
+ assert.equal(fs.readFileSync(backupPath, "utf8"), "stale-backup-marker");
494
+ // And the live database was never migrated.
495
+ const tables = getDb().prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
496
+ assert.ok(tables.includes("runs"));
497
+ });
498
+ test("refuses to migrate while the service is running, before any backup is made", () => {
499
+ freshHome();
500
+ const dbPath = seedLegacyLineages();
501
+ const runStatePath = path.join(path.dirname(dbPath), "run.json");
502
+ fs.writeFileSync(runStatePath, JSON.stringify({ serverPid: process.pid, port: 2221 }));
503
+ const report = runMigration(dbPath); // no skipServiceCheck — production path
504
+ assert.ok(report.mismatches.length > 0);
505
+ assert.match(report.mismatches[0], /service is running/);
506
+ assert.equal(fs.existsSync(`${dbPath}.pre-issue-migration-backup`), false);
507
+ });
508
+ test("refuses to migrate when server.pid names a different, live process, and never touches that marker", () => {
509
+ freshHome();
510
+ const dbPath = seedLegacyLineages();
511
+ const other = spawnLiveChild();
512
+ try {
513
+ const serverPidPath = path.join(path.dirname(dbPath), "server.pid");
514
+ fs.writeFileSync(serverPidPath, JSON.stringify({ pid: other.pid, port: 3221, startedAt: NOW }));
515
+ const report = runMigration(dbPath); // no skipServiceCheck — production path
516
+ assert.ok(report.mismatches.length > 0);
517
+ assert.match(report.mismatches[0], /service is running/);
518
+ assert.equal(fs.existsSync(`${dbPath}.pre-issue-migration-backup`), false);
519
+ // The other process's marker must be exactly as it was — not stolen, not modified.
520
+ const stillThere = JSON.parse(fs.readFileSync(serverPidPath, "utf8"));
521
+ assert.equal(stillThere.pid, other.pid);
522
+ }
523
+ finally {
524
+ other.kill();
525
+ }
526
+ });
527
+ test("a successful migration claims and releases server.pid — no marker is left behind afterward", () => {
528
+ freshHome();
529
+ const dbPath = seedLegacyLineages();
530
+ const serverPidPath = path.join(path.dirname(dbPath), "server.pid");
531
+ const report = runMigration(dbPath); // no skipServiceCheck — exercises the real claim/release path
532
+ assert.deepStrictEqual(report.mismatches, []);
533
+ assert.equal(fs.existsSync(serverPidPath), false, "the migration's own claim on server.pid must be released once it finishes, so a server can start normally afterward");
534
+ });
535
+ test("a rolled-back migration still releases server.pid", () => {
536
+ freshHome();
537
+ const dbPath = seedLegacyLineages();
538
+ getDb()
539
+ .prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
540
+ lineage_id, created_at, updated_at)
541
+ VALUES ('run-orphan', 'manual', 'run-orphan', 'code', 'Orphan', '/repo', ?, 'done', 'does-not-exist', ?, ?)`)
542
+ .run(BUILTIN_AGENT_CLAUDE_ID, NOW, NOW);
543
+ const serverPidPath = path.join(path.dirname(dbPath), "server.pid");
544
+ const report = runMigration(dbPath); // no skipServiceCheck
545
+ assert.ok(report.mismatches.length > 0);
546
+ assert.equal(fs.existsSync(serverPidPath), false, "a failed migration must still release its own claim");
547
+ });
548
+ test("injected failure inside the transaction rolls back the database and leaves the backup usable", () => {
549
+ for (const phase of ["after-insert", "after-verify", "after-rename"]) {
550
+ freshHome();
551
+ const dbPath = seedLegacyLineages();
552
+ const report = runMigration(dbPath, { skipServiceCheck: true, injectFailureAt: phase });
553
+ assert.ok(report.mismatches.length > 0, `phase ${phase} should report a failure`);
554
+ assert.match(report.mismatches[0], new RegExp(phase));
555
+ const tables = new Database(dbPath, { readonly: true }).prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
556
+ assert.ok(tables.includes("runs"), `phase ${phase}: runs table should survive rollback`);
557
+ assert.equal(tables.includes("legacy_v0_runs"), false, `phase ${phase}: rename should be rolled back`);
558
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
559
+ assert.ok(fs.existsSync(backupPath), `phase ${phase}: backup should exist`);
560
+ const backupDb = new Database(backupPath, { readonly: true });
561
+ const count = backupDb.prepare("SELECT COUNT(*) AS c FROM runs").get().c;
562
+ assert.ok(count > 0, `phase ${phase}: backup should be a usable, openable database`);
563
+ backupDb.close();
564
+ }
565
+ });
566
+ test("injected failure before the backup is made throws and creates no backup", () => {
567
+ freshHome();
568
+ const dbPath = seedLegacyLineages();
569
+ assert.throws(() => runMigration(dbPath, { skipServiceCheck: true, injectFailureAt: "before-backup" }));
570
+ assert.equal(fs.existsSync(`${dbPath}.pre-issue-migration-backup`), false);
571
+ });
572
+ test("injected failure right after the backup throws but leaves a usable backup and untouched runs table", () => {
573
+ freshHome();
574
+ const dbPath = seedLegacyLineages();
575
+ assert.throws(() => runMigration(dbPath, { skipServiceCheck: true, injectFailureAt: "after-backup" }));
576
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
577
+ assert.ok(fs.existsSync(backupPath));
578
+ const backupDb = new Database(backupPath, { readonly: true });
579
+ assert.ok(backupDb.prepare("SELECT COUNT(*) AS c FROM runs").get().c > 0);
580
+ backupDb.close();
581
+ const tables = new Database(dbPath, { readonly: true }).prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
582
+ assert.ok(tables.includes("runs"));
583
+ });
584
+ test("documented rollback: restores the pre-migration backup and undoes a completed migration end-to-end", () => {
585
+ freshHome();
586
+ const dbPath = seedLegacyLineages();
587
+ const beforeRunCount = getDb().prepare("SELECT COUNT(*) AS c FROM runs").get().c;
588
+ const report = runMigration(dbPath, { skipServiceCheck: true });
589
+ assert.deepStrictEqual(report.mismatches, []);
590
+ const tablesAfterMigration = getDb().prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
591
+ assert.ok(tablesAfterMigration.includes("legacy_v0_runs"));
592
+ // Close the app's own handle to this file before restoring it out from under itself —
593
+ // mirrors the documented procedure's "stop the service first".
594
+ closeDb();
595
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
596
+ assert.equal(result.rolledBack, true);
597
+ const restored = new Database(dbPath, { readonly: true });
598
+ const tablesAfterRollback = restored.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
599
+ assert.ok(tablesAfterRollback.includes("runs"));
600
+ assert.equal(tablesAfterRollback.includes("legacy_v0_runs"), false);
601
+ const runCount = restored.prepare("SELECT COUNT(*) AS c FROM runs").get().c;
602
+ assert.equal(runCount, beforeRunCount);
603
+ restored.close();
604
+ });
605
+ test("rollback refuses a corrupt backup file and never touches the live database", () => {
606
+ freshHome();
607
+ const dbPath = seedLegacyLineages();
608
+ runMigration(dbPath, { skipServiceCheck: true });
609
+ closeDb();
610
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
611
+ fs.writeFileSync(backupPath, "not a sqlite database");
612
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
613
+ assert.equal(result.rolledBack, false);
614
+ assert.match(result.detail, /cannot open .* as a SQLite database/);
615
+ // The live (post-migration) database must be exactly as it was — never overwritten by
616
+ // an unvalidated copy of the corrupt "backup".
617
+ const live = new Database(dbPath, { readonly: true });
618
+ const tables = live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
619
+ assert.ok(tables.includes("legacy_v0_runs"), "the live, already-migrated database must be untouched");
620
+ live.close();
621
+ // No leftover staging file either.
622
+ const dir = fs.readdirSync(path.dirname(dbPath));
623
+ assert.ok(!dir.some((f) => f.includes(".rollback-staging-")), `unexpected staging file left behind: ${dir}`);
624
+ });
625
+ test("rollback refuses a backup that fails SQLite's integrity check", () => {
626
+ freshHome();
627
+ const dbPath = seedLegacyLineages();
628
+ runMigration(dbPath, { skipServiceCheck: true });
629
+ closeDb();
630
+ // A well-formed-but-truncated SQLite file: valid header, but body chopped off partway —
631
+ // integrity_check should catch this even though it opens without throwing.
632
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
633
+ const original = fs.readFileSync(backupPath);
634
+ fs.writeFileSync(backupPath, original.subarray(0, Math.floor(original.length / 2)));
635
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
636
+ assert.equal(result.rolledBack, false);
637
+ assert.match(result.detail, /integrity check|cannot open/);
638
+ const live = new Database(dbPath, { readonly: true });
639
+ const tables = live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
640
+ assert.ok(tables.includes("legacy_v0_runs"));
641
+ live.close();
642
+ });
643
+ test("rollback refuses a backup missing the runs table (does not look like a pre-migration snapshot)", () => {
644
+ freshHome();
645
+ const dbPath = seedLegacyLineages();
646
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
647
+ // A valid SQLite database, just not a plausible pre-migration one.
648
+ const bogus = new Database(backupPath);
649
+ bogus.exec("CREATE TABLE not_runs (id TEXT)");
650
+ bogus.close();
651
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
652
+ assert.equal(result.rolledBack, false);
653
+ assert.match(result.detail, /expected legacy table "runs" not found/);
654
+ });
655
+ test("rollback refuses a completely unrelated database that happens to have a table named runs", () => {
656
+ freshHome();
657
+ const dbPath = seedLegacyLineages();
658
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
659
+ const unrelated = new Database(backupPath);
660
+ // Same table name, none of the expected legacy columns — an unrelated app's own "runs".
661
+ unrelated.exec("CREATE TABLE runs (id TEXT PRIMARY KEY, whatever TEXT)");
662
+ unrelated.close();
663
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
664
+ assert.equal(result.rolledBack, false);
665
+ assert.match(result.detail, /expected legacy table "artifacts" not found/);
666
+ });
667
+ test("rollback refuses a near-miss unrelated database with the legacy tables/columns but missing approval_gates and the target tables", () => {
668
+ // The reviewer's exact repro: an otherwise-plausible database carrying runs/artifacts/
669
+ // events with every expected legacy column, but neither approval_gates nor any of the
670
+ // issue-centric target tables migrate() would already have created in a genuine
671
+ // pre-cutover snapshot — a near miss the narrower, ad hoc check previously used here did
672
+ // not catch, but the full validateLegacySchema() (reused as of this fix) does.
673
+ freshHome();
674
+ const dbPath = seedLegacyLineages();
675
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
676
+ const nearMiss = new Database(backupPath);
677
+ nearMiss.exec(`
678
+ CREATE TABLE runs (
679
+ id TEXT PRIMARY KEY, source TEXT, external_id TEXT, external_label TEXT, title TEXT,
680
+ description TEXT, repo TEXT, agent_id TEXT, status TEXT, lineage_id TEXT,
681
+ acceptance_criteria TEXT, runtime TEXT, plan_model TEXT, execute_model TEXT,
682
+ budget_json TEXT, created_at TEXT, updated_at TEXT
683
+ );
684
+ CREATE TABLE artifacts (
685
+ id TEXT PRIMARY KEY, run_id TEXT, kind TEXT, content_json TEXT, blob_path TEXT,
686
+ author TEXT, created_at TEXT, issue_id TEXT, worker_session_id TEXT
687
+ );
688
+ CREATE TABLE events (id TEXT PRIMARY KEY, run_id TEXT, type TEXT, payload_json TEXT, ts TEXT);
689
+ `);
690
+ nearMiss.close();
691
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
692
+ assert.equal(result.rolledBack, false);
693
+ assert.match(result.detail, /expected legacy table "approval_gates" not found/);
694
+ });
695
+ test("rollback refuses a post-restart database copied over the backup path (already migrated, schema recreated)", () => {
696
+ // Reproduces the reviewer's exact false-success repro: migrate a legacy DB, run the
697
+ // ordinary migrate() a real restart performs (recreating a fresh runtime "runs" table),
698
+ // then copy *that* current, already-migrated database over the backup path — before this
699
+ // fix, a bare "does it have a runs table" check accepted it, silently reporting a rollback
700
+ // that restored nothing (the live database still carried legacy_v0_runs afterward).
701
+ freshHome();
702
+ const dbPath = seedLegacyLineages();
703
+ runMigration(dbPath, { skipServiceCheck: true });
704
+ migrate(); // the restart every real cutover is followed by — recreates a fresh "runs"
705
+ const backupPath = `${dbPath}.pre-issue-migration-backup`;
706
+ closeDb();
707
+ fs.copyFileSync(dbPath, backupPath); // overwrite the real backup with the post-restart db (operator error)
708
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
709
+ assert.equal(result.rolledBack, false);
710
+ assert.match(result.detail, /post-migration database|cutover-only table/);
711
+ // The live database is untouched — still exactly the post-migration/post-restart state.
712
+ const live = new Database(dbPath, { readonly: true });
713
+ const tables = live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
714
+ assert.ok(tables.includes("legacy_v0_runs"), "the live database must still be the migrated one — no rollback occurred");
715
+ const freshRunCount = live.prepare("SELECT COUNT(*) AS c FROM runs").get().c;
716
+ assert.equal(freshRunCount, 0, "the fresh post-restart runs table should still be empty, not the restored legacy data");
717
+ live.close();
718
+ });
719
+ test("rollback claims and releases server.pid across the whole validate/stage/rename sequence", () => {
720
+ freshHome();
721
+ const dbPath = seedLegacyLineages();
722
+ runMigration(dbPath, { skipServiceCheck: true });
723
+ closeDb();
724
+ const serverPidPath = path.join(path.dirname(dbPath), "server.pid");
725
+ const result = rollbackMigration(dbPath); // no skipServiceCheck — exercises the real claim/release path
726
+ assert.equal(result.rolledBack, true);
727
+ assert.equal(fs.existsSync(serverPidPath), false, "rollback's own claim must be released once it finishes");
728
+ });
729
+ test("rollback refuses when server.pid names a different, live process, and never touches the live database", () => {
730
+ freshHome();
731
+ const dbPath = seedLegacyLineages();
732
+ runMigration(dbPath, { skipServiceCheck: true });
733
+ closeDb();
734
+ const other = spawnLiveChild();
735
+ try {
736
+ const serverPidPath = path.join(path.dirname(dbPath), "server.pid");
737
+ fs.writeFileSync(serverPidPath, JSON.stringify({ pid: other.pid, port: 3221, startedAt: NOW }));
738
+ const result = rollbackMigration(dbPath);
739
+ assert.equal(result.rolledBack, false);
740
+ assert.match(result.detail, /service is running/);
741
+ const live = new Database(dbPath, { readonly: true });
742
+ const tables = live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name);
743
+ assert.ok(tables.includes("legacy_v0_runs"), "the live database must be untouched");
744
+ live.close();
745
+ }
746
+ finally {
747
+ other.kill();
748
+ }
749
+ });
750
+ test("rollback leaves no staging file behind after a successful restore", () => {
751
+ freshHome();
752
+ const dbPath = seedLegacyLineages();
753
+ runMigration(dbPath, { skipServiceCheck: true });
754
+ closeDb();
755
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
756
+ assert.equal(result.rolledBack, true);
757
+ const dir = fs.readdirSync(path.dirname(dbPath));
758
+ assert.ok(!dir.some((f) => f.includes(".rollback-staging-")), `unexpected staging file left behind: ${dir}`);
759
+ });
760
+ test("a migration pointed at a symlink to the real dealer.db derives server.pid next to the real file", () => {
761
+ freshHome();
762
+ const dbPath = seedLegacyLineages();
763
+ const aliasDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-issue-migration-alias-"));
764
+ const aliasPath = path.join(aliasDir, "dealer-alias.db");
765
+ fs.symlinkSync(dbPath, aliasPath);
766
+ // A live "server" recorded next to the REAL file, not the alias.
767
+ const realServerPidPath = path.join(path.dirname(dbPath), "server.pid");
768
+ const other = spawnLiveChild();
769
+ try {
770
+ fs.writeFileSync(realServerPidPath, JSON.stringify({ pid: other.pid, port: 3221, startedAt: NOW }));
771
+ // Migrating through the alias must see that same live owner, not miss it by deriving
772
+ // server.pid next to the alias directory instead.
773
+ const report = runMigration(aliasPath);
774
+ assert.ok(report.mismatches.length > 0);
775
+ assert.match(report.mismatches[0], /service is running/);
776
+ // And no server.pid was created next to the alias — everything resolved to the real dir.
777
+ assert.equal(fs.existsSync(path.join(aliasDir, "server.pid")), false);
778
+ }
779
+ finally {
780
+ other.kill();
781
+ }
782
+ });
783
+ test("rollback refuses when no backup exists", () => {
784
+ freshHome();
785
+ const dbPath = getDbPath();
786
+ const result = rollbackMigration(dbPath, { skipServiceCheck: true });
787
+ assert.equal(result.rolledBack, false);
788
+ assert.match(result.detail, /no backup found/);
789
+ });
790
+ test("rollback refuses while the service is running", () => {
791
+ freshHome();
792
+ const dbPath = seedLegacyLineages();
793
+ runMigration(dbPath, { skipServiceCheck: true });
794
+ const runStatePath = path.join(path.dirname(dbPath), "run.json");
795
+ fs.writeFileSync(runStatePath, JSON.stringify({ serverPid: process.pid, port: 2221 }));
796
+ const result = rollbackMigration(dbPath);
797
+ assert.equal(result.rolledBack, false);
798
+ assert.match(result.detail, /service is running/);
799
+ });
800
+ test("isServiceRunning: no run.json means not running", () => {
801
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-liveness-"));
802
+ const dbPath = path.join(dir, "dealer.db");
803
+ fs.writeFileSync(dbPath, "");
804
+ assert.equal(isServiceRunning(dbPath).running, false);
805
+ });
806
+ test("isServiceRunning: run.json with a dead pid means not running", () => {
807
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-liveness-"));
808
+ const dbPath = path.join(dir, "dealer.db");
809
+ fs.writeFileSync(dbPath, "");
810
+ fs.writeFileSync(path.join(dir, "run.json"), JSON.stringify({ serverPid: 999999, port: 2221 }));
811
+ assert.equal(isServiceRunning(dbPath).running, false);
812
+ });
813
+ test("isServiceRunning: run.json with a live pid means running", () => {
814
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-liveness-"));
815
+ const dbPath = path.join(dir, "dealer.db");
816
+ fs.writeFileSync(dbPath, "");
817
+ fs.writeFileSync(path.join(dir, "run.json"), JSON.stringify({ serverPid: process.pid, port: 2221 }));
818
+ const result = isServiceRunning(dbPath);
819
+ assert.equal(result.running, true);
820
+ assert.ok(result.detail?.includes(String(process.pid)));
821
+ });
822
+ test("isServiceRunning: server.pid (written by a directly-launched server, no run.json at all) means running", () => {
823
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-liveness-"));
824
+ const dbPath = path.join(dir, "dealer.db");
825
+ fs.writeFileSync(dbPath, "");
826
+ // No run.json — this is exactly what `npm run dev` / `npm run start` leave behind,
827
+ // since only the CLI daemon supervisor writes run.json.
828
+ fs.writeFileSync(path.join(dir, "server.pid"), JSON.stringify({ pid: process.pid, port: 3221 }));
829
+ const result = isServiceRunning(dbPath);
830
+ assert.equal(result.running, true);
831
+ assert.ok(result.detail?.includes(String(process.pid)));
832
+ });
833
+ test("isServiceRunning: a dead server.pid falls through to a live run.json instead of reporting not-running", () => {
834
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-liveness-"));
835
+ const dbPath = path.join(dir, "dealer.db");
836
+ fs.writeFileSync(dbPath, "");
837
+ fs.writeFileSync(path.join(dir, "server.pid"), JSON.stringify({ pid: 999999, port: 3221 }));
838
+ fs.writeFileSync(path.join(dir, "run.json"), JSON.stringify({ serverPid: process.pid, port: 2221 }));
839
+ const result = isServiceRunning(dbPath);
840
+ assert.equal(result.running, true);
841
+ assert.ok(result.detail?.includes("run.json"));
842
+ });