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