@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.
- package/README.md +212 -42
- package/dist/src/agent.d.ts +60 -9
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +92 -12
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +14 -12
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/code-execution/engine.d.ts +177 -0
- package/dist/src/code-execution/engine.d.ts.map +1 -0
- package/dist/src/code-execution/engine.js +494 -0
- package/dist/src/code-execution/engine.js.map +1 -0
- package/dist/src/code-execution/index.d.ts +5 -10
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js +5 -9
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/isolate/calls.d.ts +114 -0
- package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/calls.js +145 -0
- package/dist/src/code-execution/isolate/calls.js.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.js +5 -0
- package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
- package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
- package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-main.js +132 -0
- package/dist/src/code-execution/isolate/child-main.js.map +1 -0
- package/dist/src/code-execution/isolate/child.d.ts +44 -0
- package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child.js +315 -0
- package/dist/src/code-execution/isolate/child.js.map +1 -0
- package/dist/src/code-execution/isolate/frames.d.ts +80 -0
- package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/frames.js +210 -0
- package/dist/src/code-execution/isolate/frames.js.map +1 -0
- package/dist/src/code-execution/isolate/output.d.ts +68 -0
- package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/output.js +143 -0
- package/dist/src/code-execution/isolate/output.js.map +1 -0
- package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
- package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/prelude.js +401 -0
- package/dist/src/code-execution/isolate/prelude.js.map +1 -0
- package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
- package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/protocol.js +150 -0
- package/dist/src/code-execution/isolate/protocol.js.map +1 -0
- package/dist/src/code-execution/isolate/render.d.ts +57 -0
- package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/render.js +214 -0
- package/dist/src/code-execution/isolate/render.js.map +1 -0
- package/dist/src/code-execution/isolate/runner.d.ts +250 -0
- package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/runner.js +854 -0
- package/dist/src/code-execution/isolate/runner.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/scripts.js +357 -0
- package/dist/src/code-execution/isolate/scripts.js.map +1 -0
- package/dist/src/code-execution/quickjs.wasm +0 -0
- package/dist/src/code-execution/tool-definition.d.ts +93 -43
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +176 -142
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/deadline-timer.d.ts +3 -0
- package/dist/src/deadline-timer.d.ts.map +1 -1
- package/dist/src/deadline-timer.js +1 -1
- package/dist/src/deadline-timer.js.map +1 -1
- package/dist/src/framework.d.ts +1027 -440
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +4093 -2238
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +28 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +88 -10
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -3
- package/dist/src/held-writes.d.ts.map +1 -1
- package/dist/src/held-writes.js +16 -3
- package/dist/src/held-writes.js.map +1 -1
- package/dist/src/index.d.ts +6 -6
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +9 -8
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +471 -75
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1673 -337
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +12 -7
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +12 -7
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +9 -5
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +50 -0
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +130 -0
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -2
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +6 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +8 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +15 -6
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
- package/dist/src/mcpl/modern-connection.js +168 -53
- package/dist/src/mcpl/modern-connection.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -5
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +4 -5
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts +97 -10
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +173 -16
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +132 -14
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +227 -50
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +6 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +4 -1
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +4 -4
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +50 -21
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +158 -74
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +24 -0
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +33 -0
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +55 -7
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +163 -34
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +22 -6
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +4 -2
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +100 -13
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +8 -13
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +27 -4
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +15 -3
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +27 -5
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/unknown-tool.d.ts +4 -1
- package/dist/src/modules/unknown-tool.d.ts.map +1 -1
- package/dist/src/modules/unknown-tool.js +4 -1
- package/dist/src/modules/unknown-tool.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +3 -1
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
- package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-plan.js +794 -0
- package/dist/src/modules/workspace/edit-plan.js.map +1 -0
- package/dist/src/modules/workspace/edit-text.d.ts +117 -0
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-text.js +584 -0
- package/dist/src/modules/workspace/edit-text.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +5 -4
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +78 -25
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +484 -61
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +2173 -645
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +213 -38
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +416 -36
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +15 -8
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/text.d.ts +32 -0
- package/dist/src/modules/workspace/text.d.ts.map +1 -0
- package/dist/src/modules/workspace/text.js +67 -0
- package/dist/src/modules/workspace/text.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +210 -6
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +3 -2
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/operator-log.d.ts +52 -24
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/recovery/offline-branch.d.ts +26 -1
- package/dist/src/recovery/offline-branch.d.ts.map +1 -1
- package/dist/src/recovery/offline-branch.js +164 -39
- package/dist/src/recovery/offline-branch.js.map +1 -1
- package/dist/src/recovery/recover-cli.js +23 -4
- package/dist/src/recovery/recover-cli.js.map +1 -1
- package/dist/src/send-tools.d.ts +11 -0
- package/dist/src/send-tools.d.ts.map +1 -0
- package/dist/src/send-tools.js +11 -0
- package/dist/src/send-tools.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +4 -3
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +5 -4
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +1 -1
- package/dist/src/tool-result-history.js +1 -1
- package/dist/src/tune-out/coordinator.d.ts +21 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +139 -37
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/types/agent.d.ts +4 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +62 -9
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +62 -58
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +40 -8
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +111 -10
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/types.d.ts +16 -0
- package/dist/src/usage/types.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.d.ts +14 -1
- package/dist/src/usage/usage-tracker.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.js +81 -41
- package/dist/src/usage/usage-tracker.js.map +1 -1
- package/package.json +6 -5
- package/src/agent.ts +93 -12
- package/src/api/server.ts +14 -12
- package/src/code-execution/engine.ts +595 -0
- package/src/code-execution/index.ts +5 -17
- package/src/code-execution/isolate/calls.ts +199 -0
- package/src/code-execution/isolate/child-entry.ts +5 -0
- package/src/code-execution/isolate/child-main.ts +132 -0
- package/src/code-execution/isolate/child.ts +327 -0
- package/src/code-execution/isolate/frames.ts +209 -0
- package/src/code-execution/isolate/output.ts +161 -0
- package/src/code-execution/isolate/prelude.ts +409 -0
- package/src/code-execution/isolate/protocol.ts +240 -0
- package/src/code-execution/isolate/render.ts +230 -0
- package/src/code-execution/isolate/runner.ts +911 -0
- package/src/code-execution/isolate/scripts.ts +479 -0
- package/src/code-execution/quickjs.wasm +0 -0
- package/src/code-execution/quickjs.wasm.LICENSES +149 -0
- package/src/code-execution/tool-definition.ts +236 -167
- package/src/deadline-timer.ts +1 -1
- package/src/framework.ts +4468 -2418
- package/src/gate/event-gate.ts +78 -11
- package/src/held-writes.ts +17 -3
- package/src/index.ts +30 -22
- package/src/mcpl/channel-registry.ts +1825 -368
- package/src/mcpl/conversation-router.ts +12 -7
- package/src/mcpl/convert-block.ts +9 -5
- package/src/mcpl/inbound-source.ts +131 -0
- package/src/mcpl/index.ts +4 -2
- package/src/mcpl/log-value.ts +9 -0
- package/src/mcpl/modern-connection.ts +162 -53
- package/src/mcpl/prose-grammar.ts +4 -5
- package/src/mcpl/protocol-family.ts +199 -21
- package/src/mcpl/push-coalescer.ts +306 -62
- package/src/mcpl/push-handler.ts +10 -2
- package/src/mcpl/reference-fetcher.ts +4 -4
- package/src/mcpl/server-connection.ts +180 -79
- package/src/mcpl/tags.ts +36 -0
- package/src/mcpl/tool-result-normalize.ts +2 -1
- package/src/mcpl/transport.ts +162 -33
- package/src/mcpl/types.ts +23 -6
- package/src/module-registry.ts +92 -13
- package/src/modules/discord/index.ts +8 -13
- package/src/modules/history/index.ts +25 -3
- package/src/modules/history/semantic.ts +29 -6
- package/src/modules/index.ts +1 -0
- package/src/modules/unknown-tool.ts +4 -1
- package/src/modules/workspace/branch-intent.ts +3 -1
- package/src/modules/workspace/edit-plan.ts +954 -0
- package/src/modules/workspace/edit-text.ts +618 -0
- package/src/modules/workspace/effects.ts +70 -19
- package/src/modules/workspace/index.ts +2247 -642
- package/src/modules/workspace/observe.ts +465 -56
- package/src/modules/workspace/reconcile.ts +14 -7
- package/src/modules/workspace/text.ts +68 -0
- package/src/modules/workspace/types.ts +211 -6
- package/src/modules/workspace/watcher.ts +3 -2
- package/src/operator-log.ts +47 -19
- package/src/recovery/discord-awareness-outbox.ts +1500 -356
- package/src/recovery/offline-branch.ts +189 -55
- package/src/recovery/recover-cli.ts +25 -4
- package/src/send-tools.ts +11 -0
- package/src/tool-result-guard.ts +6 -5
- package/src/tool-result-history.ts +1 -1
- package/src/tune-out/coordinator.ts +163 -42
- package/src/types/agent.ts +4 -0
- package/src/types/events.ts +62 -9
- package/src/types/framework.ts +62 -58
- package/src/types/module.ts +40 -8
- package/src/types/trace.ts +121 -15
- package/src/usage/types.ts +16 -0
- package/src/usage/usage-tracker.ts +86 -43
- package/dist/src/code-execution/py-runner.d.ts +0 -191
- package/dist/src/code-execution/py-runner.d.ts.map +0 -1
- package/dist/src/code-execution/py-runner.js +0 -655
- package/dist/src/code-execution/py-runner.js.map +0 -1
- package/dist/src/code-execution/runtime-py.d.ts +0 -50
- package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
- package/dist/src/code-execution/runtime-py.js +0 -677
- package/dist/src/code-execution/runtime-py.js.map +0 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
- package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
- package/dist/src/mcpl/prose-stream-router.js +0 -291
- package/dist/src/mcpl/prose-stream-router.js.map +0 -1
- package/src/code-execution/py-runner.ts +0 -791
- package/src/code-execution/runtime-py.ts +0 -677
- package/src/mcpl/prose-stream-router.ts +0 -268
|
@@ -1,354 +1,1261 @@
|
|
|
1
|
-
import {
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
159
|
+
if (authorized && !authorized.has(key))
|
|
160
|
+
continue;
|
|
161
|
+
refs.push(candidate.ref);
|
|
24
162
|
}
|
|
25
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
if (
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
822
|
+
if (opts.afterCommittedState)
|
|
823
|
+
this.store?.sync();
|
|
824
|
+
await writeLedgerFile(this.path, state.doc, this.onUnsyncedWrite);
|
|
201
825
|
}
|
|
202
826
|
catch (error) {
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
1055
|
+
catch { /* the write stands: a report that fails changes nothing in the file */ }
|
|
207
1056
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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), {
|
|
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
|
|
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
|