@desplega.ai/agent-swarm 1.131.0 → 1.133.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-w53mbns9.js → actions-cskpayd7.js} +11 -9
  3. package/dist/{anthropic-messages-rjmd2e7n.js → anthropic-messages-tpxtbx7b.js} +28 -12
  4. package/dist/{app-m9gwqsqv.js → app-ang77nbb.js} +8 -6
  5. package/dist/{assistant-bp65m6f3.js → assistant-b4exebdw.js} +16 -13
  6. package/dist/{azure-openai-responses-c0yjmbfd.js → azure-openai-responses-m3921t4y.js} +8 -8
  7. package/dist/{boot-reembed-62p0at2k.js → boot-reembed-ps2aftpb.js} +9 -7
  8. package/dist/{boot-reembed-xmfp6wdh.js → boot-reembed-znyk3nxb.js} +10 -8
  9. package/dist/{boot-scrub-logs-rr8q2dms.js → boot-scrub-logs-2ax3xgz1.js} +7 -5
  10. package/dist/{claude-adapter-2y47mrkd.js → claude-adapter-3ct8kgxw.js} +13 -9
  11. package/dist/{claude-managed-adapter-gv19tdew.js → claude-managed-adapter-akx4ntf2.js} +2 -2
  12. package/dist/{cli-t1xze5qh.js → cli-13w2hwkh.js} +1 -1
  13. package/dist/{cli-efxfnq6q.js → cli-1532szb1.js} +8 -6
  14. package/dist/{cli-d2tfkcga.js → cli-23d3rseb.js} +33 -17
  15. package/dist/{cli-nm5gtp1x.js → cli-2a75z9c5.js} +196 -7
  16. package/dist/{cli-fywms69x.js → cli-2t30rj0d.js} +1 -1
  17. package/dist/{cli-cvgjwnh1.js → cli-3c3tdgab.js} +10 -10
  18. package/dist/{cli-pm6xbh84.js → cli-3zzxa9jz.js} +46 -16
  19. package/dist/{cli-cdeg08wx.js → cli-4k6233gc.js} +1 -1
  20. package/dist/{cli-t55yw3a1.js → cli-5p2tz05r.js} +2 -2
  21. package/dist/{cli-0rcyq6d9.js → cli-5z2yhzvd.js} +125 -47
  22. package/dist/{cli-3kf51j15.js → cli-6zfq31nj.js} +9 -3
  23. package/dist/{cli-er3a4bb0.js → cli-8m9de10a.js} +6 -1
  24. package/dist/{cli-gm9cbhbq.js → cli-8t14vg9w.js} +27 -8
  25. package/dist/{cli-72q8m8gk.js → cli-9nfc2es8.js} +6 -6
  26. package/dist/{cli-5ze9rv97.js → cli-9ta3pwms.js} +11 -4
  27. package/dist/{cli-78xwvkhj.js → cli-avr0ns7z.js} +2 -2
  28. package/dist/{cli-n6h8qt8n.js → cli-c897wyhw.js} +12 -7
  29. package/dist/{cli-2q72qz2s.js → cli-dgk20ft7.js} +15 -6
  30. package/dist/cli-et1bbwqf.js +13 -0
  31. package/dist/{cli-bqw0mt1n.js → cli-f5w27pj3.js} +3 -3
  32. package/dist/{cli-2wadx7p2.js → cli-f8dre4bz.js} +2 -2
  33. package/dist/{cli-e7ckcg4v.js → cli-ftwpv451.js} +170 -387
  34. package/dist/{cli-g6b8x1h8.js → cli-g854s2dw.js} +1 -1
  35. package/dist/{cli-5kx36evn.js → cli-ggmdxeqa.js} +13 -8
  36. package/dist/cli-hkwfe3qj.js +530 -0
  37. package/dist/{cli-5dsbcjjw.js → cli-jqwnqx9m.js} +1 -1
  38. package/dist/{cli-9dr5qzc0.js → cli-ny122vh4.js} +21 -10
  39. package/dist/{cli-5v4rw6yd.js → cli-pvzqhbs9.js} +2 -2
  40. package/dist/cli-pzq5rjym.js +15 -0
  41. package/dist/{cli-xpxde7rm.js → cli-qes8ef67.js} +48 -30
  42. package/dist/{cli-sajgpkqg.js → cli-qs9hhsvb.js} +1723 -325
  43. package/dist/cli-qys3rhhz.js +27 -0
  44. package/dist/{cli-rzbpmxdr.js → cli-r6tpm37h.js} +2 -2
  45. package/dist/{cli-qv61hp37.js → cli-rch80c76.js} +2 -2
  46. package/dist/{cli-0258203w.js → cli-rpp0x4zy.js} +137 -17
  47. package/dist/{cli-5n6e5yfn.js → cli-s1k0wxhe.js} +12 -7
  48. package/dist/{cli-psh3c4va.js → cli-sfd9v7sj.js} +1 -1
  49. package/dist/{cli-70xqj0z3.js → cli-sh3ytsyj.js} +49 -39
  50. package/dist/{cli-zsspvvm2.js → cli-w1mp9jy7.js} +1 -1
  51. package/dist/{cli-d3x3x1c9.js → cli-xdb4mpnp.js} +2 -2
  52. package/dist/{cli-57khth04.js → cli-xmyqv00z.js} +120 -4
  53. package/dist/{cli-yw8vghx2.js → cli-xv8t8p3c.js} +65 -32
  54. package/dist/{cli-f1am9szv.js → cli-xxv5pn4f.js} +1 -1
  55. package/dist/{cli-t76vw5db.js → cli-y08jvxzx.js} +601 -140
  56. package/dist/{cli-a4jzdpa3.js → cli-zfbf3srj.js} +650 -245
  57. package/dist/{cli-a40rnf71.js → cli-zmq89k34.js} +6 -6
  58. package/dist/{cli-qmdnhpej.js → cli-zr9q011v.js} +23 -13
  59. package/dist/cli.js +22 -22
  60. package/dist/{codex-adapter-d24q75wk.js → codex-adapter-bef93pq4.js} +9 -7
  61. package/dist/{codex-hook-hq54t9ft.js → codex-hook-e8dba65p.js} +3 -2
  62. package/dist/{codex-session-runner-g50vcsex.js → codex-session-runner-5pkxys7x.js} +9 -7
  63. package/dist/{commands-c3nrc5hg.js → commands-e23s1bff.js} +9 -7
  64. package/dist/{db-nt7fg3tw.js → db-m3nabatn.js} +17 -5
  65. package/dist/{google-generative-ai-hysrd8qx.js → google-generative-ai-8maw55xh.js} +11 -8
  66. package/dist/{google-vertex-p5arrga0.js → google-vertex-8vjqmx8g.js} +11 -8
  67. package/dist/{handlers-fkr6swat.js → handlers-akazedvr.js} +16 -13
  68. package/dist/{hook-77wpvnjc.js → hook-ze4cpf0v.js} +55 -41
  69. package/dist/{hook-g019yehy.js → hook-zfqyyenw.js} +10 -7
  70. package/dist/{http-jewj0syk.js → http-sja0n6kw.js} +730 -150
  71. package/dist/index-0gr8vd6k.js +39 -0
  72. package/dist/{index-7n2xpbd4.js → index-354vkhdv.js} +154 -74
  73. package/dist/{index-070v4eyb.js → index-5qthek5t.js} +2 -3
  74. package/dist/{index-hfvggd0z.js → index-95t3cqq5.js} +16 -14
  75. package/dist/{index-9znhar3x.js → index-sea66619.js} +3 -4
  76. package/dist/{index-6gm5he8h.js → index-vpn36mma.js} +15 -13
  77. package/dist/{keepalive-j0fjvqfd.js → keepalive-3sp3meqj.js} +16 -10
  78. package/dist/{lead-1n8x30xe.js → lead-mefeewkf.js} +39 -36
  79. package/dist/{maintenance-gvev11sa.js → maintenance-8tbrbjfg.js} +10 -8
  80. package/dist/mistral-conversations-00zrp7g4.js +717 -0
  81. package/dist/{oauth-refresh-sweep-s9ecsvh0.js → oauth-refresh-sweep-srjwjz6m.js} +8 -6
  82. package/dist/{onboard-pvm5b38w.js → onboard-mt2tgyjg.js} +7 -7
  83. package/dist/{openai-codex-responses-5g27pd6f.js → openai-codex-responses-map32h2s.js} +18 -18
  84. package/dist/{openai-completions-3vkmfz2a.js → openai-completions-w871b1cq.js} +12 -11
  85. package/dist/{openai-responses-dg4t7j7a.js → openai-responses-b0j7xnw8.js} +15 -12
  86. package/dist/{opencode-adapter-1gm38aba.js → opencode-adapter-b39jefcj.js} +5 -3
  87. package/dist/{openrouter-images-g3gm8931.js → openrouter-images-srfkywy0.js} +4 -4
  88. package/dist/{otel-impl-p6jvgd9c.js → otel-impl-z6pxt3j9.js} +224 -9
  89. package/dist/{pi-mono-adapter-t091s27g.js → pi-mono-adapter-btvqmj58.js} +1138 -408
  90. package/dist/{pricing-refresh-k49rcyed.js → pricing-refresh-kyq8cezh.js} +9 -7
  91. package/dist/{rbac-roles-cr80g96t.js → rbac-roles-3ryfrfna.js} +8 -6
  92. package/dist/{rbac-roles-30pspvy7.js → rbac-roles-6qfvbee1.js} +9 -7
  93. package/dist/{render-v2-xm457ng1.js → render-v2-p61ppjey.js} +8 -6
  94. package/dist/{seed-pricing-yhpv35fr.js → seed-pricing-j56bbj4y.js} +8 -6
  95. package/dist/{setup-nytnmwwr.js → setup-tpr6c1kn.js} +2 -2
  96. package/dist/{worker-msytr2cw.js → worker-9w1v0mbb.js} +39 -36
  97. package/openapi.json +542 -16
  98. package/package.json +6 -6
  99. package/src/be/budget-refusal-notify.ts +14 -7
  100. package/src/be/db-queries/task-artifacts.ts +182 -0
  101. package/src/be/db.ts +783 -169
  102. package/src/be/events.ts +5 -0
  103. package/src/be/migrations/130_scratch_script_retention_grace.sql +6 -0
  104. package/src/be/migrations/131_session_logs_created_at_index.sql +2 -0
  105. package/src/be/migrations/132_multi_runtime_instances.sql +44 -0
  106. package/src/be/migrations/133_workflow_run_trigger_type.sql +12 -0
  107. package/src/be/migrations/135_backfill_task_pull_request_attachments.sql +228 -0
  108. package/src/be/migrations/136_task_requester_provenance.sql +37 -0
  109. package/src/be/migrations/137_memory_retrieval_composite_index.sql +1 -0
  110. package/src/be/multi-runtime.ts +430 -0
  111. package/src/be/scripts/db.ts +43 -1
  112. package/src/be/scripts/extract-schema.ts +3 -1
  113. package/src/be/scripts/retention.ts +164 -0
  114. package/src/be/scripts/run-saved.ts +16 -1
  115. package/src/be/scripts/typecheck.ts +3 -2
  116. package/src/be/seed-skills/index.ts +7 -0
  117. package/src/be/steering.ts +1 -1
  118. package/src/be/swarm-config-guard.ts +18 -0
  119. package/src/commands/credential-wait.ts +54 -0
  120. package/src/commands/onboard/steps/core-credentials.tsx +1 -1
  121. package/src/commands/onboard/steps/custom-templates.tsx +1 -1
  122. package/src/commands/onboard/steps/harness-credentials.tsx +1 -1
  123. package/src/commands/onboard/steps/health-check.tsx +1 -1
  124. package/src/commands/onboard/steps/start.tsx +1 -1
  125. package/src/commands/profile-sync-audit.ts +56 -0
  126. package/src/commands/profile-sync.ts +287 -15
  127. package/src/commands/provider-credentials.ts +46 -19
  128. package/src/commands/runner.ts +196 -33
  129. package/src/commands/templates.ts +22 -0
  130. package/src/github/handlers.ts +55 -9
  131. package/src/heartbeat/heartbeat.ts +71 -12
  132. package/src/hooks/hook.ts +49 -33
  133. package/src/http/active-sessions.ts +11 -1
  134. package/src/http/agents.ts +261 -30
  135. package/src/http/all-routes.ts +1 -0
  136. package/src/http/config.ts +13 -2
  137. package/src/http/core.ts +102 -4
  138. package/src/http/db-query-bounded.ts +313 -0
  139. package/src/http/db-query-shared.ts +249 -0
  140. package/src/http/db-query.ts +54 -42
  141. package/src/http/events.ts +2 -0
  142. package/src/http/index.ts +27 -2
  143. package/src/http/mcp-bridge.ts +37 -3
  144. package/src/http/mcp.ts +12 -0
  145. package/src/http/memory.ts +13 -2
  146. package/src/http/openapi.ts +1 -0
  147. package/src/http/poll.ts +70 -27
  148. package/src/http/route-def.ts +43 -9
  149. package/src/http/schedules.ts +2 -2
  150. package/src/http/scripts.ts +31 -4
  151. package/src/http/session-data.ts +44 -0
  152. package/src/http/tasks.ts +2 -2
  153. package/src/http/users.ts +74 -52
  154. package/src/http/webhooks.ts +5 -2
  155. package/src/http/workflows.ts +1 -4
  156. package/src/http/x.ts +14 -0
  157. package/src/integrations/kapso/inbound.ts +1 -1
  158. package/src/jira/outbound.ts +39 -8
  159. package/src/jira/webhook-lifecycle.ts +13 -2
  160. package/src/linear/outbound.ts +43 -10
  161. package/src/oauth/keepalive.ts +13 -2
  162. package/src/prompts/base-prompt.ts +1 -1
  163. package/src/prompts/session-templates.ts +45 -7
  164. package/src/providers/claude-adapter.ts +6 -0
  165. package/src/providers/claude-managed-models.ts +34 -10
  166. package/src/providers/codex-adapter.ts +6 -0
  167. package/src/providers/opencode-adapter.ts +6 -0
  168. package/src/providers/pi-mono-adapter.ts +8 -0
  169. package/src/providers/pricing-sources.md +24 -0
  170. package/src/queue-stall-alarm.ts +216 -0
  171. package/src/scheduler/scheduler.ts +7 -0
  172. package/src/script-workflows/executor.ts +2 -1
  173. package/src/scripts-runtime/executors/native.ts +2 -1
  174. package/src/scripts-runtime/executors/types.ts +5 -0
  175. package/src/scripts-runtime/loader.ts +5 -0
  176. package/src/scripts-runtime/swarm-config.ts +8 -0
  177. package/src/scripts-runtime/swarm-sdk.ts +7 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/tests/budget-claim-gate.test.ts +20 -0
  181. package/src/tests/claude-adapter.test.ts +32 -0
  182. package/src/tests/claude-managed-adapter.test.ts +38 -7
  183. package/src/tests/codex-adapter.test.ts +30 -0
  184. package/src/tests/create-task-validation.test.ts +128 -0
  185. package/src/tests/credential-wait.test.ts +67 -1
  186. package/src/tests/db-query.test.ts +711 -2
  187. package/src/tests/http-api-integration.test.ts +81 -0
  188. package/src/tests/http-users.test.ts +166 -1
  189. package/src/tests/identity-field-budget.test.ts +176 -0
  190. package/src/tests/mcp-tools-user.test.ts +34 -0
  191. package/src/tests/mcp-user-route.test.ts +17 -0
  192. package/src/tests/metrics-http.test.ts +44 -0
  193. package/src/tests/multi-runtime-registration.test.ts +2703 -0
  194. package/src/tests/openapi-response-contract.test.ts +24 -0
  195. package/src/tests/openapi-route-headers.test.ts +83 -0
  196. package/src/tests/opencode-adapter.test.ts +40 -0
  197. package/src/tests/outbound-listener-rejection.test.ts +138 -0
  198. package/src/tests/pi-mono-adapter.test.ts +98 -0
  199. package/src/tests/profile-sync-warning.test.ts +277 -0
  200. package/src/tests/profile-sync.test.ts +232 -6
  201. package/src/tests/prompt-template-session.test.ts +32 -3
  202. package/src/tests/queue-stall-alarm.test.ts +226 -0
  203. package/src/tests/requester-comms.test.ts +32 -0
  204. package/src/tests/runner-requester-profile.test.ts +38 -0
  205. package/src/tests/sandboxed-process.test.ts +45 -0
  206. package/src/tests/schedule-target-type.test.ts +38 -0
  207. package/src/tests/script-connections.test.ts +16 -3
  208. package/src/tests/scripts-external-api.test.ts +8 -3
  209. package/src/tests/scripts-http.test.ts +12 -0
  210. package/src/tests/scripts-retention.test.ts +384 -0
  211. package/src/tests/scripts-runtime-identity.test.ts +375 -0
  212. package/src/tests/scripts-runtime.test.ts +4 -3
  213. package/src/tests/sdk-allowlist.test.ts +47 -0
  214. package/src/tests/send-task-requested-by.test.ts +9 -0
  215. package/src/tests/session-costs.test.ts +506 -1
  216. package/src/tests/shutdown-error-scrubbing.test.ts +92 -0
  217. package/src/tests/steering-core.test.ts +28 -0
  218. package/src/tests/task-artifacts.test.ts +386 -0
  219. package/src/tests/task-attachments-schema.test.ts +17 -1
  220. package/src/tests/task-completion-idempotency.test.ts +57 -0
  221. package/src/tests/task-pull-request-backfill-migration.test.ts +321 -0
  222. package/src/tests/update-profile-api.test.ts +67 -39
  223. package/src/tests/update-profile-auth.test.ts +48 -4
  224. package/src/tests/user-attribution-migration.test.ts +54 -0
  225. package/src/tests/user-identity.test.ts +72 -0
  226. package/src/tests/workflow-async-v2.test.ts +39 -1
  227. package/src/tests/workflow-http-v2.test.ts +3 -2
  228. package/src/tests/workflow-mcp-trigger-schema.test.ts +52 -3
  229. package/src/tests/workflow-retry-v2.test.ts +45 -1
  230. package/src/tests/workflow-swarm-script.test.ts +14 -5
  231. package/src/tools/db-query.ts +18 -8
  232. package/src/tools/get-task-details.ts +24 -3
  233. package/src/tools/manage-user.ts +36 -3
  234. package/src/tools/poll-task.ts +93 -14
  235. package/src/tools/script-common.ts +5 -0
  236. package/src/tools/send-task.ts +1 -4
  237. package/src/tools/store-progress.ts +13 -2
  238. package/src/tools/swarm-config/delete-config.ts +9 -0
  239. package/src/tools/swarm-config/set-config.ts +3 -2
  240. package/src/tools/task-action.ts +30 -0
  241. package/src/tools/task-tool-ctx.ts +8 -1
  242. package/src/tools/update-profile.ts +16 -21
  243. package/src/tools/utils.ts +5 -0
  244. package/src/tools/workflows/trigger-workflow.ts +7 -1
  245. package/src/types.ts +165 -1
  246. package/src/utils/github-pull-request.ts +76 -0
  247. package/src/utils/identity-field-budget.ts +73 -0
  248. package/src/utils/multi-runtime.ts +20 -0
  249. package/src/utils/requester-comms.ts +24 -0
  250. package/src/utils/sandboxed-process.ts +40 -14
  251. package/src/workflows/engine.ts +22 -2
  252. package/src/workflows/executors/swarm-script.ts +24 -1
  253. package/src/workflows/resume.ts +19 -11
  254. package/src/workflows/retry-poller.ts +1 -0
  255. package/src/workflows/triggers.ts +4 -0
  256. package/templates/schedules/daily-compounding-reflection/content.md +12 -3
  257. package/templates/skills/db-query-guidance/config.json +14 -0
  258. package/templates/skills/db-query-guidance/content.md +57 -0
  259. package/dist/cli-3te4jdkm.js +0 -225
  260. package/dist/cli-bmze986d.js +0 -7
  261. package/dist/cli-kh3ba2zf.js +0 -607
  262. package/dist/index-hp1kdbjj.js +0 -36
  263. package/dist/mistral-conversations-81fwav0j.js +0 -30578
  264. package/dist/otel-nqa12wf3.js +0 -3149
  265. package/dist/{pi-messages-tqj8q6b9.js → pi-messages-sssy32x4.js} +3 -3
@@ -1,5 +1,29 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { DbQueryInputSchema, resolveDbQuerySql } from "../http/db-query";
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createServer as createHttpServer, type Server } from "node:http";
3
+ import {
4
+ __resetSqliteVecExtensionPathCacheForTests,
5
+ closeDb,
6
+ getDb,
7
+ initDb,
8
+ resolveSqliteVecExtensionPath,
9
+ } from "../be/db";
10
+ import {
11
+ DbQueryInputSchema,
12
+ executeReadOnlyQueryGated,
13
+ handleDbQuery,
14
+ resolveDbQuerySql,
15
+ } from "../http/db-query";
16
+ import { executeReadOnlyQueryBounded, isReportableTimeout } from "../http/db-query-bounded";
17
+ import {
18
+ getDbQueryConcurrencyCap,
19
+ getDbQueryHttpBudgetMs,
20
+ getDbQueryHttpMaxRows,
21
+ getDbQueryMcpBudgetMs,
22
+ getDbQueryMcpMaxRows,
23
+ resetDbQueryBoundedWarningForTests,
24
+ resetDbQuerySpawnUnavailableWarningForTests,
25
+ } from "../http/db-query-shared";
26
+ import { getPathSegments, parseQueryParams } from "../http/utils";
3
27
 
4
28
  describe("db-query input compatibility", () => {
5
29
  test("canonical sql input resolves to sql", () => {
@@ -26,3 +50,688 @@ describe("db-query input compatibility", () => {
26
50
  expect(parsed.success).toBe(false);
27
51
  });
28
52
  });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Fix 1 — bounded child-process execution (proposal §5.1, Tests A-F).
56
+ // Needs a real on-disk database: the bounded path spawns a separate `bun -e`
57
+ // process that opens its own connection, so an in-memory/deserialized test
58
+ // template (invisible outside this process) won't work. This swaps out the
59
+ // fast in-memory `__testMigrationTemplate` for a real file for the duration
60
+ // of this describe block, following the pattern in asset-key-migration.test.ts.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const BOUNDED_TEST_DB_PATH = "./test-db-query-bounded.sqlite";
64
+ const HTTP_TEST_PORT = 13097;
65
+
66
+ // A CPU-bound query with near-zero fixture setup cost (no table/data needed)
67
+ // and deterministic timing that doesn't depend on disk cache state, unlike
68
+ // the proposal's own I/O-bound synthetic table. Reliably takes >1s in this
69
+ // sandbox; must stay a SELECT so the read-only guard lets it through.
70
+ const SLOW_QUERY = `
71
+ WITH RECURSIVE cnt(x) AS (
72
+ SELECT 1
73
+ UNION ALL
74
+ SELECT x + 1 FROM cnt WHERE x < 6000000
75
+ )
76
+ SELECT COUNT(*) AS c FROM cnt WHERE (x * x) % 998244353 = 12345
77
+ `;
78
+
79
+ const boundedTestGlobals = globalThis as typeof globalThis & {
80
+ __testMigrationTemplate?: Uint8Array;
81
+ __savedDbQueryBoundedTemplate?: Uint8Array;
82
+ };
83
+
84
+ /** Starts handleDbQuery on HTTP_TEST_PORT for the duration of `fn`, exposing a small `post` helper. */
85
+ async function withDbQueryHttpServer<T>(
86
+ fn: (post: (body: unknown) => Promise<{ status: number; body: DbQueryHttpBody }>) => Promise<T>,
87
+ ): Promise<T> {
88
+ let server: Server | undefined;
89
+ try {
90
+ server = createHttpServer(async (req, res) => {
91
+ const pathSegments = getPathSegments(req.url || "");
92
+ const queryParams = parseQueryParams(req.url || "");
93
+ const handled = await handleDbQuery(req, res, pathSegments, queryParams);
94
+ if (!handled) {
95
+ res.writeHead(404);
96
+ res.end();
97
+ }
98
+ });
99
+ await new Promise<void>((resolve) => server?.listen(HTTP_TEST_PORT, () => resolve()));
100
+
101
+ const post = async (body: unknown) => {
102
+ const res = await fetch(`http://localhost:${HTTP_TEST_PORT}/api/db-query`, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify(body),
106
+ });
107
+ return { status: res.status, body: (await res.json()) as DbQueryHttpBody };
108
+ };
109
+ return await fn(post);
110
+ } finally {
111
+ await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
112
+ }
113
+ }
114
+
115
+ interface DbQueryHttpBody {
116
+ rows?: unknown[][];
117
+ total?: number;
118
+ error?: string;
119
+ message?: string;
120
+ }
121
+
122
+ /** Env keys the flag/budget-override tests touch — reset after each so tests don't leak into each other. */
123
+ const DB_QUERY_OVERRIDE_ENV_KEYS = [
124
+ "DB_QUERY_BOUNDED_ENABLED",
125
+ "DB_QUERY_HTTP_BUDGET_MS",
126
+ "DB_QUERY_HTTP_MAX_ROWS",
127
+ "DB_QUERY_MCP_BUDGET_MS",
128
+ "DB_QUERY_MCP_MAX_ROWS",
129
+ "DB_QUERY_CONCURRENCY_CAP",
130
+ ] as const;
131
+
132
+ async function removeBoundedTestDb(): Promise<void> {
133
+ for (const suffix of ["", "-wal", "-shm"]) {
134
+ try {
135
+ await Bun.file(`${BOUNDED_TEST_DB_PATH}${suffix}`).delete();
136
+ } catch {}
137
+ }
138
+ }
139
+
140
+ describe("db-query bounded execution (Fix 1)", () => {
141
+ beforeAll(async () => {
142
+ boundedTestGlobals.__savedDbQueryBoundedTemplate = boundedTestGlobals.__testMigrationTemplate;
143
+ boundedTestGlobals.__testMigrationTemplate = undefined;
144
+ closeDb();
145
+ await removeBoundedTestDb();
146
+ initDb(BOUNDED_TEST_DB_PATH);
147
+ });
148
+
149
+ afterAll(async () => {
150
+ closeDb();
151
+ boundedTestGlobals.__testMigrationTemplate = boundedTestGlobals.__savedDbQueryBoundedTemplate;
152
+ boundedTestGlobals.__savedDbQueryBoundedTemplate = undefined;
153
+ await removeBoundedTestDb();
154
+ });
155
+
156
+ afterEach(() => {
157
+ for (const key of DB_QUERY_OVERRIDE_ENV_KEYS) delete process.env[key];
158
+ resetDbQueryBoundedWarningForTests();
159
+ resetDbQuerySpawnUnavailableWarningForTests();
160
+ });
161
+
162
+ // Test A: budget is enforced — fails today because there is no budget
163
+ // parameter or timeout path; the synchronous call only returns after the
164
+ // full scan.
165
+ test("A: rejects with a timeout error once the budget expires, well under 1s wall time", async () => {
166
+ const start = performance.now();
167
+ await expect(executeReadOnlyQueryBounded(SLOW_QUERY, [], 200)).rejects.toThrow(/budget/i);
168
+ expect(performance.now() - start).toBeLessThan(1000);
169
+ });
170
+
171
+ // Test B: the event loop stays responsive — fails today because the
172
+ // interval fires about once regardless of query duration. This is the
173
+ // assertion that encodes the actual defect.
174
+ test("B: keeps a 50ms heartbeat ticking at close to its expected rate while the child runs", async () => {
175
+ const heartbeatMs = 50;
176
+ let ticks = 0;
177
+ const heartbeat = setInterval(() => {
178
+ ticks++;
179
+ }, heartbeatMs);
180
+
181
+ const start = performance.now();
182
+ await executeReadOnlyQueryBounded(SLOW_QUERY, [], 10_000);
183
+ const elapsed = performance.now() - start;
184
+ clearInterval(heartbeat);
185
+
186
+ const expectedTicks = elapsed / heartbeatMs;
187
+ expect(ticks).toBeGreaterThanOrEqual(expectedTicks * 0.6);
188
+ });
189
+
190
+ // Review round 3, thread 3814484703 (Oscar): the concurrency cap
191
+ // (acquireBoundedQuerySlot/releaseBoundedQuerySlot in db-query-bounded.ts)
192
+ // had no coverage. acquireBoundedQuerySlot() runs synchronously, before the
193
+ // child process is even spawned, so firing DB_QUERY_CONCURRENCY_CAP + 1
194
+ // calls back-to-back (no await between them) guarantees all cap checks run
195
+ // in one JS tick, in order — no real query slowness is needed to keep every
196
+ // slot "occupied" at the moment of the check, so a cheap query keeps this
197
+ // deterministic instead of racing 9 concurrent CPU-heavy child processes
198
+ // against the sandbox's resources (a SLOW_QUERY-based version of this test
199
+ // was flaky here: concurrent recursive-CTE children intermittently hit
200
+ // "database is locked" or got signal-killed under load). Exactly one call
201
+ // must be rejected with the cap error, the other DB_QUERY_CONCURRENCY_CAP
202
+ // must resolve. The regression this guards against is subtle — swap
203
+ // acquire/try ordering, or drop the finally, and the cap silently becomes a
204
+ // permanent lockout after one rejection instead of a transient one, which
205
+ // the final assertion below catches.
206
+ test("O: concurrency cap rejects exactly one caller past the limit, and releases slots once all settle", async () => {
207
+ const cap = getDbQueryConcurrencyCap();
208
+ const calls = Array.from({ length: cap + 1 }, () =>
209
+ executeReadOnlyQueryBounded("SELECT 1", [], 10_000),
210
+ );
211
+ const settled = await Promise.allSettled(calls);
212
+
213
+ const rejected = settled.filter(
214
+ (outcome): outcome is PromiseRejectedResult => outcome.status === "rejected",
215
+ );
216
+ const fulfilled = settled.filter((outcome) => outcome.status === "fulfilled");
217
+ expect(rejected.length).toBe(1);
218
+ expect(fulfilled.length).toBe(cap);
219
+ expect((rejected[0].reason as Error).message).toMatch(/Too many concurrent/);
220
+
221
+ // The assertion Oscar said he'd insist on: slots are released, not
222
+ // permanently consumed by the rejection above. A caller landing here
223
+ // after the batch settles must succeed normally.
224
+ const result = await executeReadOnlyQueryBounded("SELECT 1", [], 10_000);
225
+ expect(result.rows.length).toBe(1);
226
+ });
227
+
228
+ // Review round 3, thread 3814484711 (Oscar): neither half of
229
+ // `isReportableTimeout`'s `timedOut && !(exitCode === 0 && stdout.length >
230
+ // 0)` was pinned. Test A already asserts the /budget/i message for a
231
+ // genuinely killed child (timedOut=true, non-zero exit) — that is the
232
+ // second case Oscar named, covered without duplicating it here. Unit-test
233
+ // the extracted predicate directly rather than racing real process timing
234
+ // (the completion-vs-kill race is not reproducible on demand): this pins
235
+ // both branches deterministically, including the exact boundary — a
236
+ // fired timer whose child still produced a clean, non-empty result must
237
+ // not be reported as a timeout.
238
+ describe("isReportableTimeout (src/http/db-query-bounded.ts)", () => {
239
+ test("a fired timer with a clean, non-empty result is not a timeout", () => {
240
+ expect(isReportableTimeout(true, 0, '{"columns":[],"rows":[]}')).toBe(false);
241
+ });
242
+
243
+ test("a fired timer with no successful output is a timeout", () => {
244
+ expect(isReportableTimeout(true, 1, "")).toBe(true);
245
+ });
246
+
247
+ test("a fired timer with exit code 0 but empty stdout is still a timeout", () => {
248
+ expect(isReportableTimeout(true, 0, "")).toBe(true);
249
+ });
250
+
251
+ test("the timer never firing is never a timeout, regardless of exit code", () => {
252
+ expect(isReportableTimeout(false, 1, "")).toBe(false);
253
+ });
254
+ });
255
+
256
+ // Test C: `total` semantics do not move — this is the regression guard for
257
+ // src/tools/db-query.ts:41 and src/http/metrics.ts:168, which compare
258
+ // `total` against the delivered row count to compute `truncated`. Must
259
+ // keep passing: the bounded path materializes fully before capping, same
260
+ // as the synchronous path.
261
+ test("C: total reflects rows returned, not rows delivered, when capped", async () => {
262
+ const db = getDb();
263
+ db.run("CREATE TABLE cap_test (id INTEGER PRIMARY KEY)");
264
+ const insert = db.prepare("INSERT INTO cap_test DEFAULT VALUES");
265
+ for (let i = 0; i < 250; i++) insert.run();
266
+
267
+ const result = await executeReadOnlyQueryBounded("SELECT id FROM cap_test", [], 5000, 100);
268
+ expect(result.total).toBe(250);
269
+ expect(result.rows.length).toBe(100);
270
+ });
271
+
272
+ // Test D: Codex review, PR #1192 thread 3815732008 — before this fix, the
273
+ // child mapped and JSON.stringified every matched row before the parent
274
+ // ever saw `maxRows`, then the parent sliced the already-serialized array.
275
+ // A query against a large table transferred and parsed a payload sized by
276
+ // the full match count, not by maxRows, even though both callers always
277
+ // supply one. The child now caps `rowArrays` before serializing (see
278
+ // CHILD_SCRIPT in db-query-bounded.ts) — this pins that at a scale where
279
+ // the two behave differently: `total` keeps the full match count, but the
280
+ // array that actually crosses the child/parent boundary is bounded by
281
+ // maxRows.
282
+ test("D: caps the serialized payload at maxRows, not at the full match count, for a large result set", async () => {
283
+ const db = getDb();
284
+ db.run("CREATE TABLE codex_cap_test (id INTEGER PRIMARY KEY)");
285
+ const insert = db.prepare("INSERT INTO codex_cap_test DEFAULT VALUES");
286
+ for (let i = 0; i < 20_000; i++) insert.run();
287
+
288
+ const result = await executeReadOnlyQueryBounded("SELECT id FROM codex_cap_test", [], 5000, 3);
289
+ expect(result.total).toBe(20_000);
290
+ expect(result.rows).toEqual([[1], [2], [3]]);
291
+ });
292
+
293
+ // Test T: Codex review, PR #1192 thread 3816302384 — bun:sqlite returns a
294
+ // BLOB column (e.g. memories.embedding) as a Uint8Array, but JSON.stringify
295
+ // on a Uint8Array serializes it as an indexed object ({"0":1,"1":2,...}),
296
+ // not a real array, since TypedArrays have no toJSON. Before the fix, the
297
+ // parent's JSON.parse handed the MCP table renderer that indexed object,
298
+ // and `String(v)` on it produced "[object Object]" instead of the
299
+ // comma-joined byte list a raw Uint8Array's own String() produces. The
300
+ // child now converts BLOB values to a plain Array before writing stdout, so
301
+ // `String()` on the round-tripped value matches `String()` on the original
302
+ // bytes.
303
+ test("T: BLOB columns round-trip as byte arrays, not JSON-object mush, across the child boundary", async () => {
304
+ const db = getDb();
305
+ db.run("CREATE TABLE blob_test (id INTEGER PRIMARY KEY, payload BLOB)");
306
+ const bytes = new Uint8Array([1, 2, 255, 0, 128]);
307
+ db.prepare("INSERT INTO blob_test (id, payload) VALUES (1, ?)").run(bytes);
308
+
309
+ const result = await executeReadOnlyQueryBounded("SELECT payload FROM blob_test", [], 5000);
310
+ const [payload] = result.rows[0] as [unknown];
311
+
312
+ expect(Array.isArray(payload)).toBe(true);
313
+ expect(payload).toEqual(Array.from(bytes));
314
+ expect(String(payload)).toBe(String(bytes));
315
+ expect(String(payload)).not.toBe("[object Object]");
316
+ });
317
+
318
+ // Test E: the read-only guard survives the new path — passes today via a
319
+ // different code path (the synchronous columnNames check); must keep
320
+ // passing with the exact same error message through the child process.
321
+ test("E: rejects a write with the exact pre-existing error message", async () => {
322
+ const db = getDb();
323
+ db.run("CREATE TABLE guard_test (id INTEGER PRIMARY KEY)");
324
+
325
+ await expect(executeReadOnlyQueryBounded("DELETE FROM guard_test", [], 5000)).rejects.toThrow(
326
+ "Only read-only queries are allowed",
327
+ );
328
+ });
329
+
330
+ // Test F: the HTTP route is capped — fails today because
331
+ // src/http/db-query.ts's handler passes no cap at all, so every row comes
332
+ // back.
333
+ test("F: /api/db-query caps rows at the new default and still reports the true total", async () => {
334
+ const db = getDb();
335
+ db.run("CREATE TABLE http_cap_test (id INTEGER PRIMARY KEY)");
336
+ const insert = db.prepare("INSERT INTO http_cap_test DEFAULT VALUES");
337
+ for (let i = 0; i < 1500; i++) insert.run();
338
+
339
+ let server: Server | undefined;
340
+ try {
341
+ server = createHttpServer(async (req, res) => {
342
+ const pathSegments = getPathSegments(req.url || "");
343
+ const queryParams = parseQueryParams(req.url || "");
344
+ const handled = await handleDbQuery(req, res, pathSegments, queryParams);
345
+ if (!handled) {
346
+ res.writeHead(404);
347
+ res.end();
348
+ }
349
+ });
350
+ await new Promise<void>((resolve) => server?.listen(HTTP_TEST_PORT, () => resolve()));
351
+
352
+ const res = await fetch(`http://localhost:${HTTP_TEST_PORT}/api/db-query`, {
353
+ method: "POST",
354
+ headers: { "Content-Type": "application/json" },
355
+ body: JSON.stringify({ sql: "SELECT id FROM http_cap_test", params: [] }),
356
+ });
357
+ expect(res.status).toBe(200);
358
+ const body = (await res.json()) as { rows: unknown[][]; total: number };
359
+ expect(body.rows.length).toBe(1000);
360
+ expect(body.total).toBeGreaterThan(1000);
361
+ } finally {
362
+ await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
363
+ }
364
+ });
365
+
366
+ // Regression guard for src/http/db-query-bounded.ts:148-150 — a non-write
367
+ // error must propagate the child's stderr as-is, not just the
368
+ // WRITE_REJECTED_EXIT_CODE path (Test E already covers that one).
369
+ // Reviewer 2's non-blocking suggestion on PR #87.
370
+ test("N: propagates the child's stderr on a non-write, non-zero exit (a SQL error)", async () => {
371
+ await expect(
372
+ executeReadOnlyQueryBounded("SELECT * FROM this_table_does_not_exist_xyz", [], 5000),
373
+ ).rejects.toThrow(/no such table/i);
374
+ });
375
+
376
+ // Test P: Codex review, PR #1192 thread 3815732016 — the standalone
377
+ // `dist/agent-swarm` binary (`bun run build:binary`) embeds the Bun
378
+ // runtime for its own execution but does not bundle a separate spawnable
379
+ // `bun` binary, so Bun.spawn(["bun", ...]) throws "Executable not found in
380
+ // $PATH" on a host that lacks one. Falls back to the synchronous
381
+ // in-process path instead of failing every db-query outright.
382
+ test("P: falls back to the synchronous in-process path when no bun executable can be spawned", async () => {
383
+ const originalSpawn = Bun.spawn;
384
+ Bun.spawn = (() => {
385
+ throw new Error('Executable not found in $PATH: "bun"');
386
+ }) as typeof Bun.spawn;
387
+
388
+ try {
389
+ const result = await executeReadOnlyQueryBounded("SELECT 1 AS one", [], 5000);
390
+ expect(result.rows).toEqual([[1]]);
391
+ expect(result.total).toBe(1);
392
+ } finally {
393
+ Bun.spawn = originalSpawn;
394
+ }
395
+ });
396
+
397
+ // Test Q: the spawn-failure fallback above must not swallow an unrelated
398
+ // spawn error (e.g. a resource-exhaustion condition) by silently routing
399
+ // it to the unbounded in-process path — only a missing-executable error
400
+ // should trigger the fallback.
401
+ test("Q: does not swallow a genuine spawn error unrelated to a missing executable", async () => {
402
+ const originalSpawn = Bun.spawn;
403
+ Bun.spawn = (() => {
404
+ throw new Error("EMFILE: too many open files");
405
+ }) as typeof Bun.spawn;
406
+
407
+ try {
408
+ await expect(executeReadOnlyQueryBounded("SELECT 1", [], 5000)).rejects.toThrow(
409
+ "EMFILE: too many open files",
410
+ );
411
+ } finally {
412
+ Bun.spawn = originalSpawn;
413
+ }
414
+ });
415
+
416
+ // -------------------------------------------------------------------------
417
+ // Feature-flag addendum (follow-up to Fix 1, PR #87): DB_QUERY_BOUNDED_ENABLED
418
+ // kill switch + DB_QUERY_HTTP_BUDGET_MS / DB_QUERY_HTTP_MAX_ROWS /
419
+ // DB_QUERY_MCP_BUDGET_MS overrides. See the plan doc addendum for the design.
420
+ // -------------------------------------------------------------------------
421
+
422
+ test("defaults apply when unset, and an invalid override falls back to the default", () => {
423
+ expect(getDbQueryHttpBudgetMs()).toBe(10_000);
424
+ expect(getDbQueryHttpMaxRows()).toBe(1000);
425
+ expect(getDbQueryMcpBudgetMs()).toBe(5_000);
426
+ expect(getDbQueryMcpMaxRows()).toBe(100);
427
+
428
+ process.env.DB_QUERY_HTTP_BUDGET_MS = "-5";
429
+ expect(getDbQueryHttpBudgetMs()).toBe(10_000);
430
+
431
+ process.env.DB_QUERY_HTTP_MAX_ROWS = "not-a-number";
432
+ expect(getDbQueryHttpMaxRows()).toBe(1000);
433
+
434
+ process.env.DB_QUERY_MCP_MAX_ROWS = "not-a-number";
435
+ expect(getDbQueryMcpMaxRows()).toBe(100);
436
+ });
437
+
438
+ // Review round 4 (desplega-bot, pullrequestreview-4975616777), non-blocking
439
+ // item on db-query-shared.ts:55: readPositiveIntEnv read raw process.env
440
+ // directly, bypassing the `^\d+$` integer-only check
441
+ // swarm-config-guard.ts's integerValidators applies to the same keys when
442
+ // set through swarm_config — a fraction or a value past Node/Bun's 32-bit
443
+ // setTimeout limit set directly as an env var sailed through and could
444
+ // fire the timer almost immediately instead of waiting that long. These
445
+ // are exactly the "direct-env" cases the DB-backed validator can't catch.
446
+ test("a fractional budget is rejected, not truncated", () => {
447
+ process.env.DB_QUERY_HTTP_BUDGET_MS = "500.5";
448
+ expect(getDbQueryHttpBudgetMs()).toBe(10_000);
449
+
450
+ process.env.DB_QUERY_MCP_BUDGET_MS = "5000.9";
451
+ expect(getDbQueryMcpBudgetMs()).toBe(5_000);
452
+ });
453
+
454
+ test("a budget above Node/Bun's 32-bit setTimeout limit falls back to the default", () => {
455
+ process.env.DB_QUERY_HTTP_BUDGET_MS = "9999999999";
456
+ expect(getDbQueryHttpBudgetMs()).toBe(10_000);
457
+ });
458
+
459
+ test("a budget exactly at the 32-bit setTimeout limit is accepted", () => {
460
+ process.env.DB_QUERY_HTTP_BUDGET_MS = "2147483647";
461
+ expect(getDbQueryHttpBudgetMs()).toBe(2_147_483_647);
462
+ });
463
+
464
+ // G: flag ON (default, unset) — the gate still runs the bounded path and
465
+ // enforces the budget, same as calling executeReadOnlyQueryBounded directly.
466
+ test("G: gated executor runs the bounded path by default and still enforces the budget", async () => {
467
+ await expect(executeReadOnlyQueryGated(SLOW_QUERY, [], 200)).rejects.toThrow(/budget/i);
468
+ });
469
+
470
+ // H: flag explicitly "true" behaves the same as unset.
471
+ test("H: DB_QUERY_BOUNDED_ENABLED=true behaves the same as unset", async () => {
472
+ process.env.DB_QUERY_BOUNDED_ENABLED = "true";
473
+ await expect(executeReadOnlyQueryGated(SLOW_QUERY, [], 200)).rejects.toThrow(/budget/i);
474
+ });
475
+
476
+ // I: flag OFF — the gate must fall back to the legacy, unbounded in-process
477
+ // path. A budget that would trip the bounded path must be ignored entirely,
478
+ // not just extended, and the query must complete normally.
479
+ test("I: DB_QUERY_BOUNDED_ENABLED=false runs the legacy in-process path, ignoring the budget", async () => {
480
+ process.env.DB_QUERY_BOUNDED_ENABLED = "false";
481
+ const result = await executeReadOnlyQueryGated(SLOW_QUERY, [], 200);
482
+ expect(result.rows.length).toBe(1);
483
+ });
484
+
485
+ // J: HTTP route, flag OFF — still caps rows via the legacy path (the cap is
486
+ // applied by executeReadOnlyQuery too, not just the bounded executor), and
487
+ // must not throw despite a budget that would trip the bounded path.
488
+ test("J: /api/db-query with DB_QUERY_BOUNDED_ENABLED=false still caps rows via the legacy path", async () => {
489
+ process.env.DB_QUERY_BOUNDED_ENABLED = "false";
490
+ process.env.DB_QUERY_HTTP_BUDGET_MS = "1";
491
+
492
+ const { status, body } = await withDbQueryHttpServer((post) =>
493
+ post({ sql: "SELECT id FROM http_cap_test", params: [] }),
494
+ );
495
+ expect(status).toBe(200);
496
+ expect(body.rows?.length).toBe(1000);
497
+ expect(body.total).toBeGreaterThan(1000);
498
+ });
499
+
500
+ // K: DB_QUERY_HTTP_BUDGET_MS actually reaches the bounded executor via the
501
+ // HTTP route — a tightened budget trips the timeout on a query that would
502
+ // otherwise pass under the 10s default. Review round 4 (desplega-bot,
503
+ // pullrequestreview-4975616777): a timeout must return a distinct,
504
+ // documented status (408) with a stable machine-readable code, not the
505
+ // same 400 every other error on this route returns.
506
+ test("K: DB_QUERY_HTTP_BUDGET_MS overrides the bounded HTTP budget, returning a distinct 408", async () => {
507
+ process.env.DB_QUERY_HTTP_BUDGET_MS = "150";
508
+ expect(getDbQueryHttpBudgetMs()).toBe(150);
509
+
510
+ const { status, body } = await withDbQueryHttpServer((post) =>
511
+ post({ sql: SLOW_QUERY, params: [] }),
512
+ );
513
+ expect(status).toBe(408);
514
+ expect(body.error).toBe("db_query_timeout");
515
+ expect(body.message).toMatch(/150ms budget/);
516
+ });
517
+
518
+ // Review round 4 (desplega-bot, pullrequestreview-4975616777): the
519
+ // concurrency cap was also collapsed into the generic 400. Saturate the
520
+ // cap with slots that never release (SELECT 1 calls made without an
521
+ // `await` between them so the acquire checks all run in the same tick —
522
+ // same technique as test O), then confirm the one HTTP call that lands on
523
+ // top gets 429 with a stable code and a Retry-After header instead of 400.
524
+ test("caps saturation via the HTTP route returns 429 with a stable code and Retry-After", async () => {
525
+ const fillers = Array.from({ length: getDbQueryConcurrencyCap() }, () =>
526
+ executeReadOnlyQueryBounded("SELECT 1", [], 10_000),
527
+ );
528
+
529
+ const { status, headers, body } = await withDbQueryHttpServer(async (_post) => {
530
+ const res = await fetch(`http://localhost:${HTTP_TEST_PORT}/api/db-query`, {
531
+ method: "POST",
532
+ headers: { "Content-Type": "application/json" },
533
+ body: JSON.stringify({ sql: "SELECT 1", params: [] }),
534
+ });
535
+ return {
536
+ status: res.status,
537
+ headers: res.headers,
538
+ body: (await res.json()) as DbQueryHttpBody,
539
+ };
540
+ });
541
+
542
+ expect(status).toBe(429);
543
+ expect(body.error).toBe("db_query_concurrency_cap");
544
+ expect(headers.get("retry-after")).not.toBeNull();
545
+
546
+ await Promise.allSettled(fillers);
547
+ });
548
+
549
+ // L: DB_QUERY_HTTP_MAX_ROWS actually reaches the HTTP route's row cap.
550
+ test("L: DB_QUERY_HTTP_MAX_ROWS overrides the HTTP row cap", async () => {
551
+ process.env.DB_QUERY_HTTP_MAX_ROWS = "5";
552
+ expect(getDbQueryHttpMaxRows()).toBe(5);
553
+
554
+ const { status, body } = await withDbQueryHttpServer((post) =>
555
+ post({ sql: "SELECT id FROM http_cap_test", params: [] }),
556
+ );
557
+ expect(status).toBe(200);
558
+ expect(body.rows?.length).toBe(5);
559
+ expect(body.total).toBeGreaterThan(5);
560
+ });
561
+
562
+ // M: DB_QUERY_MCP_BUDGET_MS actually reaches the bounded executor on the
563
+ // MCP tool's path (same gated executor the HTTP route uses, with the MCP
564
+ // budget getter instead of the HTTP one).
565
+ test("M: DB_QUERY_MCP_BUDGET_MS overrides the MCP query budget", async () => {
566
+ process.env.DB_QUERY_MCP_BUDGET_MS = "150";
567
+ expect(getDbQueryMcpBudgetMs()).toBe(150);
568
+
569
+ await expect(
570
+ executeReadOnlyQueryGated(SLOW_QUERY, [], getDbQueryMcpBudgetMs()),
571
+ ).rejects.toThrow(/150ms budget/);
572
+ });
573
+
574
+ // U: Codex review, PR #1192 thread 3816146995 — the concurrency cap must be
575
+ // an operator-tunable knob, not a hardcoded constant, so a deployment with
576
+ // more headroom than the 1 GiB default sizing assumes can raise it (and one
577
+ // with less can lower it) without a code change.
578
+ test("U: DB_QUERY_CONCURRENCY_CAP overrides the concurrency cap", async () => {
579
+ process.env.DB_QUERY_CONCURRENCY_CAP = "2";
580
+ expect(getDbQueryConcurrencyCap()).toBe(2);
581
+
582
+ const calls = Array.from({ length: 3 }, () =>
583
+ executeReadOnlyQueryBounded("SELECT 1", [], 10_000),
584
+ );
585
+ const settled = await Promise.allSettled(calls);
586
+ const rejected = settled.filter((outcome) => outcome.status === "rejected");
587
+ expect(rejected.length).toBe(1);
588
+ });
589
+
590
+ // V: review round 3 (tarasyarema, threads 3819136863/3819139208) — the MCP
591
+ // tool's row cap was a hardcoded 100 with no operator override, unlike every
592
+ // other budget/cap this PR made tunable. DB_QUERY_MCP_MAX_ROWS must actually
593
+ // reach the gated executor the MCP tool calls, the same way L proves
594
+ // DB_QUERY_HTTP_MAX_ROWS reaches the HTTP route's row cap.
595
+ test("V: DB_QUERY_MCP_MAX_ROWS overrides the MCP row cap", async () => {
596
+ process.env.DB_QUERY_MCP_MAX_ROWS = "5";
597
+ expect(getDbQueryMcpMaxRows()).toBe(5);
598
+
599
+ const result = await executeReadOnlyQueryGated(
600
+ "SELECT id FROM http_cap_test",
601
+ [],
602
+ getDbQueryMcpBudgetMs(),
603
+ getDbQueryMcpMaxRows(),
604
+ );
605
+ expect(result.rows.length).toBe(5);
606
+ expect(result.total).toBeGreaterThan(5);
607
+ });
608
+
609
+ // Review round 4 (desplega-bot, pullrequestreview-4975616777), non-blocking
610
+ // item on db-query-bounded.ts:58: the child's read-only connection set no
611
+ // busy_timeout at all (SQLite's default is 0 — SQLITE_BUSY fails
612
+ // immediately on any lock), independent of the wall-clock budget the
613
+ // caller asked for. The child now sets `PRAGMA busy_timeout` to the same
614
+ // budgetMs used for the parent's SIGKILL timer. Exercised with a real
615
+ // lock: `PRAGMA locking_mode=EXCLUSIVE` is the one way to force a genuine
616
+ // SQLITE_BUSY against a *reader* (ordinary WAL writers don't block WAL
617
+ // readers, which is the whole point of WAL — only an exclusive
618
+ // OS-level lock does). The writer releases well inside the query's
619
+ // budget; the bounded query must wait for it and succeed, which fails
620
+ // under the pre-fix code (no busy_timeout means an immediate "database is
621
+ // locked" instead of a wait).
622
+ //
623
+ // A true cross-process lock-contention integration test (a second
624
+ // connection holding an exclusive lock, released mid-wait) was tried and
625
+ // dropped: `locking_mode=EXCLUSIVE`'s release only takes effect on that
626
+ // connection's *next* statement, and the child is a separate OS process
627
+ // spawned via Bun.spawn, so its wait time is dominated by process-spawn
628
+ // overhead rather than the lock — measured elapsed times were inconsistent
629
+ // by 100s of ms across runs in this sandbox, i.e. exactly the kind of
630
+ // flaky timing-based test SOUL.md rules out. Test the actual code change
631
+ // instead: intercept Bun.spawn and assert the child's payload carries
632
+ // `busyTimeoutMs` equal to the caller's `budgetMs`, not a fixed value.
633
+ // SQLite honoring `PRAGMA busy_timeout` is documented upstream behavior,
634
+ // not something this PR needs to re-prove.
635
+ test("R: the child's busy_timeout is bound to the query's budgetMs, not a fixed value", async () => {
636
+ const originalSpawn = Bun.spawn;
637
+ let capturedPayload: { busyTimeoutMs?: number } = {};
638
+
639
+ Bun.spawn = ((): unknown => {
640
+ let written = "";
641
+ return {
642
+ stdin: {
643
+ write: (data: string) => {
644
+ written += data;
645
+ },
646
+ end: async () => {
647
+ capturedPayload = JSON.parse(written);
648
+ },
649
+ },
650
+ stdout: new ReadableStream({
651
+ start(controller) {
652
+ controller.enqueue(
653
+ new TextEncoder().encode(
654
+ JSON.stringify({ columns: ["one"], rows: [[1]], elapsed: 0, total: 1 }),
655
+ ),
656
+ );
657
+ controller.close();
658
+ },
659
+ }),
660
+ stderr: new ReadableStream({
661
+ start(controller) {
662
+ controller.close();
663
+ },
664
+ }),
665
+ exited: Promise.resolve(0),
666
+ kill: () => {},
667
+ };
668
+ }) as typeof Bun.spawn;
669
+
670
+ try {
671
+ await executeReadOnlyQueryBounded("SELECT 1", [], 4321);
672
+ expect(capturedPayload.busyTimeoutMs).toBe(4321);
673
+
674
+ await executeReadOnlyQueryBounded("SELECT 1", [], 777);
675
+ expect(capturedPayload.busyTimeoutMs).toBe(777);
676
+ } finally {
677
+ Bun.spawn = originalSpawn;
678
+ }
679
+ });
680
+
681
+ // Same review thread — "cover ... a post-timeout write/checkpoint" was the
682
+ // second half of the ask: prove a SIGKILLed child doesn't leave the WAL in
683
+ // a state that blocks a later writer. Force a genuine timeout (tight
684
+ // budget against SLOW_QUERY, already proven to SIGKILL the child in test
685
+ // A), then perform an ordinary write and a full checkpoint against the
686
+ // same on-disk file.
687
+ test("S: a write and checkpoint succeed normally right after a bounded query is killed", async () => {
688
+ await expect(executeReadOnlyQueryBounded(SLOW_QUERY, [], 200)).rejects.toThrow(/budget/i);
689
+
690
+ const db = getDb();
691
+ db.run("CREATE TABLE IF NOT EXISTS post_timeout_write (id INTEGER PRIMARY KEY)");
692
+ db.run("INSERT INTO post_timeout_write DEFAULT VALUES");
693
+ const checkpoint = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get() as { busy: number };
694
+ expect(checkpoint.busy).toBe(0);
695
+ });
696
+ });
697
+
698
+ // Review round 3, thread 3814484715 (Oscar): __resetSqliteVecExtensionPathCacheForTests
699
+ // had no caller outside its own definition. Oscar's preference, which we're
700
+ // following: write the test that needs the helper rather than drop it —
701
+ // resolve once, mutate the env var, resolve again and assert the cached
702
+ // value survives, then reset and assert the new value is picked up. This
703
+ // also pins the "a resolved `undefined` is cached too" doc comment: the
704
+ // mechanism is a `null`-sentinel check with no branching on the cached
705
+ // value's type, so the same two assertions hold regardless of whether the
706
+ // first resolution lands on a path string or `undefined`.
707
+ describe("resolveSqliteVecExtensionPath memoization (src/be/db.ts)", () => {
708
+ const ORIGINAL_EXTENSION_PATH_ENV = process.env.SQLITE_VEC_EXTENSION_PATH;
709
+
710
+ afterEach(() => {
711
+ if (ORIGINAL_EXTENSION_PATH_ENV === undefined) {
712
+ delete process.env.SQLITE_VEC_EXTENSION_PATH;
713
+ } else {
714
+ process.env.SQLITE_VEC_EXTENSION_PATH = ORIGINAL_EXTENSION_PATH_ENV;
715
+ }
716
+ __resetSqliteVecExtensionPathCacheForTests();
717
+ });
718
+
719
+ test("caches the resolved path across calls, surviving a later env var change", () => {
720
+ __resetSqliteVecExtensionPathCacheForTests();
721
+ process.env.SQLITE_VEC_EXTENSION_PATH = "/tmp/first-vec-path.so";
722
+ expect(resolveSqliteVecExtensionPath()).toBe("/tmp/first-vec-path.so");
723
+
724
+ process.env.SQLITE_VEC_EXTENSION_PATH = "/tmp/second-vec-path.so";
725
+ expect(resolveSqliteVecExtensionPath()).toBe("/tmp/first-vec-path.so");
726
+ });
727
+
728
+ test("resetting the cache picks up the new env var value", () => {
729
+ __resetSqliteVecExtensionPathCacheForTests();
730
+ process.env.SQLITE_VEC_EXTENSION_PATH = "/tmp/first-vec-path.so";
731
+ resolveSqliteVecExtensionPath();
732
+
733
+ process.env.SQLITE_VEC_EXTENSION_PATH = "/tmp/second-vec-path.so";
734
+ __resetSqliteVecExtensionPathCacheForTests();
735
+ expect(resolveSqliteVecExtensionPath()).toBe("/tmp/second-vec-path.so");
736
+ });
737
+ });