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