experimental-a2 0.20.0 → 0.22.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 (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -0,0 +1,244 @@
1
+ /**
2
+ * What the drills share: a throwaway Redis and Postgres, a scheduler made of
3
+ * timers, a telemetry sink that times every consumer run, the workspace's
4
+ * cursors, and percentiles.
5
+ */
6
+ import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
7
+ import { mkdtempSync, rmSync } from 'node:fs'
8
+ import { createServer as listen } from 'node:net'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import type { A2AttributeValue, A2Telemetry } from 'experimental-a2'
12
+ import type {
13
+ A2Scheduler,
14
+ A2Store,
15
+ DrainableServer,
16
+ } from 'experimental-a2/server'
17
+
18
+ const freePort = (): Promise<number> =>
19
+ new Promise((resolve, reject) => {
20
+ const server = listen()
21
+ server.unref()
22
+ server.on('error', reject)
23
+ server.listen(0, () => {
24
+ const address = server.address()
25
+ server.close(() =>
26
+ resolve(typeof address === 'object' && address ? address.port : 0),
27
+ )
28
+ })
29
+ })
30
+
31
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
32
+
33
+ export type Services = {
34
+ redisUrl: string
35
+ databaseUrl: string
36
+ /** Stops Postgres the way a crash would; `startPostgres` brings it back. */
37
+ stopPostgres(): void
38
+ startPostgres(): void
39
+ close(): void
40
+ }
41
+
42
+ /** A Redis and a Postgres in a temporary directory, on free ports. */
43
+ export async function startServices(): Promise<Services> {
44
+ const dir = mkdtempSync(join(tmpdir(), 'a2slack-drills-'))
45
+ const redisPort = await freePort()
46
+ const pgPort = await freePort()
47
+ const redis: ChildProcess = spawn(
48
+ 'redis-server',
49
+ [
50
+ '--port',
51
+ String(redisPort),
52
+ '--save',
53
+ '',
54
+ '--appendonly',
55
+ 'no',
56
+ '--dir',
57
+ dir,
58
+ ],
59
+ { stdio: 'ignore' },
60
+ )
61
+ for (let tries = 0; ; tries += 1) {
62
+ try {
63
+ if (
64
+ execFileSync('redis-cli', ['-p', String(redisPort), 'ping'])
65
+ .toString()
66
+ .trim() === 'PONG'
67
+ )
68
+ break
69
+ } catch {
70
+ if (tries > 100) throw new Error('redis-server did not start')
71
+ }
72
+ await sleep(50)
73
+ }
74
+ const data = join(dir, 'pg')
75
+ execFileSync('initdb', ['-U', 'postgres', '-A', 'trust', data], {
76
+ stdio: 'ignore',
77
+ })
78
+ const pgCtl = (...args: string[]) =>
79
+ execFileSync('pg_ctl', ['-D', data, ...args], { stdio: 'ignore' })
80
+ const startPostgres = () =>
81
+ pgCtl(
82
+ '-o',
83
+ `-p ${pgPort} -k ${dir} -c max_connections=300`,
84
+ '-l',
85
+ join(dir, 'pg.log'),
86
+ '-w',
87
+ 'start',
88
+ )
89
+ startPostgres()
90
+ return {
91
+ redisUrl: `redis://127.0.0.1:${redisPort}`,
92
+ databaseUrl: `postgres://postgres@127.0.0.1:${pgPort}/postgres`,
93
+ stopPostgres: () => pgCtl('-m', 'immediate', '-w', 'stop'),
94
+ startPostgres,
95
+ close() {
96
+ redis.kill('SIGKILL')
97
+ try {
98
+ pgCtl('-m', 'immediate', '-w', 'stop')
99
+ } catch {
100
+ // already stopped
101
+ }
102
+ rmSync(dir, { recursive: true, force: true })
103
+ },
104
+ }
105
+ }
106
+
107
+ /**
108
+ * A scheduler made of timers. A due drain task drains its session, the way
109
+ * Vercel Queues delivers one, so failed events retry and watchdogs fire as
110
+ * they do in production. Without a scheduler, a failed event waits for the
111
+ * session's next append or `consume`.
112
+ */
113
+ export function timerScheduler(): {
114
+ scheduler: A2Scheduler
115
+ deliverTo(server: DrainableServer): void
116
+ } {
117
+ let target: DrainableServer | undefined
118
+ return {
119
+ scheduler: {
120
+ async schedule(task) {
121
+ if (task.kind !== 'drain') return
122
+ setTimeout(
123
+ () => void target?.drain(task.sessionId).catch(() => {}),
124
+ Math.max(0, task.dueAt - Date.now()),
125
+ ).unref()
126
+ },
127
+ handler: () => async () => new Response(null, { status: 204 }),
128
+ },
129
+ deliverTo(server) {
130
+ target = server
131
+ },
132
+ }
133
+ }
134
+
135
+ /** One consumer run, as its `a2.event` span saw it. */
136
+ export type Run = {
137
+ consumer: string
138
+ index: number
139
+ /** How old the event was when the run started. */
140
+ ageMs: number
141
+ /** How many events the session had after it when it was claimed. */
142
+ lag: number
143
+ durationMs: number
144
+ failed: boolean
145
+ }
146
+
147
+ /** Telemetry that keeps every consumer run and every append's duration. */
148
+ export function recorder(): {
149
+ telemetry: A2Telemetry
150
+ runs: Run[]
151
+ reset(): void
152
+ } {
153
+ const runs: Run[] = []
154
+ const telemetry: A2Telemetry = {
155
+ async span(name, attributes, fn) {
156
+ const started = performance.now()
157
+ let failed = false
158
+ const set: Record<string, A2AttributeValue> = {}
159
+ try {
160
+ return await fn({
161
+ setAttribute: (key, value) => {
162
+ set[key] = value
163
+ },
164
+ recordError: () => {
165
+ failed = true
166
+ },
167
+ })
168
+ } catch (error) {
169
+ failed = true
170
+ throw error
171
+ } finally {
172
+ if (name === 'a2.event' && attributes['a2.event.handled'] === true)
173
+ runs.push({
174
+ consumer: String(attributes['a2.event.consumer']),
175
+ index: Number(attributes['a2.event.index']),
176
+ ageMs: Number(attributes['a2.event.age_ms'] ?? 0),
177
+ lag: Number(attributes['a2.event.lag'] ?? 0),
178
+ durationMs: performance.now() - started,
179
+ failed: failed || set['a2.event.outcome'] === 'failed',
180
+ })
181
+ }
182
+ },
183
+ }
184
+ return { telemetry, runs, reset: () => runs.splice(0) }
185
+ }
186
+
187
+ export type Summary = {
188
+ n: number
189
+ p50: number
190
+ p95: number
191
+ p99: number
192
+ max: number
193
+ }
194
+
195
+ /** Percentiles of `values`, rounded to a tenth. */
196
+ export function summarize(values: readonly number[]): Summary {
197
+ const sorted = values.toSorted((a, b) => a - b)
198
+ const at = (q: number) =>
199
+ sorted.length === 0
200
+ ? 0
201
+ : Math.round(
202
+ sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]! *
203
+ 10,
204
+ ) / 10
205
+ return {
206
+ n: sorted.length,
207
+ p50: at(0.5),
208
+ p95: at(0.95),
209
+ p99: at(0.99),
210
+ max: at(1),
211
+ }
212
+ }
213
+
214
+ /** Waits until `ready` holds, checking every `everyMs`. */
215
+ export async function until(
216
+ ready: () => Promise<boolean> | boolean,
217
+ { timeoutMs, everyMs = 50 }: { timeoutMs: number; everyMs?: number },
218
+ ): Promise<number> {
219
+ const started = performance.now()
220
+ for (;;) {
221
+ // oxlint-disable-next-line no-await-in-loop -- polling, one check at a time
222
+ if (await ready()) return performance.now() - started
223
+ if (performance.now() - started > timeoutMs)
224
+ throw new Error(`not ready after ${timeoutMs}ms`)
225
+ // oxlint-disable-next-line no-await-in-loop -- polling, one check at a time
226
+ await sleep(everyMs)
227
+ }
228
+ }
229
+
230
+ /** The workspace session as the store keys it. */
231
+ export const WORKSPACE_KEY = 'a2slack\u001fworkspace'
232
+
233
+ /** Whether none of `consumers` has an event left to run. */
234
+ export async function idle(
235
+ store: A2Store,
236
+ consumers: readonly string[],
237
+ ): Promise<boolean> {
238
+ const cursors = await store.cursors(WORKSPACE_KEY)
239
+ return cursors
240
+ .filter((cursor) => consumers.includes(cursor.consumer))
241
+ .every((cursor) => cursor.head === null)
242
+ }
243
+
244
+ export { sleep }
@@ -0,0 +1,36 @@
1
+ import { WORKSPACE } from '../model'
2
+ import { a2slackServer } from '../server'
3
+
4
+ /** What a person does. Notifications and labels are the server's: its consumers append them. */
5
+ const CLIENT_TYPES = new Set([
6
+ 'channel.created',
7
+ 'message.posted',
8
+ 'message.edited',
9
+ 'message.deleted',
10
+ 'reaction.added',
11
+ 'reaction.removed',
12
+ 'inbox.read',
13
+ ])
14
+
15
+ /** The ids the client makes, with crypto.randomUUID(). */
16
+ const RANDOM_ID =
17
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
18
+
19
+ /**
20
+ * The workspace is the app's one session: a request can't read or write any
21
+ * other, push an event the server appends, or pick an id a consumer will need.
22
+ */
23
+ const handle = (request: Request): Promise<Response> =>
24
+ a2slackServer.fetch(request, {
25
+ authorize: (operation) =>
26
+ operation.sessionId === WORKSPACE &&
27
+ (operation.type !== 'push' ||
28
+ operation.events.every(
29
+ (event) =>
30
+ CLIENT_TYPES.has(event.type) &&
31
+ (event.id === undefined || RANDOM_ID.test(event.id)),
32
+ )),
33
+ })
34
+
35
+ export const GET = handle
36
+ export const POST = handle
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Who is typing — a name picked on first visit, kept in localStorage.
3
+ * This example has no auth: the identity is client-asserted, exactly as
4
+ * far as it should be trusted (not at all). A real app derives it from
5
+ * the request in the route's `before` hook instead.
6
+ */
7
+ 'use client'
8
+ import { useSyncExternalStore } from 'react'
9
+
10
+ export type Identity = { id: string; name: string }
11
+
12
+ const KEY = 'a2slack-identity'
13
+ const listeners = new Set<() => void>()
14
+ let cached: Identity | null | undefined
15
+
16
+ function read(): Identity | null {
17
+ if (cached === undefined) {
18
+ try {
19
+ cached = JSON.parse(
20
+ localStorage.getItem(KEY) ?? 'null',
21
+ ) as Identity | null
22
+ } catch {
23
+ cached = null
24
+ }
25
+ }
26
+ return cached
27
+ }
28
+
29
+ export function useIdentity(): Identity | null {
30
+ return useSyncExternalStore(
31
+ (onChange) => {
32
+ listeners.add(onChange)
33
+ return () => listeners.delete(onChange)
34
+ },
35
+ read,
36
+ () => null,
37
+ )
38
+ }
39
+
40
+ export function saveIdentity(name: string): void {
41
+ cached = { id: crypto.randomUUID(), name: name.trim() }
42
+ localStorage.setItem(KEY, JSON.stringify(cached))
43
+ for (const notify of listeners) notify()
44
+ }
@@ -0,0 +1,8 @@
1
+ import { inboxPage } from '../db/queries'
2
+
3
+ /** Someone's inbox. There's no auth: identity is a name in localStorage. */
4
+ export async function GET(request: Request): Promise<Response> {
5
+ const user = new URL(request.url).searchParams.get('user')
6
+ if (!user) return Response.json({ error: 'no user' }, { status: 400 })
7
+ return Response.json(await inboxPage(user))
8
+ }
@@ -0,0 +1,8 @@
1
+ import { workspaceLag } from '../../lag'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ /** Each consumer's place in the workspace's log; the lag page polls it. */
6
+ export async function GET(): Promise<Response> {
7
+ return Response.json(await workspaceLag())
8
+ }
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+ /**
3
+ * How far each consumer is behind the workspace's log, every two seconds:
4
+ * where it is, what it runs next, and what it gave up on.
5
+ */
6
+ import { useEffect, useState, type ReactNode } from 'react'
7
+ import type { Lag } from '@/app/a2slack/lag'
8
+
9
+ const EVERY_MS = 2_000
10
+
11
+ export default function LagPage(): ReactNode {
12
+ const [lag, setLag] = useState<Lag | null>(null)
13
+ const [error, setError] = useState<string | null>(null)
14
+
15
+ useEffect(() => {
16
+ let stopped = false
17
+ let timer: ReturnType<typeof setTimeout> | undefined
18
+ const read = async () => {
19
+ try {
20
+ const response = await fetch('/a2slack/lag/data', { cache: 'no-store' })
21
+ if (!response.ok) throw new Error(`lag failed (${response.status})`)
22
+ const next = (await response.json()) as Lag
23
+ if (!stopped) {
24
+ setLag(next)
25
+ setError(null)
26
+ }
27
+ } catch (caught) {
28
+ if (!stopped) setError(String(caught))
29
+ }
30
+ if (!stopped) timer = setTimeout(read, EVERY_MS)
31
+ }
32
+ void read()
33
+ return () => {
34
+ stopped = true
35
+ clearTimeout(timer)
36
+ }
37
+ }, [])
38
+
39
+ return (
40
+ <div className="a2slack-view">
41
+ <div className="a2slack-header">
42
+ <span className="a2slack-header-name">lag</span>
43
+ <span className="a2slack-status">
44
+ {error ?? (lag ? `head ${lag.head}` : 'reading…')}
45
+ </span>
46
+ </div>
47
+ {lag && (
48
+ <div className="a2slack-lag">
49
+ <table>
50
+ <thead>
51
+ <tr>
52
+ <th>consumer</th>
53
+ <th>lane</th>
54
+ <th>position</th>
55
+ <th>next</th>
56
+ <th>behind</th>
57
+ <th>failures</th>
58
+ <th>last error</th>
59
+ </tr>
60
+ </thead>
61
+ <tbody>
62
+ {lag.consumers.map((row) => (
63
+ <tr
64
+ key={`${row.consumer}|${row.lane}|${row.next}`}
65
+ data-behind={row.behind > 0 ? 'yes' : undefined}
66
+ >
67
+ <td>{row.consumer}</td>
68
+ <td>{row.lane ?? '·'}</td>
69
+ <td>{row.position}</td>
70
+ <td>{row.next ?? '·'}</td>
71
+ <td>{row.behind}</td>
72
+ <td>{row.failures}</td>
73
+ <td className="a2slack-lag-error">{row.lastError ?? ''}</td>
74
+ </tr>
75
+ ))}
76
+ </tbody>
77
+ </table>
78
+ {lag.deadLetters.length > 0 && (
79
+ <>
80
+ <p className="a2slack-lag-title">dead letters</p>
81
+ <table>
82
+ <tbody>
83
+ {lag.deadLetters.map((letter) => (
84
+ <tr key={`${letter.consumer}|${letter.index}`}>
85
+ <td>{letter.consumer}</td>
86
+ <td>#{letter.index}</td>
87
+ <td>{letter.type}</td>
88
+ <td className="a2slack-lag-error">
89
+ {letter.error ?? ''}
90
+ </td>
91
+ </tr>
92
+ ))}
93
+ </tbody>
94
+ </table>
95
+ </>
96
+ )}
97
+ </div>
98
+ )}
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * How far each consumer is behind the workspace's log: its cursors as the
3
+ * store keeps them, against the log's head, and the events it gave up on.
4
+ * The lag page (lag/page.tsx) polls it.
5
+ */
6
+ import 'server-only'
7
+ import type { A2Store } from 'experimental-a2/server'
8
+ import { store } from '@/lib/store'
9
+ import { WORKSPACE, workspace } from './model'
10
+ import { a2slackServer } from './server'
11
+
12
+ export type ConsumerLag = {
13
+ consumer: string
14
+ lane: string | null
15
+ /** The last event it finished. */
16
+ position: number
17
+ /** The event it runs next; null when it has nothing to run. */
18
+ next: number | null
19
+ /**
20
+ * Events in the log after its position, while it has one to run. A
21
+ * consumer that takes some types is caught up once none of them is left.
22
+ */
23
+ behind: number
24
+ attempts: number
25
+ failures: number
26
+ lastError: string | null
27
+ }
28
+
29
+ export type Lag = {
30
+ /** The log's last event. */
31
+ head: number
32
+ consumers: ConsumerLag[]
33
+ deadLetters: {
34
+ consumer: string
35
+ index: number
36
+ type: string
37
+ error: string | null
38
+ }[]
39
+ at: string
40
+ }
41
+
42
+ // Without REDIS_URL or DATABASE_URL, the server uses A2's development store.
43
+ const lagStore = async (): Promise<A2Store> =>
44
+ store ?? (await import('experimental-a2/store-sqlite')).sqlite()
45
+
46
+ export async function workspaceLag(): Promise<Lag> {
47
+ const session = a2slackServer.session(WORKSPACE)
48
+ const [cursors, { index: head }, deadLetters] = await Promise.all([
49
+ (await lagStore()).cursors(`${workspace.name}\u001f${WORKSPACE}`),
50
+ session.state(),
51
+ session.deadLetters(),
52
+ ])
53
+ return {
54
+ head,
55
+ consumers: cursors.map((cursor) => ({
56
+ consumer: cursor.consumer,
57
+ lane: cursor.lane,
58
+ position: cursor.position,
59
+ next: cursor.head,
60
+ behind: cursor.head === null ? 0 : Math.max(0, head - cursor.position),
61
+ attempts: cursor.attemptCount,
62
+ failures: cursor.failureCount,
63
+ lastError: cursor.lastError,
64
+ })),
65
+ deadLetters: deadLetters.map((letter) => ({
66
+ consumer: letter.consumer,
67
+ index: letter.index,
68
+ type: letter.type,
69
+ error: letter.lastError,
70
+ })),
71
+ at: new Date().toISOString(),
72
+ }
73
+ }
@@ -0,0 +1,43 @@
1
+ import type { Metadata } from 'next'
2
+ import type { ReactNode } from 'react'
3
+ import { GeistSans } from 'geist/font/sans'
4
+ import { WORKSPACE } from '@/app/a2slack/model'
5
+ import { feed } from '@/app/a2slack/consumers/feed'
6
+ import { workspaceSnapshot } from '@/app/a2slack/db/queries'
7
+ import { a2slackServer } from '@/app/a2slack/server'
8
+ import { WorkspaceProvider } from '@/app/a2slack/sessions'
9
+ import { Shell } from '@/app/a2slack/ui/Shell'
10
+ import './a2slack.css'
11
+
12
+ export const metadata: Metadata = {
13
+ title: 'a2slack · a2 example',
14
+ description: 'A team chat whose whole workspace is one A2 session.',
15
+ }
16
+
17
+ // A chat is dynamic by definition; never read the workspace at build time.
18
+ export const dynamic = 'force-dynamic'
19
+
20
+ export default async function A2slackLayout({
21
+ children,
22
+ }: {
23
+ children: ReactNode
24
+ }): Promise<ReactNode> {
25
+ // The channel list as the database has it, and the log index it reflects:
26
+ // the stream resumes after `through`.
27
+ const { channels, opened, through } = await workspaceSnapshot()
28
+ // A feed that hasn't read anything yet, on a log that already has events,
29
+ // starts its backfill now instead of waiting for the next post.
30
+ if (through === 0)
31
+ await a2slackServer.session(WORKSPACE).consume(feed, { through: 0 })
32
+
33
+ return (
34
+ <div className={`a2slack-page ${GeistSans.variable}`}>
35
+ <WorkspaceProvider
36
+ sessionId={WORKSPACE}
37
+ initial={{ state: { channels, opened }, index: through }}
38
+ >
39
+ <Shell>{children}</Shell>
40
+ </WorkspaceProvider>
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,17 @@
1
+ import { messagePage } from '../db/queries'
2
+ import { channelIdSchema } from '../model'
3
+
4
+ /** A page of a channel's top-level messages, older than `before` if given. */
5
+ export async function GET(request: Request): Promise<Response> {
6
+ const params = new URL(request.url).searchParams
7
+ const channel = channelIdSchema.safeParse(params.get('channel'))
8
+ const before = Number(params.get('before') ?? Number.NaN)
9
+ if (!channel.success)
10
+ return Response.json({ error: 'unknown channel' }, { status: 400 })
11
+ return Response.json(
12
+ await messagePage(
13
+ channel.data,
14
+ Number.isSafeInteger(before) && before > 0 ? { before } : {},
15
+ ),
16
+ )
17
+ }