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
@@ -1,5 +1,4 @@
1
1
  import fs from "node:fs";
2
- import { OutboundDraftBlock, outboundMessageMatchesSummary, PlanTriageBlock } from "@agent-dealer/shared";
3
2
  export function parseNdjson(raw) {
4
3
  const events = [];
5
4
  for (const line of raw.split("\n")) {
@@ -20,10 +19,6 @@ export function parseNdjsonFile(logPath) {
20
19
  return [];
21
20
  return parseNdjson(fs.readFileSync(logPath, "utf8"));
22
21
  }
23
- function stripMarkdownFences(text) {
24
- const m = text.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/);
25
- return m ? m[1].trim() : text.trim();
26
- }
27
22
  export function extractSessionId(events) {
28
23
  for (const e of events) {
29
24
  if (e.type === "system" && typeof e.session_id === "string")
@@ -31,14 +26,6 @@ export function extractSessionId(events) {
31
26
  }
32
27
  return undefined;
33
28
  }
34
- export function extractResultIsError(events) {
35
- for (let i = events.length - 1; i >= 0; i--) {
36
- const e = events[i];
37
- if (e.type === "result")
38
- return Boolean(e.is_error);
39
- }
40
- return false;
41
- }
42
29
  export function extractResultText(events) {
43
30
  for (let i = events.length - 1; i >= 0; i--) {
44
31
  const e = events[i];
@@ -59,60 +46,6 @@ export function extractResultText(events) {
59
46
  }
60
47
  return undefined;
61
48
  }
62
- export function extractPlanMarkdown(events) {
63
- const raw = extractResultText(events) ?? "";
64
- return stripMarkdownFences(raw);
65
- }
66
- const TRIAGE_FALLBACK = {
67
- verdict: "needs_review",
68
- rationale: "Agent did not return a valid triage block",
69
- questions: [],
70
- parseFallback: true,
71
- };
72
- /** Parse the trailing fenced json triage block from plan markdown (PRD F1.2). */
73
- export function extractPlanTriage(planMarkdown) {
74
- const blocks = [...planMarkdown.matchAll(/```json\s*\n([\s\S]*?)\n```/g)];
75
- const last = blocks[blocks.length - 1];
76
- if (!last)
77
- return { markdown: planMarkdown.trim(), ...TRIAGE_FALLBACK };
78
- const strippedMarkdown = (planMarkdown.slice(0, last.index) + planMarkdown.slice(last.index + last[0].length)).trim();
79
- try {
80
- const parsed = PlanTriageBlock.parse(JSON.parse(last[1]));
81
- return {
82
- markdown: strippedMarkdown,
83
- verdict: parsed.verdict,
84
- rationale: parsed.rationale,
85
- questions: parsed.questions,
86
- parseFallback: false,
87
- };
88
- }
89
- catch {
90
- return { markdown: strippedMarkdown, ...TRIAGE_FALLBACK };
91
- }
92
- }
93
- /** Parse the trailing fenced json outbound draft block from execute result (PRD F2.2). */
94
- export function extractOutboundDraft(resultMarkdown) {
95
- const blocks = [...resultMarkdown.matchAll(/```json\s*\n([\s\S]*?)\n```/g)];
96
- const last = blocks[blocks.length - 1];
97
- if (!last) {
98
- return { markdown: resultMarkdown.trim(), hadJsonBlock: false, invalid: false, mismatch: false };
99
- }
100
- const strippedMarkdown = (resultMarkdown.slice(0, last.index) + resultMarkdown.slice(last.index + last[0].length)).trim();
101
- try {
102
- const parsed = OutboundDraftBlock.parse(JSON.parse(last[1]));
103
- const mismatch = !outboundMessageMatchesSummary(parsed.toolCall, parsed.summary.body, parsed.actionType);
104
- return {
105
- markdown: strippedMarkdown,
106
- draft: parsed,
107
- hadJsonBlock: true,
108
- invalid: false,
109
- mismatch,
110
- };
111
- }
112
- catch {
113
- return { markdown: strippedMarkdown, hadJsonBlock: true, invalid: true, mismatch: false };
114
- }
115
- }
116
49
  export function extractUsage(events, phase, runtime) {
117
50
  const result = events.find((e) => e.type === "result");
118
51
  const init = events.find((e) => e.type === "system");
@@ -132,77 +65,3 @@ export function extractUsage(events, phase, runtime) {
132
65
  numTurns: typeof result?.num_turns === "number" ? result.num_turns : undefined,
133
66
  };
134
67
  }
135
- function appendAssistantText(entries, text) {
136
- const trimmed = text.trim();
137
- if (!trimmed)
138
- return;
139
- const last = entries[entries.length - 1];
140
- if (last?.type === "assistant") {
141
- last.text = `${last.text}${text}`.slice(-4000);
142
- }
143
- else {
144
- entries.push({ type: "assistant", text: trimmed.slice(0, 4000) });
145
- }
146
- }
147
- export function buildStreamTrace(events, maxEntries = 80) {
148
- const entries = [];
149
- for (const e of events) {
150
- const t = String(e.type ?? "");
151
- if (t === "system" && e.subtype === "init") {
152
- entries.push({
153
- type: "system",
154
- text: `session ${String(e.session_id ?? "?").slice(0, 8)} · model ${String(e.model ?? "?")}`,
155
- });
156
- }
157
- if (t === "thinking") {
158
- const delta = typeof e.text === "string" ? e.text : "";
159
- if (e.subtype === "completed") {
160
- entries.push({ type: "thinking", text: "(reasoning complete)" });
161
- }
162
- else if (delta) {
163
- const last = entries[entries.length - 1];
164
- if (last?.type === "thinking" && last.text !== "(reasoning complete)") {
165
- last.text = `${last.text}${delta}`.slice(-2000);
166
- }
167
- else {
168
- entries.push({ type: "thinking", text: delta.slice(0, 2000) });
169
- }
170
- }
171
- }
172
- if (t === "assistant") {
173
- const msg = e.message;
174
- if (msg?.content) {
175
- for (const block of msg.content) {
176
- if (block.type === "thinking" && block.thinking) {
177
- entries.push({ type: "thinking", text: block.thinking.slice(0, 2000) });
178
- }
179
- else if (block.type === "text" && block.text) {
180
- appendAssistantText(entries, block.text);
181
- }
182
- else if (block.type === "tool_use" && block.name) {
183
- entries.push({ type: "tool", text: `invoke ${block.name}`, toolName: block.name });
184
- }
185
- }
186
- }
187
- }
188
- if (t === "tool_call") {
189
- const name = String(e.name ?? e.tool_name ?? "tool");
190
- entries.push({ type: "tool", text: `invoke ${name}`, toolName: name });
191
- }
192
- if (t === "rate_limit_event") {
193
- const info = e.rate_limit_info;
194
- entries.push({
195
- type: "rate_limit",
196
- text: `rate limit ${info?.status ?? "?"} (${info?.rateLimitType ?? "?"})`,
197
- });
198
- }
199
- if (t === "result") {
200
- const preview = typeof e.result === "string" ? e.result.slice(0, 500) : "";
201
- entries.push({
202
- type: "result",
203
- text: preview || (e.is_error ? "error" : "done"),
204
- });
205
- }
206
- }
207
- return entries.slice(-maxEntries);
208
- }
@@ -0,0 +1,242 @@
1
+ // packages/server/src/runners/usage-cap.ts
2
+ //
3
+ // NOT-111: detect hard usage-cap signals from CLI NDJSON logs and persist runtime_availability.
4
+ // NOT-117: Cursor/Codex text fallback must not scan assistant/tool file contents (false
5
+ // positives like "infra-attempt limit reached" in source); successful sessions never open a
6
+ // deferral from body text alone.
7
+ import fs from "node:fs";
8
+ import { usageCapFallbackCooldownMs } from "../coordinator/usage-cap-config.js";
9
+ import { recordRuntimeAvailability } from "../repository/runtime-availability.js";
10
+ import { parseCodexJsonl } from "./codex-jsonl.js";
11
+ import { parseNdjson } from "./stream-json.js";
12
+ /** Structured / result-error prose — no bare "limit reached" (NOT-117). */
13
+ const CAP_ERROR_RE = /\b(rate\s*limit(?:ed)?|usage\s*limit|quota\s*exceeded|billing_error|subscription\s*limit)\b/i;
14
+ /**
15
+ * Text-only fallback (stderr / error result). Requires rate|usage|quota|subscription
16
+ * near "limit", or an explicit billing/quota phrase — never bare "limit reached".
17
+ */
18
+ const CAP_FALLBACK_RE = /\b((?:rate|usage|quota|subscription)\s+limits?(?:\s+\w+){0,4}\s+reached|(?:rate|usage)\s*limit(?:ed)?|quota\s+exceeded|billing_error|subscription\s+limit)\b/i;
19
+ function parseResetsAt(raw) {
20
+ if (typeof raw !== "number" || !Number.isFinite(raw))
21
+ return null;
22
+ const ms = raw > 1e12 ? raw : raw * 1000;
23
+ const d = new Date(ms);
24
+ return Number.isFinite(d.getTime()) ? d : null;
25
+ }
26
+ function fallbackUntil(nowMs = Date.now()) {
27
+ return new Date(nowMs + usageCapFallbackCooldownMs()).toISOString();
28
+ }
29
+ function capReason(runtime, detail) {
30
+ return `${runtime} usage capped — ${detail}`;
31
+ }
32
+ function isHardCapRateLimitInfo(info) {
33
+ if (info.status === "rejected")
34
+ return true;
35
+ if (info.overageStatus === "rejected")
36
+ return true;
37
+ return false;
38
+ }
39
+ function signalFromRateLimitInfo(runtime, info, evidence, nowMs) {
40
+ if (!isHardCapRateLimitInfo(info))
41
+ return null;
42
+ const reset = parseResetsAt(info.resetsAt) ??
43
+ parseResetsAt(info.overageResetsAt) ??
44
+ parseResetsAt(info.overage_resets_at) ??
45
+ parseResetsAt(info.resets_at);
46
+ const unavailableUntil = reset && reset.getTime() > nowMs ? reset.toISOString() : fallbackUntil(nowMs);
47
+ const limitType = String(info.rateLimitType ?? info.rate_limit_type ?? "plan");
48
+ return {
49
+ unavailableUntil,
50
+ reason: capReason(runtime, `${limitType} limit rejected`),
51
+ evidence: { ...evidence, rate_limit_info: info },
52
+ };
53
+ }
54
+ function signalFromApiRetry(e, runtime, nowMs) {
55
+ if (e.type !== "system")
56
+ return null;
57
+ const subtype = String(e.subtype ?? "");
58
+ if (subtype !== "api_retry" && subtype !== "error")
59
+ return null;
60
+ const err = String(e.error ?? "");
61
+ // Hard cap only — transient api_retry rate_limit is not an account cap (NOT-111).
62
+ if (err !== "billing_error")
63
+ return null;
64
+ return {
65
+ unavailableUntil: fallbackUntil(nowMs),
66
+ reason: capReason(runtime, err.replace("_", " ")),
67
+ evidence: { event: e },
68
+ };
69
+ }
70
+ function signalFromResultError(e, runtime, nowMs) {
71
+ if (e.type !== "result" || !e.is_error)
72
+ return null;
73
+ const text = typeof e.result === "string" ? e.result : "";
74
+ if (!CAP_ERROR_RE.test(text))
75
+ return null;
76
+ return {
77
+ unavailableUntil: fallbackUntil(nowMs),
78
+ reason: capReason(runtime, "session ended with cap error"),
79
+ evidence: { result: text.slice(0, 500) },
80
+ };
81
+ }
82
+ /** Parse Claude/Cursor-shaped NDJSON events for a hard cap (rate_limit_event preferred over result error). */
83
+ export function extractUsageCapFromEvents(events, runtime, nowMs = Date.now()) {
84
+ let fromRateLimit = null;
85
+ let fromResult = null;
86
+ for (const e of events) {
87
+ if (e.type === "rate_limit_event") {
88
+ const info = e.rate_limit_info;
89
+ if (info && typeof info === "object") {
90
+ const sig = signalFromRateLimitInfo(runtime, info, { event: e }, nowMs);
91
+ if (sig)
92
+ fromRateLimit = sig;
93
+ }
94
+ }
95
+ const api = signalFromApiRetry(e, runtime, nowMs);
96
+ if (api)
97
+ fromRateLimit = api;
98
+ const res = signalFromResultError(e, runtime, nowMs);
99
+ if (res)
100
+ fromResult = res;
101
+ }
102
+ return fromRateLimit ?? fromResult;
103
+ }
104
+ function codexCapFromEvents(events, runtime, nowMs) {
105
+ for (let i = events.length - 1; i >= 0; i--) {
106
+ const e = events[i];
107
+ if (e.type === "turn.failed") {
108
+ const err = e.error;
109
+ let msg = "";
110
+ if (typeof err === "string")
111
+ msg = err;
112
+ else if (err && typeof err === "object") {
113
+ msg = String(err.message ?? "");
114
+ }
115
+ if (CAP_ERROR_RE.test(msg)) {
116
+ return {
117
+ unavailableUntil: fallbackUntil(nowMs),
118
+ reason: capReason(runtime, msg.slice(0, 120) || "turn failed"),
119
+ evidence: { turn_failed: err },
120
+ };
121
+ }
122
+ }
123
+ if (e.type === "error" && typeof e.message === "string" && CAP_ERROR_RE.test(e.message)) {
124
+ return {
125
+ unavailableUntil: fallbackUntil(nowMs),
126
+ reason: capReason(runtime, e.message.slice(0, 120)),
127
+ evidence: { error: e.message },
128
+ };
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+ function parseEventsForRuntime(raw, runtime) {
134
+ if (runtime === "codex_local")
135
+ return parseCodexJsonl(raw);
136
+ return parseNdjson(raw);
137
+ }
138
+ /**
139
+ * True when the stream ended successfully — used to skip Codex/Cursor text fallback
140
+ * (NOT-117). Cursor/Claude use a non-error `result`; Codex native JSONL uses
141
+ * `turn.completed` (never emits `type: "result"` until normalizeCodexEvents).
142
+ */
143
+ function sessionEndedSuccessfully(events, runtime) {
144
+ if (runtime === "codex_local") {
145
+ for (let i = events.length - 1; i >= 0; i--) {
146
+ const e = events[i];
147
+ if (e.type === "turn.failed")
148
+ return false;
149
+ if (e.type === "error")
150
+ return false;
151
+ if (e.type === "turn.completed")
152
+ return true;
153
+ }
154
+ return false;
155
+ }
156
+ for (let i = events.length - 1; i >= 0; i--) {
157
+ const e = events[i];
158
+ if (e.type === "result")
159
+ return !e.is_error;
160
+ }
161
+ return false;
162
+ }
163
+ /**
164
+ * Text fallback haystack: stderr trailer + error-result strings only — never assistant /
165
+ * tool / file-read bodies (NOT-117 false-positive source).
166
+ */
167
+ function textFallbackHaystack(raw, events) {
168
+ const stderr = raw.includes("\n--- stderr ---\n") ? raw.split("\n--- stderr ---\n")[1] ?? "" : "";
169
+ const resultBits = [];
170
+ for (const e of events) {
171
+ if (e.type === "result" && e.is_error && typeof e.result === "string") {
172
+ resultBits.push(e.result);
173
+ }
174
+ }
175
+ return `${resultBits.join("\n")}\n${stderr}`;
176
+ }
177
+ /** Detect from an in-memory NDJSON string (unit tests). */
178
+ export function detectUsageCapFromNdjson(raw, runtime, nowMs = Date.now()) {
179
+ const events = parseEventsForRuntime(raw, runtime);
180
+ if (runtime === "codex_local")
181
+ return codexCapFromEvents(events, runtime, nowMs);
182
+ return extractUsageCapFromEvents(events, runtime, nowMs);
183
+ }
184
+ /**
185
+ * Full-log detection including Cursor/Codex text fallback (unit tests that need the
186
+ * NOT-117 path without writing a temp file).
187
+ */
188
+ export function detectUsageCapFromRawLog(raw, runtime, nowMs = Date.now()) {
189
+ const events = parseEventsForRuntime(raw, runtime);
190
+ const fromEvents = runtime === "codex_local"
191
+ ? codexCapFromEvents(events, runtime, nowMs)
192
+ : extractUsageCapFromEvents(events, runtime, nowMs);
193
+ if (fromEvents)
194
+ return fromEvents;
195
+ if (runtime === "codex_local" || runtime === "cursor_local") {
196
+ // Successful sessions must not open a usage-cap deferral from log body text (NOT-117).
197
+ if (sessionEndedSuccessfully(events, runtime))
198
+ return null;
199
+ const haystack = textFallbackHaystack(raw, events);
200
+ if (CAP_FALLBACK_RE.test(haystack)) {
201
+ return {
202
+ unavailableUntil: fallbackUntil(nowMs),
203
+ reason: capReason(runtime, "stderr/log matched usage-cap pattern"),
204
+ evidence: { matched: haystack.slice(0, 300) },
205
+ };
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+ export function detectUsageCapFromLog(logPath, runtime, nowMs = Date.now()) {
211
+ if (!fs.existsSync(logPath))
212
+ return null;
213
+ return detectUsageCapFromRawLog(fs.readFileSync(logPath, "utf8"), runtime, nowMs);
214
+ }
215
+ /** Persist a cap row when detected; returns the detection or null. */
216
+ export function recordUsageCapFromLog(logPath, runtime, nowMs = Date.now()) {
217
+ const cap = detectUsageCapFromLog(logPath, runtime, nowMs);
218
+ if (!cap)
219
+ return null;
220
+ recordRuntimeAvailability({
221
+ runtime,
222
+ unavailableUntil: cap.unavailableUntil,
223
+ reason: cap.reason,
224
+ evidence: cap.evidence,
225
+ });
226
+ return cap;
227
+ }
228
+ /** Convenience for callers that already parsed events (e.g. stream-json tests). */
229
+ export function recordUsageCapFromEvents(events, runtime, nowMs = Date.now()) {
230
+ const cap = runtime === "codex_local"
231
+ ? codexCapFromEvents(events, runtime, nowMs)
232
+ : extractUsageCapFromEvents(events, runtime, nowMs);
233
+ if (!cap)
234
+ return null;
235
+ recordRuntimeAvailability({
236
+ runtime,
237
+ unavailableUntil: cap.unavailableUntil,
238
+ reason: cap.reason,
239
+ evidence: cap.evidence,
240
+ });
241
+ return cap;
242
+ }
@@ -0,0 +1,97 @@
1
+ import { test, before, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import os from "node:os";
7
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cap-home-"));
8
+ process.env.USAGE_CAP_FALLBACK_COOLDOWN_MS = "1800000";
9
+ const fixtureDir = path.dirname(fileURLToPath(import.meta.url));
10
+ const claudeFixture = fs.readFileSync(path.join(fixtureDir, "fixtures/claude-rate-limit-rejected.ndjson"), "utf8");
11
+ const { migrate } = await import("../db/index.js");
12
+ const { recordUsageCapFromEvents, detectUsageCapFromNdjson, detectUsageCapFromRawLog, detectUsageCapFromLog, extractUsageCapFromEvents, } = await import("./usage-cap.js");
13
+ const { parseNdjson } = await import("./stream-json.js");
14
+ const { runtimeAvailability, clearAllRuntimeAvailability, } = await import("../repository/runtime-availability.js");
15
+ before(() => migrate());
16
+ beforeEach(() => clearAllRuntimeAvailability());
17
+ test("Claude rate_limit_event rejected writes runtime_availability with resetsAt", () => {
18
+ const nowMs = Date.parse("2026-01-01T00:00:00.000Z");
19
+ const events = parseNdjson(claudeFixture);
20
+ const cap = recordUsageCapFromEvents(events, "claude_code", nowMs);
21
+ assert.ok(cap);
22
+ assert.equal(cap.unavailableUntil, new Date(1784283600 * 1000).toISOString());
23
+ const avail = runtimeAvailability("claude_code", nowMs);
24
+ assert.equal(avail.available, false);
25
+ if (!avail.available) {
26
+ assert.equal(avail.until, cap.unavailableUntil);
27
+ }
28
+ });
29
+ test("allowed_warning rate_limit_event is ignored (hard cap only)", () => {
30
+ const raw = [
31
+ '{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":1784283600,"rateLimitType":"five_hour"}}',
32
+ ].join("\n");
33
+ const cap = detectUsageCapFromNdjson(raw, "claude_code");
34
+ assert.equal(cap, null);
35
+ });
36
+ test("missing reset time uses fallback cooldown", () => {
37
+ const nowMs = Date.parse("2026-06-01T12:00:00.000Z");
38
+ const raw = '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","overageStatus":"rejected"}}';
39
+ const cap = detectUsageCapFromNdjson(raw, "claude_code", nowMs);
40
+ assert.ok(cap);
41
+ assert.equal(cap.unavailableUntil, new Date(nowMs + 1_800_000).toISOString());
42
+ });
43
+ test("result error without prior event uses fallback cooldown", () => {
44
+ const nowMs = Date.parse("2026-06-01T12:00:00.000Z");
45
+ const events = parseNdjson('{"type":"result","is_error":true,"result":"Rate limit exceeded for this account."}');
46
+ const cap = extractUsageCapFromEvents(events, "claude_code", nowMs);
47
+ assert.ok(cap);
48
+ assert.equal(cap.unavailableUntil, new Date(nowMs + 1_800_000).toISOString());
49
+ });
50
+ test("NOT-117: successful Cursor NDJSON with source text 'infra-attempt limit reached' does not detect or record availability", () => {
51
+ const fixture = fs.readFileSync(path.join(fixtureDir, "fixtures/cursor-success-with-infra-attempt-limit-text.ndjson"), "utf8");
52
+ const cap = detectUsageCapFromRawLog(fixture, "cursor_local");
53
+ assert.equal(cap, null);
54
+ const logPath = path.join(process.env.AGENT_DEALER_HOME, "cursor-false-positive.ndjson");
55
+ fs.writeFileSync(logPath, fixture);
56
+ assert.equal(detectUsageCapFromLog(logPath, "cursor_local"), null);
57
+ assert.equal(runtimeAvailability("cursor_local").available, true);
58
+ });
59
+ test("NOT-117: Cursor billing_error and usage-limit result errors still detect", () => {
60
+ const billing = fs.readFileSync(path.join(fixtureDir, "fixtures/cursor-billing-error.ndjson"), "utf8");
61
+ const usage = fs.readFileSync(path.join(fixtureDir, "fixtures/cursor-result-usage-limit.ndjson"), "utf8");
62
+ assert.ok(detectUsageCapFromRawLog(billing, "cursor_local"));
63
+ assert.ok(detectUsageCapFromRawLog(usage, "cursor_local"));
64
+ const rejected = [
65
+ '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","resetsAt":1784283600,"rateLimitType":"five_hour"}}',
66
+ ].join("\n");
67
+ assert.ok(detectUsageCapFromRawLog(rejected, "cursor_local"));
68
+ });
69
+ test("NOT-117: Cursor stderr trailer with usage limit still detects; bare 'limit reached' in stderr does not", () => {
70
+ const withUsage = '{"type":"result","is_error":true,"result":"failed"}\n--- stderr ---\nError: usage limit reached for this account\n';
71
+ assert.ok(detectUsageCapFromRawLog(withUsage, "cursor_local"));
72
+ const bare = '{"type":"result","is_error":true,"result":"failed"}\n--- stderr ---\nError: infra-attempt limit reached\n';
73
+ assert.equal(detectUsageCapFromRawLog(bare, "cursor_local"), null);
74
+ });
75
+ test("NOT-117: successful session ignores stderr usage-cap-like prose (structured path already empty)", () => {
76
+ const raw = '{"type":"result","is_error":false,"result":"done"}\n--- stderr ---\nwarning: usage limit reached\n';
77
+ assert.equal(detectUsageCapFromRawLog(raw, "cursor_local"), null);
78
+ });
79
+ test("NOT-117: successful Codex turn.completed skips text fallback even when stderr has usage-limit prose", () => {
80
+ const raw = [
81
+ '{"type":"thread.started","thread_id":"t1"}',
82
+ '{"type":"item.completed","item":{"type":"agent_message","text":"Done implementing."}}',
83
+ '{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}',
84
+ "",
85
+ "--- stderr ---",
86
+ "npm warn: your usage limit reached for deprecated flag foo (unrelated to billing)",
87
+ ].join("\n");
88
+ assert.equal(detectUsageCapFromRawLog(raw, "codex_local"), null);
89
+ const failed = [
90
+ '{"type":"thread.started","thread_id":"t1"}',
91
+ '{"type":"turn.failed","error":{"message":"Rate limit exceeded"}}',
92
+ "",
93
+ "--- stderr ---",
94
+ "noise",
95
+ ].join("\n");
96
+ assert.ok(detectUsageCapFromRawLog(failed, "codex_local"));
97
+ });
@@ -0,0 +1,37 @@
1
+ // packages/server/src/server-liveness.ts
2
+ //
3
+ // A liveness marker the server writes for *itself*, directly from its own entrypoint
4
+ // (index.ts), independent of how it was launched. This exists because the only prior
5
+ // liveness signal — packages/cli/src/runtime-state.ts's run.json — is written solely by
6
+ // the packaged CLI's daemon supervisor (`agent-dealer start`). The documented `npm run
7
+ // dev` and `npm run start` paths invoke this package's entrypoint directly and never go
8
+ // through that supervisor, so a check that only looked at run.json (as
9
+ // db/migrate-to-issues.ts's isServiceRunning() originally did) silently reported "not
10
+ // running" for the two most common ways this app is actually launched during development
11
+ // and self-hosted production use — the migration script's core safety promise ("refuses
12
+ // to run against a live service") was a no-op for those paths. Writing this file from the
13
+ // server's own main(), before it touches the database, makes the guard hold in every
14
+ // launch mode: dev, `npm start`, and the CLI daemon (which runs this exact same entrypoint
15
+ // as a spawned child).
16
+ //
17
+ // Thin wrapper over pid-marker.ts's generic exclusive-claim primitive — see that module
18
+ // for why the claim/release semantics are ownership-aware rather than a plain overwrite,
19
+ // and for why the migration script (db/migrate-to-issues.ts) claims this *same* file for
20
+ // the duration of a cutover.
21
+ import path from "node:path";
22
+ import { getDataDir } from "./db/index.js";
23
+ import { claimPidMarker, releasePidMarker } from "./pid-marker.js";
24
+ export function serverPidFilePath() {
25
+ return path.join(getDataDir(), "server.pid");
26
+ }
27
+ /** Claims the marker for this server process. A false return means a different, live
28
+ * process already owns this AGENT_DEALER_HOME — the caller (index.ts) must treat that as
29
+ * fatal and refuse to start, not merely warn and continue, or this server would run
30
+ * fully unmonitored: untracked by the marker, invisible to isServiceRunning(), and free
31
+ * to write to the same database a migration might concurrently believe is safe to touch. */
32
+ export function writeServerPidFile(port) {
33
+ return claimPidMarker(serverPidFilePath(), { port });
34
+ }
35
+ export function removeServerPidFile() {
36
+ releasePidMarker(serverPidFilePath());
37
+ }
@@ -0,0 +1,82 @@
1
+ // packages/server/src/server-liveness.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 { spawn } from "node:child_process";
8
+ /** A genuinely alive, same-user child pid — safe to signal, unlike e.g. pid 1 (which is
9
+ * alive but throws EPERM rather than ESRCH for an unprivileged process.kill probe). */
10
+ function spawnLiveChild() {
11
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
12
+ return { pid: child.pid, kill: () => child.kill("SIGKILL") };
13
+ }
14
+ function freshHome() {
15
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-server-liveness-"));
16
+ }
17
+ const { writeServerPidFile, removeServerPidFile, serverPidFilePath } = await import("./server-liveness.js");
18
+ test("writeServerPidFile claims the marker when none exists", () => {
19
+ freshHome();
20
+ const claimed = writeServerPidFile(3221);
21
+ assert.equal(claimed, true);
22
+ const state = JSON.parse(fs.readFileSync(serverPidFilePath(), "utf8"));
23
+ assert.equal(state.pid, process.pid);
24
+ assert.equal(state.port, 3221);
25
+ });
26
+ test("writeServerPidFile refuses to clobber a marker owned by a different, live pid", () => {
27
+ freshHome();
28
+ const other = spawnLiveChild();
29
+ try {
30
+ const filePath = serverPidFilePath();
31
+ fs.writeFileSync(filePath, JSON.stringify({ pid: other.pid, port: 9999, startedAt: "x" }));
32
+ const claimed = writeServerPidFile(3221);
33
+ assert.equal(claimed, false);
34
+ const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
35
+ assert.equal(state.pid, other.pid, "the other live owner's marker must be untouched");
36
+ }
37
+ finally {
38
+ other.kill();
39
+ }
40
+ });
41
+ test("writeServerPidFile reclaims a stale marker (dead pid)", () => {
42
+ freshHome();
43
+ const filePath = serverPidFilePath();
44
+ fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, port: 9999, startedAt: "x" }));
45
+ const claimed = writeServerPidFile(3221);
46
+ assert.equal(claimed, true);
47
+ const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
48
+ assert.equal(state.pid, process.pid);
49
+ });
50
+ test("writeServerPidFile reclaims an unreadable/corrupt marker", () => {
51
+ freshHome();
52
+ const filePath = serverPidFilePath();
53
+ fs.writeFileSync(filePath, "not json");
54
+ const claimed = writeServerPidFile(3221);
55
+ assert.equal(claimed, true);
56
+ const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
57
+ assert.equal(state.pid, process.pid);
58
+ });
59
+ test("removeServerPidFile only removes a marker that currently names this process", () => {
60
+ freshHome();
61
+ const other = spawnLiveChild();
62
+ try {
63
+ const filePath = serverPidFilePath();
64
+ fs.writeFileSync(filePath, JSON.stringify({ pid: other.pid, port: 9999, startedAt: "x" }));
65
+ removeServerPidFile(); // this process never owned it — a loser's cleanup, essentially
66
+ assert.ok(fs.existsSync(filePath), "a marker owned by a different pid must survive removeServerPidFile()");
67
+ assert.equal(JSON.parse(fs.readFileSync(filePath, "utf8")).pid, other.pid);
68
+ }
69
+ finally {
70
+ other.kill();
71
+ }
72
+ });
73
+ test("removeServerPidFile removes a marker this process does own", () => {
74
+ freshHome();
75
+ writeServerPidFile(3221);
76
+ removeServerPidFile();
77
+ assert.equal(fs.existsSync(serverPidFilePath()), false);
78
+ });
79
+ test("removeServerPidFile on an absent file is a no-op", () => {
80
+ freshHome();
81
+ assert.doesNotThrow(() => removeServerPidFile());
82
+ });
@@ -0,0 +1,44 @@
1
+ // packages/server/src/static-ui.test.ts
2
+ // NOT-142: packaged Fastify must SPA-fallback client routes so reload/deep-link work.
3
+ import { test, after } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import Fastify from "fastify";
9
+ const uiDist = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-static-ui-"));
10
+ fs.writeFileSync(path.join(uiDist, "index.html"), "<!doctype html><html><body>agent-dealer-spa</body></html>\n");
11
+ process.env.AGENT_DEALER_UI_DIST = uiDist;
12
+ const { registerStaticUi } = await import("./static-ui.js");
13
+ after(() => {
14
+ fs.rmSync(uiDist, { recursive: true, force: true });
15
+ delete process.env.AGENT_DEALER_UI_DIST;
16
+ });
17
+ async function buildApp() {
18
+ const app = Fastify();
19
+ app.get("/health", async () => ({ ok: true }));
20
+ app.get("/api/ping", async () => ({ ok: true }));
21
+ await registerStaticUi(app);
22
+ return app;
23
+ }
24
+ test("NOT-142: unknown non-API paths serve index.html for SPA deep links", async () => {
25
+ const app = await buildApp();
26
+ for (const url of ["/issues", "/issues/abc-123", "/agents", "/typo-path"]) {
27
+ const res = await app.inject({ method: "GET", url });
28
+ assert.equal(res.statusCode, 200, url);
29
+ assert.match(res.body, /agent-dealer-spa/, url);
30
+ }
31
+ await app.close();
32
+ });
33
+ test("NOT-142: /api/* and /health keep not-found/health semantics (no SPA catch-all)", async () => {
34
+ const app = await buildApp();
35
+ const health = await app.inject({ method: "GET", url: "/health" });
36
+ assert.equal(health.statusCode, 200);
37
+ assert.deepEqual(health.json(), { ok: true });
38
+ const apiOk = await app.inject({ method: "GET", url: "/api/ping" });
39
+ assert.equal(apiOk.statusCode, 200);
40
+ const apiMissing = await app.inject({ method: "GET", url: "/api/does-not-exist" });
41
+ assert.equal(apiMissing.statusCode, 404);
42
+ assert.deepEqual(apiMissing.json(), { error: "Not found" });
43
+ await app.close();
44
+ });