@animalabs/agent-framework 0.23.2 → 0.24.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 (338) hide show
  1. package/README.md +212 -42
  2. package/dist/src/agent.d.ts +60 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +92 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +14 -12
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/code-execution/engine.d.ts +177 -0
  10. package/dist/src/code-execution/engine.d.ts.map +1 -0
  11. package/dist/src/code-execution/engine.js +494 -0
  12. package/dist/src/code-execution/engine.js.map +1 -0
  13. package/dist/src/code-execution/index.d.ts +5 -10
  14. package/dist/src/code-execution/index.d.ts.map +1 -1
  15. package/dist/src/code-execution/index.js +5 -9
  16. package/dist/src/code-execution/index.js.map +1 -1
  17. package/dist/src/code-execution/isolate/calls.d.ts +114 -0
  18. package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
  19. package/dist/src/code-execution/isolate/calls.js +145 -0
  20. package/dist/src/code-execution/isolate/calls.js.map +1 -0
  21. package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
  22. package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
  23. package/dist/src/code-execution/isolate/child-entry.js +5 -0
  24. package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
  25. package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
  26. package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
  27. package/dist/src/code-execution/isolate/child-main.js +132 -0
  28. package/dist/src/code-execution/isolate/child-main.js.map +1 -0
  29. package/dist/src/code-execution/isolate/child.d.ts +44 -0
  30. package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
  31. package/dist/src/code-execution/isolate/child.js +315 -0
  32. package/dist/src/code-execution/isolate/child.js.map +1 -0
  33. package/dist/src/code-execution/isolate/frames.d.ts +80 -0
  34. package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
  35. package/dist/src/code-execution/isolate/frames.js +210 -0
  36. package/dist/src/code-execution/isolate/frames.js.map +1 -0
  37. package/dist/src/code-execution/isolate/output.d.ts +68 -0
  38. package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
  39. package/dist/src/code-execution/isolate/output.js +143 -0
  40. package/dist/src/code-execution/isolate/output.js.map +1 -0
  41. package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
  42. package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
  43. package/dist/src/code-execution/isolate/prelude.js +401 -0
  44. package/dist/src/code-execution/isolate/prelude.js.map +1 -0
  45. package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
  46. package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
  47. package/dist/src/code-execution/isolate/protocol.js +150 -0
  48. package/dist/src/code-execution/isolate/protocol.js.map +1 -0
  49. package/dist/src/code-execution/isolate/render.d.ts +57 -0
  50. package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
  51. package/dist/src/code-execution/isolate/render.js +214 -0
  52. package/dist/src/code-execution/isolate/render.js.map +1 -0
  53. package/dist/src/code-execution/isolate/runner.d.ts +250 -0
  54. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
  55. package/dist/src/code-execution/isolate/runner.js +854 -0
  56. package/dist/src/code-execution/isolate/runner.js.map +1 -0
  57. package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
  58. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
  59. package/dist/src/code-execution/isolate/scripts.js +357 -0
  60. package/dist/src/code-execution/isolate/scripts.js.map +1 -0
  61. package/dist/src/code-execution/quickjs.wasm +0 -0
  62. package/dist/src/code-execution/tool-definition.d.ts +93 -43
  63. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  64. package/dist/src/code-execution/tool-definition.js +176 -142
  65. package/dist/src/code-execution/tool-definition.js.map +1 -1
  66. package/dist/src/deadline-timer.d.ts +3 -0
  67. package/dist/src/deadline-timer.d.ts.map +1 -1
  68. package/dist/src/deadline-timer.js +1 -1
  69. package/dist/src/deadline-timer.js.map +1 -1
  70. package/dist/src/framework.d.ts +1027 -440
  71. package/dist/src/framework.d.ts.map +1 -1
  72. package/dist/src/framework.js +4093 -2238
  73. package/dist/src/framework.js.map +1 -1
  74. package/dist/src/gate/event-gate.d.ts +28 -1
  75. package/dist/src/gate/event-gate.d.ts.map +1 -1
  76. package/dist/src/gate/event-gate.js +88 -10
  77. package/dist/src/gate/event-gate.js.map +1 -1
  78. package/dist/src/held-writes.d.ts +16 -3
  79. package/dist/src/held-writes.d.ts.map +1 -1
  80. package/dist/src/held-writes.js +16 -3
  81. package/dist/src/held-writes.js.map +1 -1
  82. package/dist/src/index.d.ts +6 -6
  83. package/dist/src/index.d.ts.map +1 -1
  84. package/dist/src/index.js +9 -8
  85. package/dist/src/index.js.map +1 -1
  86. package/dist/src/mcpl/channel-registry.d.ts +471 -75
  87. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  88. package/dist/src/mcpl/channel-registry.js +1673 -337
  89. package/dist/src/mcpl/channel-registry.js.map +1 -1
  90. package/dist/src/mcpl/conversation-router.d.ts +12 -7
  91. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  92. package/dist/src/mcpl/conversation-router.js +12 -7
  93. package/dist/src/mcpl/conversation-router.js.map +1 -1
  94. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  95. package/dist/src/mcpl/convert-block.js +9 -5
  96. package/dist/src/mcpl/convert-block.js.map +1 -1
  97. package/dist/src/mcpl/inbound-source.d.ts +50 -0
  98. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  99. package/dist/src/mcpl/inbound-source.js +130 -0
  100. package/dist/src/mcpl/inbound-source.js.map +1 -1
  101. package/dist/src/mcpl/index.d.ts +2 -1
  102. package/dist/src/mcpl/index.d.ts.map +1 -1
  103. package/dist/src/mcpl/index.js +3 -2
  104. package/dist/src/mcpl/index.js.map +1 -1
  105. package/dist/src/mcpl/log-value.d.ts +6 -0
  106. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  107. package/dist/src/mcpl/log-value.js +8 -0
  108. package/dist/src/mcpl/log-value.js.map +1 -1
  109. package/dist/src/mcpl/modern-connection.d.ts +15 -6
  110. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  111. package/dist/src/mcpl/modern-connection.js +168 -53
  112. package/dist/src/mcpl/modern-connection.js.map +1 -1
  113. package/dist/src/mcpl/prose-grammar.d.ts +4 -5
  114. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  115. package/dist/src/mcpl/prose-grammar.js +4 -5
  116. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  117. package/dist/src/mcpl/protocol-family.d.ts +97 -10
  118. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  119. package/dist/src/mcpl/protocol-family.js +173 -16
  120. package/dist/src/mcpl/protocol-family.js.map +1 -1
  121. package/dist/src/mcpl/push-coalescer.d.ts +132 -14
  122. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  123. package/dist/src/mcpl/push-coalescer.js +227 -50
  124. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  125. package/dist/src/mcpl/push-handler.d.ts +6 -1
  126. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  127. package/dist/src/mcpl/push-handler.js +4 -1
  128. package/dist/src/mcpl/push-handler.js.map +1 -1
  129. package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
  130. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  131. package/dist/src/mcpl/reference-fetcher.js +4 -4
  132. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  133. package/dist/src/mcpl/server-connection.d.ts +50 -21
  134. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  135. package/dist/src/mcpl/server-connection.js +158 -74
  136. package/dist/src/mcpl/server-connection.js.map +1 -1
  137. package/dist/src/mcpl/tags.d.ts +24 -0
  138. package/dist/src/mcpl/tags.d.ts.map +1 -1
  139. package/dist/src/mcpl/tags.js +33 -0
  140. package/dist/src/mcpl/tags.js.map +1 -1
  141. package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
  142. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  143. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  144. package/dist/src/mcpl/transport.d.ts +55 -7
  145. package/dist/src/mcpl/transport.d.ts.map +1 -1
  146. package/dist/src/mcpl/transport.js +163 -34
  147. package/dist/src/mcpl/transport.js.map +1 -1
  148. package/dist/src/mcpl/types.d.ts +22 -6
  149. package/dist/src/mcpl/types.d.ts.map +1 -1
  150. package/dist/src/mcpl/types.js.map +1 -1
  151. package/dist/src/module-registry.d.ts +4 -2
  152. package/dist/src/module-registry.d.ts.map +1 -1
  153. package/dist/src/module-registry.js +100 -13
  154. package/dist/src/module-registry.js.map +1 -1
  155. package/dist/src/modules/discord/index.d.ts.map +1 -1
  156. package/dist/src/modules/discord/index.js +8 -13
  157. package/dist/src/modules/discord/index.js.map +1 -1
  158. package/dist/src/modules/history/index.d.ts.map +1 -1
  159. package/dist/src/modules/history/index.js +27 -4
  160. package/dist/src/modules/history/index.js.map +1 -1
  161. package/dist/src/modules/history/semantic.d.ts +15 -3
  162. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  163. package/dist/src/modules/history/semantic.js +27 -5
  164. package/dist/src/modules/history/semantic.js.map +1 -1
  165. package/dist/src/modules/index.d.ts +1 -1
  166. package/dist/src/modules/index.d.ts.map +1 -1
  167. package/dist/src/modules/index.js.map +1 -1
  168. package/dist/src/modules/unknown-tool.d.ts +4 -1
  169. package/dist/src/modules/unknown-tool.d.ts.map +1 -1
  170. package/dist/src/modules/unknown-tool.js +4 -1
  171. package/dist/src/modules/unknown-tool.js.map +1 -1
  172. package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
  173. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  174. package/dist/src/modules/workspace/branch-intent.js +3 -1
  175. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  176. package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
  177. package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
  178. package/dist/src/modules/workspace/edit-plan.js +794 -0
  179. package/dist/src/modules/workspace/edit-plan.js.map +1 -0
  180. package/dist/src/modules/workspace/edit-text.d.ts +117 -0
  181. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
  182. package/dist/src/modules/workspace/edit-text.js +584 -0
  183. package/dist/src/modules/workspace/edit-text.js.map +1 -0
  184. package/dist/src/modules/workspace/effects.d.ts +5 -4
  185. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/effects.js +78 -25
  187. package/dist/src/modules/workspace/effects.js.map +1 -1
  188. package/dist/src/modules/workspace/index.d.ts +484 -61
  189. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/index.js +2173 -645
  191. package/dist/src/modules/workspace/index.js.map +1 -1
  192. package/dist/src/modules/workspace/observe.d.ts +213 -38
  193. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/observe.js +416 -36
  195. package/dist/src/modules/workspace/observe.js.map +1 -1
  196. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/reconcile.js +15 -8
  198. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  199. package/dist/src/modules/workspace/text.d.ts +32 -0
  200. package/dist/src/modules/workspace/text.d.ts.map +1 -0
  201. package/dist/src/modules/workspace/text.js +67 -0
  202. package/dist/src/modules/workspace/text.js.map +1 -0
  203. package/dist/src/modules/workspace/types.d.ts +210 -6
  204. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/types.js.map +1 -1
  206. package/dist/src/modules/workspace/watcher.d.ts +3 -2
  207. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  208. package/dist/src/modules/workspace/watcher.js.map +1 -1
  209. package/dist/src/operator-log.d.ts +52 -24
  210. package/dist/src/operator-log.d.ts.map +1 -1
  211. package/dist/src/operator-log.js.map +1 -1
  212. package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
  213. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  214. package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
  215. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  216. package/dist/src/recovery/offline-branch.d.ts +26 -1
  217. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  218. package/dist/src/recovery/offline-branch.js +164 -39
  219. package/dist/src/recovery/offline-branch.js.map +1 -1
  220. package/dist/src/recovery/recover-cli.js +23 -4
  221. package/dist/src/recovery/recover-cli.js.map +1 -1
  222. package/dist/src/send-tools.d.ts +11 -0
  223. package/dist/src/send-tools.d.ts.map +1 -0
  224. package/dist/src/send-tools.js +11 -0
  225. package/dist/src/send-tools.js.map +1 -0
  226. package/dist/src/tool-result-guard.d.ts +4 -3
  227. package/dist/src/tool-result-guard.d.ts.map +1 -1
  228. package/dist/src/tool-result-guard.js +5 -4
  229. package/dist/src/tool-result-guard.js.map +1 -1
  230. package/dist/src/tool-result-history.d.ts +1 -1
  231. package/dist/src/tool-result-history.js +1 -1
  232. package/dist/src/tune-out/coordinator.d.ts +21 -0
  233. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  234. package/dist/src/tune-out/coordinator.js +139 -37
  235. package/dist/src/tune-out/coordinator.js.map +1 -1
  236. package/dist/src/types/agent.d.ts +4 -0
  237. package/dist/src/types/agent.d.ts.map +1 -1
  238. package/dist/src/types/events.d.ts +62 -9
  239. package/dist/src/types/events.d.ts.map +1 -1
  240. package/dist/src/types/framework.d.ts +62 -58
  241. package/dist/src/types/framework.d.ts.map +1 -1
  242. package/dist/src/types/module.d.ts +40 -8
  243. package/dist/src/types/module.d.ts.map +1 -1
  244. package/dist/src/types/trace.d.ts +111 -10
  245. package/dist/src/types/trace.d.ts.map +1 -1
  246. package/dist/src/usage/types.d.ts +16 -0
  247. package/dist/src/usage/types.d.ts.map +1 -1
  248. package/dist/src/usage/usage-tracker.d.ts +14 -1
  249. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  250. package/dist/src/usage/usage-tracker.js +81 -41
  251. package/dist/src/usage/usage-tracker.js.map +1 -1
  252. package/package.json +6 -5
  253. package/src/agent.ts +93 -12
  254. package/src/api/server.ts +14 -12
  255. package/src/code-execution/engine.ts +595 -0
  256. package/src/code-execution/index.ts +5 -17
  257. package/src/code-execution/isolate/calls.ts +199 -0
  258. package/src/code-execution/isolate/child-entry.ts +5 -0
  259. package/src/code-execution/isolate/child-main.ts +132 -0
  260. package/src/code-execution/isolate/child.ts +327 -0
  261. package/src/code-execution/isolate/frames.ts +209 -0
  262. package/src/code-execution/isolate/output.ts +161 -0
  263. package/src/code-execution/isolate/prelude.ts +409 -0
  264. package/src/code-execution/isolate/protocol.ts +240 -0
  265. package/src/code-execution/isolate/render.ts +230 -0
  266. package/src/code-execution/isolate/runner.ts +911 -0
  267. package/src/code-execution/isolate/scripts.ts +479 -0
  268. package/src/code-execution/quickjs.wasm +0 -0
  269. package/src/code-execution/quickjs.wasm.LICENSES +149 -0
  270. package/src/code-execution/tool-definition.ts +236 -167
  271. package/src/deadline-timer.ts +1 -1
  272. package/src/framework.ts +4468 -2418
  273. package/src/gate/event-gate.ts +78 -11
  274. package/src/held-writes.ts +17 -3
  275. package/src/index.ts +30 -22
  276. package/src/mcpl/channel-registry.ts +1825 -368
  277. package/src/mcpl/conversation-router.ts +12 -7
  278. package/src/mcpl/convert-block.ts +9 -5
  279. package/src/mcpl/inbound-source.ts +131 -0
  280. package/src/mcpl/index.ts +4 -2
  281. package/src/mcpl/log-value.ts +9 -0
  282. package/src/mcpl/modern-connection.ts +162 -53
  283. package/src/mcpl/prose-grammar.ts +4 -5
  284. package/src/mcpl/protocol-family.ts +199 -21
  285. package/src/mcpl/push-coalescer.ts +306 -62
  286. package/src/mcpl/push-handler.ts +10 -2
  287. package/src/mcpl/reference-fetcher.ts +4 -4
  288. package/src/mcpl/server-connection.ts +180 -79
  289. package/src/mcpl/tags.ts +36 -0
  290. package/src/mcpl/tool-result-normalize.ts +2 -1
  291. package/src/mcpl/transport.ts +162 -33
  292. package/src/mcpl/types.ts +23 -6
  293. package/src/module-registry.ts +92 -13
  294. package/src/modules/discord/index.ts +8 -13
  295. package/src/modules/history/index.ts +25 -3
  296. package/src/modules/history/semantic.ts +29 -6
  297. package/src/modules/index.ts +1 -0
  298. package/src/modules/unknown-tool.ts +4 -1
  299. package/src/modules/workspace/branch-intent.ts +3 -1
  300. package/src/modules/workspace/edit-plan.ts +954 -0
  301. package/src/modules/workspace/edit-text.ts +618 -0
  302. package/src/modules/workspace/effects.ts +70 -19
  303. package/src/modules/workspace/index.ts +2247 -642
  304. package/src/modules/workspace/observe.ts +465 -56
  305. package/src/modules/workspace/reconcile.ts +14 -7
  306. package/src/modules/workspace/text.ts +68 -0
  307. package/src/modules/workspace/types.ts +211 -6
  308. package/src/modules/workspace/watcher.ts +3 -2
  309. package/src/operator-log.ts +47 -19
  310. package/src/recovery/discord-awareness-outbox.ts +1500 -356
  311. package/src/recovery/offline-branch.ts +189 -55
  312. package/src/recovery/recover-cli.ts +25 -4
  313. package/src/send-tools.ts +11 -0
  314. package/src/tool-result-guard.ts +6 -5
  315. package/src/tool-result-history.ts +1 -1
  316. package/src/tune-out/coordinator.ts +163 -42
  317. package/src/types/agent.ts +4 -0
  318. package/src/types/events.ts +62 -9
  319. package/src/types/framework.ts +62 -58
  320. package/src/types/module.ts +40 -8
  321. package/src/types/trace.ts +121 -15
  322. package/src/usage/types.ts +16 -0
  323. package/src/usage/usage-tracker.ts +86 -43
  324. package/dist/src/code-execution/py-runner.d.ts +0 -191
  325. package/dist/src/code-execution/py-runner.d.ts.map +0 -1
  326. package/dist/src/code-execution/py-runner.js +0 -655
  327. package/dist/src/code-execution/py-runner.js.map +0 -1
  328. package/dist/src/code-execution/runtime-py.d.ts +0 -50
  329. package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
  330. package/dist/src/code-execution/runtime-py.js +0 -677
  331. package/dist/src/code-execution/runtime-py.js.map +0 -1
  332. package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
  333. package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
  334. package/dist/src/mcpl/prose-stream-router.js +0 -291
  335. package/dist/src/mcpl/prose-stream-router.js.map +0 -1
  336. package/src/code-execution/py-runner.ts +0 -791
  337. package/src/code-execution/runtime-py.ts +0 -677
  338. package/src/mcpl/prose-stream-router.ts +0 -268
@@ -1,14 +1,101 @@
1
- import {
2
- mkdirSync,
3
- readFileSync,
4
- renameSync,
5
- writeFileSync,
6
- } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
+ import { mkdir, open, rename, unlink } from 'node:fs/promises';
7
3
  import { dirname, join } from 'node:path';
8
4
  import { randomUUID } from 'node:crypto';
5
+ import type { JsStore } from '@animalabs/chronicle';
6
+ import { OperatorActionError } from '../operator-log.js';
7
+
8
+ /**
9
+ * Discord awareness marks: a reaction (💤 by default) that an operator may
10
+ * choose to place on Discord messages a surgery removed from a resident's
11
+ * context, so the people who wrote them can see the resident no longer has
12
+ * them in view.
13
+ *
14
+ * Marks are an explicit publication choice, separate from the surgery itself
15
+ * (`DiscordAwarenessMarks`, default `none`). Once requested, they are
16
+ * one-shot: branch moves and restarts never derive new requests from branch
17
+ * state, and a restart never activates a surgery's marks. A batch a crash
18
+ * left prepared is retired at startup, unsent, and the operator redoes the
19
+ * surgery from its source to choose marks again (see recoverAtStartup).
20
+ * Requests an activated batch owes go out with any delivery pass, a start's
21
+ * included. What a restart does finish is an interrupted suppression's body,
22
+ * on its own branch. An operator retracts or cancels explicitly. A version 2
23
+ * ledger's batches are kept as history when it is rewritten as version 3,
24
+ * and the work it had still to do is dropped, none of it sent, since nobody
25
+ * chose it (see migrateLedger).
26
+ *
27
+ * The ledger is a table with one entry per reaction, keyed by (MCPL server
28
+ * id, channel, message, emoji): what the acts that still stand want of it,
29
+ * whether Discord is owed a request for that, and what Discord last
30
+ * answered. It is never a model of what is on Discord: an answer is kept as
31
+ * an answer, and a request never answered is kept as one that may land,
32
+ * which nothing takes back. The server id is the configured route to a bot,
33
+ * not the bot's identity: if the route is re-pointed at another bot, owed
34
+ * requests act as that bot, and nothing here can detect it.
35
+ *
36
+ * Acts are ordered by the ledger's own counter (`moment`), never the clock:
37
+ * a surgery's marks choice (its prepare), its activation and each retract
38
+ * take the next moment. Between opposite wishes for one reaction the later
39
+ * act wins, and an activation never revives a reaction that a retract made
40
+ * after the surgery's choice decided against, even once that retract is
41
+ * cancelled.
42
+ *
43
+ * One instance is the file's only writer: the running framework, or the
44
+ * offline recovery while the host is stopped. It keeps the document in
45
+ * memory, reads the file when it first needs it and again after a write
46
+ * fails, and answers views from memory. Acts run one at a time, each written
47
+ * before the next starts: compact JSON to a temporary file, synced, renamed
48
+ * over the ledger, then the ledger's directory and its parent synced, by the
49
+ * rule the framework's own recovery files follow (writeRecoveryFile). A
50
+ * write that activates, completes or retires a batch syncs the store first,
51
+ * so the ledger never outlives the branch state it relies on. A write that
52
+ * throws has left the file as it was, and the act's change is dropped with
53
+ * it; a directory that can't be synced after the rename is reported
54
+ * (onUnsyncedWrite), and the write stands.
55
+ */
9
56
 
10
57
  export const DEFAULT_DISCORD_AWARENESS_EMOJI = '💤';
11
58
 
59
+ /**
60
+ * Bound text that came from elsewhere (a Discord error body, a filesystem
61
+ * error) before it is written to the ledger, logged or returned in a
62
+ * receipt: the ledger keeps its entries for the store's life, and a server's
63
+ * error text has no length limit of its own. Head and tail are kept, with
64
+ * the omitted length stated; a cut never splits a surrogate pair.
65
+ */
66
+ export function boundDiscordAwarenessText(text: string, max = 500): string {
67
+ if (text.length <= max) return text;
68
+ const tailLength = Math.min(100, Math.floor(max / 4));
69
+ let head = text.slice(0, Math.max(0, max - tailLength - 40));
70
+ let tail = text.slice(text.length - tailLength);
71
+ if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
72
+ if (/^[\uDC00-\uDFFF]/.test(tail)) tail = tail.slice(1);
73
+ return `${head} … [${text.length - head.length - tail.length} chars omitted] … ${tail}`;
74
+ }
75
+
76
+ /**
77
+ * A Discord answer that the reaction can never succeed: the message or
78
+ * channel is gone or inaccessible, the emoji can't be used, or the message
79
+ * takes no more reactions (or blocks this one). Retrying cannot help.
80
+ */
81
+ export function isPermanentDiscordReactionFailure(message: string): boolean {
82
+ return /unknown message|unknown channel|unknown emoji|missing access|missing permission|cannot access|maximum number of reactions|reaction was blocked|channel .* not found|message .* not found/i
83
+ .test(message);
84
+ }
85
+
86
+ /**
87
+ * An operator's request the ledger won't carry out: an unknown batch or
88
+ * retract request. Nothing was written. Any other error from an operator
89
+ * control is a failure to read or write the ledger.
90
+ */
91
+ export class DiscordAwarenessRefusal extends Error {
92
+ constructor(message: string) {
93
+ super(message);
94
+ this.name = 'DiscordAwarenessRefusal';
95
+ }
96
+ }
97
+
98
+ /** Where a store's awareness ledger lives: the path every release used. */
12
99
  export function defaultDiscordAwarenessOutboxPath(storePath: string): string {
13
100
  return join(storePath, 'recovery', 'discord-awareness-outbox.json');
14
101
  }
@@ -19,460 +106,1517 @@ export interface DiscordAwarenessRef {
19
106
  messageId: string;
20
107
  }
21
108
 
109
+ /** Which removed messages a publication choice covers. */
110
+ export type DiscordAwarenessScope = 'addressed' | 'all';
111
+
112
+ /**
113
+ * An operator's publication choice for one surgery. `none`, the default,
114
+ * keeps the surgery local. With `refs`, the choice authorizes exactly those
115
+ * refs (as a preview showed them): the surgery marks only the ones it
116
+ * actually removes, and never widens the set. Without `refs`, the scope is
117
+ * evaluated when the surgery applies; that form is for an immediate act whose
118
+ * choice and application are the same moment.
119
+ */
120
+ export type DiscordAwarenessMarks =
121
+ | 'none'
122
+ | { scope: DiscordAwarenessScope; refs?: DiscordAwarenessRef[] };
123
+
22
124
  export type DiscordAwarenessAction = 'add' | 'remove';
23
- export type DiscordAwarenessDeliveryStatus = 'pending' | 'applied' | 'permanent-failure';
24
125
 
25
- export interface DiscordAwarenessEntry extends DiscordAwarenessRef {
26
- desired: boolean;
27
- markerPresent: boolean;
28
- deliveryStatus: DiscordAwarenessDeliveryStatus;
29
- attempts: number;
30
- lastAction?: DiscordAwarenessAction;
31
- lastAttemptAt?: number;
32
- lastError?: string;
126
+ export interface DiscordSuppressionInterval {
127
+ fromId: string;
128
+ toId: string;
33
129
  }
34
130
 
131
+ /**
132
+ * Where a batch is in its life:
133
+ * - `prepared`: recorded before its surgery's body change, which settles it.
134
+ * One a crash leaves is retired at startup, never activated (see
135
+ * recoverAtStartup), except a suppression whose body a start on its branch
136
+ * finishes first. One an operator cancelled stays prepared, with
137
+ * `cancelled` set, and is never activated;
138
+ * - `active`: activated by its surgery, or kept so from a version 2 ledger;
139
+ * - `retired`: none of its marks will be sent: its surgery never happened or
140
+ * couldn't record its activation, a start found it left prepared, or a
141
+ * version 2 ledger never activated it.
142
+ */
143
+ export type DiscordAwarenessBatchStatus = 'prepared' | 'active' | 'retired';
144
+
145
+ /** One surgery's marks choice, and a suppression's body to resume. */
35
146
  export interface DiscordAwarenessBatch {
36
147
  id: string;
37
- status: 'prepared' | 'active';
148
+ status: DiscordAwarenessBatchStatus;
38
149
  agentName: string;
39
150
  sourceBranch: string;
40
151
  targetBranch: string;
41
152
  emoji: string;
153
+ /** The clock when it was prepared: shown, never compared. */
42
154
  createdAt: number;
43
- refs: DiscordAwarenessEntry[];
155
+ /** The ledger moment of the surgery's marks choice (its prepare). */
156
+ chosen: number;
157
+ /** `none` for a suppression's batch with no marks; `legacy` for a version 2 ledger's. */
158
+ scope: 'none' | DiscordAwarenessScope | 'legacy';
159
+ /** The refs this batch requests marks on (authorized ∩ removed). */
160
+ refs: DiscordAwarenessRef[];
161
+ /** Removed addressable messages this batch leaves unmarked. */
162
+ unmarked?: number;
163
+ /** Authorized refs the surgery did not remove. */
164
+ notRemoved?: number;
44
165
  /**
45
- * `target-branch` may be promoted when its target branch (or a descendant)
46
- * is active. `explicit` requires activate() after a second mutation.
166
+ * A suppression's removals, by message ids, which a start on its target
167
+ * branch applies again while the batch is prepared: that branch being
168
+ * active doesn't prove the redactions finished. The ids make each one
169
+ * idempotent.
47
170
  */
48
- activationPolicy?: 'target-branch' | 'explicit';
49
- /** Idempotent interval operations used to resume an interrupted suppression. */
50
171
  suppressionIntervals?: DiscordSuppressionInterval[];
172
+ cancelled?: { at: number; by?: string };
173
+ /**
174
+ * A version 2 ledger's batch: how many of its refs agent-framework 0.22.x's
175
+ * last attempt saw added (an add it recorded no error for), set when the
176
+ * ledger was rewritten as version 3. It is history, never a claim about
177
+ * what is on Discord now.
178
+ */
179
+ legacyAdded?: number;
51
180
  }
52
181
 
53
- export interface DiscordSuppressionInterval {
54
- fromId: string;
55
- toId: string;
182
+ /** A surgery's request, as it prepares a batch. */
183
+ export interface DiscordAwarenessPrepareInput {
184
+ agentName: string;
185
+ sourceBranch: string;
186
+ targetBranch: string;
187
+ refs: DiscordAwarenessRef[];
188
+ scope: 'none' | DiscordAwarenessScope;
189
+ emoji?: string;
190
+ suppressionIntervals?: DiscordSuppressionInterval[];
191
+ unmarked?: number;
192
+ notRemoved?: number;
56
193
  }
57
194
 
58
- export interface DiscordAwarenessOperation {
59
- batchId: string;
195
+ /** One retract request: removals of this bot's mark it made owed. */
196
+ interface LedgerRetract {
197
+ /** What cancel takes. */
198
+ id: string;
199
+ /** The batch it retracted, or `all`. */
200
+ target: string;
201
+ at: number;
202
+ by?: string;
203
+ /** Its ledger moment. */
204
+ moment: number;
205
+ /** How many removals it queued. */
206
+ keys: number;
207
+ cancelled?: { at: number; by?: string };
208
+ }
209
+
210
+ /**
211
+ * What the ledger will do with a batch whose body change has landed:
212
+ * - `queued`: it is active, its requests owed;
213
+ * - `not-scheduled`: none of its marks will be sent. It was retired, or it
214
+ * couldn't be, and stays prepared for the next start to retire, or it was
215
+ * never recorded; `error` says which, and why its activation failed.
216
+ */
217
+ export type DiscordAwarenessSettlement =
218
+ | { status: 'queued'; queued: number }
219
+ | { status: 'not-scheduled'; error: string };
220
+
221
+ export type DiscordAwarenessOutcome = 'confirmed' | 'failed' | 'not-sent' | 'unknown';
222
+
223
+ /** A request Discord is owed for one reaction, or one claimed to send. */
224
+ export interface DiscordAwarenessRequest extends DiscordAwarenessRef {
60
225
  emoji: string;
61
226
  action: DiscordAwarenessAction;
62
- ref: DiscordAwarenessEntry;
227
+ /** The moment of the act that owes it. */
228
+ due: number;
63
229
  }
64
230
 
65
- interface DiscordAwarenessOutboxDocument {
66
- version: 2;
67
- batches: DiscordAwarenessBatch[];
231
+ /**
232
+ * A reaction's state, as views and receipts count it:
233
+ * - `queued`: a request is owed and not yet sent;
234
+ * - `sending`: a request is on the wire;
235
+ * - `present`, `absent`: nothing is owed, and that was Discord's last answer;
236
+ * - `unknown`: nothing is owed, and the last request went unanswered;
237
+ * - `failed`: what was owed ended unconfirmed: Discord refused it for good,
238
+ * or three attempts went unconfirmed;
239
+ * - `untouched`: no request was ever answered or owed.
240
+ */
241
+ export type DiscordAwarenessReactionState =
242
+ | 'queued'
243
+ | 'sending'
244
+ | 'present'
245
+ | 'absent'
246
+ | 'unknown'
247
+ | 'failed'
248
+ | 'untouched';
249
+
250
+ /** Receipt for cancel(). Nothing is ever removed from Discord by a cancel. */
251
+ export interface DiscordAwarenessCancelReceipt {
252
+ /** The batch id or retract request id that was cancelled. */
253
+ target: string;
254
+ kind: 'batch' | 'retract';
255
+ /** Reactions this call left with nothing owed before any request for what
256
+ * the act wanted went out: for a batch not yet activated, every mark it
257
+ * would have requested. A repeated cancel stops none. */
258
+ cancelled: number;
259
+ /** The act's reactions whose last answer from Discord is what the act
260
+ * wanted (cancel undoes none of them). */
261
+ confirmed: number;
262
+ /** The act's reactions with a request on the wire or one never answered:
263
+ * any may land. */
264
+ mayStillLand: number;
68
265
  }
69
266
 
70
- interface BranchDescriptor {
267
+ /** Receipt for retract(). */
268
+ export interface DiscordAwarenessRetractReceipt {
269
+ requestId: string;
270
+ /** Removals queued: one per selected reaction, whatever was answered before. */
271
+ removalsQueued: number;
272
+ /** Reactions whose owed add this removal replaced. An add on the wire
273
+ * still settles before the removal is sent, and may land
274
+ * (keysWithUnresolvedAdds). */
275
+ addsSuperseded: number;
276
+ /** Reactions with an add on the wire or one never answered: such an add
277
+ * may land after this removal. */
278
+ keysWithUnresolvedAdds: number;
279
+ }
280
+
281
+ /** A version 2 ledger's batch whose undelivered work its rewrite as version
282
+ * 3 dropped, none of it sent (see migrateLedger). */
283
+ export interface DiscordAwarenessImportedUnsent {
284
+ batchId: string;
285
+ agentName: string;
286
+ sourceBranch: string;
287
+ targetBranch: string;
288
+ /** Marks agent-framework 0.22.x had still to add, and to remove. */
289
+ adds: number;
290
+ removals: number;
291
+ }
292
+
293
+ /** A batch startup retired (see recoverAtStartup), with what the operator
294
+ * needs to redo its surgery. */
295
+ export interface DiscordAwarenessRetirement {
296
+ batchId: string;
297
+ agentName: string;
298
+ sourceBranch: string;
299
+ targetBranch: string;
300
+ /** The marks it would have requested, none of them sent: 0 for a batch
301
+ * with none, one an operator cancelled, or a version 2 ledger's, whose
302
+ * undelivered work its rewrite dropped and reported. */
303
+ marks: number;
304
+ /** Whether it is a suppression (it names removal intervals). */
305
+ suppression: boolean;
306
+ /** A version 2 ledger's batch: its marks stay as that ledger left them. */
307
+ imported?: boolean;
308
+ }
309
+
310
+ /** Per-batch view for operators: what was asked and what is known. */
311
+ export interface DiscordAwarenessBatchView {
312
+ kind: 'batch';
71
313
  id: string;
72
- name: string;
73
- parentId?: string;
314
+ status: DiscordAwarenessBatchStatus;
315
+ scope: DiscordAwarenessBatch['scope'];
316
+ agentName: string;
317
+ sourceBranch: string;
318
+ targetBranch: string;
319
+ emoji: string;
320
+ createdAt: number;
321
+ refs: number;
322
+ unmarked?: number;
323
+ notRemoved?: number;
324
+ cancelled?: { at: number; by?: string };
325
+ /** Its refs' reactions, by state. */
326
+ reactions: Record<DiscordAwarenessReactionState, number>;
327
+ /** Its refs' reactions with a request on the wire or one never answered. */
328
+ mayStillLand: number;
329
+ /** A version 2 ledger's batch: refs 0.22.x's last attempt saw added
330
+ * (DiscordAwarenessBatch.legacyAdded). */
331
+ legacyAdded?: number;
74
332
  }
75
333
 
334
+ /** One retract request: the removals it queued and what became of them. */
335
+ export interface DiscordAwarenessRetractView {
336
+ kind: 'retract';
337
+ /** The retract's request id (what cancel takes). */
338
+ id: string;
339
+ /** The batch it retracted, or `all`. */
340
+ target: string;
341
+ at: number;
342
+ by?: string;
343
+ cancelled?: { at: number; by?: string };
344
+ /** Removals it queued. */
345
+ keys: number;
346
+ /** The reactions it still wants absent, by state. */
347
+ reactions: Record<DiscordAwarenessReactionState, number>;
348
+ /** Of those, the ones with a request on the wire or one never answered. */
349
+ mayStillLand: number;
350
+ }
351
+
352
+ export type DiscordAwarenessView = DiscordAwarenessBatchView | DiscordAwarenessRetractView;
353
+
354
+ /** Extract Discord addressing metadata without reading message content. */
76
355
  export interface DiscordMessageMetadataCarrier {
77
356
  metadata?: Record<string, unknown>;
78
357
  }
79
358
 
80
- /** Extract Discord addressing metadata without reading message content. */
81
359
  export function extractDiscordAwarenessRefs(
82
360
  messages: DiscordMessageMetadataCarrier[],
83
361
  forcedServerId?: string,
84
362
  ): DiscordAwarenessRef[] {
85
363
  const refs = new Map<string, DiscordAwarenessRef>();
86
-
87
364
  for (const message of messages) {
88
- const metadata = message.metadata ?? {};
89
- const channelId = typeof metadata.channelId === 'string' ? metadata.channelId : '';
90
- const messageId = typeof metadata.messageId === 'string' ? metadata.messageId : '';
91
- const metadataServerId = typeof metadata.serverId === 'string' ? metadata.serverId : '';
92
- const serverId = forcedServerId ?? metadataServerId;
93
- if (!channelId || !messageId || !serverId) continue;
365
+ const ref = discordRef(message, forcedServerId);
366
+ if (ref) refs.set(refKey(ref), ref);
367
+ }
368
+ return [...refs.values()];
369
+ }
94
370
 
95
- const isDiscord = serverId.toLowerCase().includes('discord') || channelId.startsWith('discord:');
96
- if (!isDiscord) continue;
371
+ /**
372
+ * A surgery's marks choice as a caller passes it at runtime: `none` (or left
373
+ * out), or `{ scope: 'addressed' | 'all', refs? }` with each ref a Discord
374
+ * address. Anything else is refused, never guessed: a bare scope string (the
375
+ * host/command vocabulary) or a misspelled scope would otherwise skip the
376
+ * scope filter and mark every addressable message. Each entry point that
377
+ * takes a choice in this form checks it with this before it plans or
378
+ * changes anything: the framework's rollback and suppression, and the
379
+ * offline recovery. host/command's undo by messages takes the bare scope
380
+ * words instead and refuses any other, and the recovery CLI parses its --marks.
381
+ */
382
+ export function checkedDiscordAwarenessMarks(marks: unknown): DiscordAwarenessMarks {
383
+ if (marks === undefined || marks === 'none') return 'none';
384
+ if (marks && typeof marks === 'object' && !Array.isArray(marks)) {
385
+ const { scope, refs } = marks as { scope?: unknown; refs?: unknown };
386
+ const isRef = (ref: unknown) => !!ref && typeof ref === 'object'
387
+ && typeof (ref as Record<string, unknown>).serverId === 'string'
388
+ && typeof (ref as Record<string, unknown>).channelId === 'string'
389
+ && typeof (ref as Record<string, unknown>).messageId === 'string';
390
+ if ((scope === 'addressed' || scope === 'all') && (refs === undefined || (Array.isArray(refs) && refs.every(isRef)))) {
391
+ return marks as DiscordAwarenessMarks;
392
+ }
393
+ }
394
+ throw new OperatorActionError(
395
+ 'invalid',
396
+ `marks must be 'none' or { scope: 'addressed' | 'all', refs? } (got ${boundDiscordAwarenessText(JSON.stringify(marks) ?? String(marks), 200)})`,
397
+ );
398
+ }
97
399
 
98
- const ref = { serverId, channelId, messageId };
99
- refs.set(refKey(ref), ref);
400
+ /**
401
+ * Select the refs a publication choice covers among removed messages.
402
+ * `addressed` means the message carried the MCPL `chat:addressed` tag (a
403
+ * mention, a reply to the bot, or a DM, as the server classified it); the
404
+ * same signal the framework uses for loci and wakes. With `marks.refs`, only
405
+ * those refs are eligible as well.
406
+ */
407
+ export function selectDiscordAwarenessRefs(
408
+ messages: DiscordMessageMetadataCarrier[],
409
+ marks: DiscordAwarenessMarks,
410
+ forcedServerId?: string,
411
+ ): { refs: DiscordAwarenessRef[]; addressable: number; unmarked: number; notRemoved: number } {
412
+ const addressable = new Map<string, { ref: DiscordAwarenessRef; addressed: boolean }>();
413
+ for (const message of messages) {
414
+ const ref = discordRef(message, forcedServerId);
415
+ if (!ref) continue;
416
+ const key = refKey(ref);
417
+ const addressed = isAddressed(message);
418
+ const existing = addressable.get(key);
419
+ addressable.set(key, { ref, addressed: addressed || existing?.addressed === true });
420
+ }
421
+ if (marks === 'none') {
422
+ return { refs: [], addressable: addressable.size, unmarked: addressable.size, notRemoved: 0 };
100
423
  }
424
+ const authorized = marks.refs ? new Set(marks.refs.map(refKey)) : null;
425
+ const refs: DiscordAwarenessRef[] = [];
426
+ for (const [key, candidate] of addressable) {
427
+ if (marks.scope === 'addressed' && !candidate.addressed) continue;
428
+ if (authorized && !authorized.has(key)) continue;
429
+ refs.push(candidate.ref);
430
+ }
431
+ const notRemoved = authorized
432
+ ? [...authorized].filter((key) => !addressable.has(key)).length
433
+ : 0;
434
+ return { refs, addressable: addressable.size, unmarked: addressable.size - refs.length, notRemoved };
435
+ }
101
436
 
102
- return [...refs.values()];
437
+ function discordRef(message: DiscordMessageMetadataCarrier, forcedServerId?: string): DiscordAwarenessRef | null {
438
+ const metadata = message.metadata ?? {};
439
+ const channelId = typeof metadata.channelId === 'string' ? metadata.channelId : '';
440
+ const messageId = typeof metadata.messageId === 'string' ? metadata.messageId : '';
441
+ const metadataServerId = typeof metadata.serverId === 'string' ? metadata.serverId : '';
442
+ const serverId = forcedServerId ?? metadataServerId;
443
+ if (!channelId || !messageId || !serverId) return null;
444
+ const isDiscord = serverId.toLowerCase().includes('discord') || channelId.startsWith('discord:');
445
+ if (!isDiscord) return null;
446
+ return { serverId, channelId, messageId };
447
+ }
448
+
449
+ function isAddressed(message: DiscordMessageMetadataCarrier): boolean {
450
+ const tags = message.metadata?.tags;
451
+ return Array.isArray(tags) && tags.includes('chat:addressed');
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // The ledger
456
+ // ---------------------------------------------------------------------------
457
+
458
+ /** One reaction's entry. */
459
+ interface LedgerEntry extends DiscordAwarenessRef {
460
+ emoji: string;
461
+ /** What the acts that still stand want of it: present (batches' marks) or
462
+ * absent (retracts' removals); null once each act that set it is cancelled. */
463
+ wanted: 'present' | 'absent' | null;
464
+ /** Those acts: batch ids for present, retract ids for absent. */
465
+ wantedBy: string[];
466
+ /** The moment of the latest retract that reached it, cancelled or not. */
467
+ retracted?: number;
468
+ /** The moment of the act that owes Discord a request now; null when nothing is owed. */
469
+ due: number | null;
470
+ /** Unconfirmed attempts (refused, or never answered) at what `due` owes. */
471
+ attempts: number;
472
+ /** The request written ahead and not yet answered, with the `due` it serves. */
473
+ sending?: { action: DiscordAwarenessAction; due: number };
474
+ /** Discord's last answer: present or absent, or unknown when the last
475
+ * request went unanswered. */
476
+ confirmed?: 'present' | 'absent' | 'unknown';
477
+ /** The actions of requests never answered: any may land or have landed,
478
+ * and nothing takes one out. */
479
+ mayLand?: DiscordAwarenessAction[];
480
+ /** What was owed ended unconfirmed (see countAttempt). */
481
+ failed?: boolean;
482
+ }
483
+
484
+ interface LedgerDocument {
485
+ version: 3;
486
+ /** The last moment an act took. */
487
+ moment: number;
488
+ batches: DiscordAwarenessBatch[];
489
+ retracts: LedgerRetract[];
490
+ entries: LedgerEntry[];
491
+ }
492
+
493
+ /** The document, with its lookups. The maps hold the document's objects. */
494
+ interface LedgerState {
495
+ doc: LedgerDocument;
496
+ batches: Map<string, DiscordAwarenessBatch>;
497
+ retracts: Map<string, LedgerRetract>;
498
+ entries: Map<string, LedgerEntry>;
103
499
  }
104
500
 
105
501
  /**
106
- * A branch-independent, durable operation ledger for Discord awareness marks.
107
- *
108
- * Completed entries are retained. Reconciliation derives whether their emoji
109
- * should currently exist from Chronicle branch ancestry, queuing `remove`
110
- * after switching away from a recovery branch and `add` when returning to it.
502
+ * Unconfirmed attempts (Discord refused it, or no answer came, so it may
503
+ * have landed) at what one act owes, after which nothing more is sent for
504
+ * it. An attempt refused before it left the host (`not-sent`) isn't
505
+ * counted. Without a bound, a refusal Discord repeats, or a server that
506
+ * never answers, would be sent again at every drain.
507
+ */
508
+ const MAX_UNCONFIRMED_ATTEMPTS = 3;
509
+
510
+ /**
511
+ * The durable awareness-mark ledger (see the module comment). Writing calls
512
+ * are async and resolve once their write is on disk; each runs alone. Views
513
+ * are synchronous, from memory: one made while a write is in progress sees
514
+ * the act that write records.
111
515
  */
112
516
  export class DiscordAwarenessOutbox {
113
- constructor(readonly path: string) {}
114
-
115
- prepare(input: {
116
- agentName: string;
117
- sourceBranch: string;
118
- targetBranch: string;
119
- refs: DiscordAwarenessRef[];
120
- emoji?: string;
121
- activationPolicy?: 'target-branch' | 'explicit';
122
- suppressionIntervals?: DiscordSuppressionInterval[];
123
- }): DiscordAwarenessBatch | null {
124
- const refs = dedupeRefs(input.refs);
125
- if (refs.length === 0 && !input.suppressionIntervals?.length) return null;
517
+ readonly path: string;
518
+ private readonly store?: JsStore;
519
+ /** Told what a version 2 ledger's rewrite drops, before it is written. */
520
+ private readonly announceImport?: (unsent: DiscordAwarenessImportedUnsent[]) => void;
521
+ /** Told of a directory that couldn't be synced after a write's rename. */
522
+ private readonly onUnsyncedWrite: (directory: string, error: unknown) => void;
523
+ /** The document, or null until it is read, and after a failed write
524
+ * whose file couldn't be read back: the next read tries again. */
525
+ private state: LedgerState | null = null;
526
+ /** The tail of the acts queued so far: each act starts after it. */
527
+ private queue: Promise<unknown> = Promise.resolve();
126
528
 
127
- const document = this.read();
128
- const batch: DiscordAwarenessBatch = {
129
- id: randomUUID(),
130
- status: 'prepared',
131
- agentName: input.agentName,
132
- sourceBranch: input.sourceBranch,
133
- targetBranch: input.targetBranch,
134
- emoji: input.emoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI,
135
- createdAt: Date.now(),
136
- refs: refs.map((ref) => ({
137
- ...ref,
138
- desired: false,
139
- markerPresent: false,
140
- deliveryStatus: 'applied',
141
- attempts: 0,
142
- })),
143
- activationPolicy: input.activationPolicy ?? 'target-branch',
144
- suppressionIntervals: input.suppressionIntervals?.map((interval) => ({ ...interval })),
145
- };
146
- document.batches.push(batch);
147
- this.write(document);
148
- return structuredClone(batch);
149
- }
150
-
151
- /** Activate after Chronicle has safely switched to the target branch. */
152
- activate(batchId: string): void {
153
- const document = this.read();
154
- const batch = document.batches.find((candidate) => candidate.id === batchId);
155
- if (!batch) throw new Error(`Discord awareness outbox batch not found: ${batchId}`);
156
- batch.status = 'active';
157
- for (const ref of batch.refs) setDesired(ref, true);
158
- this.write(document);
529
+ /**
530
+ * @param path the ledger file (defaultDiscordAwarenessOutboxPath under a store).
531
+ * @param opts.store the store whose branch state activations, completions
532
+ * and retirements rely on, synced before each is written.
533
+ * @param opts.announceImport told, before a version 2 ledger is rewritten
534
+ * as version 3, which batches' undelivered work that drops, none of it sent.
535
+ * @param opts.onUnsyncedWrite told of a directory that couldn't be synced
536
+ * after a write's rename: the write stands, though an operating system
537
+ * crash could lose it. Logged to stderr when not given.
538
+ */
539
+ constructor(
540
+ path: string,
541
+ opts: {
542
+ store?: JsStore;
543
+ announceImport?: (unsent: DiscordAwarenessImportedUnsent[]) => void;
544
+ onUnsyncedWrite?: (directory: string, error: unknown) => void;
545
+ } = {},
546
+ ) {
547
+ this.path = path;
548
+ if (opts.store) this.store = opts.store;
549
+ if (opts.announceImport) this.announceImport = opts.announceImport;
550
+ this.onUnsyncedWrite = opts.onUnsyncedWrite ?? ((directory, error) => {
551
+ console.error(`[discord-awareness] ${unsyncedWriteText(directory, path, error)}`);
552
+ });
159
553
  }
160
554
 
555
+ // -- surgery side -----------------------------------------------------------
556
+
161
557
  /**
162
- * Retire a batch that was prepared but whose operation did not complete
163
- * (fork failed, a removal threw and the source branch was restored). A
164
- * prepared batch is armed: it re-fires at boot or on branch switch through
165
- * `activatePreparedForBranch` / `preparedSuppressionsForBranch`, and an
166
- * un-completable suppression there aborts framework start. Only prepared
167
- * batches can be discarded — an active batch owns delivered markers and
168
- * must be reconciled, not dropped. Returns false when nothing was removed.
558
+ * Record a surgery's choice before its body change. Returns null when it
559
+ * carries neither marks nor a suppression's removals. A prepared batch
560
+ * never delivers: only its surgery's activate() does. If its write fails it
561
+ * throws, saying what the ledger may hold (failedPrepare), and the surgery
562
+ * doesn't run.
169
563
  */
170
- discard(batchId: string): boolean {
171
- const document = this.read();
172
- const index = document.batches.findIndex((candidate) => candidate.id === batchId);
173
- if (index < 0) return false;
174
- if (document.batches[index].status !== 'prepared') return false;
175
- document.batches.splice(index, 1);
176
- this.write(document);
177
- return true;
564
+ async prepare(input: DiscordAwarenessPrepareInput): Promise<DiscordAwarenessBatch | null> {
565
+ const refs = dedupeRefs(input.refs);
566
+ if (refs.length === 0 && !input.suppressionIntervals?.length) return null;
567
+ return this.act(async (state) => {
568
+ const batch: DiscordAwarenessBatch = {
569
+ id: randomUUID(),
570
+ status: 'prepared',
571
+ agentName: input.agentName,
572
+ sourceBranch: input.sourceBranch,
573
+ targetBranch: input.targetBranch,
574
+ emoji: input.emoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI,
575
+ createdAt: Date.now(),
576
+ chosen: ++state.doc.moment,
577
+ scope: input.scope,
578
+ refs,
579
+ ...(input.unmarked ? { unmarked: input.unmarked } : {}),
580
+ ...(input.notRemoved ? { notRemoved: input.notRemoved } : {}),
581
+ ...(input.suppressionIntervals?.length
582
+ ? { suppressionIntervals: input.suppressionIntervals.map((interval) => ({ ...interval })) }
583
+ : {}),
584
+ };
585
+ state.doc.batches.push(batch);
586
+ state.batches.set(batch.id, batch);
587
+ try {
588
+ // On disk before the surgery's switch: if the surgery is interrupted,
589
+ // a start finds the batch, retires it and alerts (recoverAtStartup),
590
+ // and finishes a suppression's removals. A write that fails leaves
591
+ // the file without it, so the surgery doesn't run.
592
+ await this.persist(state);
593
+ } catch (error) {
594
+ throw new Error(`recording awareness batch ${batch.id} failed: ${errorText(error)}`, { cause: error });
595
+ }
596
+ return structuredClone(batch);
597
+ });
178
598
  }
179
599
 
180
600
  /**
181
- * Reconcile every retained ledger entry against the active branch. A marker
182
- * is desired when any active batch for the same ref+emoji has a target branch
183
- * in the active branch's ancestry.
601
+ * Activate a prepared batch after its body change landed: each of its
602
+ * refs is wanted present, and a request is owed for it, on disk before
603
+ * this returns. Activation completes the surgery's earlier choice; it is
604
+ * not a new one, so a reaction a retract reached after that choice, even
605
+ * one cancelled since, keeps what the retract decided. Returns how many
606
+ * requests it made owed.
184
607
  */
185
- reconcileForBranch(
186
- branchName: string,
187
- branches: BranchDescriptor[] = [],
188
- ): { activated: number; queued: number } {
189
- const document = this.read();
190
- let activated = 0;
191
-
192
- for (const batch of document.batches) {
193
- if (
194
- batch.status === 'prepared'
195
- && (batch.activationPolicy ?? 'target-branch') === 'target-branch'
196
- && isTargetActive(branchName, batch.targetBranch, branches)
197
- ) {
198
- batch.status = 'active';
199
- activated++;
608
+ async activate(batchId: string): Promise<number> {
609
+ return this.act((state) => this.activateIn(state, batchId));
610
+ }
611
+
612
+ private async activateIn(state: LedgerState, batchId: string): Promise<number> {
613
+ const batch = state.batches.get(batchId);
614
+ if (!batch) throw new Error(`Discord awareness batch not found: ${batchId}`);
615
+ if (batch.status === 'active') return 0;
616
+ if (batch.status !== 'prepared') throw new Error(`Discord awareness batch ${batchId} is ${batch.status}, not prepared`);
617
+ // An operator cancelled it while its surgery was still running.
618
+ if (batch.cancelled) throw new Error(`Discord awareness batch ${batchId} was cancelled before activation`);
619
+ const moment = ++state.doc.moment;
620
+ batch.status = 'active';
621
+ let queued = 0;
622
+ for (const ref of batch.refs) {
623
+ const existing = state.entries.get(markKey(ref, batch.emoji));
624
+ if (existing?.retracted !== undefined && existing.retracted > batch.chosen) continue;
625
+ const entry = existing ?? addEntry(state, ref, batch.emoji);
626
+ if (entry.wanted === 'present') {
627
+ if (!entry.wantedBy.includes(batch.id)) entry.wantedBy.push(batch.id);
628
+ } else {
629
+ entry.wanted = 'present';
630
+ entry.wantedBy = [batch.id];
200
631
  }
632
+ owe(entry, moment);
633
+ queued++;
201
634
  }
635
+ // The activation asserts the body change just committed (the switch, or
636
+ // the last redaction): the store is synced first, so a crash can never
637
+ // leave marks owed for a change the store lost.
638
+ await this.persist(state, { afterCommittedState: true });
639
+ return queued;
640
+ }
202
641
 
203
- const desiredKeys = new Set<string>();
204
- for (const batch of document.batches) {
205
- if (batch.status !== 'active') continue;
206
- if (!isTargetActive(branchName, batch.targetBranch, branches)) continue;
207
- for (const ref of batch.refs) desiredKeys.add(markerKey(batch.emoji, ref));
642
+ /**
643
+ * Activate a batch after its body change landed, and if that cannot be
644
+ * recorded, retire it instead, so that what the caller reports is what
645
+ * the ledger will do: `queued`, or `not-scheduled`. A batch that can be
646
+ * neither activated nor retired stays prepared in the file, since a write
647
+ * that fails leaves it as it was, and a start retires it, none of its
648
+ * marks sent: the next start, or for a suppression the first start on its
649
+ * branch, once it finds the removals applied. Never throws.
650
+ */
651
+ async settleActivation(batchId: string): Promise<DiscordAwarenessSettlement> {
652
+ try {
653
+ return await this.act(async (state) => {
654
+ let detail: string;
655
+ try {
656
+ return { status: 'queued', queued: await this.activateIn(state, batchId) } as const;
657
+ } catch (error) {
658
+ detail = errorText(error);
659
+ }
660
+ // The file is as it was before the activation: the document read
661
+ // back after a failed write (persist), or unchanged by a refusal.
662
+ const batch = this.state?.batches.get(batchId);
663
+ if (batch?.status === 'prepared') {
664
+ try {
665
+ await this.retireIn(this.state!, batchId);
666
+ } catch (error) {
667
+ return { status: 'not-scheduled', error: `${detail}; retiring it also failed (${errorText(error)}), so ${leftPrepared(batch)}` } as const;
668
+ }
669
+ return { status: 'not-scheduled', error: `${detail}; it is retired instead, none of its marks sent` } as const;
670
+ } else if (!this.state) {
671
+ return { status: 'not-scheduled', error: `${detail}; the ledger can't be read back, so ${leftPrepared()}` } as const;
672
+ }
673
+ return { status: 'not-scheduled', error: detail } as const;
674
+ });
675
+ } catch (error) {
676
+ // The act never ran: the ledger can't be read.
677
+ return { status: 'not-scheduled', error: `${errorText(error)}; ${leftPrepared()}` };
208
678
  }
679
+ }
209
680
 
210
- let queued = 0;
211
- for (const batch of document.batches) {
212
- if (batch.status !== 'active') continue;
213
- for (const ref of batch.refs) {
214
- setDesired(ref, desiredKeys.has(markerKey(batch.emoji, ref)));
215
- if (ref.deliveryStatus === 'pending') queued++;
216
- }
217
- }
681
+ /**
682
+ * Retire a batch whose surgery did not complete. Only a prepared batch can
683
+ * be retired; returns false when nothing was.
684
+ */
685
+ async discard(batchId: string): Promise<boolean> {
686
+ return this.act(async (state) => {
687
+ if (state.batches.get(batchId)?.status !== 'prepared') return false;
688
+ await this.retireIn(state, batchId);
689
+ return true;
690
+ });
691
+ }
218
692
 
219
- this.write(document);
220
- return { activated, queued };
693
+ private async retireIn(state: LedgerState, batchId: string): Promise<void> {
694
+ state.batches.get(batchId)!.status = 'retired';
695
+ // Retiring a batch retires its obligations (marks and any body resume):
696
+ // the branch state its surgery left (a restored source, or a completed
697
+ // body) is synced first, so a crash can never keep the retirement while
698
+ // losing the state it relies on.
699
+ await this.persist(state, { afterCommittedState: true });
221
700
  }
222
701
 
223
- /** Backward-compatible startup helper. */
224
- activatePreparedForBranch(branchName: string, branches: BranchDescriptor[] = []): number {
225
- return this.reconcileForBranch(branchName, branches).activated;
702
+ /**
703
+ * The suppressions whose body a start on `branch` finishes: prepared
704
+ * batches that name removal intervals, cancelled or not, whose target is
705
+ * exactly that branch: a surgery's leftovers, or a version 2 ledger's that
706
+ * agent-framework 0.22.x never activated. Branch ancestry is never
707
+ * consulted. Throws when the ledger can't be read.
708
+ */
709
+ pendingSuppressionsFor(branch: string): DiscordAwarenessBatch[] {
710
+ return this.loaded().doc.batches
711
+ .filter((batch) => isPendingSuppression(batch) && batch.targetBranch === branch)
712
+ .map((batch) => structuredClone(batch));
226
713
  }
227
714
 
228
- pending(serverId?: string): DiscordAwarenessOperation[] {
229
- const operations: DiscordAwarenessOperation[] = [];
230
- for (const batch of this.read().batches) {
231
- if (batch.status !== 'active') continue;
232
- for (const ref of batch.refs) {
233
- if (ref.deliveryStatus !== 'pending') continue;
234
- if (serverId !== undefined && ref.serverId !== serverId) continue;
235
- operations.push({
715
+ /**
716
+ * Retire a suppression's batch once a start on its branch has finished
717
+ * its removals, none of its marks sent: a restart never activates a
718
+ * surgery's marks. `announceRetirement` is told before that is written,
719
+ * for the reason recoverAtStartup's `announce` is. Nothing is written for
720
+ * a batch that isn't prepared.
721
+ */
722
+ async completeSuppression(batchId: string, announceRetirement?: () => void): Promise<void> {
723
+ return this.act(async (state) => {
724
+ if (state.batches.get(batchId)?.status !== 'prepared') return;
725
+ announceRetirement?.();
726
+ // Certifies the redactions just made: written only after they are synced.
727
+ await this.retireIn(state, batchId);
728
+ });
729
+ }
730
+
731
+ /**
732
+ * Startup reconciliation, run once before any delivery. It derives nothing
733
+ * from branch ancestry, and it activates nothing:
734
+ * - a request a previous process left on the wire is recorded unanswered
735
+ * (recordUnanswered): it may have reached Discord. It counts toward the
736
+ * bound like any unanswered attempt, and `givenUp` is how many reactions
737
+ * that brought there, which nothing more is sent for;
738
+ * - a prepared batch, which an interrupted surgery left, or a failed one
739
+ * that couldn't retire it, is retired, none of its marks sent: the
740
+ * operator redoes the surgery from its source to choose marks again. A
741
+ * suppression whose body is pending is kept instead: a start on its
742
+ * branch finishes its removals and then retires it (completeSuppression).
743
+ * Each retirement is reported in `retired`, and the store is synced before
744
+ * any is written. `announce` is told what it retires and records
745
+ * unanswered before that is written: each is written once, so a notice
746
+ * given after the write would be lost for good if the process ended
747
+ * between the two. If the write doesn't land, the next start says it
748
+ * again.
749
+ */
750
+ async recoverAtStartup(
751
+ announce?: (planned: { retired: DiscordAwarenessRetirement[]; unknownAttempts: number; givenUp: number }) => void,
752
+ ): Promise<{ retired: DiscordAwarenessRetirement[]; unknownAttempts: number; givenUp: number }> {
753
+ return this.act(async (state) => {
754
+ let unknownAttempts = 0;
755
+ let givenUp = 0;
756
+ for (const entry of state.doc.entries) {
757
+ if (!entry.sending) continue;
758
+ unknownAttempts++;
759
+ if (recordUnanswered(entry)) givenUp++;
760
+ }
761
+ const retired: DiscordAwarenessRetirement[] = [];
762
+ for (const batch of state.doc.batches) {
763
+ if (batch.status !== 'prepared' || isPendingSuppression(batch)) continue;
764
+ batch.status = 'retired';
765
+ retired.push({
236
766
  batchId: batch.id,
237
- emoji: batch.emoji,
238
- action: ref.desired ? 'add' : 'remove',
239
- ref: { ...ref },
767
+ agentName: batch.agentName,
768
+ sourceBranch: batch.sourceBranch,
769
+ targetBranch: batch.targetBranch,
770
+ // A version 2 ledger's marks were reported when its rewrite dropped them.
771
+ marks: !batch.cancelled && batch.scope !== 'legacy' ? batch.refs.length : 0,
772
+ suppression: !!batch.suppressionIntervals?.length,
773
+ ...(batch.scope === 'legacy' ? { imported: true } : {}),
240
774
  });
241
775
  }
242
- }
243
- return operations;
244
- }
245
-
246
- recordSuccess(batchId: string, ref: DiscordAwarenessRef, action: DiscordAwarenessAction): void {
247
- const document = this.read();
248
- const entry = findEntry(document, batchId, ref);
249
- if (!entry) return;
250
- entry.markerPresent = action === 'add';
251
- entry.deliveryStatus = entry.markerPresent === entry.desired ? 'applied' : 'pending';
252
- entry.attempts++;
253
- entry.lastAction = action;
254
- entry.lastAttemptAt = Date.now();
255
- delete entry.lastError;
256
- this.write(document);
257
- }
258
-
259
- recordFailure(
260
- batchId: string,
261
- ref: DiscordAwarenessRef,
262
- action: DiscordAwarenessAction,
263
- error: string,
264
- permanent: boolean,
265
- ): void {
266
- const document = this.read();
267
- const entry = findEntry(document, batchId, ref);
268
- if (!entry) return;
269
- entry.deliveryStatus = permanent ? 'permanent-failure' : 'pending';
270
- entry.attempts++;
271
- entry.lastAction = action;
272
- entry.lastAttemptAt = Date.now();
273
- entry.lastError = error;
274
- this.write(document);
275
- }
276
-
277
- /** Compatibility alias for older callers that only acknowledged adds. */
278
- acknowledge(batchId: string, ref: DiscordAwarenessRef): void {
279
- this.recordSuccess(batchId, ref, 'add');
776
+ announce?.({ retired, unknownAttempts, givenUp });
777
+ if (unknownAttempts > 0 || retired.length > 0) {
778
+ await this.persist(state, { afterCommittedState: retired.length > 0 });
779
+ }
780
+ return { retired, unknownAttempts, givenUp };
781
+ });
782
+ }
783
+
784
+ // -- operator controls ------------------------------------------------------
785
+
786
+ /**
787
+ * Stop a batch's marks, or a retract's removals (by its request id), from
788
+ * being sent or sent again: the act is taken out of each reaction it
789
+ * wants, and a reaction no act wants any more is owed nothing. A request
790
+ * on the wire still has its answer recorded, before anything else goes for
791
+ * that reaction. Nothing is ever removed from Discord by a cancel: the
792
+ * receipt reports what may already have landed, and retract removes it.
793
+ */
794
+ async cancel(target: string, by?: string): Promise<DiscordAwarenessCancelReceipt> {
795
+ return this.act(async (state) => {
796
+ const retract = state.retracts.get(target);
797
+ const batch = state.batches.get(target);
798
+ if (!retract && !batch) {
799
+ throw new DiscordAwarenessRefusal(
800
+ `Discord awareness batch or retract request not found: ${target}; the marks list (listDiscordAwareness()) names them`,
801
+ );
802
+ }
803
+ const kind = retract ? 'retract' : 'batch';
804
+ const subject = (retract ?? batch)!;
805
+ const wanted = retract ? 'absent' : 'present';
806
+ // The reactions the act still wants: those no later act has taken
807
+ // over. A prepared batch wants none yet, and a cancelled act none any
808
+ // more.
809
+ const reached = state.doc.entries.filter((entry) => entry.wantedBy.includes(target));
810
+ const receipt: DiscordAwarenessCancelReceipt = {
811
+ target,
812
+ kind,
813
+ cancelled: 0,
814
+ confirmed: reached.filter((entry) => entry.confirmed === wanted).length,
815
+ mayStillLand: reached.filter(mayStillLand).length,
816
+ };
817
+ // A repeated cancel finds nothing the act still wants, and writes nothing.
818
+ if (subject.cancelled) return receipt;
819
+ subject.cancelled = { at: Date.now(), ...(by ? { by } : {}) };
820
+ if (batch?.status === 'prepared') {
821
+ // Its surgery is still running, or it was left for a start to
822
+ // retire: none of its marks was requested, and none will be.
823
+ receipt.cancelled = batch.refs.length;
824
+ } else {
825
+ for (const entry of state.doc.entries) {
826
+ const index = entry.wantedBy.indexOf(target);
827
+ if (index < 0) continue;
828
+ entry.wantedBy.splice(index, 1);
829
+ if (entry.wantedBy.length > 0) continue;
830
+ if (entry.due !== null && entry.attempts === 0 && entry.sending?.due !== entry.due) receipt.cancelled++;
831
+ entry.wanted = null;
832
+ entry.due = null;
833
+ entry.attempts = 0;
834
+ }
835
+ }
836
+ await this.persist(state);
837
+ return receipt;
838
+ });
839
+ }
840
+
841
+ /**
842
+ * Remove this bot's mark (through the configured route) from a batch's
843
+ * refs, or (`all`) from every ref of every batch not retired, whether
844
+ * prepared or active, and every reaction the ledger holds. That covers
845
+ * every mark a version 2 ledger recorded: one of its batches that
846
+ * agent-framework 0.22.x activated stays active, and 0.22.x attempted only
847
+ * an active batch's refs. A reaction is one per (route, message, emoji),
848
+ * whichever batches asked for it, so this acts across batches. A removal
849
+ * is owed for every selected reaction, whatever was answered before: an
850
+ * answer can't establish what is on Discord now, and removing an absent
851
+ * reaction does nothing. It is the latest act on those reactions: an add
852
+ * owed there is replaced, and a batch whose choice came before it, once
853
+ * activated, leaves them as this decides. The receipt reports the adds
854
+ * that may still land.
855
+ */
856
+ async retract(target: string | 'all', by?: string): Promise<DiscordAwarenessRetractReceipt> {
857
+ return this.act(async (state) => {
858
+ const keys = new Map<string, DiscordAwarenessRef & { emoji: string }>();
859
+ const select = (ref: DiscordAwarenessRef, emoji: string) => {
860
+ keys.set(markKey(ref, emoji), {
861
+ serverId: ref.serverId,
862
+ channelId: ref.channelId,
863
+ messageId: ref.messageId,
864
+ emoji,
865
+ });
866
+ };
867
+ if (target === 'all') {
868
+ for (const batch of state.doc.batches) {
869
+ if (batch.status === 'retired') continue;
870
+ for (const ref of batch.refs) select(ref, batch.emoji);
871
+ }
872
+ for (const entry of state.doc.entries) select(entry, entry.emoji);
873
+ } else {
874
+ const batch = state.batches.get(target);
875
+ if (!batch) {
876
+ throw new DiscordAwarenessRefusal(
877
+ `Discord awareness batch not found: ${target}; the marks list (listDiscordAwareness()) names them, or retract all`,
878
+ );
879
+ }
880
+ for (const ref of batch.refs) select(ref, batch.emoji);
881
+ }
882
+ const retract: LedgerRetract = {
883
+ id: randomUUID(),
884
+ target,
885
+ at: Date.now(),
886
+ ...(by ? { by } : {}),
887
+ moment: ++state.doc.moment,
888
+ keys: keys.size,
889
+ };
890
+ const receipt: DiscordAwarenessRetractReceipt = {
891
+ requestId: retract.id,
892
+ removalsQueued: keys.size,
893
+ addsSuperseded: 0,
894
+ keysWithUnresolvedAdds: 0,
895
+ };
896
+ for (const [key, ref] of keys) {
897
+ const entry = state.entries.get(key) ?? addEntry(state, ref, ref.emoji);
898
+ if (entry.wanted === 'present' && entry.due !== null) receipt.addsSuperseded++;
899
+ if (entry.sending?.action === 'add' || entry.mayLand?.includes('add')) receipt.keysWithUnresolvedAdds++;
900
+ entry.retracted = retract.moment;
901
+ if (entry.wanted === 'absent') entry.wantedBy.push(retract.id);
902
+ else {
903
+ entry.wanted = 'absent';
904
+ entry.wantedBy = [retract.id];
905
+ }
906
+ owe(entry, retract.moment);
907
+ }
908
+ state.doc.retracts.push(retract);
909
+ state.retracts.set(retract.id, retract);
910
+ await this.persist(state);
911
+ return receipt;
912
+ });
913
+ }
914
+
915
+ // -- delivery ---------------------------------------------------------------
916
+
917
+ /**
918
+ * The requests owed and not on the wire, oldest act first, for one server
919
+ * or every one. Throws when the ledger can't be read.
920
+ */
921
+ due(serverId?: string): DiscordAwarenessRequest[] {
922
+ return owedRequests(this.loaded(), serverId);
923
+ }
924
+
925
+ /**
926
+ * Choose a server's next owed request and write it ahead, in one act:
927
+ * what is owed is read as the ledger holds it now, so a cancel or retract
928
+ * made while an earlier request was on the wire is never overridden by an
929
+ * older list. A reaction with a request on the wire is never chosen again
930
+ * until its answer is written. `skip` excludes requests already tried in
931
+ * this pass. Resolves once the write-ahead is on disk, with the request to
932
+ * send, or null when nothing is owed.
933
+ *
934
+ * A delivery pass claims only once its last request's answer is written,
935
+ * so a request of this server still marked on the wire here is one whose
936
+ * answer couldn't be written. It is recorded as unanswered first, as a
937
+ * start records one an earlier process left: it may land, and what it
938
+ * served is owed again.
939
+ */
940
+ async claim(
941
+ serverId: string,
942
+ skip: (request: DiscordAwarenessRequest) => boolean = () => false,
943
+ ): Promise<DiscordAwarenessRequest | null> {
944
+ return this.act(async (state) => {
945
+ let released = false;
946
+ for (const entry of state.doc.entries) {
947
+ if (entry.sending && entry.serverId === serverId) {
948
+ recordUnanswered(entry);
949
+ released = true;
950
+ }
951
+ }
952
+ const request = owedRequests(state, serverId).find((candidate) => !skip(candidate));
953
+ if (!request) {
954
+ if (released) await this.persist(state);
955
+ return null;
956
+ }
957
+ const entry = state.entries.get(markKey(request, request.emoji))!;
958
+ entry.sending = { action: request.action, due: request.due };
959
+ await this.persist(state);
960
+ return request;
961
+ });
962
+ }
963
+
964
+ /**
965
+ * Record a claimed request's answer. An answer ends what is owed only if
966
+ * it is for the act its request served: one for an older act updates what
967
+ * Discord last answered, and what may land, alone, so an act made while a
968
+ * request was out still gets its own request. Resolves to whether this
969
+ * ended what is owed without a confirmation (refused for good, or at the
970
+ * third unconfirmed attempt), so nothing more is sent for it.
971
+ */
972
+ async recordOutcome(
973
+ request: DiscordAwarenessRequest,
974
+ outcome: DiscordAwarenessOutcome,
975
+ detail: { permanent?: boolean } = {},
976
+ ): Promise<boolean> {
977
+ return this.act(async (state) => {
978
+ const entry = state.entries.get(markKey(request, request.emoji));
979
+ if (!entry?.sending || entry.sending.due !== request.due || entry.sending.action !== request.action) {
980
+ throw new Error(
981
+ `No ${request.action} for ${request.channelId}/${request.messageId} is on the wire to record an answer for`,
982
+ );
983
+ }
984
+ let ended = false;
985
+ if (outcome === 'confirmed') {
986
+ delete entry.sending;
987
+ entry.confirmed = request.action === 'add' ? 'present' : 'absent';
988
+ if (request.due === entry.due) {
989
+ entry.due = null;
990
+ entry.attempts = 0;
991
+ }
992
+ } else if (outcome === 'unknown') {
993
+ ended = recordUnanswered(entry);
994
+ } else {
995
+ delete entry.sending;
996
+ // A request refused before it left the host isn't an attempt.
997
+ if (outcome === 'failed') ended = countAttempt(entry, request.due, detail.permanent === true);
998
+ }
999
+ await this.persist(state);
1000
+ return ended;
1001
+ });
280
1002
  }
281
1003
 
1004
+ // -- views ------------------------------------------------------------------
1005
+
282
1006
  batches(): DiscordAwarenessBatch[] {
283
- return structuredClone(this.read().batches);
1007
+ return this.loaded().doc.batches.map((batch) => structuredClone(batch));
284
1008
  }
285
1009
 
286
- preparedSuppressionsForBranch(
287
- branchName: string,
288
- branches: BranchDescriptor[] = [],
289
- ): DiscordAwarenessBatch[] {
290
- return structuredClone(this.read().batches.filter((batch) =>
291
- batch.status === 'prepared'
292
- && batch.activationPolicy === 'explicit'
293
- && !!batch.suppressionIntervals?.length
294
- && isTargetActive(branchName, batch.targetBranch, branches)));
1010
+ view(): DiscordAwarenessView[] {
1011
+ const state = this.loaded();
1012
+ const views: DiscordAwarenessView[] = [];
1013
+ for (const batch of state.doc.batches) {
1014
+ if (batch.status === 'retired') continue;
1015
+ const reactions = emptyCounts();
1016
+ let landing = 0;
1017
+ for (const ref of batch.refs) {
1018
+ const entry = state.entries.get(markKey(ref, batch.emoji));
1019
+ reactions[reactionState(entry)]++;
1020
+ if (entry && mayStillLand(entry)) landing++;
1021
+ }
1022
+ views.push({
1023
+ kind: 'batch',
1024
+ id: batch.id,
1025
+ status: batch.status,
1026
+ scope: batch.scope,
1027
+ agentName: batch.agentName,
1028
+ sourceBranch: batch.sourceBranch,
1029
+ targetBranch: batch.targetBranch,
1030
+ emoji: batch.emoji,
1031
+ createdAt: batch.createdAt,
1032
+ refs: batch.refs.length,
1033
+ ...(batch.unmarked !== undefined ? { unmarked: batch.unmarked } : {}),
1034
+ ...(batch.notRemoved !== undefined ? { notRemoved: batch.notRemoved } : {}),
1035
+ ...(batch.cancelled ? { cancelled: { ...batch.cancelled } } : {}),
1036
+ reactions,
1037
+ mayStillLand: landing,
1038
+ ...(batch.legacyAdded !== undefined ? { legacyAdded: batch.legacyAdded } : {}),
1039
+ });
1040
+ }
1041
+ for (const retract of state.doc.retracts) {
1042
+ const reactions = emptyCounts();
1043
+ let landing = 0;
1044
+ for (const entry of state.doc.entries) {
1045
+ if (!entry.wantedBy.includes(retract.id)) continue;
1046
+ reactions[reactionState(entry)]++;
1047
+ if (mayStillLand(entry)) landing++;
1048
+ }
1049
+ views.push({
1050
+ kind: 'retract',
1051
+ id: retract.id,
1052
+ target: retract.target,
1053
+ at: retract.at,
1054
+ ...(retract.by ? { by: retract.by } : {}),
1055
+ ...(retract.cancelled ? { cancelled: { ...retract.cancelled } } : {}),
1056
+ keys: retract.keys,
1057
+ reactions,
1058
+ mayStillLand: landing,
1059
+ });
1060
+ }
1061
+ return views;
295
1062
  }
296
1063
 
297
- private read(): DiscordAwarenessOutboxDocument {
298
- let parsed: unknown;
299
- try {
300
- parsed = JSON.parse(readFileSync(this.path, 'utf8'));
301
- } catch (error) {
302
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
303
- return { version: 2, batches: [] };
1064
+ // -- internals --------------------------------------------------------------
1065
+
1066
+ /**
1067
+ * Run one act after every act queued before it, on the document as it is
1068
+ * then (read first if it isn't held). A version 2 ledger is rewritten as
1069
+ * version 3 by the first act, before the act's own change.
1070
+ */
1071
+ private act<T>(fn: (state: LedgerState) => Promise<T>): Promise<T> {
1072
+ const run = this.queue.then(async () => {
1073
+ const state = this.state ?? await this.open();
1074
+ try {
1075
+ return await fn(state);
1076
+ } catch (error) {
1077
+ // An act that throws has left the file as it was, so whatever it
1078
+ // changed in memory goes too: the document is read back.
1079
+ this.forget();
1080
+ throw error;
304
1081
  }
1082
+ });
1083
+ this.queue = run.catch(() => undefined);
1084
+ return run;
1085
+ }
1086
+
1087
+ /** Drop the document held in memory and read the file back; if it can't
1088
+ * be read, the next read tries again. */
1089
+ private forget(): void {
1090
+ this.state = null;
1091
+ try {
1092
+ this.loaded();
1093
+ } catch { /* unread: the next read tries again */ }
1094
+ }
1095
+
1096
+ /**
1097
+ * Read the ledger for an act. A version 2 ledger is rewritten as version
1098
+ * 3 (migrateLedger), the work it drops announced before the write; the
1099
+ * rewritten document is held only once its write is on disk.
1100
+ */
1101
+ private async open(): Promise<LedgerState> {
1102
+ const read = readLedger(this.path);
1103
+ if (read.version === 3) return (this.state = read.state);
1104
+ const { state, unsent } = migrateLedger(read.document);
1105
+ // Said before it's written: the rewrite is made once, so a notice given
1106
+ // after it would be lost for good if the process ended between the two.
1107
+ // If the write doesn't land, the next act says it again.
1108
+ if (unsent.length > 0) {
1109
+ console.error(
1110
+ `[discord-awareness] rewriting ${this.path} as version 3: ${unsent.length} batch(es) had marks ` +
1111
+ 'agent-framework 0.22.x had still to add or remove, and none of them will be sent, since nobody chose them: ' +
1112
+ unsent.map((batch) => `batch ${batch.batchId} (${batch.agentName}, ${batch.sourceBranch} to ` +
1113
+ `${batch.targetBranch}): ${batch.adds} to add, ${batch.removals} to remove`).join('; ') +
1114
+ '. A batch 0.22.x never activated placed no marks; retracting a batch takes off the mark of its emoji ' +
1115
+ 'from each of its messages, whichever batch placed it',
1116
+ );
1117
+ this.announceImport?.(unsent);
1118
+ }
1119
+ await writeLedgerFile(this.path, state.doc, this.onUnsyncedWrite);
1120
+ return (this.state = state);
1121
+ }
1122
+
1123
+ /**
1124
+ * The document for a view: as held, or read now. A version 2 ledger is
1125
+ * refused here: only an act rewrites it.
1126
+ */
1127
+ private loaded(): LedgerState {
1128
+ if (this.state) return this.state;
1129
+ const read = readLedger(this.path);
1130
+ if (read.version !== 3) {
305
1131
  throw new Error(
306
- `Could not read Discord awareness outbox ${this.path}: ${error instanceof Error ? error.message : String(error)}`,
1132
+ `Discord awareness ledger ${this.path} is version 2, which its first write rewrites as version 3, at a ` +
1133
+ 'start or an offline recovery; nothing is read from it before that',
307
1134
  );
308
1135
  }
1136
+ return (this.state = read.state);
1137
+ }
309
1138
 
310
- if (isDocumentV2(parsed)) return parsed;
311
- if (isDocumentV1(parsed)) return migrateV1(parsed);
312
- throw new Error(`Invalid Discord awareness outbox document: ${this.path}`);
1139
+ /**
1140
+ * Write the document. A write that throws has left the file as it was, so
1141
+ * the act's change, already made in memory, is dropped: the document is
1142
+ * read back from the file, and if it can't be read, the next read tries
1143
+ * again. `afterCommittedState` syncs the store first.
1144
+ */
1145
+ private async persist(state: LedgerState, opts: { afterCommittedState?: boolean } = {}): Promise<void> {
1146
+ try {
1147
+ if (opts.afterCommittedState) this.store?.sync();
1148
+ await writeLedgerFile(this.path, state.doc, this.onUnsyncedWrite);
1149
+ } catch (error) {
1150
+ this.forget();
1151
+ throw error;
1152
+ }
313
1153
  }
1154
+ }
1155
+
1156
+ // ---------------------------------------------------------------------------
1157
+ // Entries
1158
+ // ---------------------------------------------------------------------------
1159
+
1160
+ function addEntry(state: LedgerState, ref: DiscordAwarenessRef, emoji: string): LedgerEntry {
1161
+ const entry: LedgerEntry = {
1162
+ serverId: ref.serverId,
1163
+ channelId: ref.channelId,
1164
+ messageId: ref.messageId,
1165
+ emoji,
1166
+ wanted: null,
1167
+ wantedBy: [],
1168
+ due: null,
1169
+ attempts: 0,
1170
+ };
1171
+ state.doc.entries.push(entry);
1172
+ state.entries.set(markKey(ref, emoji), entry);
1173
+ return entry;
1174
+ }
1175
+
1176
+ /** An act at `moment` owes Discord a request, whatever was answered before. */
1177
+ function owe(entry: LedgerEntry, moment: number): void {
1178
+ entry.due = moment;
1179
+ entry.attempts = 0;
1180
+ delete entry.failed;
1181
+ }
1182
+
1183
+ /**
1184
+ * The request on the wire for this entry went unanswered: its action may
1185
+ * land, or have landed, and what Discord holds is unknown. It counts toward
1186
+ * the bound if it served what is owed now. This is the one place both an
1187
+ * answer the connection couldn't get and a request a previous process left
1188
+ * on the wire (recoverAtStartup) are recorded. Returns whether that ended
1189
+ * what is owed.
1190
+ */
1191
+ function recordUnanswered(entry: LedgerEntry): boolean {
1192
+ const sent = entry.sending!;
1193
+ delete entry.sending;
1194
+ const mayLand = entry.mayLand ?? [];
1195
+ if (!mayLand.includes(sent.action)) mayLand.push(sent.action);
1196
+ entry.mayLand = mayLand;
1197
+ entry.confirmed = 'unknown';
1198
+ return countAttempt(entry, sent.due, false);
1199
+ }
1200
+
1201
+ /**
1202
+ * Count an unconfirmed attempt at what `due` owed. Only an attempt at what
1203
+ * is owed now counts: one for an older act changes nothing owed. Discord's
1204
+ * refusal for good, or the bound, ends what is owed: nothing more is sent
1205
+ * for it, and the entry is `failed` until a new act owes a request.
1206
+ * Returns whether it ended.
1207
+ */
1208
+ function countAttempt(entry: LedgerEntry, due: number, permanent: boolean): boolean {
1209
+ if (due !== entry.due) return false;
1210
+ entry.attempts++;
1211
+ if (!permanent && entry.attempts < MAX_UNCONFIRMED_ATTEMPTS) return false;
1212
+ entry.due = null;
1213
+ entry.failed = true;
1214
+ return true;
1215
+ }
1216
+
1217
+ function mayStillLand(entry: LedgerEntry): boolean {
1218
+ return !!entry.sending || !!entry.mayLand?.length;
1219
+ }
1220
+
1221
+ function reactionState(entry: LedgerEntry | undefined): DiscordAwarenessReactionState {
1222
+ if (!entry) return 'untouched';
1223
+ if (entry.sending) return 'sending';
1224
+ if (entry.due !== null) return 'queued';
1225
+ if (entry.failed) return 'failed';
1226
+ return entry.confirmed ?? 'untouched';
1227
+ }
314
1228
 
315
- private write(document: DiscordAwarenessOutboxDocument): void {
316
- mkdirSync(dirname(this.path), { recursive: true });
317
- const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
318
- writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
319
- renameSync(temporary, this.path);
1229
+ /** The requests owed and not on the wire, oldest act first, then in the
1230
+ * order the ledger first held their reactions. */
1231
+ function owedRequests(state: LedgerState, serverId?: string): DiscordAwarenessRequest[] {
1232
+ const owed: DiscordAwarenessRequest[] = [];
1233
+ for (const entry of state.doc.entries) {
1234
+ if (entry.due === null || entry.sending || entry.wanted === null) continue;
1235
+ if (serverId !== undefined && entry.serverId !== serverId) continue;
1236
+ owed.push({
1237
+ serverId: entry.serverId,
1238
+ channelId: entry.channelId,
1239
+ messageId: entry.messageId,
1240
+ emoji: entry.emoji,
1241
+ action: entry.wanted === 'present' ? 'add' : 'remove',
1242
+ due: entry.due,
1243
+ });
320
1244
  }
1245
+ // A stable sort: reactions owed by one act keep the ledger's order.
1246
+ return owed.sort((a, b) => a.due - b.due);
321
1247
  }
322
1248
 
323
- function setDesired(ref: DiscordAwarenessEntry, desired: boolean): void {
324
- const changed = ref.desired !== desired;
325
- ref.desired = desired;
326
- if (ref.markerPresent === desired) {
327
- ref.deliveryStatus = 'applied';
328
- return;
1249
+ /** Requests a batch's activation made owed that are still owed. */
1250
+ function emptyCounts(): Record<DiscordAwarenessReactionState, number> {
1251
+ return { queued: 0, sending: 0, present: 0, absent: 0, unknown: 0, failed: 0, untouched: 0 };
1252
+ }
1253
+
1254
+ /**
1255
+ * A suppression whose body may still need applying on its target branch: a
1256
+ * prepared batch that names removal intervals. Its surgery's activation
1257
+ * means the body completed, and a retired batch is never resumed: its
1258
+ * surgery gave the body up after putting the source back, or
1259
+ * completeSuppression retired it once a start finished its body. A version
1260
+ * 2 ledger's batch that 0.22.x activated is taken as finished, as 0.22.x
1261
+ * took it.
1262
+ */
1263
+ function isPendingSuppression(batch: DiscordAwarenessBatch): boolean {
1264
+ return batch.status === 'prepared' && !!batch.suppressionIntervals?.length;
1265
+ }
1266
+
1267
+ function errorText(error: unknown): string {
1268
+ return boundDiscordAwarenessText(error instanceof Error ? error.message : String(error));
1269
+ }
1270
+
1271
+ /** What becomes of a batch left prepared after its surgery's body landed:
1272
+ * none of its marks is ever sent. */
1273
+ function leftPrepared(batch?: DiscordAwarenessBatch): string {
1274
+ if (!batch) return 'it stays prepared, and a start that can read the ledger retires it, none of its marks sent';
1275
+ return batch.suppressionIntervals?.length
1276
+ ? `it stays prepared, and the first start on ${batch.targetBranch} finds its removals applied and retires it, none of its marks sent`
1277
+ : 'it stays prepared, and the next start retires it, none of its marks sent';
1278
+ }
1279
+
1280
+ // ---------------------------------------------------------------------------
1281
+ // The file
1282
+ // ---------------------------------------------------------------------------
1283
+
1284
+ /**
1285
+ * Read and check the ledger file: a missing file is an empty ledger. A file
1286
+ * this build can't read for its meaning is refused, never guessed at: one
1287
+ * it can't read or parse, one that isn't the shape its version promises,
1288
+ * and any version but 2 and 3, version 1 with its own reason.
1289
+ */
1290
+ function readLedger(path: string):
1291
+ | { version: 3; state: LedgerState }
1292
+ | { version: 2; document: { version: 2; batches: unknown[] } } {
1293
+ let raw: string;
1294
+ try {
1295
+ raw = readFileSync(path, 'utf8');
1296
+ } catch (error) {
1297
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
1298
+ return { version: 3, state: indexed({ version: 3, moment: 0, batches: [], retracts: [], entries: [] }) };
1299
+ }
1300
+ throw new Error(`Could not read Discord awareness ledger ${path}: ${errorText(error)}`);
1301
+ }
1302
+ let parsed: unknown;
1303
+ try {
1304
+ parsed = JSON.parse(raw);
1305
+ } catch {
1306
+ throw new Error(`Invalid Discord awareness ledger ${path}: it isn't JSON`);
329
1307
  }
330
- const action: DiscordAwarenessAction = desired ? 'add' : 'remove';
331
- if (changed || ref.lastAction !== action || ref.deliveryStatus !== 'permanent-failure') {
332
- ref.deliveryStatus = 'pending';
1308
+ const version = !!parsed && typeof parsed === 'object' ? (parsed as { version?: unknown }).version : undefined;
1309
+ if (version === 3) {
1310
+ if (!isLedgerDocument(parsed)) throw new Error(`Invalid Discord awareness ledger ${path}`);
1311
+ return { version: 3, state: indexed(parsed) };
333
1312
  }
1313
+ if (version === 2) {
1314
+ // A ledger the released readers would have refused is refused here too,
1315
+ // never read for a meaning: a malformed field (`activationPolicy:
1316
+ // 'explicit '`) would otherwise retire a suppression whose removals a
1317
+ // start on its branch still owes.
1318
+ if (!isLegacyLedger(parsed)) throw new Error(`Invalid Discord awareness ledger ${path}`);
1319
+ return { version: 2, document: parsed as { version: 2; batches: unknown[] } };
1320
+ }
1321
+ if (version === 1) {
1322
+ throw new Error(
1323
+ `Discord awareness ledger ${path} is version 1, which only agent-framework commit b16731b2 wrote ` +
1324
+ '(2026-07-15, before any release). Every release rewrites it as version 2 at its next write, and this ' +
1325
+ 'build reads versions 2 and 3. Nothing is read from it until the file is moved away. Moving it gives up ' +
1326
+ 'only work it still had to send, which this build would retire unsent anyway, since that commit took each ' +
1327
+ 'mark out of the file once Discord confirmed it. A mark added just before that process ended, before its ' +
1328
+ 'confirmation was written, stays on Discord, and retract can\'t find it',
1329
+ );
1330
+ }
1331
+ throw new Error(
1332
+ `Discord awareness ledger ${path} is ${version === undefined ? 'of no version' : `version ${JSON.stringify(version)}`}, ` +
1333
+ 'which this build can\'t read: it reads versions 2 and 3',
1334
+ );
334
1335
  }
335
1336
 
336
- function isTargetActive(
337
- activeName: string,
338
- targetName: string,
339
- branches: BranchDescriptor[],
340
- ): boolean {
341
- if (activeName === targetName) return true;
342
- const byName = new Map(branches.map((branch) => [branch.name, branch]));
343
- const byId = new Map(branches.map((branch) => [branch.id, branch]));
344
- let cursor = byName.get(activeName);
345
- const visited = new Set<string>();
346
- while (cursor?.parentId && !visited.has(cursor.id)) {
347
- visited.add(cursor.id);
348
- cursor = byId.get(cursor.parentId);
349
- if (cursor?.name === targetName) return true;
1337
+ function indexed(doc: LedgerDocument): LedgerState {
1338
+ return {
1339
+ doc,
1340
+ batches: new Map(doc.batches.map((batch) => [batch.id, batch])),
1341
+ retracts: new Map(doc.retracts.map((retract) => [retract.id, retract])),
1342
+ entries: new Map(doc.entries.map((entry) => [markKey(entry, entry.emoji), entry])),
1343
+ };
1344
+ }
1345
+
1346
+ /**
1347
+ * Write the document: compact JSON to a temporary file beside the ledger,
1348
+ * synced, renamed over it, then its directory synced, for the ledger's entry
1349
+ * in it, and that directory's parent, for the directory's own entry, which
1350
+ * is new when a write, this one or an earlier one, had to make it. This is
1351
+ * the framework's writeRecoveryFile rule. A failure before the rename removes
1352
+ * the temporary file and throws, leaving the ledger as it was. A directory
1353
+ * that can't be synced after it is reported, and the write stands: the new
1354
+ * ledger is in place, though an operating system crash could lose it.
1355
+ * Serialized before the first await, so the text is the document as the act
1356
+ * left it.
1357
+ */
1358
+ async function writeLedgerFile(
1359
+ path: string,
1360
+ doc: LedgerDocument,
1361
+ onUnsynced: (directory: string, error: unknown) => void,
1362
+ ): Promise<void> {
1363
+ const text = `${JSON.stringify(doc)}\n`;
1364
+ const directory = dirname(path);
1365
+ await mkdir(directory, { recursive: true });
1366
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
1367
+ try {
1368
+ const file = await open(temporary, 'w', 0o600);
1369
+ try {
1370
+ await file.writeFile(text, 'utf8');
1371
+ await file.sync();
1372
+ } finally {
1373
+ await file.close();
1374
+ }
1375
+ await rename(temporary, path);
1376
+ } catch (error) {
1377
+ await unlink(temporary).catch(() => undefined);
1378
+ throw error;
350
1379
  }
351
- return false;
1380
+ for (const synced of [directory, dirname(directory)]) {
1381
+ try {
1382
+ await syncDirectory(synced);
1383
+ } catch (error) {
1384
+ try {
1385
+ onUnsynced(synced, error);
1386
+ } catch { /* the write stands: a report that fails changes nothing in the file */ }
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ /**
1392
+ * Sync a directory, so its entries reach the disk, as the framework's
1393
+ * syncRecoveryDirectory does: not on win32, where a directory can't be
1394
+ * opened, and a sync refused with EINVAL or EBADF, the answer where the
1395
+ * filesystem can't sync a directory, is let go, since it would come again on
1396
+ * every write. Any other failure throws, for the write to report.
1397
+ */
1398
+ async function syncDirectory(directory: string): Promise<void> {
1399
+ if (process.platform === 'win32') return;
1400
+ const handle = await open(directory, 'r');
1401
+ try {
1402
+ await handle.sync();
1403
+ } catch (error) {
1404
+ const code = (error as NodeJS.ErrnoException).code;
1405
+ if (code !== 'EINVAL' && code !== 'EBADF') throw error;
1406
+ } finally {
1407
+ await handle.close();
1408
+ }
1409
+ }
1410
+
1411
+ /** The report of a directory that couldn't be synced, in the words of the
1412
+ * framework's recovery-sync alert. */
1413
+ export function unsyncedWriteText(directory: string, path: string, error: unknown): string {
1414
+ return `syncing ${directory} failed, so ${path} may not survive a crash: ${errorText(error)}`;
1415
+ }
1416
+
1417
+ const isString = (value: unknown): value is string => typeof value === 'string';
1418
+ const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
1419
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
1420
+ !!value && typeof value === 'object' && !Array.isArray(value);
1421
+ const isOptional = (value: unknown, check: (value: unknown) => boolean) => value === undefined || check(value);
1422
+ const isRefShape = (value: unknown): boolean => isRecord(value)
1423
+ && isString(value.serverId) && isString(value.channelId) && isString(value.messageId);
1424
+ const isAction = (value: unknown): boolean => value === 'add' || value === 'remove';
1425
+ const isCancelled = (value: unknown): boolean => isRecord(value) && isNumber(value.at) && isOptional(value.by, isString);
1426
+ const isInterval = (value: unknown): boolean => isRecord(value) && isString(value.fromId) && isString(value.toId);
1427
+
1428
+ /** Whether a parsed version 3 document has the shape this build writes. */
1429
+ function isLedgerDocument(value: unknown): value is LedgerDocument {
1430
+ if (!isRecord(value) || value.version !== 3 || !isNumber(value.moment)) return false;
1431
+ const { batches, retracts, entries } = value;
1432
+ return Array.isArray(batches) && batches.every((batch) => isRecord(batch)
1433
+ && isString(batch.id)
1434
+ && (batch.status === 'prepared' || batch.status === 'active' || batch.status === 'retired')
1435
+ && isString(batch.agentName) && isString(batch.sourceBranch) && isString(batch.targetBranch)
1436
+ && isString(batch.emoji) && isNumber(batch.createdAt) && isNumber(batch.chosen)
1437
+ && (batch.scope === 'none' || batch.scope === 'addressed' || batch.scope === 'all' || batch.scope === 'legacy')
1438
+ && Array.isArray(batch.refs) && batch.refs.every(isRefShape)
1439
+ && isOptional(batch.unmarked, isNumber) && isOptional(batch.notRemoved, isNumber)
1440
+ && isOptional(batch.suppressionIntervals, (intervals) => Array.isArray(intervals) && intervals.every(isInterval))
1441
+ && isOptional(batch.cancelled, isCancelled) && isOptional(batch.legacyAdded, isNumber))
1442
+ && Array.isArray(retracts) && retracts.every((retract) => isRecord(retract)
1443
+ && isString(retract.id) && isString(retract.target) && isNumber(retract.at)
1444
+ && isOptional(retract.by, isString) && isNumber(retract.moment) && isNumber(retract.keys)
1445
+ && isOptional(retract.cancelled, isCancelled))
1446
+ && Array.isArray(entries) && entries.every((entry) => isRefShape(entry) && isRecord(entry)
1447
+ && isString(entry.emoji)
1448
+ && (entry.wanted === 'present' || entry.wanted === 'absent' || entry.wanted === null)
1449
+ && Array.isArray(entry.wantedBy) && entry.wantedBy.every(isString)
1450
+ && isOptional(entry.retracted, isNumber)
1451
+ && (entry.due === null || isNumber(entry.due)) && isNumber(entry.attempts)
1452
+ && isOptional(entry.sending, (sending) => isRecord(sending) && isAction(sending.action) && isNumber(sending.due))
1453
+ && isOptional(entry.confirmed, (confirmed) => confirmed === 'present' || confirmed === 'absent' || confirmed === 'unknown')
1454
+ && isOptional(entry.mayLand, (mayLand) => Array.isArray(mayLand) && mayLand.every(isAction))
1455
+ && isOptional(entry.failed, (failed) => typeof failed === 'boolean'));
352
1456
  }
353
1457
 
354
- function findEntry(
355
- document: DiscordAwarenessOutboxDocument,
356
- batchId: string,
357
- ref: DiscordAwarenessRef,
358
- ): DiscordAwarenessEntry | undefined {
359
- return document.batches
360
- .find((batch) => batch.id === batchId)
361
- ?.refs.find((candidate) => refKey(candidate) === refKey(ref));
1458
+ // ---------------------------------------------------------------------------
1459
+ // Version 2
1460
+ // ---------------------------------------------------------------------------
1461
+
1462
+ interface LegacyEntry extends DiscordAwarenessRef {
1463
+ desired: boolean;
1464
+ markerPresent: boolean;
1465
+ deliveryStatus: 'pending' | 'applied' | 'permanent-failure';
1466
+ attempts: number;
1467
+ lastAction?: DiscordAwarenessAction;
1468
+ lastAttemptAt?: number;
1469
+ lastError?: string;
1470
+ }
1471
+
1472
+ interface LegacyBatch {
1473
+ id: string;
1474
+ status: 'prepared' | 'active';
1475
+ agentName: string;
1476
+ sourceBranch: string;
1477
+ targetBranch: string;
1478
+ emoji: string;
1479
+ createdAt: number;
1480
+ refs: LegacyEntry[];
1481
+ activationPolicy?: 'target-branch' | 'explicit';
1482
+ suppressionIntervals?: DiscordSuppressionInterval[];
362
1483
  }
363
1484
 
1485
+ /**
1486
+ * Whether a parsed ledger is a version 2 ledger the released readers
1487
+ * accepted (every release with the outbox, 0.6.7 to 0.22.3, checked these
1488
+ * fields alike): batches `prepared` or `active`, with an activation policy
1489
+ * and suppression intervals of the shapes they wrote, and refs that are
1490
+ * full entries.
1491
+ */
1492
+ function isLegacyLedger(value: unknown): boolean {
1493
+ if (!isRecord(value) || value.version !== 2 || !Array.isArray(value.batches)) return false;
1494
+ return value.batches.every((batch) => isRecord(batch)
1495
+ && isString(batch.id)
1496
+ && (batch.status === 'prepared' || batch.status === 'active')
1497
+ && isString(batch.agentName) && isString(batch.sourceBranch) && isString(batch.targetBranch)
1498
+ && isString(batch.emoji) && typeof batch.createdAt === 'number'
1499
+ && Array.isArray(batch.refs)
1500
+ && (batch.activationPolicy === undefined || batch.activationPolicy === 'target-branch'
1501
+ || batch.activationPolicy === 'explicit')
1502
+ && isOptional(batch.suppressionIntervals, (intervals) => Array.isArray(intervals) && intervals.every(isInterval))
1503
+ && batch.refs.every(isLegacyEntry));
1504
+ }
1505
+
1506
+ /** Whether a version 2 ledger's ref is an entry as the released readers required it. */
1507
+ function isLegacyEntry(value: unknown): boolean {
1508
+ return isRefShape(value) && isRecord(value)
1509
+ && typeof value.desired === 'boolean'
1510
+ && typeof value.markerPresent === 'boolean'
1511
+ && (value.deliveryStatus === 'pending' || value.deliveryStatus === 'applied'
1512
+ || value.deliveryStatus === 'permanent-failure')
1513
+ && typeof value.attempts === 'number'
1514
+ && (value.lastAction === undefined || isAction(value.lastAction))
1515
+ && (value.lastAttemptAt === undefined || typeof value.lastAttemptAt === 'number')
1516
+ && (value.lastError === undefined || typeof value.lastError === 'string');
1517
+ }
1518
+
1519
+ /**
1520
+ * Rewrite a version 2 ledger as version 3. Batches keep their ids, take
1521
+ * scope `legacy`, and keep, of what the old ledger recorded about each ref's
1522
+ * attempts, only how many refs the last attempt saw added (`legacyAdded`):
1523
+ * never a judgment about what is on Discord. No entries are made: a retract
1524
+ * of a batch reaches its refs, and one of `all` the refs of every batch it
1525
+ * activated, the only ones 0.22.x marked. The work the old ledger had
1526
+ * still to do (its pending adds and removals, and every mark of a batch it
1527
+ * never activated) is dropped, none of it sent, and returned in `unsent`:
1528
+ * it was queued before marks were an explicit choice, so nobody chose it. A
1529
+ * batch it activated stays active. One it never activated is retired,
1530
+ * except a suppression whose body may be pending on its branch, which stays
1531
+ * prepared, as an interrupted surgery's does, for a start on that branch to
1532
+ * finish and retire (see pendingSuppressionsFor). A second listing of a
1533
+ * batch id is skipped.
1534
+ */
1535
+ function migrateLedger(
1536
+ document: { version: 2; batches: unknown[] },
1537
+ ): { state: LedgerState; unsent: DiscordAwarenessImportedUnsent[] } {
1538
+ const doc: LedgerDocument = { version: 3, moment: 0, batches: [], retracts: [], entries: [] };
1539
+ const unsent: DiscordAwarenessImportedUnsent[] = [];
1540
+ const seen = new Set<string>();
1541
+ for (const raw of document.batches as LegacyBatch[]) {
1542
+ if (seen.has(raw.id)) continue;
1543
+ seen.add(raw.id);
1544
+ const legacyActive = raw.status === 'active';
1545
+ const pendingBody = raw.activationPolicy === 'explicit' && !!raw.suppressionIntervals?.length;
1546
+ doc.batches.push({
1547
+ id: raw.id,
1548
+ status: legacyActive ? 'active' : pendingBody ? 'prepared' : 'retired',
1549
+ agentName: raw.agentName,
1550
+ sourceBranch: raw.sourceBranch,
1551
+ targetBranch: raw.targetBranch,
1552
+ emoji: raw.emoji,
1553
+ createdAt: raw.createdAt,
1554
+ chosen: ++doc.moment,
1555
+ scope: 'legacy',
1556
+ refs: dedupeRefs(raw.refs),
1557
+ ...(raw.suppressionIntervals?.length
1558
+ ? { suppressionIntervals: raw.suppressionIntervals.map((interval) => ({ fromId: interval.fromId, toId: interval.toId })) }
1559
+ : {}),
1560
+ // Only `lastError` speaks to the last attempt's outcome: the old
1561
+ // writer cleared it on success and set it on failure, and nothing else
1562
+ // touched it. `deliveryStatus` was its reconciliation state, rewritten
1563
+ // by branch switches without any request.
1564
+ legacyAdded: new Set(raw.refs
1565
+ .filter((entry) => !!entry.attempts && entry.lastAction === 'add' && entry.lastError === undefined)
1566
+ .map((entry) => refKey(entry))).size,
1567
+ });
1568
+ let adds = 0;
1569
+ let removals = 0;
1570
+ const counted = new Set<string>();
1571
+ for (const entry of raw.refs) {
1572
+ // A ref listed twice is one ref.
1573
+ if (counted.has(refKey(entry))) continue;
1574
+ counted.add(refKey(entry));
1575
+ // What 0.22.x had still to do for this ref, dropped here.
1576
+ if ((!legacyActive || entry.deliveryStatus === 'pending') && (!legacyActive || entry.desired || entry.markerPresent)) {
1577
+ if (entry.desired || !legacyActive) adds++;
1578
+ else removals++;
1579
+ }
1580
+ }
1581
+ if (adds + removals > 0) {
1582
+ unsent.push({
1583
+ batchId: raw.id,
1584
+ agentName: raw.agentName,
1585
+ sourceBranch: raw.sourceBranch,
1586
+ targetBranch: raw.targetBranch,
1587
+ adds,
1588
+ removals,
1589
+ });
1590
+ }
1591
+ }
1592
+ return { state: indexed(doc), unsent };
1593
+ }
1594
+
1595
+ // ---------------------------------------------------------------------------
1596
+ // Keys
1597
+ // ---------------------------------------------------------------------------
1598
+
364
1599
  function dedupeRefs(refs: DiscordAwarenessRef[]): DiscordAwarenessRef[] {
365
1600
  const deduped = new Map<string, DiscordAwarenessRef>();
366
1601
  for (const ref of refs) {
367
1602
  if (!ref.serverId || !ref.channelId || !ref.messageId) continue;
368
- deduped.set(refKey(ref), { ...ref });
1603
+ deduped.set(refKey(ref), { serverId: ref.serverId, channelId: ref.channelId, messageId: ref.messageId });
369
1604
  }
370
1605
  return [...deduped.values()];
371
1606
  }
372
1607
 
373
- function refKey(ref: DiscordAwarenessRef): string {
374
- return `${ref.serverId}\0${ref.channelId}\0${ref.messageId}`;
1608
+ /** The Discord channel id a ref's channel names: its last part, when it's
1609
+ * spelled `discord:<guild>:<channel>`. Delivery sends it, and keys use it. */
1610
+ export function discordChannelOf(channelId: string): string {
1611
+ return channelId.startsWith('discord:') ? channelId.split(':').at(-1)! : channelId;
375
1612
  }
376
1613
 
377
- function markerKey(emoji: string, ref: DiscordAwarenessRef): string {
378
- return `${emoji}\0${refKey(ref)}`;
1614
+ /** A ref's key, by its channel's Discord id, so one channel spelled two ways
1615
+ * is one channel. */
1616
+ function refKey(ref: DiscordAwarenessRef): string {
1617
+ return `${ref.serverId}\0${discordChannelOf(ref.channelId)}\0${ref.messageId}`;
379
1618
  }
380
1619
 
381
- function isEntry(value: unknown): value is DiscordAwarenessEntry {
382
- if (!value || typeof value !== 'object') return false;
383
- const entry = value as Partial<DiscordAwarenessEntry>;
384
- return typeof entry.serverId === 'string'
385
- && typeof entry.channelId === 'string'
386
- && typeof entry.messageId === 'string'
387
- && typeof entry.desired === 'boolean'
388
- && typeof entry.markerPresent === 'boolean'
389
- && (entry.deliveryStatus === 'pending'
390
- || entry.deliveryStatus === 'applied'
391
- || entry.deliveryStatus === 'permanent-failure')
392
- && typeof entry.attempts === 'number'
393
- && (entry.lastAction === undefined || entry.lastAction === 'add' || entry.lastAction === 'remove')
394
- && (entry.lastAttemptAt === undefined || typeof entry.lastAttemptAt === 'number')
395
- && (entry.lastError === undefined || typeof entry.lastError === 'string');
396
- }
397
-
398
- function isDocumentV2(value: unknown): value is DiscordAwarenessOutboxDocument {
399
- if (!value || typeof value !== 'object') return false;
400
- const document = value as { version?: unknown; batches?: unknown };
401
- if (document.version !== 2 || !Array.isArray(document.batches)) return false;
402
- return document.batches.every((batch) => {
403
- if (!batch || typeof batch !== 'object') return false;
404
- const candidate = batch as Partial<DiscordAwarenessBatch>;
405
- return typeof candidate.id === 'string'
406
- && (candidate.status === 'prepared' || candidate.status === 'active')
407
- && typeof candidate.agentName === 'string'
408
- && typeof candidate.sourceBranch === 'string'
409
- && typeof candidate.targetBranch === 'string'
410
- && typeof candidate.emoji === 'string'
411
- && typeof candidate.createdAt === 'number'
412
- && Array.isArray(candidate.refs)
413
- && (candidate.activationPolicy === undefined
414
- || candidate.activationPolicy === 'target-branch'
415
- || candidate.activationPolicy === 'explicit')
416
- && (candidate.suppressionIntervals === undefined
417
- || (Array.isArray(candidate.suppressionIntervals)
418
- && candidate.suppressionIntervals.every((interval) =>
419
- !!interval
420
- && typeof interval.fromId === 'string'
421
- && typeof interval.toId === 'string')))
422
- && candidate.refs.every(isEntry);
423
- });
424
- }
425
-
426
- interface LegacyDocument {
427
- version: 1;
428
- batches: Array<{
429
- id: string;
430
- status: 'prepared' | 'pending';
431
- agentName: string;
432
- sourceBranch: string;
433
- targetBranch: string;
434
- emoji: string;
435
- createdAt: number;
436
- refs: DiscordAwarenessRef[];
437
- activationPolicy?: 'target-branch' | 'explicit';
438
- }>;
439
- }
440
-
441
- function isDocumentV1(value: unknown): value is LegacyDocument {
442
- if (!value || typeof value !== 'object') return false;
443
- const document = value as { version?: unknown; batches?: unknown };
444
- if (document.version !== 1 || !Array.isArray(document.batches)) return false;
445
- return document.batches.every((batch) => {
446
- if (!batch || typeof batch !== 'object') return false;
447
- const candidate = batch as LegacyDocument['batches'][number];
448
- return typeof candidate.id === 'string'
449
- && (candidate.status === 'prepared' || candidate.status === 'pending')
450
- && typeof candidate.agentName === 'string'
451
- && typeof candidate.sourceBranch === 'string'
452
- && typeof candidate.targetBranch === 'string'
453
- && typeof candidate.emoji === 'string'
454
- && typeof candidate.createdAt === 'number'
455
- && Array.isArray(candidate.refs)
456
- && candidate.refs.every((ref) => !!ref
457
- && typeof ref.serverId === 'string'
458
- && typeof ref.channelId === 'string'
459
- && typeof ref.messageId === 'string');
460
- });
461
- }
462
-
463
- function migrateV1(document: LegacyDocument): DiscordAwarenessOutboxDocument {
464
- return {
465
- version: 2,
466
- batches: document.batches.map((batch) => ({
467
- ...batch,
468
- status: batch.status === 'pending' ? 'active' : 'prepared',
469
- refs: batch.refs.map((ref) => ({
470
- ...ref,
471
- desired: batch.status === 'pending',
472
- markerPresent: false,
473
- deliveryStatus: batch.status === 'pending' ? 'pending' : 'applied',
474
- attempts: 0,
475
- })),
476
- })),
477
- };
1620
+ function markKey(ref: DiscordAwarenessRef, emoji: string): string {
1621
+ return `${emoji}\0${refKey(ref)}`;
478
1622
  }