@desplega.ai/agent-swarm 2.2.0 → 2.4.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 (266) hide show
  1. package/README.md +1 -0
  2. package/dist/{acp-adapter-k0vwfyvn.js → acp-adapter-kedstxx6.js} +6 -6
  3. package/dist/{actions-w4pnb8vz.js → actions-vget6r1k.js} +18 -16
  4. package/dist/{amp-adapter-0cmxb2t5.js → amp-adapter-xeb5mktv.js} +2 -2
  5. package/dist/{app-zneem7g9.js → app-mqap8t8m.js} +3 -3
  6. package/dist/{assistant-wbzd2rnr.js → assistant-h0gv97mv.js} +20 -18
  7. package/dist/{boot-reembed-4kx69j3g.js → boot-reembed-qr8a894z.js} +1 -1
  8. package/dist/{boot-reembed-db0wwgy1.js → boot-reembed-rj85jjhw.js} +1 -1
  9. package/dist/{boot-scrub-sweep-tfv4d3mm.js → boot-scrub-sweep-eyfvn6v9.js} +3 -3
  10. package/dist/{boot-seeds-w2hmkxn5.js → boot-seeds-xs9a8dx1.js} +4 -4
  11. package/dist/{claude-adapter-5qrc2paq.js → claude-adapter-bm23cye7.js} +5 -5
  12. package/dist/{claude-managed-adapter-n7ahjyx6.js → claude-managed-adapter-1mwrsm9d.js} +2 -2
  13. package/dist/{claude-managed-setup-7ra32em4.js → claude-managed-setup-gkfgks4j.js} +1 -1
  14. package/dist/{claude-sdk-session-cd02gc76.js → claude-sdk-session-apb4ypz3.js} +5 -5
  15. package/dist/cli-02vhhssk.js +848 -0
  16. package/dist/{cli-4ync7m60.js → cli-0ebey9hg.js} +1 -1
  17. package/dist/{cli-665j3feb.js → cli-0xk505e8.js} +1 -1
  18. package/dist/{cli-8c1m9mhy.js → cli-1cevz46c.js} +5 -5
  19. package/dist/{cli-gzvb3d4a.js → cli-1jx0srjt.js} +3 -3
  20. package/dist/{cli-zj4d6afs.js → cli-1pa3rz69.js} +21 -6
  21. package/dist/{cli-vmbakbq0.js → cli-2rwet3r7.js} +1 -1
  22. package/dist/{cli-azs9bhc5.js → cli-3wjs22x3.js} +2 -2
  23. package/dist/{cli-tba8fxf9.js → cli-4fhyq711.js} +45 -36
  24. package/dist/{cli-ak4c12mh.js → cli-5ff2sjyt.js} +1 -1
  25. package/dist/{cli-6q7pyd3k.js → cli-5s5hx57y.js} +5 -3
  26. package/dist/{cli-h23mr2s6.js → cli-6ftrrwm1.js} +9 -371
  27. package/dist/{cli-9ac0mkg5.js → cli-8b9wvjps.js} +1 -1
  28. package/dist/cli-8cxkw1f5.js +382 -0
  29. package/dist/{cli-xhgtay7w.js → cli-8rwvksbc.js} +2 -2
  30. package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
  31. package/dist/{cli-ksrphveb.js → cli-a1esmmee.js} +14 -2
  32. package/dist/{cli-xnp2vkx7.js → cli-avmqhatn.js} +7 -7
  33. package/dist/{cli-xx13gqf3.js → cli-b80a1100.js} +1 -1
  34. package/dist/{cli-z1hh99j0.js → cli-bfc3k1x5.js} +3 -3
  35. package/dist/{cli-0k9rcwfy.js → cli-bhwa8rzc.js} +33 -15
  36. package/dist/{cli-8cvvytad.js → cli-bn26xbcs.js} +2 -2
  37. package/dist/{cli-k28p6xh7.js → cli-c8fwpgqb.js} +1 -1
  38. package/dist/{cli-rc3r0bnh.js → cli-ccn0eybf.js} +1 -1
  39. package/dist/{cli-9td5ss65.js → cli-dsgj3e7a.js} +2 -2
  40. package/dist/{cli-jrk8mj0t.js → cli-ecshf504.js} +2 -2
  41. package/dist/{cli-raejy0mp.js → cli-eqgfs202.js} +1 -1
  42. package/dist/{cli-ppapy34w.js → cli-f3nx1h8z.js} +2 -2
  43. package/dist/{cli-c9vrwcpb.js → cli-ffbrkh1r.js} +74 -35
  44. package/dist/{cli-mnry16kh.js → cli-kk2r80n5.js} +4 -4
  45. package/dist/{cli-c0dpzwnx.js → cli-kz116kvw.js} +3 -3
  46. package/dist/{cli-e5y285rg.js → cli-mvg2hf8p.js} +4 -4
  47. package/dist/{cli-2jm992rm.js → cli-mz1ezzzh.js} +2 -2
  48. package/dist/{cli-dw0njd9q.js → cli-nme7wery.js} +6 -6
  49. package/dist/{cli-mvsfbbgq.js → cli-nrrg0fvy.js} +18 -4
  50. package/dist/{cli-8jtrekpy.js → cli-p1m2b6gc.js} +7 -7
  51. package/dist/{cli-4c7k75rq.js → cli-rg7ntypm.js} +378 -84
  52. package/dist/{cli-z3dk189k.js → cli-rsd7ga7v.js} +10 -8
  53. package/dist/{cli-etvn5ej2.js → cli-sppc0cmw.js} +1 -1
  54. package/dist/{cli-8w65vwee.js → cli-tg9ksbn8.js} +1 -1
  55. package/dist/{cli-th8krqbf.js → cli-tgvpmnwz.js} +2 -2
  56. package/dist/{cli-hwc4a9xm.js → cli-tj4j2ts4.js} +6 -6
  57. package/dist/{cli-jhhb2v9n.js → cli-ttaz6c7m.js} +2 -2
  58. package/dist/{cli-w7z94e6c.js → cli-tx00c0h6.js} +4 -2
  59. package/dist/{cli-4g629sgr.js → cli-wrv09v7q.js} +20 -1
  60. package/dist/{cli-cby329ny.js → cli-wyrr38xm.js} +2 -2
  61. package/dist/{cli-5ks6x36q.js → cli-x1p5dhq2.js} +3 -3
  62. package/dist/{cli-bddxamg7.js → cli-x719j3vs.js} +220 -629
  63. package/dist/{cli-ztcxv5nv.js → cli-xfe63zx3.js} +1 -1
  64. package/dist/{cli-q6gw45wj.js → cli-ywyrffff.js} +1 -1
  65. package/dist/{cli-p46xdr1m.js → cli-zvze9rbm.js} +1 -1
  66. package/dist/cli.js +15 -15
  67. package/dist/{codex-adapter-m0fx3v3s.js → codex-adapter-pp2gq8gr.js} +3 -3
  68. package/dist/{codex-hook-x6tm6y5m.js → codex-hook-qwzccd64.js} +1 -1
  69. package/dist/{codex-session-runner-j9m2gfyy.js → codex-session-runner-0vzn09t8.js} +3 -3
  70. package/dist/{commands-ktnqwrv4.js → commands-psqhkttb.js} +2 -2
  71. package/dist/{controller-nex2he8v.js → controller-t20vz2k8.js} +2 -2
  72. package/dist/{controllers-connect-6yhwqv9v.js → controllers-connect-gkxmqw9d.js} +24 -20
  73. package/dist/{cursor-adapter-02s5ekva.js → cursor-adapter-3v542qe5.js} +1 -1
  74. package/dist/{db-jvr86v7f.js → db-2xbyenjr.js} +2 -2
  75. package/dist/{db-19tw9r5s.js → db-nc1rd869.js} +3 -2
  76. package/dist/{dispatcher-d3mnsg2y.js → dispatcher-ewceb8ts.js} +17 -15
  77. package/dist/{dsh-adapter-63rvej88.js → dsh-adapter-75xwh78t.js} +1 -1
  78. package/dist/{easter-egg-3d-6hg2r942.js → easter-egg-3d-xsc47jwf.js} +1 -1
  79. package/dist/{execute-bcqc69a5.js → execute-gzmw1mnw.js} +1 -1
  80. package/dist/{external-channel-44cwer32.js → external-channel-a5dsmxn4.js} +2 -2
  81. package/dist/{google-generative-ai-phpmwxvg.js → google-generative-ai-bmq8gkf3.js} +1 -1
  82. package/dist/{google-vertex-es9ybgdy.js → google-vertex-b1rvp52s.js} +1 -1
  83. package/dist/{grok-adapter-ysp907f6.js → grok-adapter-vj88xr23.js} +7 -7
  84. package/dist/handlers-zbs90bny.js +39 -0
  85. package/dist/{heartbeat-2etacsqq.js → heartbeat-t2m9r3wa.js} +20 -18
  86. package/dist/{hook-mzxxs5n2.js → hook-8femx1bg.js} +4 -4
  87. package/dist/{hook-3kvmax6d.js → hook-xa677f8r.js} +5 -5
  88. package/dist/{http-fn978m0g.js → http-xpmmq028.js} +940 -269
  89. package/dist/{index-e4tsa1h7.js → index-8pwc1jc8.js} +17 -15
  90. package/dist/{index-w2wr89xd.js → index-cdzyfrsp.js} +1 -1
  91. package/dist/{index-1r8hw4vd.js → index-dg2tmj99.js} +17 -15
  92. package/dist/index-nxa6me0q.js +41 -0
  93. package/dist/{index-039x2sag.js → index-s7ckdajq.js} +42 -14
  94. package/dist/{index-wgsw9xcz.js → index-syztqdt6.js} +1 -1
  95. package/dist/{index-x0q2a4f9.js → index-ysd45kgc.js} +1 -1
  96. package/dist/{index-g909y089.js → index-zgvqzjsw.js} +8 -8
  97. package/dist/{keepalive-d4nzs76x.js → keepalive-3nr4st3r.js} +3 -3
  98. package/dist/{lead-8h5n9k5f.js → lead-td8h2sfn.js} +33 -31
  99. package/dist/{maintenance-ea6jfw42.js → maintenance-verwpe2c.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-3f25djq3.js → oauth-refresh-sweep-9q2t0y3q.js} +2 -2
  101. package/dist/{onboard-pvk7hdb2.js → onboard-aytqedmq.js} +2 -2
  102. package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-f4hryjny.js} +4 -4
  103. package/dist/{otel-impl-qb7nhgdp.js → otel-impl-hckw4wt1.js} +1 -1
  104. package/dist/{pi-mono-adapter-zasp3ze3.js → pi-mono-adapter-hxby0ce1.js} +6 -6
  105. package/dist/{pricing-refresh-6ehwkcmp.js → pricing-refresh-5nvme3sm.js} +3 -3
  106. package/dist/{rbac-roles-km0f9cmm.js → rbac-roles-pd8c26m1.js} +5 -3
  107. package/dist/{rbac-roles-9648nh00.js → rbac-roles-vbpy21mg.js} +2 -2
  108. package/dist/{render-v2-xkze1wef.js → render-v2-7nqgvk2q.js} +3 -3
  109. package/dist/{resume-tq0bbvt5.js → resume-b6djcj3c.js} +6 -6
  110. package/dist/{runtime-ar8310ba.js → runtime-txrxc9za.js} +1 -1
  111. package/dist/session-access-fc3f9y12.js +25 -0
  112. package/dist/{setup-rnrazxvd.js → setup-n39szm6b.js} +1 -1
  113. package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-wyp2k6z3.js} +2 -2
  114. package/dist/{work-objects-e75q2rqs.js → work-objects-jem2fre0.js} +20 -18
  115. package/dist/{worker-4ww4hjef.js → worker-wr6xgfr2.js} +33 -31
  116. package/openapi.json +1011 -115
  117. package/package.json +2 -2
  118. package/src/apps/action-caller.ts +6 -0
  119. package/src/apps/script-types.ts +16 -3
  120. package/src/be/asset-key-audit.ts +4 -13
  121. package/src/be/db/active-sessions.ts +5 -0
  122. package/src/be/db/session-visibility.ts +29 -0
  123. package/src/be/db/tasks/read.ts +61 -3
  124. package/src/be/db/tasks/write.ts +15 -6
  125. package/src/be/db.ts +149 -66
  126. package/src/be/events.ts +11 -0
  127. package/src/be/migrations/227_inbox_item_state_last_read_at.sql +1 -0
  128. package/src/be/migrations/230_session_shares.sql +23 -0
  129. package/src/be/rbac-roles.ts +2 -0
  130. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +22 -4
  131. package/src/be/session-shares.ts +244 -0
  132. package/src/be/swarm-config-guard.ts +23 -2
  133. package/src/commands/runner.ts +11 -0
  134. package/src/heartbeat/heartbeat.ts +25 -7
  135. package/src/http/active-sessions.ts +13 -2
  136. package/src/http/agents.ts +10 -3
  137. package/src/http/all-routes.ts +1 -0
  138. package/src/http/api-keys.ts +3 -1
  139. package/src/http/approval-requests.ts +2 -1
  140. package/src/http/apps.ts +36 -11
  141. package/src/http/assets.ts +26 -4
  142. package/src/http/config.ts +3 -0
  143. package/src/http/context.ts +2 -0
  144. package/src/http/core.ts +39 -2
  145. package/src/http/events.ts +6 -2
  146. package/src/http/favorite-owner.ts +1 -1
  147. package/src/http/fs.ts +38 -3
  148. package/src/http/inbox-state.ts +58 -8
  149. package/src/http/index.ts +3 -1
  150. package/src/http/memory.ts +3 -1
  151. package/src/http/ranking.ts +367 -0
  152. package/src/http/realtime.ts +26 -21
  153. package/src/http/route-def.ts +77 -11
  154. package/src/http/script-runs.ts +2 -1
  155. package/src/http/scripts.ts +8 -0
  156. package/src/http/session-access.ts +179 -0
  157. package/src/http/session-data.ts +10 -2
  158. package/src/http/sessions.ts +212 -12
  159. package/src/http/stats.ts +7 -3
  160. package/src/http/tasks.ts +83 -7
  161. package/src/http/used-by.ts +11 -3
  162. package/src/kv-reserved-namespaces.ts +1 -0
  163. package/src/prompts/session-templates.ts +3 -1
  164. package/src/providers/amp-adapter.ts +1 -1
  165. package/src/ranking/telemetry-ranking.ts +147 -0
  166. package/src/ranking/usage.ts +57 -0
  167. package/src/rbac/admission.ts +22 -7
  168. package/src/rbac/legacy-policy.ts +2 -0
  169. package/src/rbac/permissions.ts +9 -0
  170. package/src/realtime/auth.ts +69 -0
  171. package/src/realtime/tickets.ts +4 -1
  172. package/src/realtime/transport.ts +77 -9
  173. package/src/slack/blocks.ts +18 -0
  174. package/src/slack/dm.ts +29 -0
  175. package/src/telemetry-contract/EVENTS.md +3 -0
  176. package/src/telemetry-contract/SOURCE.json +37 -4
  177. package/src/telemetry-contract/catalog.json +69 -0
  178. package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
  179. package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
  180. package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
  181. package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
  182. package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
  183. package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
  184. package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
  185. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
  186. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
  187. package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
  188. package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
  189. package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
  190. package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
  191. package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
  192. package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
  193. package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
  194. package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
  195. package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
  196. package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
  197. package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
  198. package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
  199. package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
  200. package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
  201. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
  202. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
  203. package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
  204. package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
  205. package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
  206. package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
  207. package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
  208. package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
  209. package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
  210. package/src/telemetry-contract/ranking/taxonomy.json +8 -0
  211. package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
  212. package/src/telemetry-contract/types.gen.ts +708 -1
  213. package/src/telemetry.ts +26 -2
  214. package/src/tests/apps-rbac.test.ts +189 -2
  215. package/src/tests/apps-script-types.test.ts +16 -3
  216. package/src/tests/asset-key-api.test.ts +128 -0
  217. package/src/tests/asset-key-invariants.test.ts +179 -3
  218. package/src/tests/asset-key-migration.test.ts +35 -0
  219. package/src/tests/check-pr-body.test.ts +23 -0
  220. package/src/tests/check-ranking-entry.test.ts +146 -0
  221. package/src/tests/db-extraction-contract.test.ts +1 -0
  222. package/src/tests/db-task-read-contract.test.ts +1 -0
  223. package/src/tests/docs-index-footprint.test.js +2 -2
  224. package/src/tests/heartbeat-checklist.test.ts +88 -0
  225. package/src/tests/heartbeat-jev-gate.test.ts +394 -10
  226. package/src/tests/inbox-state.test.ts +167 -0
  227. package/src/tests/kv-tool.test.ts +15 -0
  228. package/src/tests/org-identity.test.ts +44 -0
  229. package/src/tests/ranking-listing.test.ts +112 -0
  230. package/src/tests/ranking-route.test.ts +319 -0
  231. package/src/tests/ranking-usage.test.ts +50 -0
  232. package/src/tests/rbac-admission-e2e.test.ts +8 -0
  233. package/src/tests/rbac-admission.test.ts +38 -0
  234. package/src/tests/rbac-charact-http.test.ts +4 -3
  235. package/src/tests/rbac-engine.test.ts +2 -0
  236. package/src/tests/rbac-roles.test.ts +2 -0
  237. package/src/tests/realtime-room-surfaces.test.ts +3 -1
  238. package/src/tests/realtime-session-join-race.test.ts +142 -0
  239. package/src/tests/realtime-tickets.test.ts +24 -17
  240. package/src/tests/realtime-transport.test.ts +91 -0
  241. package/src/tests/script-connections.test.ts +2 -0
  242. package/src/tests/session-route-posture.test.ts +639 -0
  243. package/src/tests/session-sharing-routes.test.ts +282 -0
  244. package/src/tests/session-sharing.test.ts +526 -0
  245. package/src/tests/sessions-detail-batch.test.ts +7 -1
  246. package/src/tests/slack-tool-blocks.test.ts +101 -0
  247. package/src/tests/slack-update.test.ts +55 -0
  248. package/src/tests/slack-user-dm.test.ts +177 -0
  249. package/src/tests/used-by-route.test.ts +10 -0
  250. package/src/tools/slack-post.ts +18 -12
  251. package/src/tools/slack-read.ts +13 -1
  252. package/src/tools/slack-reply.ts +7 -10
  253. package/src/tools/slack-start-thread.ts +18 -11
  254. package/src/tools/slack-update.ts +2 -9
  255. package/src/types.ts +37 -0
  256. package/src/utils/org-identity.ts +33 -0
  257. package/templates/skills/apps/SKILL.md +17 -0
  258. package/templates/skills/apps/content.md +17 -0
  259. package/templates/skills/code-quality/content.md +2 -2
  260. package/templates/skills/pages/content.md +4 -0
  261. package/templates/skills/swarm-scripts/SKILL.md +6 -0
  262. package/templates/skills/swarm-scripts/content.md +6 -0
  263. package/dist/handlers-t5pbabwf.js +0 -37
  264. package/dist/index-qyjbb7br.js +0 -39
  265. package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
  266. /package/dist/{cli-j0v75y13.js → cli-y83zk5sb.js} +0 -0
@@ -1,3 +1,4 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import {
@@ -19,7 +20,9 @@ type Fixture = {
19
20
  configs: Record<string, string>;
20
21
  heartbeatMd?: string;
21
22
  prevStatus?: string;
23
+ reportedFailures?: unknown;
22
24
  prevOutput?: string | null;
25
+ prevCreatedAt?: string | null;
23
26
  failed?: Array<Record<string, unknown>>;
24
27
  approvalStatus?: string;
25
28
  /** Stands in for `ctx.swarm.systemOne_decide`; returns the bridge result. */
@@ -33,6 +36,7 @@ function fakeCtx(f: Fixture) {
33
36
  kvSets: 0,
34
37
  decides: [] as Array<Record<string, any>>,
35
38
  approvalReads: 0,
39
+ previousReads: 0,
36
40
  };
37
41
  const table = (columns: string[], rows: unknown[][]) => ({
38
42
  success: true,
@@ -56,17 +60,48 @@ function fakeCtx(f: Fixture) {
56
60
  },
57
61
  };
58
62
  },
59
- db_query: async ({ sql }: { sql: string }) => {
63
+ db_query: async ({ sql, params = [] }: { sql: string; params?: any[] }) => {
60
64
  if (sql.includes("FROM approval_requests")) {
61
65
  calls.approvalReads++;
62
66
  return table(["status"], f.approvalStatus ? [[f.approvalStatus]] : []);
63
67
  }
64
68
  if (sql.includes("heartbeatMd")) return table(["heartbeatMd"], [[f.heartbeatMd ?? ""]]);
65
- if (sql.includes("'heartbeat-checklist'")) {
66
- return table(
67
- ["id", "output", "status"],
68
- [["prev-id", f.prevOutput ?? null, f.prevStatus ?? ""]],
69
- );
69
+ if (sql.includes("'heartbeat-checklist'") || sql.includes("finishedAt <= ?")) {
70
+ if (sql.includes("'heartbeat-checklist'")) calls.previousReads++;
71
+ // Execute historical selection against real rows, including its bounds and limit.
72
+ const db = new Database(":memory:");
73
+ try {
74
+ db.run(`CREATE TABLE agent_tasks (
75
+ id TEXT, output TEXT, task TEXT, agentId TEXT, taskType TEXT,
76
+ status TEXT, createdAt TEXT, finishedAt TEXT
77
+ )`);
78
+ if (f.prevCreatedAt !== null)
79
+ db.run("INSERT INTO agent_tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
80
+ "prev-id",
81
+ f.prevOutput ?? null,
82
+ f.prevStatus ?? "",
83
+ LEAD,
84
+ "heartbeat-checklist",
85
+ "completed",
86
+ f.prevCreatedAt ?? "2026-10-15T13:00:00.000Z",
87
+ null,
88
+ ]);
89
+ for (const failure of f.failed ?? []) {
90
+ db.run("INSERT INTO agent_tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
91
+ failure.id,
92
+ null,
93
+ failure.task,
94
+ failure.agentId,
95
+ "test",
96
+ "failed",
97
+ "2026-10-15T06:00:00.000Z",
98
+ failure.finishedAt,
99
+ ]);
100
+ }
101
+ return { data: { rows: db.query(sql).all(...params) } };
102
+ } finally {
103
+ db.close();
104
+ }
70
105
  }
71
106
  if (sql.includes("completed24")) {
72
107
  return table(
@@ -75,7 +110,10 @@ function fakeCtx(f: Fixture) {
75
110
  );
76
111
  }
77
112
  if (sql.includes("status = 'failed' AND finishedAt")) {
78
- const rows = f.failed ?? [];
113
+ const rows = (f.failed ?? [])
114
+ .filter((r) => String(r.finishedAt) > params[0])
115
+ .sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt)))
116
+ .slice(0, 10);
79
117
  return table(
80
118
  ["id", "agentId", "task", "failureReason", "finishedAt"],
81
119
  rows.map((r) => [r.id, r.agentId, r.task, r.failureReason, r.finishedAt]),
@@ -93,8 +131,13 @@ function fakeCtx(f: Fixture) {
93
131
  }
94
132
  throw new Error(`unexpected sql: ${sql}`);
95
133
  },
96
- kv_getOrNull: async ({ key }: { key: string }) =>
97
- kv.has(key) ? { key, value: kv.get(key) } : null,
134
+ kv_getOrNull: async ({ namespace, key }: { namespace: string; key: string }) => {
135
+ if (namespace === "heartbeat-checklist:reported-failures") {
136
+ expect(key).toBe("prev-id");
137
+ return f.reportedFailures === undefined ? null : { key, value: f.reportedFailures };
138
+ }
139
+ return kv.has(key) ? { key, value: kv.get(key) } : null;
140
+ },
98
141
  kv_set: async ({ key, value }: { key: string; value: unknown }) => {
99
142
  calls.kvSets++;
100
143
  kv.set(key, value);
@@ -168,6 +211,7 @@ describe("heartbeat-jev-gate with a provider", () => {
168
211
  test("wake at or above the threshold, with the new failures and due items in summary", async () => {
169
212
  const { ctx } = fakeCtx({
170
213
  configs: { TYPESAFE_API_KEY: "x" },
214
+ reportedFailures: ["aaaaaaaa-0000"],
171
215
  heartbeatMd: [
172
216
  "# Standing orders",
173
217
  `- Watch: release on ${MMDD} — lift after ship`,
@@ -176,7 +220,7 @@ describe("heartbeat-jev-gate with a provider", () => {
176
220
  ].join("\n"),
177
221
  // The previous checklist already listed aaaaaaaa, so only bbbbbbbb is new.
178
222
  prevStatus:
179
- '## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
223
+ '## Current System Status [auto-generated]\n## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
180
224
  prevOutput: "All clear.\n\nNothing to do.",
181
225
  failed: [
182
226
  {
@@ -217,6 +261,346 @@ describe("heartbeat-jev-gate with a provider", () => {
217
261
  });
218
262
  });
219
263
 
264
+ for (const ordersFirst of [true, false]) {
265
+ test(`previous failures are not new with standing orders ${ordersFirst ? "before" : "after"} status`, async () => {
266
+ const failures =
267
+ '## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n';
268
+ const orders = "## Your Standing Orders\n- Already triaged.\n";
269
+ const status = `## Current System Status [auto-generated]\n${failures}`;
270
+ const { ctx } = fakeCtx({
271
+ configs: { TYPESAFE_API_KEY: "x" },
272
+ reportedFailures: ["aaaaaaaa-0000"],
273
+ prevStatus: ordersFirst ? orders + status : status + orders,
274
+ failed: [
275
+ {
276
+ id: "aaaaaaaa-0000",
277
+ agentId: "12345678-bbbb",
278
+ task: "old failure",
279
+ failureReason: "boom",
280
+ finishedAt: "2026-10-15T12:00:00Z",
281
+ },
282
+ {
283
+ id: "bbbbbbbb-0000",
284
+ agentId: "12345678-bbbb",
285
+ task: "new failure",
286
+ failureReason: "exit 1",
287
+ finishedAt: "2026-10-15T13:01:00.000Z",
288
+ },
289
+ ],
290
+ jev: jevSays(0.9),
291
+ });
292
+ const verdict = await run(ctx, { dryRun: true });
293
+ expect(verdict.state.new_failures_since_previous_heartbeat).toEqual([
294
+ "bbbbbbbb: new failure | exit 1",
295
+ ]);
296
+ expect(verdict.state.failures_last_6h_total).toBe(2);
297
+ });
298
+ }
299
+
300
+ test.each([
301
+ "## Recent Failures (last 6h) [auto-generated]\nNone.\n",
302
+ "",
303
+ ])("failures outside the previous Recent Failures section stay new (%s)", async (section) => {
304
+ const { ctx } = fakeCtx({
305
+ configs: { TYPESAFE_API_KEY: "x" },
306
+ prevStatus: `## Current System Status [auto-generated]\n${section}## Gate Findings [auto-generated]\n- [aaaaaaaa] "quoted failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n`,
307
+ failed: [
308
+ {
309
+ id: "aaaaaaaa-0000",
310
+ agentId: "12345678-bbbb",
311
+ task: "quoted failure",
312
+ failureReason: "boom",
313
+ finishedAt: "2026-10-15T13:01:00.000Z",
314
+ },
315
+ ],
316
+ jev: jevSays(0.9),
317
+ });
318
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
319
+ "aaaaaaaa: quoted failure | boom",
320
+ ]);
321
+ });
322
+
323
+ test.each([
324
+ "- Item 544: fix `## Recent Failures` parsing.\n",
325
+ "## Recent Failures\nSnapshot notes.\n",
326
+ '## Recent Failures (last 6h) [auto-generated]\n- [bbbbbbbb] "snapshot quote" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
327
+ ])("snapshot failure headings cannot hijack generated status (%s)", async (snapshot) => {
328
+ const { ctx, calls } = fakeCtx({
329
+ configs: { TYPESAFE_API_KEY: "x" },
330
+ reportedFailures: ["aaaaaaaa-0000"],
331
+ prevStatus: [
332
+ "## Your Standing Orders (snapshot from HEARTBEAT.md)",
333
+ snapshot,
334
+ "## Instructions",
335
+ "Review the generated status.",
336
+ "## Current System Status [auto-generated]",
337
+ "## Recent Failures (last 6h) [auto-generated]",
338
+ '- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
339
+ ].join("\n"),
340
+ failed: [
341
+ {
342
+ id: "aaaaaaaa-0000",
343
+ agentId: "12345678-bbbb",
344
+ task: "old failure",
345
+ failureReason: "boom",
346
+ finishedAt: "2026-10-15T12:00:00Z",
347
+ },
348
+ {
349
+ id: "bbbbbbbb-0000",
350
+ agentId: "12345678-bbbb",
351
+ task: "snapshot quote",
352
+ failureReason: "boom",
353
+ finishedAt: "2026-10-15T13:01:00.000Z",
354
+ },
355
+ ],
356
+ jev: jevSays(0.9),
357
+ });
358
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
359
+ "bbbbbbbb: snapshot quote | boom",
360
+ ]);
361
+ expect(calls.previousReads).toBe(1);
362
+ });
363
+
364
+ test("an absent generated failures section cannot use a trailing snapshot heading", async () => {
365
+ const { ctx, calls } = fakeCtx({
366
+ configs: { TYPESAFE_API_KEY: "x" },
367
+ prevStatus: [
368
+ "## Current System Status [auto-generated]",
369
+ "## Task Overview [auto-generated]",
370
+ "- Failed (24h): 0",
371
+ "## Agent Status [auto-generated]",
372
+ "- Online: 6",
373
+ "## Your Standing Orders (snapshot from HEARTBEAT.md)",
374
+ "## Recent Failures (last 6h) [auto-generated]",
375
+ '- [aaaaaaaa] "snapshot quote" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
376
+ ].join("\n"),
377
+ failed: [
378
+ {
379
+ id: "aaaaaaaa-0000",
380
+ agentId: "12345678-bbbb",
381
+ task: "snapshot quote",
382
+ failureReason: "boom",
383
+ finishedAt: "2026-10-15T13:01:00.000Z",
384
+ },
385
+ ],
386
+ jev: jevSays(0.9),
387
+ });
388
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
389
+ "aaaaaaaa: snapshot quote | boom",
390
+ ]);
391
+ expect(calls.previousReads).toBe(1);
392
+ });
393
+
394
+ test("a failed-task title heading does not hide later failures", async () => {
395
+ const { ctx, calls } = fakeCtx({
396
+ configs: { TYPESAFE_API_KEY: "x" },
397
+ reportedFailures: ["aaaaaaaa-0000", "bbbbbbbb-0000"],
398
+ prevStatus: [
399
+ "## Current System Status [auto-generated]",
400
+ "## Recent Failures (last 6h) [auto-generated]",
401
+ '- [aaaaaaaa] "Fix the task\n## Requirements" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
402
+ '- [bbbbbbbb] "later failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
403
+ "## Gate Findings [auto-generated]",
404
+ '- [cccccccc] "outside failures" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
405
+ ].join("\n"),
406
+ failed: [
407
+ {
408
+ id: "aaaaaaaa-0000",
409
+ agentId: "12345678-bbbb",
410
+ task: "Fix the task\n## Requirements",
411
+ failureReason: "boom",
412
+ finishedAt: "2026-10-15T12:00:00Z",
413
+ },
414
+ {
415
+ id: "bbbbbbbb-0000",
416
+ agentId: "12345678-bbbb",
417
+ task: "later failure",
418
+ failureReason: "boom",
419
+ finishedAt: "2026-10-15T12:00:00Z",
420
+ },
421
+ {
422
+ id: "cccccccc-0000",
423
+ agentId: "12345678-bbbb",
424
+ task: "outside failures",
425
+ failureReason: "boom",
426
+ finishedAt: "2026-10-15T13:01:00.000Z",
427
+ },
428
+ ],
429
+ jev: jevSays(0.9),
430
+ });
431
+ const verdict = await run(ctx, { dryRun: true });
432
+ expect(verdict.state.new_failures_since_previous_heartbeat).toEqual([
433
+ "cccccccc: outside failures | boom",
434
+ ]);
435
+ expect(verdict.state.failures_last_6h_total).toBe(3);
436
+ expect(calls.previousReads).toBe(1);
437
+ });
438
+
439
+ test("code-fenced snapshot system and agent markers cannot hide previous failures", async () => {
440
+ const { ctx, calls } = fakeCtx({
441
+ configs: { TYPESAFE_API_KEY: "x" },
442
+ reportedFailures: ["aaaaaaaa-0000"],
443
+ prevStatus: [
444
+ "## Your Standing Orders (snapshot from HEARTBEAT.md)",
445
+ "```md",
446
+ "## Current System Status [auto-generated]",
447
+ "## Agent Status [auto-generated]",
448
+ "```",
449
+ "## Current System Status [auto-generated]",
450
+ "## Recent Failures (last 6h) [auto-generated]",
451
+ '- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00.000Z',
452
+ "## Agent Status [auto-generated]",
453
+ ].join("\n"),
454
+ failed: [
455
+ {
456
+ id: "aaaaaaaa-0000",
457
+ agentId: "12345678-bbbb",
458
+ task: "old failure",
459
+ failureReason: "boom",
460
+ finishedAt: "2026-10-15T12:00:00.000Z",
461
+ },
462
+ ],
463
+ jev: jevSays(0.9),
464
+ });
465
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toBe(
466
+ "none",
467
+ );
468
+ expect(calls.previousReads).toBe(1);
469
+ });
470
+
471
+ test("a generated Requirements heading in a failed title cannot hide later failures", async () => {
472
+ const { ctx } = fakeCtx({
473
+ configs: { TYPESAFE_API_KEY: "x" },
474
+ reportedFailures: ["aaaaaaaa-0000", "bbbbbbbb-0000"],
475
+ prevStatus: [
476
+ "## Current System Status [auto-generated]",
477
+ "## Recent Failures (last 6h) [auto-generated]",
478
+ '- [aaaaaaaa] "Fix\n## Requirements [auto-generated]\nbody" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00.000Z',
479
+ '- [bbbbbbbb] "later failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:01:00.000Z',
480
+ "## Agent Status [auto-generated]",
481
+ ].join("\n"),
482
+ failed: [
483
+ {
484
+ id: "aaaaaaaa-0000",
485
+ agentId: "12345678-bbbb",
486
+ task: "Fix\n## Requirements [auto-generated]\nbody",
487
+ failureReason: "boom",
488
+ finishedAt: "2026-10-15T12:00:00.000Z",
489
+ },
490
+ {
491
+ id: "bbbbbbbb-0000",
492
+ agentId: "12345678-bbbb",
493
+ task: "later failure",
494
+ failureReason: "boom",
495
+ finishedAt: "2026-10-15T12:01:00.000Z",
496
+ },
497
+ ],
498
+ jev: jevSays(0.9),
499
+ });
500
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toBe(
501
+ "none",
502
+ );
503
+ });
504
+
505
+ test("a failure finished after previous creation stays new even if its ID is quoted", async () => {
506
+ const { ctx } = fakeCtx({
507
+ configs: { TYPESAFE_API_KEY: "x" },
508
+ prevStatus:
509
+ '## Current System Status [auto-generated]\n## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "new failure" — agent: 12345678, reason: boom, at: 2026-10-15T13:00:00.001Z',
510
+ failed: [
511
+ {
512
+ id: "aaaaaaaa-0000",
513
+ agentId: "12345678-bbbb",
514
+ task: "new failure",
515
+ failureReason: "boom",
516
+ finishedAt: "2026-10-15T13:00:00.001Z",
517
+ },
518
+ ],
519
+ jev: jevSays(0.9),
520
+ });
521
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
522
+ "aaaaaaaa: new failure | boom",
523
+ ]);
524
+ });
525
+
526
+ test.each([
527
+ { reportedFailures: ["aaaaaaaa-0000"] },
528
+ { reportedFailures: '["aaaaaaaa-0000"]' },
529
+ ])("a failure unseen during gathering stays new before checklist creation (%j)", async ({
530
+ reportedFailures,
531
+ }) => {
532
+ const { ctx } = fakeCtx({
533
+ configs: { TYPESAFE_API_KEY: "x" },
534
+ reportedFailures,
535
+ prevCreatedAt: "2026-10-15T13:00:00.000Z",
536
+ failed: [
537
+ {
538
+ id: "aaaaaaaa-0000",
539
+ agentId: "12345678-bbbb",
540
+ task: "reported",
541
+ failureReason: "boom",
542
+ finishedAt: "2026-10-15T12:00:00.000Z",
543
+ },
544
+ {
545
+ id: "bbbbbbbb-0000",
546
+ agentId: "12345678-bbbb",
547
+ task: "unseen",
548
+ failureReason: "boom",
549
+ finishedAt: "2026-10-15T12:59:59.999Z",
550
+ },
551
+ ],
552
+ jev: jevSays(0.9),
553
+ });
554
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
555
+ "bbbbbbbb: unseen | boom",
556
+ ]);
557
+ });
558
+
559
+ test.each(
560
+ [undefined, "not JSON", {}, [42]].map((reportedFailures) => ({ reportedFailures })),
561
+ )("missing or invalid receipts treat every current failure as new (%j)", async ({
562
+ reportedFailures,
563
+ }) => {
564
+ const { ctx } = fakeCtx({
565
+ configs: { TYPESAFE_API_KEY: "x" },
566
+ reportedFailures,
567
+ prevStatus: '## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "quoted"',
568
+ failed: [
569
+ {
570
+ id: "aaaaaaaa-0000",
571
+ agentId: "12345678-bbbb",
572
+ task: "still new",
573
+ failureReason: "boom",
574
+ finishedAt: "2026-10-15T12:00:00.000Z",
575
+ },
576
+ ],
577
+ jev: jevSays(0.9),
578
+ });
579
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
580
+ "aaaaaaaa: still new | boom",
581
+ ]);
582
+ });
583
+
584
+ test("without a previous checklist all current failures are new", async () => {
585
+ const { ctx } = fakeCtx({
586
+ configs: { TYPESAFE_API_KEY: "x" },
587
+ prevCreatedAt: null,
588
+ failed: [
589
+ {
590
+ id: "aaaaaaaa-0000",
591
+ agentId: "12345678-bbbb",
592
+ task: "first failure",
593
+ failureReason: "boom",
594
+ finishedAt: "2026-10-15T12:00:00.000Z",
595
+ },
596
+ ],
597
+ jev: jevSays(0.9),
598
+ });
599
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
600
+ "aaaaaaaa: first failure | boom",
601
+ ]);
602
+ });
603
+
220
604
  test("threshold and time zone come from swarm config", async () => {
221
605
  const { ctx } = fakeCtx({
222
606
  configs: {
@@ -0,0 +1,167 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createServer, type Server } from "node:http";
3
+ import {
4
+ closeDb,
5
+ createUser,
6
+ getDbClient,
7
+ initDb,
8
+ listInboxState,
9
+ upsertInboxState,
10
+ } from "../be/db";
11
+ import { handleInboxState } from "../http/inbox-state";
12
+ import { getPathSegments, parseQueryParams } from "../http/utils";
13
+ import { listenOnFreePort } from "./test-net";
14
+
15
+ const TEST_DB_PATH = "./test-inbox-state.sqlite";
16
+ const earlier = "2026-01-01T00:00:00.000Z";
17
+ const later = "2026-02-01T00:00:00.000Z";
18
+ let userId: string;
19
+ let server: Server;
20
+ let baseUrl: string;
21
+
22
+ beforeAll(async () => {
23
+ initDb(TEST_DB_PATH);
24
+ userId = (await createUser({ name: "Inbox User", email: "inbox-state@example.com" })).id;
25
+ server = createServer(async (req, res) => {
26
+ const url = req.url ?? "";
27
+ if (await handleInboxState(req, res, getPathSegments(url), parseQueryParams(url))) return;
28
+ res.writeHead(404).end();
29
+ });
30
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await new Promise<void>((resolve) => server.close(() => resolve()));
35
+ closeDb();
36
+ for (const suffix of ["", "-wal", "-shm"]) {
37
+ const file = Bun.file(`${TEST_DB_PATH}${suffix}`);
38
+ if (await file.exists()) await file.delete();
39
+ }
40
+ });
41
+
42
+ async function patch(path: string, body: Record<string, unknown>) {
43
+ return fetch(`${baseUrl}/api/inbox-state${path}`, {
44
+ method: "PATCH",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ userId, ...body }),
47
+ });
48
+ }
49
+
50
+ describe("inbox read cursor", () => {
51
+ test("sets, preserves, moves back, and clears the cursor independently of sticky readAt", async () => {
52
+ const item = {
53
+ userId,
54
+ itemType: "to_read" as const,
55
+ itemId: "cursor",
56
+ status: "open" as const,
57
+ };
58
+ const initial = await upsertInboxState(item);
59
+ expect(initial.lastReadAt).toBeUndefined();
60
+ const set = await upsertInboxState({ ...item, readAt: earlier, lastReadAt: later });
61
+ expect(set.lastReadAt).toBe(later);
62
+ expect((await upsertInboxState({ ...item, readAt: later })).lastReadAt).toBe(later);
63
+ const movedBack = await upsertInboxState({ ...item, readAt: later, lastReadAt: earlier });
64
+ expect(movedBack.lastReadAt).toBe(earlier);
65
+ expect(movedBack.readAt).toBe(earlier);
66
+ const cleared = await upsertInboxState({ ...item, status: "done", lastReadAt: null });
67
+ expect(cleared.lastReadAt).toBeUndefined();
68
+ expect(cleared.readAt).toBe(earlier);
69
+ expect(cleared.status).toBe("done");
70
+ expect(
71
+ (await listInboxState({ userId })).find((row) => row.id === set.id)?.lastReadAt,
72
+ ).toBeUndefined();
73
+ });
74
+
75
+ test("single PATCH accepts a timestamp and null", async () => {
76
+ const item = { itemType: "to_read", itemId: "http-cursor", status: "open" };
77
+ const set = await patch("", { ...item, readAt: earlier, lastReadAt: later });
78
+ expect(set.status).toBe(200);
79
+ expect((await set.json()).item.lastReadAt).toBe(later);
80
+ const cleared = await patch("", { ...item, lastReadAt: null });
81
+ expect(cleared.status).toBe(200);
82
+ expect((await cleared.json()).item).toMatchObject({ readAt: earlier });
83
+ expect(
84
+ (await listInboxState({ userId })).find((row) => row.itemId === item.itemId)?.lastReadAt,
85
+ ).toBeUndefined();
86
+ });
87
+ });
88
+
89
+ describe("bulk inbox state", () => {
90
+ test("returns rows in request order and applies single-item cursor semantics", async () => {
91
+ const item = { itemType: "to_read", itemId: "bulk-cursor", status: "open" };
92
+ const response = await patch("/bulk", {
93
+ items: [
94
+ { ...item, readAt: earlier, lastReadAt: later },
95
+ { ...item, readAt: later },
96
+ { ...item, lastReadAt: earlier },
97
+ { ...item, lastReadAt: null },
98
+ { ...item, itemId: "bulk-other", status: "dismissed" },
99
+ ],
100
+ });
101
+ expect(response.status).toBe(200);
102
+ const { items } = await response.json();
103
+ expect(items.map((row: { itemId: string }) => row.itemId)).toEqual([
104
+ "bulk-cursor",
105
+ "bulk-cursor",
106
+ "bulk-cursor",
107
+ "bulk-cursor",
108
+ "bulk-other",
109
+ ]);
110
+ expect(items.map((row: { lastReadAt?: string }) => row.lastReadAt)).toEqual([
111
+ later,
112
+ later,
113
+ earlier,
114
+ undefined,
115
+ undefined,
116
+ ]);
117
+ expect(items.slice(0, 4).every((row: { readAt: string }) => row.readAt === earlier)).toBe(true);
118
+ });
119
+
120
+ test("rejects an invalid item before writing anything", async () => {
121
+ const response = await patch("/bulk", {
122
+ items: [
123
+ { itemType: "to_read", itemId: "must-not-write", status: "open" },
124
+ { itemType: "to_read", itemId: "invalid-cursor", status: "open", lastReadAt: "invalid" },
125
+ ],
126
+ });
127
+ expect(response.status).toBe(400);
128
+ expect((await listInboxState({ userId })).some((row) => row.itemId === "must-not-write")).toBe(
129
+ false,
130
+ );
131
+ });
132
+
133
+ test("rejects empty batches and more than 200 items", async () => {
134
+ for (const count of [0, 201]) {
135
+ const response = await patch("/bulk", {
136
+ items: Array.from({ length: count }, (_, i) => ({
137
+ itemType: "to_read",
138
+ itemId: `limit-${i}`,
139
+ status: "open",
140
+ })),
141
+ });
142
+ expect(response.status).toBe(400);
143
+ }
144
+ expect((await listInboxState({ userId })).some((row) => row.itemId.startsWith("limit-"))).toBe(
145
+ false,
146
+ );
147
+ });
148
+
149
+ test("rolls back earlier writes when a later upsert fails", async () => {
150
+ await getDbClient().run(`CREATE TRIGGER reject_inbox_state_test BEFORE INSERT ON inbox_item_state
151
+ WHEN NEW.itemId = 'reject-write' BEGIN SELECT RAISE(ABORT, 'test write failure'); END`);
152
+ try {
153
+ const response = await patch("/bulk", {
154
+ items: [
155
+ { itemType: "to_read", itemId: "rolled-back", status: "open" },
156
+ { itemType: "to_read", itemId: "reject-write", status: "open" },
157
+ ],
158
+ });
159
+ expect(response.status).toBe(500);
160
+ expect((await listInboxState({ userId })).some((row) => row.itemId === "rolled-back")).toBe(
161
+ false,
162
+ );
163
+ } finally {
164
+ await getDbClient().run("DROP TRIGGER reject_inbox_state_test");
165
+ }
166
+ });
167
+ });
@@ -118,6 +118,21 @@ describe("kv MCP tools", () => {
118
118
  expect(getRes.structuredContent.entry?.value).toEqual({ hello: "world" });
119
119
  });
120
120
 
121
+ test.each([
122
+ "heartbeat-checklist",
123
+ "heartbeat-checklist:reported-failures",
124
+ ])("kv-set refuses reserved heartbeat receipts even for lead (%s)", async (namespace) => {
125
+ const result = (await buildServer().set.handler(
126
+ { namespace, key: "checklist-id", value: ["forged-id"] },
127
+ meta(lead),
128
+ )) as StructuredResult<{ success: boolean; message: string }>;
129
+ expect(result.structuredContent.success).toBe(false);
130
+ expect(result.structuredContent.message).toContain("reserved for heartbeat checklist receipts");
131
+ expect(
132
+ await getDbClient().get("SELECT key FROM kv_entries WHERE namespace = ?", [namespace]),
133
+ ).toBeNull();
134
+ });
135
+
121
136
  test("kv-get returns entry=null for missing keys", async () => {
122
137
  const tools = buildServer();
123
138
  const getRes = (await tools.get.handler({ key: "nope" }, meta(agentA))) as StructuredResult<{