@animalabs/agent-framework 0.26.2 → 0.28.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 +99 -8
- package/dist/src/agent.d.ts +291 -23
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +596 -61
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +5 -4
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/auto-continue.d.ts +34 -0
- package/dist/src/auto-continue.d.ts.map +1 -0
- package/dist/src/auto-continue.js +36 -0
- package/dist/src/auto-continue.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +5 -1
- package/dist/src/code-execution/isolate/scripts.js.map +1 -1
- package/dist/src/cut-off-round.d.ts +24 -0
- package/dist/src/cut-off-round.d.ts.map +1 -0
- package/dist/src/cut-off-round.js +87 -0
- package/dist/src/cut-off-round.js.map +1 -0
- package/dist/src/framework.d.ts +1038 -193
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +5967 -1514
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +405 -46
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +2646 -399
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +3 -2
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +2 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/gate/recipe-reconcile.d.ts +103 -0
- package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
- package/dist/src/gate/recipe-reconcile.js +325 -0
- package/dist/src/gate/recipe-reconcile.js.map +1 -0
- package/dist/src/gate/reply-judge.d.ts +116 -0
- package/dist/src/gate/reply-judge.d.ts.map +1 -0
- package/dist/src/gate/reply-judge.js +467 -0
- package/dist/src/gate/reply-judge.js.map +1 -0
- package/dist/src/gate/types.d.ts +229 -8
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/heartbeat/heartbeat.d.ts +184 -0
- package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
- package/dist/src/heartbeat/heartbeat.js +752 -0
- package/dist/src/heartbeat/heartbeat.js.map +1 -0
- package/dist/src/heartbeat/reminder-time.d.ts +5 -0
- package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
- package/dist/src/heartbeat/reminder-time.js +107 -0
- package/dist/src/heartbeat/reminder-time.js.map +1 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
- package/dist/src/mcpl/capability-mask.js +3 -2
- package/dist/src/mcpl/capability-mask.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +433 -113
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1154 -435
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +16 -8
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +29 -15
- 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 +14 -2
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +32 -7
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +26 -10
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +40 -69
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +4 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +14 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +73 -28
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +6 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +3 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +20 -2
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +33 -11
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +177 -145
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +380 -46
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +736 -77
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +93 -4
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +176 -11
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +9 -2
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +12 -2
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +21 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-presence.d.ts +311 -0
- package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
- package/dist/src/mcpl/tool-presence.js +697 -0
- package/dist/src/mcpl/tool-presence.js.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js +14 -6
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +46 -0
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
- package/dist/src/mcpl/tool-signature.js +321 -0
- package/dist/src/mcpl/tool-signature.js.map +1 -0
- package/dist/src/mcpl/toolset-host.d.ts +251 -0
- package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
- package/dist/src/mcpl/toolset-host.js +720 -0
- package/dist/src/mcpl/toolset-host.js.map +1 -0
- package/dist/src/mcpl/toolsets-module.d.ts +83 -0
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
- package/dist/src/mcpl/toolsets-module.js +523 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +2 -2
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +3 -2
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +24 -2
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +7 -9
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +21 -9
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +27 -9
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +77 -17
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +2 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +73 -28
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +108 -6
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +273 -57
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/workspace/answers.d.ts +22 -0
- package/dist/src/modules/workspace/answers.d.ts.map +1 -0
- package/dist/src/modules/workspace/answers.js +123 -0
- package/dist/src/modules/workspace/answers.js.map +1 -0
- package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +6 -2
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/direct-write.d.ts +184 -0
- package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
- package/dist/src/modules/workspace/direct-write.js +417 -0
- package/dist/src/modules/workspace/direct-write.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +48 -17
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/edit-text.d.ts +27 -12
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/edit-text.js +61 -29
- package/dist/src/modules/workspace/edit-text.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +10 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +21 -14
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +308 -78
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +2103 -720
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +75 -63
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +366 -180
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/read-page.d.ts +41 -0
- package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
- package/dist/src/modules/workspace/read-page.js +219 -0
- package/dist/src/modules/workspace/read-page.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +26 -16
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/result-text.d.ts +68 -0
- package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/result-text.js +94 -0
- package/dist/src/modules/workspace/result-text.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +9 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +14 -0
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/text.d.ts +29 -12
- package/dist/src/modules/workspace/text.d.ts.map +1 -1
- package/dist/src/modules/workspace/text.js +35 -33
- package/dist/src/modules/workspace/text.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +137 -60
- 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.map +1 -1
- package/dist/src/modules/workspace/watcher.js +8 -3
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/prose-drafts.d.ts +4 -3
- package/dist/src/prose-drafts.d.ts.map +1 -1
- package/dist/src/prose-drafts.js +3 -2
- package/dist/src/prose-drafts.js.map +1 -1
- package/dist/src/reasoning-loss-notice.d.ts +23 -0
- package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
- package/dist/src/reasoning-loss-notice.js +50 -0
- package/dist/src/reasoning-loss-notice.js.map +1 -0
- package/dist/src/reasoning-loss.d.ts +24 -0
- package/dist/src/reasoning-loss.d.ts.map +1 -0
- package/dist/src/reasoning-loss.js +102 -0
- package/dist/src/reasoning-loss.js.map +1 -0
- package/dist/src/send-tools.d.ts +3 -2
- package/dist/src/send-tools.d.ts.map +1 -1
- package/dist/src/send-tools.js +3 -2
- package/dist/src/send-tools.js.map +1 -1
- package/dist/src/sent-rows.d.ts +59 -0
- package/dist/src/sent-rows.d.ts.map +1 -0
- package/dist/src/sent-rows.js +145 -0
- package/dist/src/sent-rows.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +17 -10
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stable-hash.d.ts +3 -0
- package/dist/src/stable-hash.d.ts.map +1 -0
- package/dist/src/stable-hash.js +15 -0
- package/dist/src/stable-hash.js.map +1 -0
- package/dist/src/stored-injections.d.ts +99 -0
- package/dist/src/stored-injections.d.ts.map +1 -0
- package/dist/src/stored-injections.js +106 -0
- package/dist/src/stored-injections.js.map +1 -0
- package/dist/src/text-as-stored.d.ts +4 -3
- package/dist/src/text-as-stored.d.ts.map +1 -1
- package/dist/src/text-as-stored.js +4 -3
- package/dist/src/text-as-stored.js.map +1 -1
- package/dist/src/thinking-drops.d.ts +74 -0
- package/dist/src/thinking-drops.d.ts.map +1 -0
- package/dist/src/thinking-drops.js +96 -0
- package/dist/src/thinking-drops.js.map +1 -0
- package/dist/src/tool-args.d.ts +12 -0
- package/dist/src/tool-args.d.ts.map +1 -1
- package/dist/src/tool-args.js +35 -1
- package/dist/src/tool-args.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +9 -8
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +9 -8
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +3 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +12 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +44 -17
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +176 -24
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +411 -91
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +17 -9
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +84 -37
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +4 -1
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/types/agent.d.ts +217 -7
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +33 -9
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +39 -3
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +95 -9
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +91 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/agent.ts +661 -67
- package/src/api/server.ts +5 -4
- package/src/auto-continue.ts +43 -0
- package/src/code-execution/isolate/scripts.ts +11 -3
- package/src/cut-off-round.ts +98 -0
- package/src/framework.ts +6168 -1540
- package/src/gate/event-gate.ts +2895 -405
- package/src/gate/index.ts +10 -1
- package/src/gate/recipe-reconcile.ts +355 -0
- package/src/gate/reply-judge.ts +512 -0
- package/src/gate/types.ts +234 -9
- package/src/heartbeat/heartbeat.ts +804 -0
- package/src/heartbeat/reminder-time.ts +107 -0
- package/src/index.ts +11 -4
- package/src/mcpl/capability-mask.ts +3 -2
- package/src/mcpl/channel-registry.ts +1222 -468
- package/src/mcpl/conversation-router.ts +34 -16
- package/src/mcpl/convert-block.ts +14 -1
- package/src/mcpl/hook-orchestrator.ts +33 -6
- package/src/mcpl/inbound-source.ts +42 -69
- package/src/mcpl/index.ts +6 -1
- package/src/mcpl/inference-router.ts +79 -34
- package/src/mcpl/prose-grammar.ts +7 -0
- package/src/mcpl/protocol-family.ts +2 -0
- package/src/mcpl/push-coalescer.ts +51 -13
- package/src/mcpl/reference-fetcher.ts +201 -162
- package/src/mcpl/references.ts +851 -77
- package/src/mcpl/server-connection.ts +189 -11
- package/src/mcpl/tags.ts +13 -2
- package/src/mcpl/tool-lifecycle.ts +21 -0
- package/src/mcpl/tool-presence.ts +716 -0
- package/src/mcpl/tool-result-normalize.ts +18 -6
- package/src/mcpl/tool-signature.ts +316 -0
- package/src/mcpl/toolset-host.ts +727 -0
- package/src/mcpl/toolsets-module.ts +545 -0
- package/src/mcpl/transport.ts +3 -2
- package/src/mcpl/types.ts +25 -2
- package/src/mcpl/visible-content.ts +22 -9
- package/src/module-registry.ts +85 -26
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +70 -30
- package/src/modules/history/semantic.ts +308 -54
- package/src/modules/workspace/answers.ts +133 -0
- package/src/modules/workspace/branch-intent.ts +10 -5
- package/src/modules/workspace/direct-write.ts +460 -0
- package/src/modules/workspace/disk-agreement.ts +52 -21
- package/src/modules/workspace/edit-text.ts +84 -35
- package/src/modules/workspace/effects.ts +22 -13
- package/src/modules/workspace/index.ts +2142 -723
- package/src/modules/workspace/observe.ts +381 -177
- package/src/modules/workspace/read-page.ts +228 -0
- package/src/modules/workspace/reconcile.ts +27 -15
- package/src/modules/workspace/result-text.ts +121 -0
- package/src/modules/workspace/sync.ts +16 -0
- package/src/modules/workspace/text.ts +58 -32
- package/src/modules/workspace/types.ts +137 -60
- package/src/modules/workspace/watcher.ts +8 -3
- package/src/prose-drafts.ts +4 -3
- package/src/reasoning-loss-notice.ts +62 -0
- package/src/reasoning-loss.ts +120 -0
- package/src/send-tools.ts +3 -2
- package/src/sent-rows.ts +162 -0
- package/src/silent-heartbeat.ts +17 -10
- package/src/stable-hash.ts +14 -0
- package/src/stored-injections.ts +131 -0
- package/src/text-as-stored.ts +4 -3
- package/src/thinking-drops.ts +135 -0
- package/src/tool-args.ts +34 -1
- package/src/tool-image-ledger.ts +9 -8
- package/src/tool-presentation.ts +3 -1
- package/src/tool-result-guard.ts +45 -17
- package/src/tool-result-history.ts +471 -90
- package/src/tune-out/coordinator.ts +80 -37
- package/src/tune-out/tools.ts +4 -1
- package/src/types/agent.ts +220 -7
- package/src/types/events.ts +33 -9
- package/src/types/framework.ts +36 -3
- package/src/types/module.ts +91 -9
- package/src/types/trace.ts +94 -3
package/src/gate/event-gate.ts
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* inference tokens, not whether the agent should know about the event.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { existsSync, readFileSync,
|
|
15
|
-
import { dirname, join } from 'node:path';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import { basename, dirname, join } from 'node:path';
|
|
16
16
|
import { GateScript } from './gate-script.js';
|
|
17
|
+
import { canonical, duplicateName, lastRecipePath, noChanges, reconcileLine, reconcileWithRecipe, ruleName, type WrittenConfig } from './recipe-reconcile.js';
|
|
18
|
+
import { closestNames, isNearMiss } from '../closest-names.js';
|
|
17
19
|
import { MAX_TIMER_MS } from '../deadline-timer.js';
|
|
18
20
|
import { safeSlice } from '../safe-slice.js';
|
|
19
21
|
|
|
@@ -24,15 +26,25 @@ import type {
|
|
|
24
26
|
GatePolicyMatch,
|
|
25
27
|
GateBehavior,
|
|
26
28
|
GateDecision,
|
|
29
|
+
GateEntryRef,
|
|
30
|
+
WakeLine,
|
|
27
31
|
GateEventInfo,
|
|
28
32
|
GatePolicyStats,
|
|
29
33
|
GateStatus,
|
|
30
34
|
ShadowWarning,
|
|
31
35
|
GateProbeRow,
|
|
36
|
+
JudgeConfig,
|
|
37
|
+
JudgeEventSummary,
|
|
38
|
+
JudgeFn,
|
|
39
|
+
JudgeVerdict,
|
|
40
|
+
UpcomingWake,
|
|
32
41
|
} from './types.js';
|
|
33
42
|
|
|
34
43
|
// Re-export types consumers need
|
|
35
|
-
export type {
|
|
44
|
+
export type {
|
|
45
|
+
GateConfig, GateOptions, GateDecision, GateEntryRef, GateEventInfo, WakeLine,
|
|
46
|
+
JudgeConfig, JudgeEventSummary, JudgeFn, JudgeRequest, JudgeVerdict, ReplyJudgeOptions, UpcomingWake,
|
|
47
|
+
} from './types.js';
|
|
36
48
|
|
|
37
49
|
// ---------------------------------------------------------------------------
|
|
38
50
|
// Constants
|
|
@@ -59,6 +71,30 @@ function parseGateJson(text: string): unknown {
|
|
|
59
71
|
const MIN_DEBOUNCE_MS = 100;
|
|
60
72
|
/** Maximum debounce value (ms). */
|
|
61
73
|
const MAX_DEBOUNCE_MS = 300_000;
|
|
74
|
+
/** Maximum `judge.maxWaitMs` (ms): an hour. */
|
|
75
|
+
const MAX_JUDGE_WAIT_MS = 3_600_000;
|
|
76
|
+
/** Longest `judge.instructions` (characters) a rule may hold: they go to the
|
|
77
|
+
* judge with every batch, so they're kept short. */
|
|
78
|
+
const MAX_JUDGE_INSTRUCTIONS = 4_000;
|
|
79
|
+
/** How long a judge request may take by default (ms) before onError decides. */
|
|
80
|
+
const DEFAULT_JUDGE_TIMEOUT_MS = 20_000;
|
|
81
|
+
/** Judge failures in a row after which gate_status names the judge as failing. */
|
|
82
|
+
const JUDGE_FAILING_STREAK = 3;
|
|
83
|
+
|
|
84
|
+
/** Whether a check made at `last` is still fresh at `now`, less than the
|
|
85
|
+
* reload throttle ago. A clock stepped backwards since makes it stale, so
|
|
86
|
+
* the next check runs rather than wait for the clock to catch up. */
|
|
87
|
+
function checkedRecently(now: number, last: number): boolean {
|
|
88
|
+
const elapsed = now - last;
|
|
89
|
+
return elapsed >= 0 && elapsed < RELOAD_THROTTLE_MS;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A read that failed because nothing is at the path. Any other failure
|
|
93
|
+
* (EACCES on a directory, ELOOP, ENOTDIR) is a file that's there but can't
|
|
94
|
+
* be read. */
|
|
95
|
+
function isMissing(err: unknown): boolean {
|
|
96
|
+
return (err as NodeJS.ErrnoException | null)?.code === 'ENOENT';
|
|
97
|
+
}
|
|
62
98
|
|
|
63
99
|
// ---------------------------------------------------------------------------
|
|
64
100
|
// Default config
|
|
@@ -104,12 +140,26 @@ interface PendingEvent {
|
|
|
104
140
|
* whose `channelId` is the adapter's raw id (a bare Discord snowflake for a
|
|
105
141
|
* DM) — unroutable. Absent when the host could not resolve one. */
|
|
106
142
|
routeChannelId?: string;
|
|
143
|
+
/** The event's own identity among what the framework stored at arrival:
|
|
144
|
+
* a channel message's id, a push event's event id (isNewestEntry). */
|
|
145
|
+
messageId?: string;
|
|
146
|
+
eventId?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Where the host put a message the gate wrote: the agent whose window it
|
|
150
|
+
* went to and, when it was held for that agent's live turn, the id it waits
|
|
151
|
+
* under in the host's queue, or else the id it was stored under. */
|
|
152
|
+
export interface WritePlacement {
|
|
153
|
+
agent?: string;
|
|
154
|
+
deferredId?: string;
|
|
155
|
+
messageId?: string;
|
|
107
156
|
}
|
|
108
157
|
|
|
109
158
|
/**
|
|
110
159
|
* Where a gate-requested wake came from, handed to the framework alongside the
|
|
111
160
|
* reason so the turn's InferenceRequest can carry it (host telemetry stamps
|
|
112
|
-
* "who woke the agent" from it). Ids only, never content or display names
|
|
161
|
+
* "who woke the agent" from it). Ids only, never content or display names,
|
|
162
|
+
* except `line`, which is the batch's own text for the agent's window.
|
|
113
163
|
*/
|
|
114
164
|
export interface WakeProvenance {
|
|
115
165
|
/** Composite channel id of the chosen event — telemetry only, never a
|
|
@@ -131,14 +181,52 @@ export interface WakeProvenance {
|
|
|
131
181
|
* (2026-09-30: a DM reply published into a guild channel). Ambient-only
|
|
132
182
|
* batches still set nothing and keep the legacy fallback. */
|
|
133
183
|
routeChannelId?: string;
|
|
184
|
+
/** The server of `routeChannelId`'s channel, beside it whenever the chosen
|
|
185
|
+
* event named its server: channel ids are unique only within one
|
|
186
|
+
* server's connection (MCPL §14.2). */
|
|
187
|
+
routeServerId?: string;
|
|
134
188
|
/** Timestamp (ms) of the chosen event, so a consumer coalescing several
|
|
135
189
|
* requests can order by event recency, not by flush order. */
|
|
136
190
|
at?: number;
|
|
191
|
+
/** The batch's `[Gate: …]` line, when the gate handed it to the wake rather
|
|
192
|
+
* than wrote it: a WakeLine, the parts its text is rendered from when it's
|
|
193
|
+
* written (wakeLineText), decided by a fresh start of the wake's turn as
|
|
194
|
+
* WakeLine says. Unlike the fields above, this is content for the agent's
|
|
195
|
+
* window, not telemetry: it carries the channel's name as the agent is
|
|
196
|
+
* shown it. */
|
|
197
|
+
line?: WakeLine;
|
|
137
198
|
}
|
|
138
199
|
|
|
139
200
|
interface DebounceState {
|
|
140
201
|
timer: ReturnType<typeof setTimeout>;
|
|
141
202
|
events: PendingEvent[];
|
|
203
|
+
/** When the batch's first event arrived (ms), for a judge's maxWaitMs. */
|
|
204
|
+
firstAt: number;
|
|
205
|
+
/** When its timer fires (ms), as of its last re-arm (upcomingWakes). */
|
|
206
|
+
dueAt: number;
|
|
207
|
+
/** The judge the batch goes to when it fires, as its rule held it when the
|
|
208
|
+
* batch began: a rule removed or changed meanwhile has its batch judged
|
|
209
|
+
* as the rule it was asked. Absent for a debounce. */
|
|
210
|
+
judge?: JudgeConfig;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A batch the reply judge is deciding now. */
|
|
214
|
+
interface JudgingState {
|
|
215
|
+
events: PendingEvent[];
|
|
216
|
+
judge: JudgeConfig;
|
|
217
|
+
started: number;
|
|
218
|
+
abort: AbortController;
|
|
219
|
+
/** The judge's time limit, cleared once it's decided or the gate disposed. */
|
|
220
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
221
|
+
/** Batches of the same rule that fired meanwhile, judged once this one is
|
|
222
|
+
* decided: delivered with it on reply, judged afresh on wait. */
|
|
223
|
+
queued: PendingEvent[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A rule's verdict counts and newest verdict (gate_status judgeState). */
|
|
227
|
+
interface JudgeRecord {
|
|
228
|
+
verdicts: { reply: number; wait: number; error: number };
|
|
229
|
+
last?: NonNullable<GatePolicyStats['judgeState']>['last'];
|
|
142
230
|
}
|
|
143
231
|
|
|
144
232
|
/** Token bucket for one (policy, key) pair under a rate_limit behavior. */
|
|
@@ -236,11 +324,39 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
|
|
|
236
324
|
...(channelId ? { channelId } : {}),
|
|
237
325
|
...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
|
|
238
326
|
...(pick.addressed ? { addressed: true } : {}),
|
|
239
|
-
...(pick.addressed && pick.routeChannelId
|
|
327
|
+
...(pick.addressed && pick.routeChannelId
|
|
328
|
+
? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
|
|
329
|
+
: {}),
|
|
240
330
|
at: pick.timestamp,
|
|
241
331
|
};
|
|
242
332
|
}
|
|
243
333
|
|
|
334
|
+
/** A `[Gate: …]` line's reference to messages already in the window: how
|
|
335
|
+
* many, in which channel, and how long ago the oldest arrived, as of `now`. */
|
|
336
|
+
function referenceLine(count: number, channel: string, oldest: number, now: number): string {
|
|
337
|
+
const age = Math.round((now - oldest) / 1000);
|
|
338
|
+
return `- ${count} message${count > 1 ? 's' : ''} in ${channel} ` +
|
|
339
|
+
`(oldest ~${age}s ago) — already shown above at arrival; this is a ` +
|
|
340
|
+
`batched wake, not new content`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A handed batched-wake line's text (WakeLine), as it reads when written at
|
|
344
|
+
* `now`: the gate's words, its message's age counted to then. */
|
|
345
|
+
export function wakeLineText(line: WakeLine, now: number): string {
|
|
346
|
+
return `[Gate: 1 event matched]\n\n${referenceLine(1, line.shown, line.at, now)}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The event's own stored entry, as the framework is asked about it. */
|
|
350
|
+
function entryRef(event: PendingEvent): GateEntryRef {
|
|
351
|
+
return {
|
|
352
|
+
eventType: event.eventType,
|
|
353
|
+
...(event.serverId !== undefined ? { serverId: event.serverId } : {}),
|
|
354
|
+
...(event.channelId !== undefined ? { channelId: event.channelId } : {}),
|
|
355
|
+
...(event.messageId !== undefined ? { messageId: event.messageId } : {}),
|
|
356
|
+
...(event.eventId !== undefined ? { eventId: event.eventId } : {}),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
244
360
|
/**
|
|
245
361
|
* Default route-channel resolution when the host supplies none: a
|
|
246
362
|
* channel-incoming event's channel id is already the registered composite id;
|
|
@@ -262,9 +378,21 @@ function formatBehavior(b: import('./types.js').GateBehavior): string {
|
|
|
262
378
|
const k = b.passive_sample.keyBy ? `,keyBy:${b.passive_sample.keyBy}` : '';
|
|
263
379
|
return `passive_sample:${b.passive_sample.every}${k}`;
|
|
264
380
|
}
|
|
381
|
+
if ('judge' in b) {
|
|
382
|
+
const w = b.judge.maxWaitMs !== undefined ? `,maxWait:${b.judge.maxWaitMs}` : '';
|
|
383
|
+
return `judge:${b.judge.debounceMs}${w}`;
|
|
384
|
+
}
|
|
265
385
|
return 'unknown';
|
|
266
386
|
}
|
|
267
387
|
|
|
388
|
+
/** The quiet period (ms) of a batching behavior: a debounce's, or a judge's. */
|
|
389
|
+
function batchQuietMs(b: GateBehavior): number | undefined {
|
|
390
|
+
if (typeof b !== 'object') return undefined;
|
|
391
|
+
if ('debounce' in b) return b.debounce;
|
|
392
|
+
if ('judge' in b) return b.judge.debounceMs;
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
268
396
|
/**
|
|
269
397
|
* Stable fingerprint for a GateBehavior, used during reload to detect
|
|
270
398
|
* config changes that invalidate per-policy state. JSON.stringify is
|
|
@@ -277,169 +405,1407 @@ function behaviorFingerprint(b: import('./types.js').GateBehavior): string {
|
|
|
277
405
|
}
|
|
278
406
|
|
|
279
407
|
// ---------------------------------------------------------------------------
|
|
280
|
-
//
|
|
408
|
+
// Reading rules
|
|
281
409
|
// ---------------------------------------------------------------------------
|
|
282
410
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
411
|
+
// A rule is read as written when it can be, and every problem it has is named.
|
|
412
|
+
// gate.json and the host's starting rules are what a deployment already runs,
|
|
413
|
+
// so a rule with a problem is read the way earlier releases read it, and no
|
|
414
|
+
// event that woke the agent there goes quiet: a behavior the gate doesn't know
|
|
415
|
+
// is read as "always"; a field of the wrong type, one slip from one the gate
|
|
416
|
+
// reads there, or one the gate reads at another level of the rule (a match's
|
|
417
|
+
// "tagsAny" beside "name"), as left out, its problem naming where it's read;
|
|
418
|
+
// and a number out of range the way the starting rules ran it, but for a
|
|
419
|
+
// debounce, read at its nearest bound, which can send a batch later or sooner
|
|
420
|
+
// but drops no event in it, and a refill interval of 0, read as refilling at
|
|
421
|
+
// once. A rule is left out only where earlier releases refused it. One that
|
|
422
|
+
// skips or defers is read past a problem too, holding back what they held back:
|
|
423
|
+
// left out, the events it held back would go on to the rules after it, where a
|
|
424
|
+
// rate limit, a sample or a debounce could spend them and then hold back an
|
|
425
|
+
// event that woke the agent before. Under a default that holds events back, a
|
|
426
|
+
// file that leaves out a rule that could wake the agent isn't used, since that
|
|
427
|
+
// would hold back what the rule was written to wake on; earlier releases didn't
|
|
428
|
+
// use it either. A change through addPolicy, whose caller is there to fix it,
|
|
429
|
+
// refuses any problem, and nothing is saved. A field name that is no slip of
|
|
430
|
+
// one the gate reads there, and that it reads at no other level, changes
|
|
431
|
+
// nothing, a slip of another level's field included: it loads, and a note names
|
|
432
|
+
// it with the fields its place holds. In a rule, a name holding a field's whole
|
|
433
|
+
// name is such a name beside that field, an extra that explains it, and a slip
|
|
434
|
+
// of the field where it's given in the field's place (fieldNames); at
|
|
435
|
+
// gate.json's top, where it can be a label of the file, it's such a name either
|
|
436
|
+
// way. A file earlier releases loaded loads as they read it: a slip of
|
|
437
|
+
// "policies", a list of rules parked under a name the gate doesn't read, a
|
|
438
|
+
// rule's fields at gate.json's top and a "policies" that isn't a list each do
|
|
439
|
+
// nothing, and a note names each, so where "policies" writes no rules the file
|
|
440
|
+
// has none, and under "skip" or "defer" it holds back every event, as it did. A
|
|
441
|
+
// "policies" holding nothing writes no rules, as one left out does. A null is a
|
|
442
|
+
// field left out, and so is "", with a note: earlier releases read most fields
|
|
443
|
+
// that way, and none narrows anything by them. Where they refused a rule or a
|
|
444
|
+
// file for a null or a "" (a passthrough, a behavior's kind, gate.json's
|
|
445
|
+
// default), it now loads without it.
|
|
446
|
+
|
|
447
|
+
const CONFIG_FIELDS = ['default', 'policies'];
|
|
448
|
+
const POLICY_FIELDS = ['name', 'match', 'behavior', 'resets', 'passthrough'];
|
|
449
|
+
const MATCH_FIELDS = [
|
|
450
|
+
'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
|
|
451
|
+
];
|
|
452
|
+
const FILTER_FIELDS = ['type', 'pattern'];
|
|
453
|
+
const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample', 'judge'] as const;
|
|
454
|
+
const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
|
|
455
|
+
const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
|
|
456
|
+
const JUDGE_FIELDS = ['debounceMs', 'maxWaitMs', 'instructions'];
|
|
457
|
+
|
|
458
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
459
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A value that sets nothing: null, "", an empty list, or an object holding only such values. */
|
|
463
|
+
function holdsNothing(value: unknown): boolean {
|
|
464
|
+
return value === null || value === '' || (Array.isArray(value) && value.length === 0)
|
|
465
|
+
|| (isPlainObject(value) && Object.values(value).every(holdsNothing));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Whether a failed write left its file as it was: writeFileSync reports a
|
|
470
|
+
* failure once it has the file open, so it may have changed it, as its write
|
|
471
|
+
* or its close.
|
|
472
|
+
*/
|
|
473
|
+
function beforeWriting(err: unknown): boolean {
|
|
474
|
+
const syscall = (err as NodeJS.ErrnoException).syscall;
|
|
475
|
+
return syscall !== 'write' && syscall !== 'close';
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* A change's refusal where gate.json has no rule list it could edit without
|
|
480
|
+
* writing over what it holds: a list, JSON that isn't an object, or a
|
|
481
|
+
* "policies" that isn't a list and holds something. `runs` is what the gate
|
|
482
|
+
* runs meanwhile (rulesRun).
|
|
483
|
+
*/
|
|
484
|
+
function noListRefusal(noList: string, runs: string): string {
|
|
485
|
+
return `${noList}, so it has no list of rules a change could edit without writing over what it holds, and nothing `
|
|
486
|
+
+ `was saved. Meanwhile the gate runs ${runs}. Until gate.json is an object whose "policies" is a list holding its `
|
|
487
|
+
+ 'rules, a change is refused the same way.';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* A change's refusal where gate.json is there but couldn't be used as a file
|
|
492
|
+
* a change edits: it couldn't be read, or it isn't JSON (`notJson`). Nothing is
|
|
493
|
+
* written over it, so what it holds, often an operator's unfinished edit, stays
|
|
494
|
+
* as it is. `runs` is what the gate runs meanwhile (rulesRun).
|
|
495
|
+
*/
|
|
496
|
+
function unusableRefusal(unusable: { error: string; notJson?: true }, runs: string): string {
|
|
497
|
+
if (!unusable.notJson) {
|
|
498
|
+
return `gate.json couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written over it. `
|
|
499
|
+
+ `Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json readable; until then, a change is `
|
|
500
|
+
+ 'refused the same way.';
|
|
286
501
|
}
|
|
502
|
+
return `gate.json isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without writing `
|
|
503
|
+
+ `over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change is `
|
|
504
|
+
+ 'refused the same way.';
|
|
505
|
+
}
|
|
287
506
|
|
|
288
|
-
|
|
507
|
+
/**
|
|
508
|
+
* A change's refusal at a start that didn't use gate.json, which has no rules
|
|
509
|
+
* of its own (start-without): a change puts the rules gate.json then holds in
|
|
510
|
+
* force, so it would take out of force what the start runs in its place.
|
|
511
|
+
* `runs` is what the gate runs meanwhile (rulesRun); gate_status's entries
|
|
512
|
+
* (notUsedClause, describeStart) say which rules keep gate.json out of use
|
|
513
|
+
* and each fix.
|
|
514
|
+
*/
|
|
515
|
+
function notUsedRefusal(runs: string): string {
|
|
516
|
+
return "gate.json isn't used at this start, which runs as one without it does, and a change would put gate.json's rules in "
|
|
517
|
+
+ `force in place of what that runs, so nothing was saved. Meanwhile the gate runs ${runs}. gate_status says what keeps `
|
|
518
|
+
+ 'gate.json out of use and what would put it in use, such as giving it rules under "policies", or fixing the starting '
|
|
519
|
+
+ 'rules, which whoever runs this host can do, for the next start; until then, a change is refused the same way.';
|
|
520
|
+
}
|
|
289
521
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
522
|
+
/**
|
|
523
|
+
* A value as a message quotes it: its JSON, cut short when long, never inside a
|
|
524
|
+
* surrogate pair, since the message reaches the agent's context.
|
|
525
|
+
*/
|
|
526
|
+
export function shownValue(value: unknown): string {
|
|
527
|
+
if (value === undefined) return 'missing';
|
|
528
|
+
// JSON writes NaN and the infinities as null.
|
|
529
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
|
|
530
|
+
let json: string;
|
|
531
|
+
try {
|
|
532
|
+
json = JSON.stringify(value) ?? String(value);
|
|
533
|
+
} catch {
|
|
534
|
+
json = String(value);
|
|
293
535
|
}
|
|
536
|
+
return json.length > 80 ? `${safeSlice(json, 0, 77)}...` : json;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Entries of a list as a message quotes them. */
|
|
540
|
+
function shownValues(values: readonly unknown[]): string {
|
|
541
|
+
return values.map(shownValue).join(', ');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** `a`, `a and b`, `a, b and c` (or "or"). */
|
|
545
|
+
function joinedList(items: readonly string[], joiner: 'and' | 'or' = 'and'): string {
|
|
546
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} ${joiner} ${items[items.length - 1]}`;
|
|
547
|
+
}
|
|
294
548
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
549
|
+
/** `"a"`, `"a" and "b"`, `"a", "b" and "c"` (or "or"). */
|
|
550
|
+
function quotedList(names: readonly string[], joiner: 'and' | 'or' = 'and'): string {
|
|
551
|
+
return joinedList(names.map((n) => `"${n}"`), joiner);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** 1st, 2nd, 3rd, 4th, 11th, 21st. */
|
|
555
|
+
function ordinal(n: number): string {
|
|
556
|
+
const tens = n % 100;
|
|
557
|
+
const suffix = tens >= 11 && tens <= 13 ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] ?? 'th');
|
|
558
|
+
return `${n}${suffix}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** A message part as one sentence, for joining several. */
|
|
562
|
+
function sentence(text: string): string {
|
|
563
|
+
return /[.?!]$/.test(text) ? text : `${text}.`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** How a note speaks of what it checks: a rule's fields, or a tool's parameters. */
|
|
567
|
+
const RULE_FIELDS_TERMS = { unread: "which the gate doesn't read", listed: 'Its fields are' };
|
|
568
|
+
const TOOL_PARAMETERS_TERMS = { unread: "which it doesn't take", listed: 'Its parameters are' };
|
|
569
|
+
|
|
570
|
+
/** A field name one slip from one or more the gate reads in its place. */
|
|
571
|
+
interface Slip {
|
|
572
|
+
key: string;
|
|
573
|
+
/** Every field it could be meant for, in closestNames' order: `tags` is part of "tagsAny", "tagsAll" and "tagsNone". */
|
|
574
|
+
meant: string[];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* An object's field names that the gate doesn't read in its place: slips of
|
|
579
|
+
* one it does, and the rest. A name is a slip of each field isNearMiss finds
|
|
580
|
+
* it close to and, where `held`, of each whose whole name it holds, ignoring
|
|
581
|
+
* case, that the object doesn't give: sent in that field's place, it's the
|
|
582
|
+
* field under another name (`matchTags` with no `match`), where beside it,
|
|
583
|
+
* it's an extra. The object is one withoutEmpty read, so a field given as
|
|
584
|
+
* null or "" isn't given.
|
|
585
|
+
*/
|
|
586
|
+
function fieldNames(
|
|
587
|
+
obj: Record<string, unknown>, fields: readonly string[], held = true,
|
|
588
|
+
): { slips: Slip[]; unread: string[] } {
|
|
589
|
+
const slips: Slip[] = [];
|
|
590
|
+
const unread: string[] = [];
|
|
591
|
+
for (const key of Object.keys(obj)) {
|
|
592
|
+
if (obj[key] === undefined || fields.includes(key)) continue;
|
|
593
|
+
// Every field the gate reads has four characters or more, so holding one
|
|
594
|
+
// needs no length of its own here. A field of two characters or fewer
|
|
595
|
+
// would be held by names that have nothing to do with it ("valid" holds
|
|
596
|
+
// "id"), and holding it would need the three characters isNearMiss asks
|
|
597
|
+
// of a part.
|
|
598
|
+
const holds = (field: string): boolean => key.toLowerCase().includes(field.toLowerCase());
|
|
599
|
+
const near = fields.filter((field) => isNearMiss(key, field) || (held && !Object.hasOwn(obj, field) && holds(field)));
|
|
600
|
+
if (near.length === 0) {
|
|
601
|
+
unread.push(key);
|
|
602
|
+
continue;
|
|
299
603
|
}
|
|
604
|
+
// closestNames keeps every name decided on above, in its order.
|
|
605
|
+
slips.push({ key, meant: closestNames(key, near, near.length) });
|
|
300
606
|
}
|
|
607
|
+
return { slips, unread };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** `"tags" (did you mean "tagsAny", "tagsAll" or "tagsNone"?)` */
|
|
611
|
+
function slipShown(slip: Slip): string {
|
|
612
|
+
return `"${slip.key}" (did you mean ${quotedList(slip.meant, 'or')}?)`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Names an object has that are fields read at another level, with where that is (`homes`). */
|
|
616
|
+
interface Misplaced {
|
|
617
|
+
keys: string[];
|
|
618
|
+
homes: string[];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* An object's names that do nothing in its place, split into those that are
|
|
623
|
+
* fields read elsewhere, grouped by where (`homesOf`), and the rest.
|
|
624
|
+
*/
|
|
625
|
+
function placedElsewhere(
|
|
626
|
+
unread: readonly string[], homesOf: (key: string) => string[],
|
|
627
|
+
): { misplaced: Misplaced[]; rest: string[] } {
|
|
628
|
+
const misplaced: Misplaced[] = [];
|
|
629
|
+
const rest: string[] = [];
|
|
630
|
+
for (const key of unread) {
|
|
631
|
+
const homes = homesOf(key);
|
|
632
|
+
if (homes.length === 0) {
|
|
633
|
+
rest.push(key);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const same = misplaced.find((m) => m.homes.join() === homes.join());
|
|
637
|
+
if (same) same.keys.push(key);
|
|
638
|
+
else misplaced.push({ keys: [key], homes });
|
|
639
|
+
}
|
|
640
|
+
return { misplaced, rest };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** `"tagsAny", a field of "match"`; `"tagsAny" and "source", fields of "match"` */
|
|
644
|
+
function misplacedShown(m: Misplaced): string {
|
|
645
|
+
return `${quotedList(m.keys)}, ${m.keys.length === 1 ? 'a field' : 'fields'} of ${joinedList(m.homes)}`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The levels of a gate.json the gate reads fields at, each as a message names
|
|
650
|
+
* it, `file` naming the file itself.
|
|
651
|
+
*/
|
|
652
|
+
function fieldLevels(file: string): ReadonlyArray<{ name: string; fields: readonly string[] }> {
|
|
653
|
+
return [
|
|
654
|
+
{ name: file, fields: CONFIG_FIELDS },
|
|
655
|
+
{ name: 'a rule', fields: POLICY_FIELDS },
|
|
656
|
+
{ name: '"match"', fields: MATCH_FIELDS },
|
|
657
|
+
{ name: '"match.filter"', fields: FILTER_FIELDS },
|
|
658
|
+
{ name: '"behavior"', fields: BEHAVIOR_KINDS },
|
|
659
|
+
{ name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
|
|
660
|
+
{ name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
|
|
661
|
+
{ name: '"behavior.judge"', fields: JUDGE_FIELDS },
|
|
662
|
+
];
|
|
663
|
+
}
|
|
301
664
|
|
|
302
|
-
|
|
665
|
+
/** Where a name is a field the gate reads, at a level other than the one whose fields are `here`. */
|
|
666
|
+
function homesOf(file: string, here: readonly string[]): (key: string) => string[] {
|
|
667
|
+
return (key) => fieldLevels(file)
|
|
668
|
+
.filter((level) => level.fields !== here && level.fields.includes(key))
|
|
669
|
+
.map((level) => level.name);
|
|
303
670
|
}
|
|
304
671
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
672
|
+
/** Where each field of an object schema's nested objects is taken: `tagsAny` in "match". */
|
|
673
|
+
function nestedFields(properties: Readonly<Record<string, unknown>>, path = ''): Map<string, string[]> {
|
|
674
|
+
const homes = new Map<string, string[]>();
|
|
675
|
+
const add = (field: string, where: string) => homes.set(field, [...(homes.get(field) ?? []), where]);
|
|
676
|
+
for (const [name, schema] of Object.entries(properties)) {
|
|
677
|
+
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) continue;
|
|
678
|
+
const where = path ? `${path}.${name}` : name;
|
|
679
|
+
for (const field of Object.keys(schema.properties)) add(field, `"${where}"`);
|
|
680
|
+
for (const [field, inner] of nestedFields(schema.properties, where)) for (const at of inner) add(field, at);
|
|
308
681
|
}
|
|
682
|
+
return homes;
|
|
683
|
+
}
|
|
309
684
|
|
|
310
|
-
|
|
685
|
+
/** A note naming the fields an object has that do nothing in its place, with the fields that do. */
|
|
686
|
+
function noteUnread(
|
|
687
|
+
where: string, unread: readonly string[], fields: readonly string[], notes: string[], terms = RULE_FIELDS_TERMS,
|
|
688
|
+
): void {
|
|
689
|
+
if (unread.length === 0) return;
|
|
690
|
+
notes.push(
|
|
691
|
+
`${where} has ${quotedList(unread)}, ${terms.unread}, so ${unread.length === 1 ? 'it does' : 'they do'} `
|
|
692
|
+
+ `nothing. ${terms.listed} ${quotedList(fields)}.`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
311
695
|
|
|
312
|
-
|
|
313
|
-
|
|
696
|
+
/**
|
|
697
|
+
* A tool's input, its parameters given by its schema's `properties`, read as a
|
|
698
|
+
* rule's fields are: a null or "" parameter is left out (a note names each "",
|
|
699
|
+
* with what leaving it out does where `means` says), a slip of a parameter
|
|
700
|
+
* (fieldNames) is refused, naming the one meant, and so is a field of one of
|
|
701
|
+
* its object parameters given on its own, naming that parameter; any other
|
|
702
|
+
* name is noted, or named in that refusal. Returns the input without what was
|
|
703
|
+
* left out.
|
|
704
|
+
*/
|
|
705
|
+
export function checkToolInput(
|
|
706
|
+
tool: string, input: Record<string, unknown>, properties: Readonly<Record<string, unknown>>, notes: string[],
|
|
707
|
+
means: Readonly<Record<string, string>> = {},
|
|
708
|
+
): Record<string, unknown> {
|
|
709
|
+
const params = Object.keys(properties);
|
|
710
|
+
const given = withoutEmpty(tool, input, notes, means);
|
|
711
|
+
const { slips, unread } = fieldNames(given, params);
|
|
712
|
+
const homes = nestedFields(properties);
|
|
713
|
+
const { misplaced, rest } = placedElsewhere(unread, (key) => homes.get(key) ?? []);
|
|
714
|
+
if (slips.length > 0 || misplaced.length > 0) {
|
|
715
|
+
// Every name it doesn't take is named, so the call that follows the refusal doesn't count on one.
|
|
716
|
+
const has = [
|
|
717
|
+
...(slips.length > 0 ? [`${slips.map(slipShown).join(' and ')}, ${TOOL_PARAMETERS_TERMS.unread}`] : []),
|
|
718
|
+
...misplaced.map((m) => `${misplacedShown(m)}, ${TOOL_PARAMETERS_TERMS.unread} here`),
|
|
719
|
+
...(rest.length > 0 ? [`${quotedList(rest)}, ${TOOL_PARAMETERS_TERMS.unread}`] : []),
|
|
720
|
+
];
|
|
721
|
+
// Where a field has one home, show it there, as the call would give it. A
|
|
722
|
+
// value shownValue cuts short isn't one the call could give, so then the
|
|
723
|
+
// parameter is named alone.
|
|
724
|
+
const moves = misplaced.filter((m) => m.homes.length === 1).map((m) => {
|
|
725
|
+
const it = m.keys.length === 1 ? 'it' : 'them';
|
|
726
|
+
const shown = m.keys.map((key) => [key, shownValue(given[key])] as const);
|
|
727
|
+
return shown.every(([key, value]) => value === JSON.stringify(given[key]))
|
|
728
|
+
? `Put ${it} in ${m.homes[0]}: {${shown.map(([key, value]) => `${JSON.stringify(key)}: ${value}`).join(', ')}}.`
|
|
729
|
+
: `Put ${it} in ${m.homes[0]}.`;
|
|
730
|
+
});
|
|
731
|
+
throw new Error(
|
|
732
|
+
`${tool} has ${has.join(', and ')}, so nothing was saved. ${moves.map((move) => `${move} `).join('')}`
|
|
733
|
+
+ `${TOOL_PARAMETERS_TERMS.listed} ${quotedList(params)}.`,
|
|
734
|
+
);
|
|
314
735
|
}
|
|
736
|
+
noteUnread(tool, rest, params, notes, TOOL_PARAMETERS_TERMS);
|
|
737
|
+
return given;
|
|
738
|
+
}
|
|
315
739
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
740
|
+
/**
|
|
741
|
+
* An object's fields as the gate reads them: a null or "" is a field left out,
|
|
742
|
+
* and a note names each "", with what leaving it out does where that's more
|
|
743
|
+
* than setting no constraint (`means`, by field). Every other field stays an
|
|
744
|
+
* own field of the copy, "__proto__" included, which JSON gives as one: set by
|
|
745
|
+
* assignment, it would make its value the copy's prototype, whose fields the
|
|
746
|
+
* gate would then read as the object's own.
|
|
747
|
+
*/
|
|
748
|
+
function withoutEmpty(
|
|
749
|
+
where: string, obj: Record<string, unknown>, notes: string[], means: Readonly<Record<string, string>> = {},
|
|
750
|
+
): Record<string, unknown> {
|
|
751
|
+
const kept: Array<[string, unknown]> = [];
|
|
752
|
+
const empty: string[] = [];
|
|
753
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
754
|
+
if (value === null || value === undefined) continue;
|
|
755
|
+
if (value === '') empty.push(key);
|
|
756
|
+
else kept.push([key, value]);
|
|
757
|
+
}
|
|
758
|
+
const out: Record<string, unknown> = Object.fromEntries(kept);
|
|
759
|
+
if (empty.length > 0) {
|
|
760
|
+
const meant = empty.flatMap((key) => {
|
|
761
|
+
// An own field only: a name such as "constructor" would find Object.prototype's.
|
|
762
|
+
const what = Object.hasOwn(means, key) ? means[key] : undefined;
|
|
763
|
+
return what ? [what] : [];
|
|
764
|
+
});
|
|
765
|
+
notes.push(
|
|
766
|
+
`${where} has ${quotedList(empty)} as "", so ${empty.length === 1 ? "it's" : "they're"} left out`
|
|
767
|
+
+ `${meant.length > 0 ? `: ${meant.join(', and ')}` : ''}.`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** What leaving out a rule's field does, where that's more than setting no constraint. */
|
|
774
|
+
const RULE_LEFT_OUT = { behavior: 'a rule without one has the behavior "always"', match: 'a rule without one matches every event' };
|
|
775
|
+
/** The same for gate.json's own fields, under the default it then has. */
|
|
776
|
+
function configLeftOut(defaultBehavior: unknown): Record<string, string> {
|
|
777
|
+
return {
|
|
778
|
+
default: 'its default is "always"',
|
|
779
|
+
policies: defaultBehavior === 'skip' || defaultBehavior === 'defer'
|
|
780
|
+
? `it has no rules, so under its default, "${defaultBehavior}", every event is held back`
|
|
781
|
+
: 'it has no rules, so every event gets the default, "always"',
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** A problem a rule has. */
|
|
786
|
+
interface RuleProblem {
|
|
787
|
+
/** What's wrong: the field, what it holds, and what's needed. */
|
|
788
|
+
text: string;
|
|
789
|
+
/** How the rule is read past it ("so …"), the way earlier releases read it.
|
|
790
|
+
* Absent where they refused the rule, which is then left out. */
|
|
791
|
+
read?: string;
|
|
792
|
+
/** The reading only narrows what the rule matches (an empty scope entry),
|
|
793
|
+
* so a rule that holds events back holds back no more for it. */
|
|
794
|
+
narrows?: boolean;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** A rule as the gate reads it. */
|
|
798
|
+
interface RuleReading {
|
|
799
|
+
/** How messages name it: `Policy "x"`, or its place when it has no name. */
|
|
800
|
+
where: string;
|
|
801
|
+
/** The rule as read, unless a problem has no reading. */
|
|
802
|
+
policy?: GatePolicy;
|
|
803
|
+
/** It wakes on none of what it matches, even where it can't be read: it
|
|
804
|
+
* skips or defers, or it's a rate limit whose tokens are a number not over
|
|
805
|
+
* 0. Left out, such a rule holds back nothing it was written to wake on,
|
|
806
|
+
* though what it matches goes on to the rules after it, as for any rule
|
|
807
|
+
* left out. */
|
|
808
|
+
quiet: boolean;
|
|
809
|
+
/** Every problem it has, in the order its fields are read. */
|
|
810
|
+
problems: RuleProblem[];
|
|
811
|
+
/** Its fields that do nothing, and those given as "", each a sentence. */
|
|
812
|
+
notes: string[];
|
|
813
|
+
/** Set where it would be in force, and nothing left in its match narrows it. */
|
|
814
|
+
matchesAll?: boolean;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* A rule's notes, ending with what a match that narrows nothing does. A
|
|
819
|
+
* refusal says it of the rule read without what's wrong in its match, as
|
|
820
|
+
* earlier releases read it, since that's why what's wrong there matters.
|
|
821
|
+
*/
|
|
822
|
+
function notesOf(reading: RuleReading, refused = false): string[] {
|
|
823
|
+
if (!reading.matchesAll) return reading.notes;
|
|
824
|
+
return [...reading.notes, refused
|
|
825
|
+
? "Read without what's wrong in its match, as earlier releases read it, the rule would match every event."
|
|
826
|
+
: `${reading.where}: nothing left in match narrows the rule, so it matches every event.`];
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Where a rule's fields are read into: the rule's name in messages, its problems, and its notes. */
|
|
830
|
+
interface Reader {
|
|
831
|
+
where: string;
|
|
832
|
+
/** How messages name the file the rule is in, for a field of the file's own put in the rule. */
|
|
833
|
+
file: string;
|
|
834
|
+
problems: RuleProblem[];
|
|
835
|
+
notes: string[];
|
|
836
|
+
/** Set where its rate limit's tokens are a number not over 0, so it wakes on none of what it matches. */
|
|
837
|
+
noTokens?: boolean;
|
|
838
|
+
/** Set where its match is left with nothing that narrows it, once something in it was named. */
|
|
839
|
+
matchesAll?: boolean;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** How a slip is read: as a field left out, which does nothing. */
|
|
843
|
+
const SLIP_READ = 'so it does nothing';
|
|
844
|
+
/** How a field of the wrong type is read: as one left out. */
|
|
845
|
+
const DROPPED = 'so the rule is read without it';
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* An object's field names, at `level` of the rule (such as "match"): a slip of
|
|
849
|
+
* a field is a problem, read as the field left out, and so is a field the gate
|
|
850
|
+
* reads at another level, named with where that is (`misplacedReadOf` says
|
|
851
|
+
* how the rule then reads, where that's more than the field doing nothing);
|
|
852
|
+
* any other name the gate doesn't read is noted.
|
|
853
|
+
*/
|
|
854
|
+
function readFieldNames(
|
|
855
|
+
r: Reader, level: string | null, obj: Record<string, unknown>, fields: readonly string[],
|
|
856
|
+
readOf: (slip: Slip) => string = () => SLIP_READ,
|
|
857
|
+
misplacedReadOf: (m: Misplaced) => string | undefined = () => undefined,
|
|
858
|
+
): void {
|
|
859
|
+
const { slips, unread } = fieldNames(obj, fields);
|
|
860
|
+
const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields));
|
|
861
|
+
noteUnread(level ? `${r.where}: ${level}` : r.where, rest, fields, r.notes);
|
|
862
|
+
for (const slip of slips) {
|
|
863
|
+
r.problems.push({ text: `${level ?? 'it'} has ${slipShown(slip)}, which the gate doesn't read`, read: readOf(slip) });
|
|
864
|
+
}
|
|
865
|
+
for (const m of misplaced) {
|
|
866
|
+
r.problems.push({
|
|
867
|
+
text: `${level ?? 'it'} has ${misplacedShown(m)}, which the gate doesn't read here`,
|
|
868
|
+
read: misplacedReadOf(m) ?? (m.keys.length === 1 ? SLIP_READ : 'so they do nothing'),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** "so that entry is dropped", "so those entries are dropped". */
|
|
874
|
+
function droppedEntries(count: number): string {
|
|
875
|
+
return count === 1 ? 'so that entry is dropped' : 'so those entries are dropped';
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* A rule, read as written, or past each problem it has. `unnamed` names a rule
|
|
880
|
+
* without a name in messages, and `file` the file it's in.
|
|
881
|
+
*/
|
|
882
|
+
function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReading {
|
|
883
|
+
if (!isPlainObject(raw)) {
|
|
884
|
+
return {
|
|
885
|
+
where: unnamed,
|
|
886
|
+
problems: [{ text: `it's ${shownValue(raw)}, where a rule is an object, such as {"name": "my-rule", "behavior": "always"}` }],
|
|
887
|
+
notes: [],
|
|
888
|
+
quiet: false,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
const name = nameOf(raw);
|
|
892
|
+
const r: Reader = { where: name ? `Policy "${name}"` : unnamed, file, problems: [], notes: [] };
|
|
893
|
+
const obj = withoutEmpty(r.where, raw, r.notes, RULE_LEFT_OUT);
|
|
894
|
+
// Field names first, so a slip of one is named before what its absence does.
|
|
895
|
+
readFieldNames(r, null, obj, POLICY_FIELDS, (slip) => {
|
|
896
|
+
if (slip.meant.includes('behavior') && obj.behavior === undefined) return 'so the rule\'s behavior is "always"';
|
|
897
|
+
if (slip.meant.includes('match') && obj.match === undefined) return 'so the rule matches every event';
|
|
898
|
+
return SLIP_READ;
|
|
899
|
+
}, (m) => {
|
|
900
|
+
const of = (fields: readonly string[]) => m.keys.some((key) => fields.includes(key));
|
|
901
|
+
if (obj.match === undefined && (of(MATCH_FIELDS) || of(FILTER_FIELDS))) return 'so the rule matches every event';
|
|
902
|
+
if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS) || of(JUDGE_FIELDS))) {
|
|
903
|
+
return 'so the rule\'s behavior is "always"';
|
|
904
|
+
}
|
|
905
|
+
return undefined;
|
|
906
|
+
});
|
|
907
|
+
if (!name) {
|
|
908
|
+
r.problems.push({
|
|
909
|
+
text: obj.name === undefined ? 'it has no "name"' : `name is ${shownValue(obj.name)}, where a non-empty string is needed`,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
const { behavior, kind } = readBehavior(r, obj.behavior);
|
|
913
|
+
const match = readMatch(r, obj.match);
|
|
914
|
+
const resets = readResets(r, obj.resets);
|
|
915
|
+
const passthrough = readPassthrough(r, obj.passthrough, behavior, kind);
|
|
916
|
+
const reading: RuleReading = {
|
|
917
|
+
where: r.where, problems: r.problems, notes: r.notes, quiet: kind === 'skip' || kind === 'defer' || r.noTokens === true,
|
|
918
|
+
};
|
|
919
|
+
if (name && behavior && r.problems.every((p) => p.read !== undefined)) {
|
|
920
|
+
const policy: GatePolicy = { name, match, behavior };
|
|
921
|
+
if (resets) policy.resets = resets;
|
|
922
|
+
if (passthrough) policy.passthrough = true;
|
|
923
|
+
reading.policy = policy;
|
|
924
|
+
if (r.matchesAll) reading.matchesAll = true;
|
|
925
|
+
}
|
|
926
|
+
return reading;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
type BehaviorKind = 'always' | 'defer' | 'skip' | (typeof BEHAVIOR_KINDS)[number];
|
|
930
|
+
|
|
931
|
+
const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit", "passive_sample" and "judge"';
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* A rule's behavior: "always" when left out; one of the three names; or an
|
|
935
|
+
* object holding one of the timing kinds. Anything else but a list or an
|
|
936
|
+
* object was read as "always", a rule that wakes on every event it matches,
|
|
937
|
+
* and is read so; a list, or an object holding none of the kinds, was
|
|
938
|
+
* refused, and the rule is left out. Of
|
|
939
|
+
* two kinds or more, "debounce", then "rate_limit", then "passive_sample" was
|
|
940
|
+
* the one read, whatever their order, and is read so; "judge", newer than
|
|
941
|
+
* those, is read only where none of them is given. Each other kind is read
|
|
942
|
+
* on its own as well, so that a refusal names its problems too, and a load
|
|
943
|
+
* reads past them, since it isn't read either way. `kind` is the behavior's
|
|
944
|
+
* kind even where it can't be read, for passthrough's check.
|
|
945
|
+
*/
|
|
946
|
+
function readBehavior(r: Reader, value: unknown): { behavior?: GateBehavior | undefined; kind?: BehaviorKind | undefined } {
|
|
947
|
+
if (value === undefined) return { behavior: 'always', kind: 'always' };
|
|
948
|
+
if (value === 'always' || value === 'defer' || value === 'skip') return { behavior: value, kind: value };
|
|
949
|
+
if (!isPlainObject(value)) {
|
|
950
|
+
if (Array.isArray(value)) {
|
|
951
|
+
r.problems.push({ text: `behavior is ${shownValue(value)}, where ${BEHAVIORS_NEEDED} is needed` });
|
|
952
|
+
return {};
|
|
953
|
+
}
|
|
954
|
+
r.problems.push({ text: `behavior is ${shownValue(value)}, where ${BEHAVIORS_NEEDED} is needed`, read: 'so it\'s read as "always"' });
|
|
955
|
+
return { behavior: 'always', kind: 'always' };
|
|
956
|
+
}
|
|
957
|
+
const b = withoutEmpty(`${r.where}: behavior`, value, r.notes);
|
|
958
|
+
readFieldNames(r, 'behavior', b, BEHAVIOR_KINDS);
|
|
959
|
+
const kinds = BEHAVIOR_KINDS.filter((k) => b[k] !== undefined);
|
|
960
|
+
const kind = kinds[0];
|
|
961
|
+
if (kind === undefined) {
|
|
962
|
+
r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit", "passive_sample" and "judge"' });
|
|
963
|
+
return {};
|
|
964
|
+
}
|
|
965
|
+
if (kinds.length > 1) {
|
|
966
|
+
r.problems.push({ text: `behavior holds ${quotedList(kinds)}, and a rule has one behavior`, read: `so only its "${kind}" is read` });
|
|
967
|
+
}
|
|
968
|
+
const behavior = READ_KIND[kind](r, b[kind]);
|
|
969
|
+
for (const other of kinds.slice(1)) {
|
|
970
|
+
const unread: Reader = { where: r.where, file: r.file, problems: [], notes: [] };
|
|
971
|
+
READ_KIND[other](unread, b[other]);
|
|
972
|
+
for (const p of unread.problems) r.problems.push({ text: p.text, read: `which goes unread with the rest of "${other}"` });
|
|
973
|
+
}
|
|
974
|
+
return { behavior, kind };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* A debounce: a number of milliseconds. One out of the gate's range is read
|
|
979
|
+
* at its nearest bound. Earlier releases refused it in gate.json and ran it
|
|
980
|
+
* as written in the starting rules, where one over 2,147,483,647 ms ran after
|
|
981
|
+
* 1 ms, the most a timer takes; read at the bound, a batch goes out later or
|
|
982
|
+
* sooner than it did there, and every event in it is still delivered.
|
|
983
|
+
*/
|
|
984
|
+
function readDebounce(r: Reader, ms: unknown): GateBehavior | undefined {
|
|
985
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
986
|
+
r.problems.push({ text: `behavior.debounce is ${shownValue(ms)}, where a number of milliseconds is needed, such as 60000` });
|
|
987
|
+
return undefined;
|
|
988
|
+
}
|
|
989
|
+
if (ms < MIN_DEBOUNCE_MS) {
|
|
990
|
+
r.problems.push({
|
|
991
|
+
text: `behavior.debounce is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
|
|
992
|
+
});
|
|
993
|
+
return { debounce: MIN_DEBOUNCE_MS };
|
|
994
|
+
}
|
|
995
|
+
if (ms > MAX_DEBOUNCE_MS) {
|
|
996
|
+
r.problems.push({
|
|
997
|
+
text: `behavior.debounce is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
|
|
998
|
+
});
|
|
999
|
+
return { debounce: MAX_DEBOUNCE_MS };
|
|
1000
|
+
}
|
|
1001
|
+
return { debounce: ms };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* A rate limit. A refill interval under 0 ran as a bucket that never refills,
|
|
1006
|
+
* waking on its first tokens and then on none of what the rule matched, and
|
|
1007
|
+
* is read so. One of 0 refilled the bucket once and then never, its clock left
|
|
1008
|
+
* at NaN; it's read as refilling at once, every time, so the rule limits
|
|
1009
|
+
* nothing, which holds back nothing it woke the agent on (applyRateLimit). A
|
|
1010
|
+
* rule whose tokens aren't a positive number is left out, as earlier releases'
|
|
1011
|
+
* check refused it. Tokens that are a number not over 0, which ran unchecked
|
|
1012
|
+
* from the starting rules, woke the agent on nothing the rule matched, so that
|
|
1013
|
+
* rule is quiet (noTokens): leaving it out holds back nothing it was written
|
|
1014
|
+
* to wake on, though what it matches goes on to the rules after it, as for any
|
|
1015
|
+
* rule left out.
|
|
1016
|
+
*/
|
|
1017
|
+
function readRateLimit(r: Reader, value: unknown): GateBehavior | undefined {
|
|
1018
|
+
if (!isPlainObject(value)) {
|
|
1019
|
+
r.problems.push({
|
|
1020
|
+
text: `behavior.rate_limit is ${shownValue(value)}, where an object is needed, such as {"tokens": 3, "refillIntervalMs": 60000}`,
|
|
1021
|
+
});
|
|
1022
|
+
return undefined;
|
|
1023
|
+
}
|
|
1024
|
+
const conf = withoutEmpty(`${r.where}: behavior.rate_limit`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
|
|
1025
|
+
readFieldNames(r, 'behavior.rate_limit', conf, RATE_LIMIT_FIELDS);
|
|
1026
|
+
const tokens = conf.tokens;
|
|
1027
|
+
const refill = conf.refillIntervalMs;
|
|
1028
|
+
const tokensUsable = typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0;
|
|
1029
|
+
if (typeof tokens === 'number' && !(tokens > 0)) r.noTokens = true;
|
|
1030
|
+
if (!tokensUsable) {
|
|
1031
|
+
r.problems.push({ text: `behavior.rate_limit.tokens is ${shownValue(tokens)}, where a positive number is needed` });
|
|
1032
|
+
}
|
|
1033
|
+
const refillUsable = typeof refill === 'number' && Number.isFinite(refill);
|
|
1034
|
+
if (!refillUsable) {
|
|
1035
|
+
r.problems.push({
|
|
1036
|
+
text: `behavior.rate_limit.refillIntervalMs is ${shownValue(refill)}, where a positive number of milliseconds is needed`,
|
|
1037
|
+
});
|
|
1038
|
+
} else if (refill <= 0) {
|
|
1039
|
+
r.problems.push({
|
|
1040
|
+
text: `behavior.rate_limit.refillIntervalMs is ${refill}, where a positive number of milliseconds is needed`,
|
|
1041
|
+
read: refill === 0
|
|
1042
|
+
? 'so its tokens refill at once, and it limits nothing it matches'
|
|
1043
|
+
: "so its tokens never refill, and once they're spent it wakes the agent on nothing it matches",
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
const keyBy = readKeyBy(r, 'rate_limit', conf.keyBy);
|
|
1047
|
+
if (!tokensUsable || !refillUsable) return undefined;
|
|
1048
|
+
const out: { tokens: number; refillIntervalMs: number; keyBy?: string } = { tokens, refillIntervalMs: refill };
|
|
1049
|
+
if (keyBy !== undefined) out.keyBy = keyBy;
|
|
1050
|
+
return { rate_limit: out };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* A sample. A step at or under 0 sampled every event, as 1 does, and a
|
|
1055
|
+
* fraction its next whole number, since the count was compared with it, so
|
|
1056
|
+
* each is read that way.
|
|
1057
|
+
*/
|
|
1058
|
+
function readPassiveSample(r: Reader, value: unknown): GateBehavior | undefined {
|
|
1059
|
+
if (!isPlainObject(value)) {
|
|
1060
|
+
r.problems.push({ text: `behavior.passive_sample is ${shownValue(value)}, where an object is needed, such as {"every": 10}` });
|
|
1061
|
+
return undefined;
|
|
1062
|
+
}
|
|
1063
|
+
const conf = withoutEmpty(`${r.where}: behavior.passive_sample`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
|
|
1064
|
+
readFieldNames(r, 'behavior.passive_sample', conf, PASSIVE_SAMPLE_FIELDS);
|
|
1065
|
+
const given = conf.every;
|
|
1066
|
+
let every: number | undefined;
|
|
1067
|
+
if (typeof given !== 'number' || !Number.isFinite(given)) {
|
|
1068
|
+
r.problems.push({ text: `behavior.passive_sample.every is ${shownValue(given)}, where a whole number of 1 or more is needed` });
|
|
1069
|
+
} else if (Number.isInteger(given) && given > 0) {
|
|
1070
|
+
every = given;
|
|
1071
|
+
} else {
|
|
1072
|
+
every = given <= 0 ? 1 : Math.ceil(given);
|
|
1073
|
+
r.problems.push({
|
|
1074
|
+
text: `behavior.passive_sample.every is ${given}, ${given <= 0 ? 'under 1' : 'not a whole number'}`,
|
|
1075
|
+
read: `so it's read as ${every}`,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
const keyBy = readKeyBy(r, 'passive_sample', conf.keyBy);
|
|
1079
|
+
if (every === undefined) return undefined;
|
|
1080
|
+
const out: { every: number; keyBy?: string } = { every };
|
|
1081
|
+
if (keyBy !== undefined) out.keyBy = keyBy;
|
|
1082
|
+
return { passive_sample: out };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* A judge: a debounce's quiet period, read as `debounce` is, with at its
|
|
1087
|
+
* nearest bound one out of range; an optional longest wait, from that
|
|
1088
|
+
* quiet period to an hour, likewise; and the agent's optional instructions to
|
|
1089
|
+
* the judge, text of at most MAX_JUDGE_INSTRUCTIONS characters. Earlier
|
|
1090
|
+
* releases knew no judge, so nothing deployed has one: a rule whose judge
|
|
1091
|
+
* can't be read is left out, and its instructions, which go to a model with
|
|
1092
|
+
* every batch, are never cut to fit.
|
|
1093
|
+
*/
|
|
1094
|
+
function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
|
|
1095
|
+
if (!isPlainObject(value)) {
|
|
1096
|
+
r.problems.push({
|
|
1097
|
+
text: `behavior.judge is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
|
|
1098
|
+
});
|
|
1099
|
+
return undefined;
|
|
1100
|
+
}
|
|
1101
|
+
const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
|
|
1102
|
+
maxWaitMs: 'a batch is judged only after its quiet period',
|
|
1103
|
+
instructions: 'the judge decides without the agent\'s word',
|
|
1104
|
+
});
|
|
1105
|
+
readFieldNames(r, 'behavior.judge', conf, JUDGE_FIELDS);
|
|
1106
|
+
let ok = true;
|
|
1107
|
+
const ms = conf.debounceMs;
|
|
1108
|
+
let debounceMs: number | undefined;
|
|
1109
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
1110
|
+
r.problems.push({ text: `behavior.judge.debounceMs is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
|
|
1111
|
+
ok = false;
|
|
1112
|
+
} else if (ms < MIN_DEBOUNCE_MS) {
|
|
1113
|
+
r.problems.push({
|
|
1114
|
+
text: `behavior.judge.debounceMs is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
|
|
1115
|
+
});
|
|
1116
|
+
debounceMs = MIN_DEBOUNCE_MS;
|
|
1117
|
+
} else if (ms > MAX_DEBOUNCE_MS) {
|
|
1118
|
+
r.problems.push({
|
|
1119
|
+
text: `behavior.judge.debounceMs is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
|
|
1120
|
+
});
|
|
1121
|
+
debounceMs = MAX_DEBOUNCE_MS;
|
|
1122
|
+
} else {
|
|
1123
|
+
debounceMs = ms;
|
|
1124
|
+
}
|
|
1125
|
+
let maxWaitMs: number | undefined;
|
|
1126
|
+
const wait = conf.maxWaitMs;
|
|
1127
|
+
if (wait !== undefined) {
|
|
1128
|
+
if (typeof wait !== 'number' || !Number.isFinite(wait)) {
|
|
1129
|
+
r.problems.push({ text: `behavior.judge.maxWaitMs is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
|
|
1130
|
+
ok = false;
|
|
1131
|
+
} else {
|
|
1132
|
+
const floor = debounceMs ?? MIN_DEBOUNCE_MS;
|
|
1133
|
+
if (wait < floor) {
|
|
1134
|
+
r.problems.push({
|
|
1135
|
+
text: `behavior.judge.maxWaitMs is ${wait} ms, under the rule's ${floor} ms quiet period`,
|
|
1136
|
+
read: `so it's read as ${floor} ms`,
|
|
1137
|
+
});
|
|
1138
|
+
maxWaitMs = floor;
|
|
1139
|
+
} else if (wait > MAX_JUDGE_WAIT_MS) {
|
|
1140
|
+
r.problems.push({
|
|
1141
|
+
text: `behavior.judge.maxWaitMs is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
|
|
1142
|
+
read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
|
|
1143
|
+
});
|
|
1144
|
+
maxWaitMs = MAX_JUDGE_WAIT_MS;
|
|
1145
|
+
} else {
|
|
1146
|
+
maxWaitMs = wait;
|
|
344
1147
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
const said = conf.instructions;
|
|
1151
|
+
let instructions: string | undefined;
|
|
1152
|
+
if (said !== undefined) {
|
|
1153
|
+
if (typeof said !== 'string') {
|
|
1154
|
+
r.problems.push({ text: `behavior.judge.instructions is ${shownValue(said)}, where text is needed` });
|
|
1155
|
+
ok = false;
|
|
1156
|
+
} else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
|
|
1157
|
+
r.problems.push({
|
|
1158
|
+
text: `behavior.judge.instructions is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
|
|
1159
|
+
});
|
|
1160
|
+
ok = false;
|
|
1161
|
+
} else {
|
|
1162
|
+
instructions = said;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
if (!ok || debounceMs === undefined) return undefined;
|
|
1166
|
+
const out: JudgeConfig = { debounceMs };
|
|
1167
|
+
if (maxWaitMs !== undefined) out.maxWaitMs = maxWaitMs;
|
|
1168
|
+
if (instructions !== undefined) out.instructions = instructions;
|
|
1169
|
+
return { judge: out };
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** How each kind of behavior object is read. */
|
|
1173
|
+
const READ_KIND: Readonly<Record<(typeof BEHAVIOR_KINDS)[number], (r: Reader, value: unknown) => GateBehavior | undefined>> = {
|
|
1174
|
+
debounce: readDebounce,
|
|
1175
|
+
rate_limit: readRateLimit,
|
|
1176
|
+
passive_sample: readPassiveSample,
|
|
1177
|
+
judge: readJudge,
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
/** A timing kind's keyBy: a field name. One of another type was read as none. */
|
|
1181
|
+
function readKeyBy(r: Reader, kind: 'rate_limit' | 'passive_sample', value: unknown): string | undefined {
|
|
1182
|
+
if (value === undefined) return undefined;
|
|
1183
|
+
if (typeof value === 'string') return value;
|
|
1184
|
+
r.problems.push({
|
|
1185
|
+
text: `behavior.${kind}.keyBy is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
|
|
1186
|
+
read: `so it does nothing, and every event the rule matches shares one ${kind === 'rate_limit' ? 'bucket' : 'count'}`,
|
|
1187
|
+
});
|
|
1188
|
+
return undefined;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* A rule's match: every field optional, and one left out matches anything. A
|
|
1193
|
+
* field of the wrong type, or an entry of one in a list, was dropped, which
|
|
1194
|
+
* widened the rule, and is read so; an empty entry in a list, dropped, but for
|
|
1195
|
+
* one in `scope`, which was kept and narrows the rule, since no event's type is
|
|
1196
|
+
* "". A match that isn't an object matched every event, and so does one left
|
|
1197
|
+
* with nothing that narrows it, which readRule notes, for a rule it can read,
|
|
1198
|
+
* where something in the match was named: dropped, left out or unread.
|
|
1199
|
+
*/
|
|
1200
|
+
function readMatch(r: Reader, value: unknown): GatePolicyMatch {
|
|
1201
|
+
const match: GatePolicyMatch = {};
|
|
1202
|
+
if (value === undefined) return match;
|
|
1203
|
+
if (!isPlainObject(value)) {
|
|
1204
|
+
r.problems.push({
|
|
1205
|
+
text: `match is ${shownValue(value)}, where an object is needed, such as {"source": "discord"}`,
|
|
1206
|
+
read: 'so the rule matches every event',
|
|
1207
|
+
});
|
|
1208
|
+
return match;
|
|
1209
|
+
}
|
|
1210
|
+
const named = r.problems.length + r.notes.length;
|
|
1211
|
+
const m = withoutEmpty(`${r.where}: match`, value, r.notes);
|
|
1212
|
+
readFieldNames(r, 'match', m, MATCH_FIELDS);
|
|
1213
|
+
|
|
1214
|
+
if (m.scope !== undefined) {
|
|
1215
|
+
if (!Array.isArray(m.scope)) {
|
|
1216
|
+
r.problems.push({
|
|
1217
|
+
text: `match.scope is ${shownValue(m.scope)}, where a list of event types is needed, such as ["mcpl:channel-incoming"]`,
|
|
1218
|
+
read: 'so the rule matches every type of event',
|
|
1219
|
+
});
|
|
1220
|
+
} else {
|
|
1221
|
+
const types = m.scope.filter((s): s is string => typeof s === 'string');
|
|
1222
|
+
const dropped = m.scope.filter((s) => typeof s !== 'string');
|
|
1223
|
+
if (dropped.length > 0) {
|
|
1224
|
+
r.problems.push({
|
|
1225
|
+
text: `match.scope holds ${shownValues(dropped)}, which ${dropped.length === 1 ? "isn't an event type" : "aren't event types"}`,
|
|
1226
|
+
read: types.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and the rule matches every type of event`,
|
|
1227
|
+
});
|
|
351
1228
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
1229
|
+
if (types.includes('')) {
|
|
1230
|
+
r.problems.push({
|
|
1231
|
+
text: 'match.scope holds "", and no event\'s type is ""',
|
|
1232
|
+
read: types.every((t) => t === '') ? 'so the rule matches no event' : 'so that entry matches no event',
|
|
1233
|
+
narrows: true,
|
|
1234
|
+
});
|
|
357
1235
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1236
|
+
match.scope = types;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
for (const [field, example] of [
|
|
1240
|
+
['source', '"discord"'], ['channel', '"discord:*:12345"'], ['mount', '"project"'], ['pathGlob', '"notes/*.md"'],
|
|
1241
|
+
] as const) {
|
|
1242
|
+
const given = m[field];
|
|
1243
|
+
if (given === undefined) continue;
|
|
1244
|
+
if (typeof given === 'string') match[field] = given;
|
|
1245
|
+
else r.problems.push({ text: `match.${field} is ${shownValue(given)}, where a string is needed, such as ${example}`, read: DROPPED });
|
|
1246
|
+
}
|
|
1247
|
+
for (const [field, example] of [
|
|
1248
|
+
['metadataTrue', '["mentioned"]'], ['tagsAny', '["chat:ambient"]'], ['tagsAll', '["chat:ambient"]'], ['tagsNone', '["chat:ambient"]'],
|
|
1249
|
+
] as const) {
|
|
1250
|
+
const given = m[field];
|
|
1251
|
+
if (given === undefined) continue;
|
|
1252
|
+
if (!Array.isArray(given)) {
|
|
1253
|
+
r.problems.push({
|
|
1254
|
+
text: `match.${field} is ${shownValue(given)}, where a list of non-empty strings is needed, such as ${example}`, read: DROPPED,
|
|
1255
|
+
});
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
const kept = given.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
|
1259
|
+
const dropped = given.filter((s) => typeof s !== 'string' || s.length === 0);
|
|
1260
|
+
if (dropped.length > 0) {
|
|
1261
|
+
r.problems.push({
|
|
1262
|
+
text: `match.${field} holds ${shownValues(dropped)}, where each entry is a non-empty string`,
|
|
1263
|
+
read: kept.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and with none left the list narrows nothing`,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
if (kept.length > 0) match[field] = kept;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (m.filter !== undefined) {
|
|
1270
|
+
if (!isPlainObject(m.filter)) {
|
|
1271
|
+
r.problems.push({
|
|
1272
|
+
text: `match.filter is ${shownValue(m.filter)}, where an object is needed, such as {"type": "text", "pattern": "release"}`,
|
|
1273
|
+
read: DROPPED,
|
|
1274
|
+
});
|
|
1275
|
+
} else {
|
|
1276
|
+
// An empty pattern matches every event's content, so a filter with one
|
|
1277
|
+
// narrows nothing, as before, and it's left out, its other problems named.
|
|
1278
|
+
const emptyPattern = m.filter.pattern === '';
|
|
1279
|
+
const f = withoutEmpty(`${r.where}: match.filter`, emptyPattern ? { ...m.filter, pattern: undefined } : m.filter, r.notes);
|
|
1280
|
+
readFieldNames(r, 'match.filter', f, FILTER_FIELDS);
|
|
1281
|
+
const without = 'so the rule is read without its filter';
|
|
1282
|
+
const type = f.type === 'text' || f.type === 'regex' ? f.type : undefined;
|
|
1283
|
+
if (type === undefined) {
|
|
1284
|
+
r.problems.push({ text: `match.filter.type is ${shownValue(f.type)}, where "text" or "regex" is needed`, read: without });
|
|
361
1285
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
1286
|
+
if (emptyPattern) {
|
|
1287
|
+
r.notes.push(`${r.where}: match.filter's pattern is "", so the filter narrows nothing, and it's left out.`);
|
|
1288
|
+
} else if (typeof f.pattern !== 'string') {
|
|
1289
|
+
r.problems.push({
|
|
1290
|
+
text: `match.filter.pattern is ${shownValue(f.pattern)}, where the text or regular expression to look for is needed`,
|
|
1291
|
+
read: without,
|
|
1292
|
+
});
|
|
1293
|
+
} else if (type === 'text') {
|
|
1294
|
+
match.filter = { type, pattern: f.pattern };
|
|
1295
|
+
} else if (type === 'regex') {
|
|
1296
|
+
try {
|
|
1297
|
+
new RegExp(f.pattern);
|
|
1298
|
+
match.filter = { type, pattern: f.pattern };
|
|
1299
|
+
} catch (e) {
|
|
1300
|
+
r.problems.push({ text: `match.filter.pattern isn't a regular expression the gate can read (${errorText(e)})` });
|
|
1301
|
+
}
|
|
365
1302
|
}
|
|
366
|
-
behavior = { passive_sample: out };
|
|
367
|
-
} else {
|
|
368
|
-
throw new Error(`Policy "${obj.name}": unknown behavior — expected always | defer | skip | { debounce } | { rate_limit } | { passive_sample }`);
|
|
369
1303
|
}
|
|
370
|
-
} else {
|
|
371
|
-
behavior = 'always';
|
|
372
1304
|
}
|
|
373
1305
|
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
1306
|
+
// Left with nothing that narrows it once something in it was named, it
|
|
1307
|
+
// matches every event. Empty lists and nulls alone always meant no
|
|
1308
|
+
// constraint, and name nothing.
|
|
1309
|
+
const narrows = Object.entries(match).some(([field, v]) => !(field === 'scope' && Array.isArray(v) && v.length === 0));
|
|
1310
|
+
if (!narrows && r.problems.length + r.notes.length > named) r.matchesAll = true;
|
|
1311
|
+
return match;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** A rule's resets: names of rules whose state it clears when it fires. An entry that isn't a name was dropped. */
|
|
1315
|
+
function readResets(r: Reader, value: unknown): string[] | undefined {
|
|
1316
|
+
if (value === undefined) return undefined;
|
|
1317
|
+
if (!Array.isArray(value)) {
|
|
1318
|
+
r.problems.push({
|
|
1319
|
+
text: `resets is ${shownValue(value)}, where a list of rule names is needed, such as ["other-rule"]`,
|
|
1320
|
+
read: 'so the rule resets no other',
|
|
1321
|
+
});
|
|
1322
|
+
return undefined;
|
|
1323
|
+
}
|
|
1324
|
+
// Unknown names are ignored at runtime, so reload races don't blow up.
|
|
1325
|
+
const names = value.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
|
1326
|
+
const dropped = value.filter((s) => typeof s !== 'string' || s.length === 0);
|
|
1327
|
+
if (dropped.length > 0) {
|
|
1328
|
+
r.problems.push({
|
|
1329
|
+
text: `resets holds ${shownValues(dropped)}, where each entry is a rule's name`,
|
|
1330
|
+
read: names.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and the rule resets no other`,
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
return names.length > 0 ? names : undefined;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* A rule's passthrough. It's meaningful only on the counting behaviors, where
|
|
1338
|
+
* "matched but didn't fire" is a distinct outcome that can fall through. On
|
|
1339
|
+
* always, defer, skip or a debounce it would be a no-op at best and a
|
|
1340
|
+
* misleading promise at worst, so it's refused rather than ignored.
|
|
1341
|
+
*/
|
|
1342
|
+
function readPassthrough(
|
|
1343
|
+
r: Reader, value: unknown, behavior: GateBehavior | undefined, kind: BehaviorKind | undefined,
|
|
1344
|
+
): boolean {
|
|
1345
|
+
if (value === undefined || value === false) return false;
|
|
1346
|
+
if (value !== true) {
|
|
1347
|
+
r.problems.push({ text: `passthrough is ${shownValue(value)}, where true or false is needed` });
|
|
1348
|
+
return false;
|
|
1349
|
+
}
|
|
1350
|
+
if (kind === undefined || kind === 'rate_limit' || kind === 'passive_sample') return true;
|
|
1351
|
+
r.problems.push({
|
|
1352
|
+
text: `passthrough is only valid with rate_limit or passive_sample behaviors — for "${behavior ? formatBehavior(behavior) : kind}" `
|
|
1353
|
+
+ 'there is no non-firing match to fall through on',
|
|
1354
|
+
});
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** What a load or a start does with a rule. */
|
|
1359
|
+
type RuleOutcome = 'as-written' | 'read' | 'left-out';
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* A rule with no problem is in force as written. One with a problem is read
|
|
1363
|
+
* past it, the way earlier releases read it, so it wakes the agent wherever
|
|
1364
|
+
* they did and holds back what they held back, a rule that skips or defers
|
|
1365
|
+
* included. It's left out only where they refused it.
|
|
1366
|
+
*/
|
|
1367
|
+
function ruleOutcome(reading: RuleReading): RuleOutcome {
|
|
1368
|
+
if (reading.problems.length === 0) return 'as-written';
|
|
1369
|
+
return reading.policy === undefined ? 'left-out' : 'read';
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** What a load or a start says of a rule: how it's read past its problems, or that it's left out, and its notes. */
|
|
1373
|
+
function readingSentences(reading: RuleReading, outcome: RuleOutcome): string[] {
|
|
1374
|
+
const said: string[] = [];
|
|
1375
|
+
if (outcome === 'read') {
|
|
1376
|
+
// Read past a problem that can widen it, a rule that holds events back holds back what it now matches.
|
|
1377
|
+
const behavior = reading.policy?.behavior;
|
|
1378
|
+
const holds = (behavior === 'skip' || behavior === 'defer') && reading.problems.some((p) => !p.narrows)
|
|
1379
|
+
? ` Read so, it ${behavior === 'skip' ? 'skips' : 'defers'} every event it now matches, as earlier releases did.`
|
|
1380
|
+
: '';
|
|
1381
|
+
said.push(`${reading.where}: ${reading.problems.map((p) => `${p.text}, ${p.read}`).join('; ')}.${holds}`);
|
|
1382
|
+
} else if (outcome === 'left-out') {
|
|
1383
|
+
said.push(`${reading.where} is left out: ${reading.problems.map((p) => p.text).join('; ')}.`);
|
|
1384
|
+
}
|
|
1385
|
+
return [...said, ...notesOf(reading)];
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** addPolicy's refusal of a rule with problems: every one of them, and its notes. */
|
|
1389
|
+
function refusalOf(reading: RuleReading): string {
|
|
1390
|
+
return [`${reading.where} wasn't saved: ${reading.problems.map((p) => p.text).join('; ')}.`, ...notesOf(reading, true)].join(' ');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/** gate.json, or the host's starting rules, as the gate reads them. */
|
|
1394
|
+
type ConfigReading =
|
|
1395
|
+
/** Not used: why, and what a change would edit, since one that parses is
|
|
1396
|
+
* edited as written, whether it's used or not (rulesForChange); where it's
|
|
1397
|
+
* the rules left out, each one's name, or undefined for one without. */
|
|
1398
|
+
| { refused: string; listed: Listed; silenced?: Array<string | undefined> }
|
|
1399
|
+
| {
|
|
1400
|
+
/** What's in force from it. */
|
|
1401
|
+
config: GateConfig;
|
|
1402
|
+
/** Each rule as given, as read, and what's done with it. */
|
|
1403
|
+
rules: Array<{ raw: unknown; reading: RuleReading; outcome: RuleOutcome }>;
|
|
1404
|
+
/** What the gate says of it, each a sentence: its own problems and notes, then each rule's. */
|
|
1405
|
+
sentences: string[];
|
|
1406
|
+
/** Its own fields as loaded, and its rule list, each rule as written, for
|
|
1407
|
+
* a write to edit by name; or, for one with no rule list to edit (a list,
|
|
1408
|
+
* or a "policies" that isn't one and holds something), what it is instead. */
|
|
1409
|
+
listed: Listed;
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
/** A gate.json's own fields and its rule list, as written, or why it has none a write could edit. */
|
|
1413
|
+
type Listed = { top: Record<string, unknown>; rules: unknown[] } | { noList: string };
|
|
1414
|
+
|
|
1415
|
+
/** A rule's name as written, when it has a usable one. */
|
|
1416
|
+
function nameOf(rule: unknown): string | undefined {
|
|
1417
|
+
return isPlainObject(rule) && typeof rule.name === 'string' && rule.name ? rule.name : undefined;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/** What a load of gate.json says, from what its reading says of it: nothing, when that's nothing. */
|
|
1421
|
+
function loadNote(sentences: readonly string[]): string | null {
|
|
1422
|
+
return sentences.length > 0 ? `gate.json loaded. ${sentences.join(' ')}` : null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** The names of the rules a gate.json holds as written: none, for one with no rule list. */
|
|
1426
|
+
function heldNames(listed: Listed): Set<string> {
|
|
1427
|
+
return new Set('noList' in listed ? [] : listed.rules.map(nameOf).filter((name): name is string => name !== undefined));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* gate.json, or the host's starting rules, read (`file` names which in
|
|
1432
|
+
* messages). A file earlier releases loaded loads as they read it, with a
|
|
1433
|
+
* note naming what they didn't read: a list is read as no rules under the
|
|
1434
|
+
* default "always", and a slip of "policies", a name the gate doesn't read
|
|
1435
|
+
* (a list of rules parked under one included), a field of a rule at the
|
|
1436
|
+
* file's top and a "policies" that isn't a list each as nothing, so where
|
|
1437
|
+
* "policies" writes no rules the file has none, and its default decides every
|
|
1438
|
+
* event. A "policies" holding nothing, an empty list or one that isn't a
|
|
1439
|
+
* list, writes no rules, as one left out does. Earlier releases refused it
|
|
1440
|
+
* whole where it isn't an object, or its default isn't one of the three, and
|
|
1441
|
+
* it isn't used. Each rule is read on its own. Under a default that holds
|
|
1442
|
+
* events back, a rule left out that could wake the agent would hold back what
|
|
1443
|
+
* it was written to wake on, so the file isn't used, as earlier releases,
|
|
1444
|
+
* which refused such a rule and so the file, didn't use it. `start` is what a
|
|
1445
|
+
* later start ran over a gate.json with no rules of its own, for its note: the
|
|
1446
|
+
* host's starting rules beside one with no rule list, or, where gate.json
|
|
1447
|
+
* isn't used at that start, the clause saying why and what runs instead
|
|
1448
|
+
* (notUsedClause), which the sentence saying it has no rules carries.
|
|
1449
|
+
*/
|
|
1450
|
+
function readConfig(
|
|
1451
|
+
raw: unknown, file: 'gate.json' | 'the starting config', start?: 'beside' | { notUsed: string },
|
|
1452
|
+
): ConfigReading {
|
|
1453
|
+
const File = file === 'gate.json' ? file : 'The starting config';
|
|
1454
|
+
if (Array.isArray(raw)) {
|
|
1455
|
+
return {
|
|
1456
|
+
config: { policies: [], default: 'always' },
|
|
1457
|
+
rules: [],
|
|
1458
|
+
sentences: [
|
|
1459
|
+
`${File} is a list, where an object is needed, such as {"policies": [{"name": "my-rule", "behavior": "always"}]}, `
|
|
1460
|
+
+ (start === 'beside'
|
|
1461
|
+
? 'so none of it is read, and the gate runs the host\'s starting rules beside it, under the default, "always".'
|
|
1462
|
+
: 'so none of it is read, and every event gets the default, "always".'),
|
|
1463
|
+
],
|
|
1464
|
+
listed: { noList: `${File} is a list` },
|
|
391
1465
|
};
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
1466
|
+
}
|
|
1467
|
+
if (!isPlainObject(raw)) return { refused: `${file} must be a JSON object`, listed: { noList: `${File} is ${shownValue(raw)}` } };
|
|
1468
|
+
|
|
1469
|
+
const sentences: string[] = [];
|
|
1470
|
+
// At a start that doesn't use it, what having no rules does is that.
|
|
1471
|
+
const notUsedWhy = typeof start === 'object' ? start.notUsed : undefined;
|
|
1472
|
+
const notUsed = notUsedWhy === undefined ? undefined : `it has no rules, so ${notUsedWhy}`;
|
|
1473
|
+
const obj = withoutEmpty(file, raw, sentences, { ...configLeftOut(raw.default), ...(notUsed ? { policies: notUsed } : {}) });
|
|
1474
|
+
// Here a name holding "default" or "policies" isn't a slip of it: at the
|
|
1475
|
+
// top, such a name can be a label of the file ("policiesUpdated").
|
|
1476
|
+
const names = fieldNames(obj, CONFIG_FIELDS, false);
|
|
1477
|
+
const { misplaced, rest } = placedElsewhere(names.unread, homesOf(file, CONFIG_FIELDS));
|
|
1478
|
+
noteUnread(file, rest, CONFIG_FIELDS, sentences);
|
|
1479
|
+
for (const m of misplaced) {
|
|
1480
|
+
sentences.push(
|
|
1481
|
+
`${file} has ${misplacedShown(m)}, which the gate doesn't read here, so ${m.keys.length === 1 ? 'it does' : 'they do'} nothing.`,
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
// A "policies" holding nothing writes no rules, as one left out does.
|
|
1485
|
+
const noPolicies = obj.policies === undefined || holdsNothing(obj.policies);
|
|
1486
|
+
// What a change edits, used or not: its own fields and rule list as written
|
|
1487
|
+
// (a list left out, null or "", or one holding nothing, is an empty one),
|
|
1488
|
+
// beside whatever else it holds; or, where "policies" isn't a list and holds
|
|
1489
|
+
// something, what it is instead, since a list there would write over it.
|
|
1490
|
+
const listed: Listed = noPolicies || Array.isArray(obj.policies)
|
|
1491
|
+
? { top: raw, rules: Array.isArray(obj.policies) ? obj.policies : [] }
|
|
1492
|
+
: { noList: `${File}'s "policies" is ${shownValue(obj.policies)}` };
|
|
1493
|
+
const given = obj.default;
|
|
1494
|
+
if (given !== undefined && given !== 'always' && given !== 'defer' && given !== 'skip') {
|
|
1495
|
+
return { refused: `${file} "default" must be "always", "defer", or "skip", got: ${shownValue(given)}`, listed };
|
|
1496
|
+
}
|
|
1497
|
+
const defaultBehavior = given ?? 'always';
|
|
1498
|
+
// Beside a start that runs the host's starting rules, since it has no list to add them to, they're what its default
|
|
1499
|
+
// meets; at one that doesn't use it, what runs in its place is.
|
|
1500
|
+
const noRules = start === 'beside'
|
|
1501
|
+
? `it has no rules, so the gate runs the host's starting rules beside it, under its default, "${defaultBehavior}"`
|
|
1502
|
+
: notUsed ?? configLeftOut(defaultBehavior).policies;
|
|
1503
|
+
for (const slip of names.slips) {
|
|
1504
|
+
let read = SLIP_READ;
|
|
1505
|
+
// Where "policies" writes no rules, what a slip of it holds leaves the file with none.
|
|
1506
|
+
if (slip.meant.includes('policies') && noPolicies) read = `and ${noRules}`;
|
|
1507
|
+
else if (slip.meant.includes('default') && obj.default === undefined) read = 'so its default is "always"';
|
|
1508
|
+
sentences.push(`${File} has ${slipShown(slip)}, which the gate doesn't read, ${read}.`);
|
|
1509
|
+
}
|
|
1510
|
+
const policies: unknown[] = Array.isArray(obj.policies) ? obj.policies : [];
|
|
1511
|
+
if (!Array.isArray(obj.policies) && obj.policies !== undefined) {
|
|
1512
|
+
sentences.push(
|
|
1513
|
+
`${File}'s "policies" is ${shownValue(obj.policies)}, where a list of rules is needed, such as `
|
|
1514
|
+
+ `[{"name": "my-rule", "behavior": "always"}], and ${noRules}.`,
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const rules = policies.map((rule, i) => {
|
|
1519
|
+
const reading = readRule(rule, `The ${ordinal(i + 1)} policy`, file);
|
|
1520
|
+
return { raw: rule, reading, outcome: ruleOutcome(reading) };
|
|
1521
|
+
});
|
|
1522
|
+
if (defaultBehavior !== 'always') {
|
|
1523
|
+
const silenced = rules.filter((rule) => rule.outcome === 'left-out' && !rule.reading.quiet);
|
|
1524
|
+
if (silenced.length > 0) {
|
|
1525
|
+
const one = silenced.length === 1;
|
|
1526
|
+
return {
|
|
1527
|
+
refused: [
|
|
1528
|
+
`${file} leaves out ${one ? 'a rule' : 'rules'} that could wake the agent, and under its default, "${defaultBehavior}", `
|
|
1529
|
+
+ `what ${one ? 'it was' : 'they were'} written to wake it on would be held back.`,
|
|
1530
|
+
...silenced.flatMap((rule) => readingSentences(rule.reading, rule.outcome)),
|
|
1531
|
+
].join(' '),
|
|
1532
|
+
listed,
|
|
1533
|
+
silenced: silenced.map((rule) => nameOf(rule.raw)),
|
|
1534
|
+
};
|
|
405
1535
|
}
|
|
406
1536
|
}
|
|
1537
|
+
for (const rule of rules) sentences.push(...readingSentences(rule.reading, rule.outcome));
|
|
1538
|
+
// A list with no rules, or none the gate reads, where no sentence above said it has none.
|
|
1539
|
+
if (notUsed !== undefined && !sentences.some((said) => said.includes(notUsed))) {
|
|
1540
|
+
sentences.push(`${File} has no rules${rules.length > 0 ? ' but ones the gate leaves out' : ''}, so ${notUsedWhy}.`);
|
|
1541
|
+
}
|
|
1542
|
+
return {
|
|
1543
|
+
config: {
|
|
1544
|
+
policies: rules.filter((rule) => rule.outcome !== 'left-out').map((rule) => rule.reading.policy as GatePolicy),
|
|
1545
|
+
default: defaultBehavior,
|
|
1546
|
+
},
|
|
1547
|
+
rules,
|
|
1548
|
+
sentences,
|
|
1549
|
+
listed,
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
407
1552
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1553
|
+
/** One of the host's starting rules: as given, with its name when it has one, and as read. */
|
|
1554
|
+
interface StartingRule { name: string | undefined; raw: unknown; policy: GatePolicy | undefined; quiet: boolean }
|
|
1555
|
+
|
|
1556
|
+
/** The host's starting rules (initialConfig), read as gate.json is, for the seed and the reconcile. */
|
|
1557
|
+
interface StartingRules {
|
|
1558
|
+
/** Why none of them is used, when that's so. */
|
|
1559
|
+
refusedWhole?: string;
|
|
1560
|
+
/** What they put in force when gate.json can't be loaded: each rule not left out, as read. */
|
|
1561
|
+
config?: GateConfig;
|
|
1562
|
+
/** With `config`, the starting config as given, which a first start writes,
|
|
1563
|
+
* since gate.json is read the same way. */
|
|
1564
|
+
given?: unknown;
|
|
1565
|
+
/** Each starting rule as given, with its name when it has one, for the reconcile to add by name, and as read:
|
|
1566
|
+
* the rule, unless it's left out, and whether it wakes on none of what it matches (RuleReading.quiet). */
|
|
1567
|
+
rules: StartingRule[];
|
|
1568
|
+
/** What the gate says of them, each a sentence. */
|
|
1569
|
+
sentences: string[];
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function readStartingRules(raw: unknown): StartingRules {
|
|
1573
|
+
const read = readConfig(raw, 'the starting config');
|
|
1574
|
+
if ('refused' in read) return { refusedWhole: read.refused, rules: [], sentences: [] };
|
|
1575
|
+
return {
|
|
1576
|
+
config: read.config,
|
|
1577
|
+
given: raw,
|
|
1578
|
+
rules: read.rules.map((rule) => ({ name: nameOf(rule.raw), raw: rule.raw, policy: rule.reading.policy, quiet: rule.reading.quiet })),
|
|
1579
|
+
sentences: read.sentences,
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* The recipe the gate last applied (lastRecipePath), as written, or null when
|
|
1585
|
+
* it has none on record. It's read as the host's starting rules are, so a
|
|
1586
|
+
* recipe recorded as given reads back as the start read it. A record that
|
|
1587
|
+
* exists and isn't one the gate wrote (it can't be read, isn't JSON, is
|
|
1588
|
+
* refused as the starting rules are, or names a rule twice) gives null with
|
|
1589
|
+
* the reason, which quotes nothing from the file but a rule's name: a start
|
|
1590
|
+
* then removes and redefines nothing, as the first does, and says why.
|
|
1591
|
+
*/
|
|
1592
|
+
function readLastRecipe(path: string): { recipe: WrittenConfig | null; problem?: string } {
|
|
1593
|
+
if (!existsSync(path)) return { recipe: null };
|
|
1594
|
+
let raw: unknown;
|
|
1595
|
+
try {
|
|
1596
|
+
raw = parseGateJson(readFileSync(path, 'utf-8'));
|
|
1597
|
+
} catch (err) {
|
|
1598
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
1599
|
+
return { recipe: null, problem: code ? `reading it failed with ${code}` : "it isn't JSON" };
|
|
1600
|
+
}
|
|
1601
|
+
const read = readStartingRules(raw);
|
|
1602
|
+
if (read.refusedWhole !== undefined) return { recipe: null, problem: "it isn't read as the starting rules are" };
|
|
1603
|
+
const rules = read.rules.map((rule) => rule.raw);
|
|
1604
|
+
const twice = duplicateName(rules);
|
|
1605
|
+
if (twice !== undefined) return { recipe: null, problem: `it names the rule "${twice}" twice` };
|
|
1606
|
+
return { recipe: { rules, default: isPlainObject(raw) ? raw.default : undefined } };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* A rule's meaning, for the start's merge: the rule as the gate reads it,
|
|
1611
|
+
* where it reads, and as written where it doesn't. So a rule `wake_add_rule`
|
|
1612
|
+
* wrote as read agrees with the same rule written more briefly.
|
|
1613
|
+
*/
|
|
1614
|
+
function ruleMeaning(rule: unknown): string {
|
|
1615
|
+
const reading = readRule(rule, 'A policy');
|
|
1616
|
+
return reading.policy ? `read ${canonical(reading.policy)}` : `written ${canonical(rule)}`;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** What a start's write of the host's starting rules to gate.json did. */
|
|
1620
|
+
type StartWrite =
|
|
1621
|
+
| { kind: 'none' }
|
|
1622
|
+
/** A first start's write of the starting rules threw: at its open, or partway. */
|
|
1623
|
+
| { kind: 'seed-failed'; error: string; atOpen: boolean }
|
|
1624
|
+
/**
|
|
1625
|
+
* A later start's write to gate.json threw, or couldn't be made; `config` is
|
|
1626
|
+
* what it was writing: the rules it read plus the ones it was adding
|
|
1627
|
+
* (`names`), with the merge's other changes (`changing`: the rules it was
|
|
1628
|
+
* removing, redefining or moving, and "default").
|
|
1629
|
+
*/
|
|
1630
|
+
| {
|
|
1631
|
+
kind: 'reconcile-failed'; names: string[]; changing?: string[]; error: string; atOpen: boolean; config: GateConfig;
|
|
1632
|
+
/** The text it was writing, whose reading is `config`, and what a load of it says, when the write itself failed. */
|
|
1633
|
+
text?: string;
|
|
1634
|
+
note?: string | null;
|
|
1635
|
+
sentences?: readonly string[];
|
|
1636
|
+
}
|
|
1637
|
+
/** A later start over a gate.json with no rule list to add the ones it lacks to: `config` is gate.json's default with them, which the gate runs beside it, unsaved. */
|
|
1638
|
+
| { kind: 'reconcile-unsaved'; names: string[]; error: string; config: GateConfig }
|
|
1639
|
+
/** A later start over a gate.json with no rules of its own (no rule list, an empty one, or one holding only rules the
|
|
1640
|
+
* gate leaves out) whose default, `default`, holds events back, where the host's starting rules can't all be put in
|
|
1641
|
+
* force beside it or in it: some that could wake the agent are left out (`leftOut`, by name, and `nameless`, a count of
|
|
1642
|
+
* those with no name) or kept out by gate.json's rules of their names, which the gate leaves out (`heldOut`); or
|
|
1643
|
+
* they're refused whole under their own default. gate.json isn't used at that start, which runs as one without it
|
|
1644
|
+
* does. */
|
|
1645
|
+
| {
|
|
1646
|
+
kind: 'start-without'; default: NonNullable<GateConfig['default']>;
|
|
1647
|
+
leftOut?: string[]; nameless?: number; heldOut?: string[];
|
|
1648
|
+
};
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* What gate_status says about the start, which stays until the next one: the
|
|
1652
|
+
* host's starting rules that couldn't be used and what the start did instead,
|
|
1653
|
+
* how it read them past problems, and rules a reconcile couldn't add. Worded
|
|
1654
|
+
* about the start, so later writes and reloads
|
|
1655
|
+
* leave it true; configSource says what's in force now. `held` is the names
|
|
1656
|
+
* of the rules gate.json holds as written, when it loaded.
|
|
1657
|
+
*/
|
|
1658
|
+
function describeStart(starting: StartingRules, write: StartWrite, held: ReadonlySet<string> | null): string | null {
|
|
1659
|
+
const parts: string[] = [];
|
|
1660
|
+
if (starting.refusedWhole !== undefined) {
|
|
1661
|
+
// gate.json's rules: as loaded, or as read before a write of the start's
|
|
1662
|
+
// own left it unable to load; or none, where gate.json wasn't used either.
|
|
1663
|
+
const instead = write.kind === 'start-without'
|
|
1664
|
+
? `gate.json, which has no rules the gate reads, wasn't used either: under its default, "${write.default}", it would hold back every `
|
|
1665
|
+
+ 'event, so the gate started with no rules, as a start without gate.json does. Giving gate.json rules under '
|
|
1666
|
+
+ '"policies", or fixing the starting rules, puts rules in force (a fix to the starting rules, at the next start).'
|
|
1667
|
+
: held !== null || write.kind === 'reconcile-failed'
|
|
1668
|
+
? "The gate started with gate.json's rules."
|
|
1669
|
+
: 'The gate started with no rules, as on a host without starting rules.';
|
|
1670
|
+
parts.push(`At start, the host's starting rules weren't used: ${sentence(starting.refusedWhole)} ${instead}`);
|
|
1671
|
+
}
|
|
1672
|
+
if (starting.sentences.length > 0) parts.push(`In the host's starting rules, ${starting.sentences.join(' ')}`);
|
|
1673
|
+
// A reconcile whose write failed and left gate.json loading: the rules it
|
|
1674
|
+
// was adding didn't reach it, unless the write landed before failing.
|
|
1675
|
+
if (write.kind === 'reconcile-failed' && held) {
|
|
1676
|
+
if (!write.names.every((name) => held.has(name))) {
|
|
1677
|
+
parts.push(
|
|
1678
|
+
`At start, the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}), `
|
|
1679
|
+
+ "so they weren't put in force. "
|
|
1680
|
+
+ (write.text !== undefined
|
|
1681
|
+
? 'Whoever runs this host can fix what kept them from being written.'
|
|
1682
|
+
: 'A start adds them once what kept them out is fixed.'),
|
|
1683
|
+
);
|
|
436
1684
|
}
|
|
1685
|
+
// The merge's other changes: removals, redefinitions, moves and the default.
|
|
1686
|
+
if (write.changing?.length) {
|
|
1687
|
+
parts.push(
|
|
1688
|
+
`At start, the recipe's changes to gate.json (${quotedList(write.changing)}) `
|
|
1689
|
+
+ (write.atOpen
|
|
1690
|
+
? `weren't saved (${write.error}), so the gate started with gate.json's rules as they were. `
|
|
1691
|
+
: `may not all have been saved (${write.error}): the gate started with gate.json as the write left it. `)
|
|
1692
|
+
+ (write.text !== undefined
|
|
1693
|
+
? 'Whoever runs this host can fix what kept them from being written.'
|
|
1694
|
+
: 'A start applies them once what kept them out is fixed.'),
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return parts.length > 0 ? parts.join(' ') : null;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Why gate.json, which has no rules of its own, isn't used at a start that runs
|
|
1703
|
+
* as one without it (start-without), and what runs in its place: the clause
|
|
1704
|
+
* after "it has no rules, so" in its note.
|
|
1705
|
+
*/
|
|
1706
|
+
function notUsedClause(write: Extract<StartWrite, { kind: 'start-without' }>, starting: StartingRules): string {
|
|
1707
|
+
const back = 'Giving gate.json rules under "policies"';
|
|
1708
|
+
const out = outOfForce(write);
|
|
1709
|
+
if (out) {
|
|
1710
|
+
return `it isn't used at this start: under its default, "${write.default}", ${out.said}, so the gate runs the starting `
|
|
1711
|
+
+ `rules under their own default, "${starting.config?.default ?? 'always'}", as a start without gate.json does. ${back}, or ${out.fix}, puts it in use`
|
|
1712
|
+
+ (out.nextStart ? ' (a fix to the starting rules, at the next start)' : '');
|
|
437
1713
|
}
|
|
1714
|
+
return `under its default, "${write.default}", it would hold back every event: it isn't used at this start, and the gate runs `
|
|
1715
|
+
+ `no rules, as a start without gate.json does, so every event gets the default, "always". ${back}, or fixing the starting rules, puts rules in force`
|
|
1716
|
+
+ ' (a fix to the starting rules, at the next start)';
|
|
1717
|
+
}
|
|
438
1718
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
1719
|
+
/**
|
|
1720
|
+
* The starting rules a start that doesn't use gate.json couldn't put in force
|
|
1721
|
+
* beside it or in it, which could wake the agent, and what would put them in
|
|
1722
|
+
* force: null where the starting rules were refused whole.
|
|
1723
|
+
*/
|
|
1724
|
+
function outOfForce(write: Extract<StartWrite, { kind: 'start-without' }>): { said: string; fix: string; nextStart: boolean } | null {
|
|
1725
|
+
const nameless = write.nameless ?? 0;
|
|
1726
|
+
const left = [...(write.leftOut ?? []).map((name) => `"${name}"`), ...(nameless === 0 ? [] : [nameless === 1 ? 'one with no name' : `${nameless} with no name`])];
|
|
1727
|
+
const held = write.heldOut ?? [];
|
|
1728
|
+
const said: string[] = [];
|
|
1729
|
+
const fix: string[] = [];
|
|
1730
|
+
if (left.length > 0) {
|
|
1731
|
+
said.push(`the host's starting rules would leave out ${joinedList(left)}, which could wake the agent`);
|
|
1732
|
+
fix.push(`fixing ${joinedList(left)} in the starting rules`);
|
|
1733
|
+
}
|
|
1734
|
+
if (held.length > 0) {
|
|
1735
|
+
said.push(`the host's ${quotedList(held)}, which could wake the agent, would be kept out by gate.json's `
|
|
1736
|
+
+ `${held.length === 1 ? 'rule of that name' : 'rules of those names'}, which the gate leaves out`);
|
|
1737
|
+
fix.push(`fixing gate.json's ${quotedList(held)}`);
|
|
1738
|
+
}
|
|
1739
|
+
// While gate.json has no rules of its own, every one of them has to be put
|
|
1740
|
+
// in force for it to be used, so where both kinds keep it out, both fixes
|
|
1741
|
+
// are needed; giving gate.json rules of its own is the other way, which the
|
|
1742
|
+
// entry gives first.
|
|
1743
|
+
// The starting rules are read at a start, so a fix to them puts gate.json in
|
|
1744
|
+
// use from the next one.
|
|
1745
|
+
return said.length > 0
|
|
1746
|
+
? { said: said.join(', and '), fix: fix.length > 1 ? `both ${fix.join(', and ')}` : fix[0]!, nextStart: left.length > 0 }
|
|
1747
|
+
: null;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* What gate_status says when the start left rules in force unsaved: a first
|
|
1752
|
+
* start's seed that failed, a reconcile whose write left gate.json unable to
|
|
1753
|
+
* load (`loaded` false), or, over a gate.json with no rule list (`loaded`
|
|
1754
|
+
* true), the starting rules it lacks, run beside it, or, over one with no rules
|
|
1755
|
+
* of its own, the starting rules run under their own default where gate.json
|
|
1756
|
+
* isn't used. It goes once gate.json is written, or a reload applies its text
|
|
1757
|
+
* or, but where the start ran rules beside or in place of such a gate.json,
|
|
1758
|
+
* finds it holding the text in force.
|
|
1759
|
+
*/
|
|
1760
|
+
function describeUnsaved(starting: StartingRules, write: StartWrite, loaded: boolean): string | null {
|
|
1761
|
+
if (write.kind === 'reconcile-unsaved') {
|
|
1762
|
+
if (!loaded) return null;
|
|
1763
|
+
return `At start, the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}). `
|
|
1764
|
+
+ `The gate runs them under gate.json's default, "${write.config.default ?? 'always'}", as if gate.json held them, but `
|
|
1765
|
+
+ "they aren't saved in it: once gate.json changes and loads, what it holds is in force instead. Once gate.json has a "
|
|
1766
|
+
+ 'list of rules under "policies", a start adds them to it, as to any gate.json with a list.';
|
|
1767
|
+
}
|
|
1768
|
+
if (write.kind === 'start-without') {
|
|
1769
|
+
// Starting rules refused whole put none in force, and describeStart says so.
|
|
1770
|
+
const out = outOfForce(write);
|
|
1771
|
+
if (!out) return null;
|
|
1772
|
+
return `At start, gate.json, which has no rules the gate reads, wasn't used: under its default, "${write.default}", ${out.said}. The gate `
|
|
1773
|
+
+ `runs the starting rules under their own default, "${starting.config?.default ?? 'always'}", as a start without gate.json `
|
|
1774
|
+
+ 'does, but they aren\'t saved in it: once gate.json changes and loads, what it holds is in force instead. Giving '
|
|
1775
|
+
+ `gate.json rules under "policies", or ${out.fix}, puts it in use`
|
|
1776
|
+
+ `${out.nextStart ? ' (a fix to the starting rules, at the next start)' : ''}.`;
|
|
1777
|
+
}
|
|
1778
|
+
if (loaded) return null;
|
|
1779
|
+
if (write.kind === 'seed-failed') {
|
|
1780
|
+
return write.atOpen
|
|
1781
|
+
? `At start, gate.json couldn't be created (${write.error}), so the host's starting rules are in force but aren't saved in it. `
|
|
1782
|
+
+ 'Whoever runs this host can fix what keeps it from being written.'
|
|
1783
|
+
: `At start, writing the host's starting rules to gate.json failed partway (${write.error}), so gate.json may be left `
|
|
1784
|
+
+ "part-written. The starting rules are in force but aren't saved in it, and a restart before gate.json is fixed starts "
|
|
1785
|
+
+ 'from them too. Whoever runs this host can fix what kept it from being written, and gate.json.';
|
|
1786
|
+
}
|
|
1787
|
+
if (write.kind === 'reconcile-failed') {
|
|
1788
|
+
// A write of the recipe's merge that did more than add rules, or one that only added them.
|
|
1789
|
+
const merging = (write.changing?.length ?? 0) > 0;
|
|
1790
|
+
const changes = quotedList([...write.names, ...(write.changing ?? [])]);
|
|
1791
|
+
const what = merging
|
|
1792
|
+
? write.atOpen
|
|
1793
|
+
? `the recipe's changes to gate.json (${changes}) couldn't be written (${write.error}), and gate.json couldn't be loaded after that`
|
|
1794
|
+
: `writing the recipe's changes to gate.json (${changes}) failed partway (${write.error}), so gate.json may be left part-written`
|
|
1795
|
+
: write.atOpen
|
|
1796
|
+
? `the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}), and gate.json `
|
|
1797
|
+
+ "couldn't be loaded after that"
|
|
1798
|
+
: `adding the host's starting rules ${quotedList(write.names)} to gate.json failed partway (${write.error}), so gate.json `
|
|
1799
|
+
+ 'may be left part-written';
|
|
1800
|
+
// What those rules hold that the gate reads past or leaves out, since
|
|
1801
|
+
// gate.json's own entry is now its load error.
|
|
1802
|
+
const read = write.sentences?.length ? `, read as a load reads them: ${write.sentences.join(' ')}` : '.';
|
|
1803
|
+
const runs = merging ? 'the rules that write held' : "the rules it read from gate.json and the ones it was adding";
|
|
1804
|
+
return `At start, ${what}. The gate runs ${runs}, which aren't saved${read} `
|
|
1805
|
+
+ `A restart before gate.json is fixed starts from ${starting.config ? "the host's starting rules" : 'no rules'}. `
|
|
1806
|
+
+ 'Whoever runs this host can fix what kept it from being written, and gate.json.';
|
|
1807
|
+
}
|
|
1808
|
+
return null;
|
|
443
1809
|
}
|
|
444
1810
|
|
|
445
1811
|
// ---------------------------------------------------------------------------
|
|
@@ -544,6 +1910,45 @@ export function formatShadowWarning(w: ShadowWarning): string {
|
|
|
544
1910
|
+ `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`;
|
|
545
1911
|
}
|
|
546
1912
|
|
|
1913
|
+
/** The text of a thrown value, for a log line. It never throws itself, whatever was thrown. */
|
|
1914
|
+
function errorText(err: unknown): string {
|
|
1915
|
+
try {
|
|
1916
|
+
return err instanceof Error ? err.message : String(err);
|
|
1917
|
+
} catch {
|
|
1918
|
+
return 'a value that could not be printed';
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Run a caller's callback once the change it's told of is made and traced. A
|
|
1924
|
+
* throw from it is logged, not thrown, so the call answers as the change it
|
|
1925
|
+
* made: told the change had failed, an agent could make it a second time.
|
|
1926
|
+
*/
|
|
1927
|
+
function afterChange(name: string, callback: () => void): void {
|
|
1928
|
+
try {
|
|
1929
|
+
callback();
|
|
1930
|
+
} catch (err) {
|
|
1931
|
+
console.error(`[gate] ${name} threw once the change was made, and the change stands: ${errorText(err)}`);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/** The privileged-users file's error when some entries of its list aren't user ids. */
|
|
1936
|
+
function skippedPrivilegedIds(skipped: unknown[], loaded: number): string {
|
|
1937
|
+
const one = skipped.length === 1;
|
|
1938
|
+
const shown = skipped.slice(0, 5).map((x) => {
|
|
1939
|
+
if (typeof x === 'number' && Number.isInteger(x)) {
|
|
1940
|
+
return `${x} (a number this large can't be read exactly: write the id as a string, in quotes)`;
|
|
1941
|
+
}
|
|
1942
|
+
const json = JSON.stringify(x) ?? String(x);
|
|
1943
|
+
return json.length > 40 ? `${safeSlice(json, 0, 39)}…` : json;
|
|
1944
|
+
});
|
|
1945
|
+
const more = skipped.length > shown.length ? `, and ${skipped.length - shown.length} more` : '';
|
|
1946
|
+
const rest = loaded === 0 ? 'no id loaded, so no one is privileged' : `${loaded} ${loaded === 1 ? 'id' : 'ids'} did load`;
|
|
1947
|
+
return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file ${one ? "isn't a user id" : "aren't user ids"}, `
|
|
1948
|
+
+ `so ${one ? 'it was' : 'they were'} skipped: ${shown.join(', ')}${more}. A user id is a non-empty string, or a safe integer `
|
|
1949
|
+
+ `(a whole number within 2^53 - 1 of zero); ${rest}.`;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
547
1952
|
// ---------------------------------------------------------------------------
|
|
548
1953
|
// EventGate
|
|
549
1954
|
// ---------------------------------------------------------------------------
|
|
@@ -554,15 +1959,63 @@ export class EventGate {
|
|
|
554
1959
|
/** Optional agent-authored programmable gate (gate.js). */
|
|
555
1960
|
private gateScript: GateScript;
|
|
556
1961
|
private compiledPolicies: CompiledPolicy[] = [];
|
|
557
|
-
|
|
1962
|
+
/** The text of gate.json whose rules are in force, from a load or a write
|
|
1963
|
+
* (or the start's reconcile, whose write failed); or, while what's in force
|
|
1964
|
+
* is what a start ran beside gate.json or in its place (configSource not
|
|
1965
|
+
* "file"), gate.json's text as that start found it.
|
|
1966
|
+
* A reload parses the file again only when its text differs; a file that
|
|
1967
|
+
* holds it again after failing to load (a chmod that makes it readable,
|
|
1968
|
+
* an edit undone) needs nothing applied, only its error cleared. */
|
|
1969
|
+
private configText: string | undefined;
|
|
1970
|
+
/** The notes on fields the text in force has that the gate doesn't read, if any. */
|
|
1971
|
+
private configNote: string | null = null;
|
|
558
1972
|
private lastReloadCheck: number = 0;
|
|
559
1973
|
private configSource: 'file' | 'initial' | 'default' = 'default';
|
|
560
1974
|
private lastReloadTimestamp: number | null = null;
|
|
561
|
-
|
|
1975
|
+
/** The current problem with each file the gate reads, one each, replaced
|
|
1976
|
+
* whenever that file is read again (gate_status `errors`). */
|
|
1977
|
+
private configError: string | null = null;
|
|
1978
|
+
/** Whether gate.json's error is one that keeps what it holds out of force
|
|
1979
|
+
* (configUnreadable), so gate_status says what's in force instead. */
|
|
1980
|
+
private configUnused = false;
|
|
1981
|
+
private privilegedError: string | null = null;
|
|
1982
|
+
/** What the start did with the host's starting rules that it couldn't use
|
|
1983
|
+
* or add (describeStart), kept apart from gate.json's own error, until the
|
|
1984
|
+
* next start. */
|
|
1985
|
+
private startNotice: string | null = null;
|
|
1986
|
+
/** The rules in force that the start couldn't save (describeUnsaved),
|
|
1987
|
+
* until gate.json is written or a reload applies its text. */
|
|
1988
|
+
private startUnsaved: string | null = null;
|
|
1989
|
+
/** What the start's merge of the recipe couldn't do (seedOrReconcile), said with startNotice until the next start. */
|
|
1990
|
+
private startMergeNote: string | null = null;
|
|
1991
|
+
/** Whether the start's unsaved rules (startUnsaved) are a merge's that did more than add rules. */
|
|
1992
|
+
private startUnsavedMerged = false;
|
|
562
1993
|
|
|
563
1994
|
// Debounce state
|
|
564
1995
|
private debounceTimers = new Map<string, DebounceState>();
|
|
565
1996
|
|
|
1997
|
+
// Reply judge state (`judge` rules). The judge itself, when the host wires
|
|
1998
|
+
// one; without it a judge rule's batch is delivered as a debounce's.
|
|
1999
|
+
private judgeFn: JudgeFn | undefined;
|
|
2000
|
+
private judgeTimeoutMs: number;
|
|
2001
|
+
private judgeOnError: 'wake' | 'skip';
|
|
2002
|
+
/** Per rule, the batch the judge is deciding now. */
|
|
2003
|
+
private judging = new Map<string, JudgingState>();
|
|
2004
|
+
/** Per rule, a batch that fired while a turn changing the judged context ran or the host was
|
|
2005
|
+
* quiesced, held to be judged once both are clear: the judge reads the
|
|
2006
|
+
* context the turn leaves, which may already answer it. */
|
|
2007
|
+
private judgeHeld = new Map<string, { events: PendingEvent[]; judge: JudgeConfig }>();
|
|
2008
|
+
private judgeRecords = new Map<string, JudgeRecord>();
|
|
2009
|
+
private changesJudgedContextFn: ((agentName: string) => boolean) | undefined;
|
|
2010
|
+
/** Lifetime count of events dropped from batches waiting on the judge, at
|
|
2011
|
+
* the inference buffer's cap (MAX_INFERENCE_BUFFER per rule). */
|
|
2012
|
+
private droppedJudgeEvents = 0;
|
|
2013
|
+
/** Judge requests failed in a row, and the newest failure's reason: a host
|
|
2014
|
+
* whose provider can't serve the judge's model fails every time, which
|
|
2015
|
+
* gate_status says once it's a streak (judgeFailingNotice). */
|
|
2016
|
+
private judgeFailStreak = 0;
|
|
2017
|
+
private judgeLastFailure = '';
|
|
2018
|
+
|
|
566
2019
|
// Rate-limit state — policy name → key → bucket.
|
|
567
2020
|
private rateLimitBuckets = new Map<string, Map<string, RateBucket>>();
|
|
568
2021
|
// Passive-sample state — policy name → key → counter (count since last fire).
|
|
@@ -613,12 +2066,18 @@ export class EventGate {
|
|
|
613
2066
|
// self-wake (an external wake supersedes it; the timer would otherwise
|
|
614
2067
|
// land as a redundant empty wake right after the turn).
|
|
615
2068
|
private selfWakeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
2069
|
+
/** When each agent's pending self-wake fires, and what armed it
|
|
2070
|
+
* (upcomingWakes). Read only while its timer is in selfWakeTimers, so an
|
|
2071
|
+
* entry a fire or a cancel left behind says nothing. */
|
|
2072
|
+
private selfWakeDue = new Map<string, { at: number; source: string }>();
|
|
616
2073
|
|
|
617
2074
|
// Privileged users who may wake the agent through sleep. Hot-reloaded from
|
|
618
2075
|
// privilegedUsersPath on change.
|
|
619
2076
|
private privilegedUsersPath: string | undefined;
|
|
620
2077
|
private privilegedUserIds = new Set<string>();
|
|
621
|
-
|
|
2078
|
+
/** The privileged-users file's text as last read, whether or not it loaded;
|
|
2079
|
+
* undefined when it was missing or its last read failed. */
|
|
2080
|
+
private privilegedText: string | undefined;
|
|
622
2081
|
private lastPrivilegedCheck = 0;
|
|
623
2082
|
|
|
624
2083
|
// Dependency-injected callbacks
|
|
@@ -629,10 +2088,14 @@ export class EventGate {
|
|
|
629
2088
|
metadata?: Record<string, unknown>,
|
|
630
2089
|
/** Registry agent to deliver into; absent = primary (historical). */
|
|
631
2090
|
forAgent?: string,
|
|
2091
|
+
/** Out-param: where the message landed, for the wake that goes with it. */
|
|
2092
|
+
placement?: WritePlacement,
|
|
632
2093
|
) => unknown;
|
|
633
|
-
private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance) => void;
|
|
2094
|
+
private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
|
|
634
2095
|
private getAgentNamesFn: () => string[];
|
|
635
2096
|
private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined;
|
|
2097
|
+
private channelShownFn?: (channel: { serverId: string; channelId: string }) => string;
|
|
2098
|
+
private isNewestEntryFn?: (entry: GateEntryRef) => boolean;
|
|
636
2099
|
/** Clock injection — keeps the new rate_limit / passive_sample paths
|
|
637
2100
|
* testable without monkey-patching Date.now globally. */
|
|
638
2101
|
private now: () => number;
|
|
@@ -647,17 +2110,49 @@ export class EventGate {
|
|
|
647
2110
|
content: Array<{ type: 'text'; text: string }>,
|
|
648
2111
|
metadata?: Record<string, unknown>,
|
|
649
2112
|
forAgent?: string,
|
|
2113
|
+
placement?: WritePlacement,
|
|
650
2114
|
) => unknown;
|
|
651
|
-
|
|
2115
|
+
/** `after`: the placement of the message the wake is for, when it has
|
|
2116
|
+
* one, so the host can drop the wake once a request that carried the
|
|
2117
|
+
* message is answered. */
|
|
2118
|
+
requestInference: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
|
|
652
2119
|
getAgentNames: () => string[];
|
|
653
2120
|
/** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
|
|
654
2121
|
now?: () => number;
|
|
655
2122
|
/** Per-event timeout (ms) for the optional gate.js script. Default 50. */
|
|
656
2123
|
scriptTimeoutMs?: number;
|
|
2124
|
+
/** The reply judge `judge` rules ask (JudgeConfig). Absent: a judge
|
|
2125
|
+
* rule's batch is delivered as a debounce's, and gate_status says so. */
|
|
2126
|
+
judge?: JudgeFn;
|
|
2127
|
+
/** Whether a turn of this agent changes the context the judge reads. Such
|
|
2128
|
+
* turns, and only they, hold a judge rule's batch until they end, and one
|
|
2129
|
+
* that starts while the judge decides stops the judge, whose batch is
|
|
2130
|
+
* then judged after the turn. Absent: every agent's turns do. */
|
|
2131
|
+
changesJudgedContext?: (agentName: string) => boolean;
|
|
2132
|
+
/** How long a judge request may take (ms). Default 20,000. */
|
|
2133
|
+
judgeTimeoutMs?: number;
|
|
2134
|
+
/** What a judge failure does: wake (default) or skip. */
|
|
2135
|
+
judgeOnError?: 'wake' | 'skip';
|
|
657
2136
|
/** Map an event to the registered channel it arrived on (the host knows
|
|
658
|
-
* how push-event origins compose into channel ids).
|
|
659
|
-
*
|
|
2137
|
+
* how push-event origins compose into channel ids). The gate routes a
|
|
2138
|
+
* batched wake by it, tests a policy's `match.channel` against it when
|
|
2139
|
+
* the event's own id misses, and keys `keyBy: "channelId"` counts on it.
|
|
2140
|
+
* A resolver that throws counts as returning nothing. Default: a
|
|
2141
|
+
* channel-incoming event's own channel id; nothing for push events, so
|
|
2142
|
+
* a push then matches and counts by its own id alone. */
|
|
660
2143
|
resolveRouteChannel?: (info: GateEventInfo) => string | undefined;
|
|
2144
|
+
/** How the agent is shown a channel, its server's and its id (the
|
|
2145
|
+
* framework's ChannelRegistry.nameShown): a batched wake's summary names
|
|
2146
|
+
* each channel so, since two servers' channels can share an id. Default:
|
|
2147
|
+
* the id. */
|
|
2148
|
+
channelShown?: (channel: { serverId: string; channelId: string }) => string;
|
|
2149
|
+
/** Whether an MCPL event's own context entry is still the newest entry in
|
|
2150
|
+
* the window a batched wake's line would be written to, with nothing
|
|
2151
|
+
* waiting to be written there. A batched wake for that one message
|
|
2152
|
+
* doesn't write its line when it fires: it hands the line to the wake
|
|
2153
|
+
* (WakeProvenance.line), for a fresh start of the wake's turn to decide
|
|
2154
|
+
* (WakeLine). Absent: every batched wake writes its line, as before. */
|
|
2155
|
+
isNewestEntry?: (entry: GateEntryRef) => boolean;
|
|
661
2156
|
}) {
|
|
662
2157
|
this.configPath = opts.configPath;
|
|
663
2158
|
this.privilegedUsersPath = opts.privilegedUsersPath;
|
|
@@ -666,91 +2161,470 @@ export class EventGate {
|
|
|
666
2161
|
this.requestInferenceFn = opts.requestInference;
|
|
667
2162
|
this.getAgentNamesFn = opts.getAgentNames;
|
|
668
2163
|
this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
|
|
2164
|
+
this.channelShownFn = opts.channelShown;
|
|
2165
|
+
this.isNewestEntryFn = opts.isNewestEntry;
|
|
669
2166
|
this.now = opts.now ?? (() => Date.now());
|
|
2167
|
+
this.judgeFn = opts.judge;
|
|
2168
|
+
this.changesJudgedContextFn = opts.changesJudgedContext;
|
|
2169
|
+
this.judgeTimeoutMs = opts.judgeTimeoutMs !== undefined && Number.isFinite(opts.judgeTimeoutMs) && opts.judgeTimeoutMs > 0
|
|
2170
|
+
? Math.min(opts.judgeTimeoutMs, MAX_TIMER_MS)
|
|
2171
|
+
: DEFAULT_JUDGE_TIMEOUT_MS;
|
|
2172
|
+
this.judgeOnError = opts.judgeOnError === 'skip' ? 'skip' : 'wake';
|
|
670
2173
|
this.loadPrivileged();
|
|
671
2174
|
|
|
672
|
-
// Seed or reconcile the on-disk config
|
|
2175
|
+
// Seed or reconcile the on-disk config from the host's starting rules
|
|
2176
|
+
// (`initialConfig`), which are read as gate.json is, rule by rule: a rule
|
|
2177
|
+
// with a problem is read past it, or left out (readConfig).
|
|
673
2178
|
//
|
|
674
|
-
// First start: write `initialConfig`
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
|
|
2179
|
+
// First start: write `initialConfig` as given, since gate.json is read the
|
|
2180
|
+
// same way, and record it as the recipe last applied, beside gate.json.
|
|
2181
|
+
// Later starts: merge the recipe into gate.json against that record,
|
|
2182
|
+
// three ways and as written (seedOrReconcile), so a rule the recipe
|
|
2183
|
+
// dropped, renamed, moved or redefined follows it, while a rule edited at
|
|
2184
|
+
// run time or by `workspace--edit _config/gate.json`, and one no recipe
|
|
2185
|
+
// declared, stays. `default` follows the same rule: an unedited one takes
|
|
2186
|
+
// the recipe's.
|
|
2187
|
+
const initial = opts.initialConfig;
|
|
2188
|
+
const starting = initial !== undefined ? readStartingRules(initial) : undefined;
|
|
2189
|
+
const write: StartWrite = starting ? this.seedOrReconcile(starting) : { kind: 'none' };
|
|
2190
|
+
|
|
2191
|
+
// Load config: gate.json; without it, the starting rules as read, unless
|
|
2192
|
+
// none of them is used; and otherwise no rules, as a host without any has. When a
|
|
2193
|
+
// reconcile's own write left gate.json unable to load, the gate runs what
|
|
2194
|
+
// that write held, the rules it read as the recipe's merge changed them,
|
|
2195
|
+
// since a failed write of ours shouldn't take rules out of force. Over a gate.json
|
|
2196
|
+
// with no rule list to add them to, it runs gate.json's default with the
|
|
2197
|
+
// starting rules it lacks, as earlier releases did once they had written
|
|
2198
|
+
// them over it, where seedOrReconcile gives them (reconcile-unsaved). Where
|
|
2199
|
+
// gate.json has no rules of its own and they can't run beside it or be
|
|
2200
|
+
// added to it (start-without), earlier releases couldn't load what they
|
|
2201
|
+
// wrote and ran the starting rules from memory, so gate.json isn't used,
|
|
2202
|
+
// and the start runs as one without it does.
|
|
2203
|
+
const read = this.readConfigFile();
|
|
2204
|
+
if (read.state === 'loaded') {
|
|
2205
|
+
// Beside a gate.json with no rule list, the host's starting rules it
|
|
2206
|
+
// lacks, and its note says they run beside it, or, in place of one with
|
|
2207
|
+
// no rules of its own, those a start without it runs, and its note says
|
|
2208
|
+
// why.
|
|
2209
|
+
const beside = write.kind === 'reconcile-unsaved';
|
|
2210
|
+
const without = write.kind === 'start-without' ? write : null;
|
|
2211
|
+
this.config = beside ? write.config : without ? (starting?.config ?? { ...DEFAULT_CONFIG }) : read.config;
|
|
2212
|
+
this.configText = read.text;
|
|
2213
|
+
const start = beside ? 'beside' : without && starting ? { notUsed: notUsedClause(without, starting) } : undefined;
|
|
2214
|
+
const asStarted = start ? readConfig(parseGateJson(read.text), 'gate.json', start) : null;
|
|
2215
|
+
this.configNote = asStarted && 'sentences' in asStarted ? loadNote(asStarted.sentences) : read.note;
|
|
2216
|
+
this.configError = this.configNote;
|
|
2217
|
+
this.configSource = beside || starting?.config && without ? 'initial' : without ? 'default' : 'file';
|
|
2218
|
+
if (this.configNote) console.error(`[gate] ${this.configNote}`);
|
|
2219
|
+
} else if (write.kind === 'reconcile-failed') {
|
|
2220
|
+
this.config = write.config;
|
|
2221
|
+
// A change over the part-written gate.json is refused; once gate.json is
|
|
2222
|
+
// gone, this text is what a change edits, so every rule it holds keeps its name.
|
|
2223
|
+
this.configText = write.text;
|
|
2224
|
+
this.configNote = write.note ?? null;
|
|
2225
|
+
this.configSource = 'file';
|
|
2226
|
+
} else {
|
|
2227
|
+
this.config = starting?.config ?? { ...DEFAULT_CONFIG };
|
|
2228
|
+
this.configSource = starting?.config ? 'initial' : 'default';
|
|
2229
|
+
}
|
|
2230
|
+
// What the start did is kept apart from gate.json's load error, so the
|
|
2231
|
+
// load above can't clear it when it succeeds.
|
|
2232
|
+
if (starting) {
|
|
2233
|
+
const described = describeStart(starting, write, read.state === 'loaded' ? heldNames(read.listed) : null);
|
|
2234
|
+
this.startNotice = [described, this.startMergeNote].filter((part): part is string => part !== null).join(' ') || null;
|
|
2235
|
+
this.startUnsaved = describeUnsaved(starting, write, read.state === 'loaded');
|
|
2236
|
+
this.startUnsavedMerged = write.kind === 'reconcile-failed' && (write.changing?.length ?? 0) > 0;
|
|
2237
|
+
}
|
|
2238
|
+
for (const said of [this.startNotice, this.startUnsaved]) {
|
|
2239
|
+
if (said) console.error(`[gate] ${said}`);
|
|
2240
|
+
}
|
|
2241
|
+
this.compiledPolicies = this.compilePolicies(this.config);
|
|
2242
|
+
this.refreshShadowWarnings('startup');
|
|
2243
|
+
|
|
2244
|
+
// Programmable gate: an optional agent-authored `gate.js` next to gate.json.
|
|
2245
|
+
// Absent ⇒ inactive (zero overhead beyond a throttled stat check).
|
|
2246
|
+
this.gateScript = new GateScript(
|
|
2247
|
+
join(dirname(this.configPath), 'gate.js'),
|
|
2248
|
+
opts.scriptTimeoutMs ?? 50,
|
|
2249
|
+
this.now,
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// =========================================================================
|
|
2254
|
+
// Config loading
|
|
2255
|
+
// =========================================================================
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* Write the starting rules to gate.json, as given, when it's absent, and
|
|
2259
|
+
* record them as the recipe last applied (lastRecipePath); or, when it
|
|
2260
|
+
* loads, merge them into its own rule list against that record, three ways
|
|
2261
|
+
* and as written (reconcileWithRecipe), and record them. A rule as the last
|
|
2262
|
+
* recipe left it follows the new one, a rule edited since keeps its edit, a
|
|
2263
|
+
* rule no recipe declared stays, and `default` follows the same rule. A
|
|
2264
|
+
* rule gate.json holds holds its name, whether the gate reads it or leaves
|
|
2265
|
+
* it out. gate.json is written before the record, so a stop between the two
|
|
2266
|
+
* leaves a state the next start reads rightly: the other order would make
|
|
2267
|
+
* the old recipe's rules read as edits. Nothing is recorded where gate.json
|
|
2268
|
+
* isn't written as the merge asks, or a name is given twice. Where
|
|
2269
|
+
* gate.json has no rules of its own and, under its default, one of them that
|
|
2270
|
+
* could wake the agent can't be put in force (left out, with no name, or kept
|
|
2271
|
+
* out by a rule of gate.json's of its name that the gate leaves out), or
|
|
2272
|
+
* they're refused whole and that default holds events back, gate.json isn't
|
|
2273
|
+
* used at this start, which runs as one without it does. Otherwise, where
|
|
2274
|
+
* gate.json has no rule list to add them to, it writes nothing, and the gate
|
|
2275
|
+
* runs beside it those of them it reads. Says what it did when that wasn't
|
|
2276
|
+
* simply done or not needed.
|
|
2277
|
+
*/
|
|
2278
|
+
private seedOrReconcile(starting: StartingRules): StartWrite {
|
|
2279
|
+
const read = this.readConfigFile();
|
|
2280
|
+
if (read.state === 'absent') {
|
|
2281
|
+
if (!starting.config) return { kind: 'none' };
|
|
2282
|
+
try {
|
|
2283
|
+
mkdirSync(dirname(this.configPath), { recursive: true });
|
|
2284
|
+
writeFileSync(this.configPath, JSON.stringify(starting.given, null, 2) + '\n');
|
|
2285
|
+
} catch (err) {
|
|
2286
|
+
return { kind: 'seed-failed', error: errorText(err), atOpen: beforeWriting(err) };
|
|
2287
|
+
}
|
|
2288
|
+
const twice = duplicateName(starting.rules.map((rule) => rule.raw));
|
|
2289
|
+
if (twice !== undefined) {
|
|
2290
|
+
this.startMergeNote = `At start, the recipe names the rule "${twice}" twice, so gate.json was seeded with it as given and it `
|
|
2291
|
+
+ "wasn't recorded as the recipe last applied: a later start merges the recipe by name, once gate.json and the recipe "
|
|
2292
|
+
+ 'name each rule once.';
|
|
2293
|
+
} else {
|
|
2294
|
+
this.recordRecipe(lastRecipePath(this.configPath), starting.given, 'gate.json was seeded with the recipe');
|
|
2295
|
+
}
|
|
2296
|
+
return { kind: 'none' };
|
|
2297
|
+
}
|
|
2298
|
+
if (read.state !== 'loaded') return { kind: 'none' };
|
|
2299
|
+
const held = heldNames(read.listed);
|
|
2300
|
+
const fileDefault = read.config.default ?? 'always';
|
|
2301
|
+
// No rule list, an empty one, or one holding only rules the gate leaves out.
|
|
2302
|
+
const noRules = read.config.policies.length === 0;
|
|
2303
|
+
// Starting rules refused whole put none beside or in a gate.json with no
|
|
2304
|
+
// rules of its own, which holds events back. Earlier releases wrote them
|
|
2305
|
+
// over it or into it, couldn't load what they wrote, and ran them from
|
|
2306
|
+
// memory, so gate.json isn't used at this start either.
|
|
2307
|
+
if (noRules && starting.refusedWhole !== undefined && fileDefault !== 'always') {
|
|
2308
|
+
return { kind: 'start-without', default: fileDefault };
|
|
2309
|
+
}
|
|
2310
|
+
// Nor can a start put in force, beside or in a gate.json with no rules of
|
|
2311
|
+
// its own, a starting rule a load leaves out, one with no name, which it
|
|
2312
|
+
// can't add by name, or one whose name gate.json holds in a rule the gate
|
|
2313
|
+
// leaves out, which keeps the host's rule of that name out. Where, under
|
|
2314
|
+
// gate.json's default, one of them could wake the agent, earlier releases
|
|
2315
|
+
// ran the starting rules from memory: they couldn't load what they wrote
|
|
2316
|
+
// over gate.json or into it, or, refusing its rule, didn't load it at all.
|
|
2317
|
+
// So gate.json isn't used at this start, which runs as one without it does.
|
|
2318
|
+
if (noRules && fileDefault !== 'always') {
|
|
2319
|
+
const out = starting.rules.filter((rule) => !rule.quiet && (rule.policy === undefined || (rule.name !== undefined && held.has(rule.name))));
|
|
2320
|
+
if (out.length > 0) {
|
|
2321
|
+
const named = out.flatMap((rule) => (rule.name === undefined ? [] : [rule.name]));
|
|
2322
|
+
return {
|
|
2323
|
+
kind: 'start-without',
|
|
2324
|
+
default: fileDefault,
|
|
2325
|
+
leftOut: named.filter((name) => !held.has(name)),
|
|
2326
|
+
nameless: out.length - named.length,
|
|
2327
|
+
heldOut: named.filter((name) => held.has(name)),
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
if ('noList' in read.listed) {
|
|
2332
|
+
const missing = starting.rules.filter((rule): rule is StartingRule & { name: string } => rule.name !== undefined && !held.has(rule.name));
|
|
2333
|
+
if (missing.length === 0) return { kind: 'none' };
|
|
2334
|
+
const names = missing.map((rule) => rule.name);
|
|
2335
|
+
// Adding them would write over what gate.json holds. Earlier releases
|
|
2336
|
+
// wrote them over it and ran them under its default, so the gate runs
|
|
2337
|
+
// them so, unsaved, beside it.
|
|
2338
|
+
const error = `${read.listed.noList}, so there's no list of rules to add them to without writing over what it holds`;
|
|
2339
|
+
// The ones it runs: a starting rule left out is named as the starting rules are read.
|
|
2340
|
+
const running = missing.flatMap((rule) => (rule.policy === undefined ? [] : [rule.policy]));
|
|
2341
|
+
if (running.length === 0) return { kind: 'reconcile-failed', names, atOpen: true, config: read.config, error };
|
|
2342
|
+
return { kind: 'reconcile-unsaved', names: running.map((policy) => policy.name), error, config: { policies: running, default: fileDefault } };
|
|
2343
|
+
}
|
|
2344
|
+
// Starting rules refused whole have nothing to merge or record.
|
|
2345
|
+
if (starting.refusedWhole !== undefined) return { kind: 'none' };
|
|
2346
|
+
// The merge, as written: gate.json's rule list and default against the
|
|
2347
|
+
// recipe's, three ways, with the recipe last applied.
|
|
2348
|
+
const recordPath = lastRecipePath(this.configPath);
|
|
2349
|
+
const last = readLastRecipe(recordPath);
|
|
2350
|
+
const given = isPlainObject(starting.given) ? starting.given : {};
|
|
2351
|
+
let merged: ReturnType<typeof reconcileWithRecipe>;
|
|
2352
|
+
let appended = false;
|
|
2353
|
+
let appendedNote = '';
|
|
2354
|
+
try {
|
|
2355
|
+
merged = reconcileWithRecipe(
|
|
2356
|
+
{ rules: read.listed.rules, default: read.listed.top.default },
|
|
2357
|
+
{ rules: starting.rules.map((rule) => rule.raw), default: given.default },
|
|
2358
|
+
last.recipe,
|
|
2359
|
+
ruleMeaning,
|
|
2360
|
+
);
|
|
2361
|
+
} catch (err) {
|
|
2362
|
+
// A name given twice: a merge by name can't say which rule was meant.
|
|
2363
|
+
// So the start adds the rules gate.json lacks, after its own, as
|
|
2364
|
+
// earlier releases did, and removes, redefines and records nothing.
|
|
2365
|
+
const missing = starting.rules.filter((rule): rule is StartingRule & { name: string } => rule.name !== undefined && !held.has(rule.name));
|
|
2366
|
+
const lacked = [...new Set(missing.map((rule) => rule.name))];
|
|
2367
|
+
appended = true;
|
|
2368
|
+
// What it did is said once its write is made (appendedNote), and a
|
|
2369
|
+
// write that fails is said as any start's is.
|
|
2370
|
+
const why = `At start, ${errorText(err)}, and the merge with the recipe needs one rule per name, so it didn't run`;
|
|
2371
|
+
const remedy = 'Once gate.json and the recipe name each rule once, a start merges the recipe.';
|
|
2372
|
+
this.startMergeNote = `${why}. ${remedy}`;
|
|
2373
|
+
appendedNote = `${why}: `
|
|
2374
|
+
+ (lacked.length > 0
|
|
2375
|
+
? `the start added the rules gate.json lacked (${quotedList(lacked)}), after its own, and removed, redefined and recorded nothing. `
|
|
2376
|
+
: 'gate.json is as it was, and nothing was recorded. ')
|
|
2377
|
+
+ remedy;
|
|
2378
|
+
merged = {
|
|
2379
|
+
rules: [...read.listed.rules, ...missing.map((rule) => rule.raw)],
|
|
2380
|
+
default: read.listed.top.default,
|
|
2381
|
+
defaultChanged: false,
|
|
2382
|
+
changes: { ...noChanges(), added: missing.map((rule) => rule.name) },
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
const { changes } = merged;
|
|
2386
|
+
if (last.problem !== undefined) changes.recordUnreadable = last.problem;
|
|
2387
|
+
const top = { ...read.listed.top };
|
|
2388
|
+
if (merged.defaultChanged) {
|
|
2389
|
+
if (merged.default === undefined) delete top.default;
|
|
2390
|
+
else top.default = merged.default;
|
|
2391
|
+
}
|
|
2392
|
+
const reconciled = { ...top, policies: merged.rules };
|
|
2393
|
+
const names = [...changes.added, ...changes.restored];
|
|
2394
|
+
const changing = [...changes.removed, ...changes.updated, ...changes.moved, ...(changes.defaultChange ? ['default'] : [])];
|
|
2395
|
+
const written = canonical(reconciled) !== canonical({ ...read.listed.top, policies: read.listed.rules });
|
|
2396
|
+
if (written) {
|
|
2397
|
+
// What the write holds, as a load reads it, for a write that fails partway.
|
|
2398
|
+
const holds = readConfig(reconciled, 'gate.json');
|
|
2399
|
+
if ('refused' in holds) {
|
|
2400
|
+
// A write of ours doesn't take gate.json's own rules out of force:
|
|
2401
|
+
// nothing is written or recorded, so it loads as it did. (With no
|
|
2402
|
+
// rules of its own, gate.json isn't used at this start, above.)
|
|
2403
|
+
const error = 'with them, gate.json would leave out a rule that could wake the agent, and under its default, '
|
|
2404
|
+
+ `"${changes.defaultChange?.to ?? read.config.default ?? 'always'}", it wouldn't be used`;
|
|
2405
|
+
return { kind: 'reconcile-failed', names, changing, atOpen: true, config: read.config, error };
|
|
2406
|
+
}
|
|
2407
|
+
const text = JSON.stringify(reconciled, null, 2) + '\n';
|
|
683
2408
|
try {
|
|
684
|
-
|
|
685
|
-
if (!existsSync(this.configPath)) {
|
|
686
|
-
writeFileSync(this.configPath, JSON.stringify(opts.initialConfig, null, 2) + '\n');
|
|
687
|
-
} else {
|
|
688
|
-
const existing = this.loadConfig();
|
|
689
|
-
if (existing) {
|
|
690
|
-
const existingNames = new Set(existing.policies.map(p => p.name));
|
|
691
|
-
const missing = opts.initialConfig.policies.filter(p => !existingNames.has(p.name));
|
|
692
|
-
if (missing.length > 0) {
|
|
693
|
-
const reconciled: GateConfig = {
|
|
694
|
-
...existing,
|
|
695
|
-
policies: [...existing.policies, ...missing],
|
|
696
|
-
};
|
|
697
|
-
writeFileSync(this.configPath, JSON.stringify(reconciled, null, 2) + '\n');
|
|
698
|
-
opts.emitTrace({
|
|
699
|
-
type: 'gate:config-reconciled',
|
|
700
|
-
configPath: this.configPath,
|
|
701
|
-
addedPolicies: missing.map(p => p.name),
|
|
702
|
-
timestamp: Date.now(),
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
2409
|
+
writeFileSync(this.configPath, text);
|
|
707
2410
|
} catch (err) {
|
|
708
|
-
|
|
709
|
-
|
|
2411
|
+
return {
|
|
2412
|
+
kind: 'reconcile-failed',
|
|
2413
|
+
names,
|
|
2414
|
+
changing,
|
|
2415
|
+
error: errorText(err),
|
|
2416
|
+
atOpen: beforeWriting(err),
|
|
2417
|
+
config: holds.config,
|
|
2418
|
+
text,
|
|
2419
|
+
note: loadNote(holds.sentences),
|
|
2420
|
+
sentences: holds.sentences,
|
|
2421
|
+
};
|
|
710
2422
|
}
|
|
711
2423
|
}
|
|
712
|
-
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
2424
|
+
if (appended) this.startMergeNote = appendedNote;
|
|
2425
|
+
// Said before the record is written, so a failed record write still
|
|
2426
|
+
// leaves what the start did to gate.json named. A start that only added
|
|
2427
|
+
// rules, since the merge couldn't run, is said in startNotice instead.
|
|
2428
|
+
const line = appended ? null : reconcileLine(changes);
|
|
2429
|
+
if (line) console.error(line);
|
|
2430
|
+
if (line || names.length > 0) {
|
|
2431
|
+
this.emitTrace({
|
|
2432
|
+
type: 'gate:config-reconciled',
|
|
2433
|
+
configPath: this.configPath,
|
|
2434
|
+
...changes,
|
|
2435
|
+
// Every name the start added to gate.json, as an append's trace gave.
|
|
2436
|
+
addedPolicies: names,
|
|
2437
|
+
timestamp: Date.now(),
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
if (!appended && (!last.recipe || canonical({ rules: last.recipe.rules, default: last.recipe.default }) !== canonical({ rules: starting.rules.map((rule) => rule.raw), default: given.default }))) {
|
|
2441
|
+
this.recordRecipe(recordPath, starting.given, written ? 'gate.json holds the recipe merged into it' : 'the merge left gate.json as it was');
|
|
2442
|
+
}
|
|
2443
|
+
return { kind: 'none' };
|
|
725
2444
|
}
|
|
726
2445
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
2446
|
+
/**
|
|
2447
|
+
* Record the starting config, as given, as the recipe last applied, after
|
|
2448
|
+
* gate.json holds what the start merged (`holds` says what that is). A
|
|
2449
|
+
* write that fails is said with the start: the next start merges against
|
|
2450
|
+
* the record as the write left it.
|
|
2451
|
+
*/
|
|
2452
|
+
private recordRecipe(recordPath: string, given: unknown, holds: string): void {
|
|
2453
|
+
try {
|
|
2454
|
+
writeFileSync(recordPath, JSON.stringify(given, null, 2) + '\n');
|
|
2455
|
+
} catch (err) {
|
|
2456
|
+
this.startMergeNote = `At start, ${holds}, and the gate runs it, but recording the recipe in ${basename(recordPath)} failed `
|
|
2457
|
+
+ `(${errorText(err)}), so the next start merges against the record as this write left it. Whoever runs this host can `
|
|
2458
|
+
+ 'fix what kept it from being written.';
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
730
2461
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
2462
|
+
/**
|
|
2463
|
+
* gate.json as it is on disk: absent; loaded; refused, when it's JSON that
|
|
2464
|
+
* readConfig doesn't use; or unreadable, when it can't be read or isn't
|
|
2465
|
+
* JSON (`notJson`, where it could be read). A loaded file comes with its note,
|
|
2466
|
+
* which names what it holds that does nothing or is read past a problem, and
|
|
2467
|
+
* a loaded or refused one with its own fields and rule list as written, for a
|
|
2468
|
+
* write to edit (Listed).
|
|
2469
|
+
* Reading applies nothing: whoever applies a loaded config records its text
|
|
2470
|
+
* as `configText`. With `unlessUnchanged`, a file whose text is
|
|
2471
|
+
* `configText` comes back `unchanged`, unparsed, since the rules in force
|
|
2472
|
+
* came from it, or are what a start ran beside it or in its place.
|
|
2473
|
+
*/
|
|
2474
|
+
private readConfigFile(opts?: { unlessUnchanged?: boolean }):
|
|
2475
|
+
| { state: 'absent' }
|
|
2476
|
+
| { state: 'unchanged' }
|
|
2477
|
+
| { state: 'loaded'; config: GateConfig; text: string; note: string | null; listed: Listed }
|
|
2478
|
+
| { state: 'refused'; error: string; listed: Listed; silenced?: Array<string | undefined> }
|
|
2479
|
+
| { state: 'unreadable'; error: string; notJson?: true } {
|
|
2480
|
+
let raw: string;
|
|
2481
|
+
try {
|
|
2482
|
+
raw = readFileSync(this.configPath, 'utf-8');
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
if (!isMissing(err)) return this.configUnreadable(err);
|
|
2485
|
+
// No file is no problem with it: the rules in force stay.
|
|
2486
|
+
this.configError = null;
|
|
2487
|
+
this.configUnused = false;
|
|
2488
|
+
return { state: 'absent' };
|
|
2489
|
+
}
|
|
2490
|
+
// A file holding the text in force has that text's notes as its error,
|
|
2491
|
+
// whatever a failed read or a look at other text since left there: beside
|
|
2492
|
+
// the host's starting rules, they say those run beside it.
|
|
2493
|
+
const inForce = raw === this.configText;
|
|
2494
|
+
if (opts?.unlessUnchanged && inForce) {
|
|
2495
|
+
this.configError = this.configNote;
|
|
2496
|
+
this.configUnused = false;
|
|
2497
|
+
return { state: 'unchanged' };
|
|
734
2498
|
}
|
|
2499
|
+
let parsed: unknown;
|
|
735
2500
|
try {
|
|
736
|
-
|
|
737
|
-
const stat = statSync(this.configPath);
|
|
738
|
-
this.configMtime = stat.mtimeMs;
|
|
739
|
-
const config = validateConfig(parseGateJson(raw));
|
|
740
|
-
this.configSource = 'file';
|
|
741
|
-
this.configErrors = [];
|
|
742
|
-
return config;
|
|
2501
|
+
parsed = parseGateJson(raw);
|
|
743
2502
|
} catch (err) {
|
|
744
|
-
|
|
745
|
-
|
|
2503
|
+
return { ...this.configUnreadable(err), notJson: true };
|
|
2504
|
+
}
|
|
2505
|
+
const read = readConfig(parsed, 'gate.json');
|
|
2506
|
+
// JSON the gate doesn't use is still the operator's file, edited as written.
|
|
2507
|
+
if ('refused' in read) {
|
|
2508
|
+
return { ...this.configUnreadable(new Error(read.refused)), state: 'refused', listed: read.listed, silenced: read.silenced };
|
|
2509
|
+
}
|
|
2510
|
+
// A field the gate doesn't read does nothing, and a rule is read past a
|
|
2511
|
+
// problem or left out, so the file loads, and each is named.
|
|
2512
|
+
const note = loadNote(read.sentences);
|
|
2513
|
+
this.configError = inForce ? this.configNote : note;
|
|
2514
|
+
this.configUnused = false;
|
|
2515
|
+
return { state: 'loaded', config: read.config, text: raw, note, listed: read.listed };
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/**
|
|
2519
|
+
* gate.json's current error, replacing the last one, and traced and logged
|
|
2520
|
+
* when it's new, so an edit that doesn't load is in the log once.
|
|
2521
|
+
*/
|
|
2522
|
+
private configUnreadable(err: unknown): { state: 'unreadable'; error: string } {
|
|
2523
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2524
|
+
if (msg !== this.configError) {
|
|
2525
|
+
console.error(`[gate] ${this.configPath} couldn't be used (${msg}), so what it holds isn't in force`);
|
|
746
2526
|
this.emitTrace({
|
|
747
2527
|
type: 'gate:config-error',
|
|
748
2528
|
error: msg,
|
|
749
2529
|
configPath: this.configPath,
|
|
750
2530
|
timestamp: Date.now(),
|
|
751
2531
|
});
|
|
752
|
-
return null; // Caller keeps previous config
|
|
753
2532
|
}
|
|
2533
|
+
this.configError = msg;
|
|
2534
|
+
this.configUnused = true;
|
|
2535
|
+
return { state: 'unreadable', error: msg };
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
/**
|
|
2539
|
+
* gate.json's error as gate_status shows it: one that keeps what the file
|
|
2540
|
+
* holds out of force says what's in force instead, so it reads on its own.
|
|
2541
|
+
*/
|
|
2542
|
+
private configErrorShown(): string | null {
|
|
2543
|
+
if (this.configError === null || !this.configUnused) return this.configError;
|
|
2544
|
+
return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}.`;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/** What the gate runs, by where its rules came from, for a text that says so: "the host's starting rules". */
|
|
2548
|
+
private rulesRun(): string {
|
|
2549
|
+
if (this.config.policies.length === 0) return `no rules: every event gets the default, "${this.config.default ?? 'always'}"`;
|
|
2550
|
+
// Rules from a gate.json text, with the start's unsaved: only a start
|
|
2551
|
+
// whose reconcile write left gate.json unable to load runs those, since a
|
|
2552
|
+
// write of gate.json that lands, a reload that finds it holding a text
|
|
2553
|
+
// that loads, or, while gate.json's rules are in force, one that finds it
|
|
2554
|
+
// holding the text in force, clears startUnsaved.
|
|
2555
|
+
return this.configSource === 'file'
|
|
2556
|
+
? this.startUnsaved !== null
|
|
2557
|
+
? this.startUnsavedMerged
|
|
2558
|
+
? 'the rules its start was writing to gate.json'
|
|
2559
|
+
: 'the rules it read from gate.json at start and the ones it was adding'
|
|
2560
|
+
: 'the rules of the last gate.json it used'
|
|
2561
|
+
: "the host's starting rules";
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/**
|
|
2565
|
+
* What a policy change edits: gate.json's own fields and rule list as
|
|
2566
|
+
* written, each rule as written, so a write keeps every rule it doesn't
|
|
2567
|
+
* change, problems and unknown fields included, whether gate.json loads or
|
|
2568
|
+
* is JSON the gate doesn't use. Over one it doesn't use, writeConfig writes
|
|
2569
|
+
* only what loads, so a change is refused, naming what keeps the file out
|
|
2570
|
+
* and what would bring it into force (`remedy`), unless it mends that, and
|
|
2571
|
+
* the file stays as its operator wrote it. When
|
|
2572
|
+
* gate.json is absent, the same of the text whose rules are in force, so a
|
|
2573
|
+
* rule it leaves out keeps its name; or the rules in force, where no text
|
|
2574
|
+
* is, it has no rule list, or what's in force is what a start ran beside or
|
|
2575
|
+
* in place of it. A change is refused (`refused`) for a gate.json that can't
|
|
2576
|
+
* be read or isn't JSON (unusableRefusal), whose rules can't be edited
|
|
2577
|
+
* without writing over what it holds, and for one with no rule list a write
|
|
2578
|
+
* could edit without that (a list, a "policies" that isn't one and holds
|
|
2579
|
+
* something, or JSON that isn't an object): so a removal or an anchor is
|
|
2580
|
+
* refused too, rather than looked for in rules that may not be what the
|
|
2581
|
+
* file holds. It's refused as well for a gate.json that holds the text a
|
|
2582
|
+
* start didn't use (start-without), since what the change wrote would put
|
|
2583
|
+
* its rules in force in place of what the start runs.
|
|
2584
|
+
*/
|
|
2585
|
+
private rulesForChange():
|
|
2586
|
+
| { top: Record<string, unknown>; rules: unknown[]; remedy?: string }
|
|
2587
|
+
| { refused: string } {
|
|
2588
|
+
const read = this.readConfigFile();
|
|
2589
|
+
if (read.state === 'unreadable') return { refused: unusableRefusal(read, this.rulesRun()) };
|
|
2590
|
+
if (read.state === 'loaded' || read.state === 'refused') {
|
|
2591
|
+
if ('noList' in read.listed) return { refused: noListRefusal(read.listed.noList, this.rulesRun()) };
|
|
2592
|
+
// Rules not gate.json's in force while it holds the text in force: what a
|
|
2593
|
+
// start ran in place of one with no rules of its own, until it changes.
|
|
2594
|
+
if (read.state === 'loaded' && read.text === this.configText && this.configSource !== 'file') {
|
|
2595
|
+
return { refused: notUsedRefusal(this.rulesRun()) };
|
|
2596
|
+
}
|
|
2597
|
+
const edit = { top: read.listed.top, rules: [...read.listed.rules] };
|
|
2598
|
+
if (read.state === 'loaded') return edit;
|
|
2599
|
+
// What would bring it into force, for a change that doesn't. A change
|
|
2600
|
+
// finds its rule by name: a replacement takes the first rule with it,
|
|
2601
|
+
// and a removal every one.
|
|
2602
|
+
const names = [...new Set(read.silenced ?? [])];
|
|
2603
|
+
const only = names.length === 1 ? names[0] : undefined;
|
|
2604
|
+
const sharing = only === undefined ? 0 : edit.rules.filter((rule) => nameOf(rule) === only).length;
|
|
2605
|
+
// A removal that would leave no rules in force is refused (writeConfig),
|
|
2606
|
+
// so it isn't offered.
|
|
2607
|
+
const rest = readConfig({ ...edit.top, policies: edit.rules.filter((rule) => nameOf(rule) !== only) }, 'gate.json');
|
|
2608
|
+
const removes = !('refused' in rest) && rest.config.policies.length > 0;
|
|
2609
|
+
return {
|
|
2610
|
+
...edit,
|
|
2611
|
+
remedy: sharing === 1
|
|
2612
|
+
? `${removes ? 'Replacing or removing' : 'Replacing'} "${only}" by name would bring gate.json into force.`
|
|
2613
|
+
: sharing > 1
|
|
2614
|
+
? `gate.json has ${sharing} rules named "${only}", and a change by that name replaces the first of them`
|
|
2615
|
+
+ `${removes ? ' or removes them all' : ''}.`
|
|
2616
|
+
: 'No change through the gate can bring it into force, so gate.json has to be fixed by an edit of its own; '
|
|
2617
|
+
+ 'until then, a change is refused the same way.',
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
// While the rules in force aren't gate.json's, configText is the text a
|
|
2621
|
+
// start ran them beside or in place of, so its rules aren't the ones in force.
|
|
2622
|
+
const text = this.configText !== undefined && this.configSource === 'file'
|
|
2623
|
+
? readConfig(parseGateJson(this.configText), 'gate.json')
|
|
2624
|
+
: undefined;
|
|
2625
|
+
return text && !('refused' in text) && !('noList' in text.listed)
|
|
2626
|
+
? { top: text.listed.top, rules: [...text.listed.rules] }
|
|
2627
|
+
: { top: { policies: [], default: this.config.default ?? 'always' }, rules: [...this.config.policies] as unknown[] };
|
|
754
2628
|
}
|
|
755
2629
|
|
|
756
2630
|
private compilePolicies(config: GateConfig): CompiledPolicy[] {
|
|
@@ -778,54 +2652,68 @@ export class EventGate {
|
|
|
778
2652
|
});
|
|
779
2653
|
}
|
|
780
2654
|
|
|
2655
|
+
/**
|
|
2656
|
+
* Clear the state of each rule `was` held in force that `next` leaves out or
|
|
2657
|
+
* gives another behavior: a reload calls it before it puts `next` in force,
|
|
2658
|
+
* a write after. Without this, a rule that came back, or whose tokens 100
|
|
2659
|
+
* became 5 (or keyBy switched, or every 10 became 3), would run on the
|
|
2660
|
+
* buckets and counters of the rule it was. A rule left out loses its stats
|
|
2661
|
+
* (matchCount, lastMatchTimestamp) too; a rule kept keeps them, which are
|
|
2662
|
+
* history, not live state, and one kept with the same behavior keeps all its
|
|
2663
|
+
* state, its denial and fire counts included. Rules that share a name share
|
|
2664
|
+
* its state, so a name keeps it only where its rules, in order, keep their
|
|
2665
|
+
* behaviors. Each pending debounce batch is sent last, as its timer would
|
|
2666
|
+
* have sent it, once every rule's state is cleared; one whose delivery throws
|
|
2667
|
+
* is named in the log and dropped (fireDebounce), and the rest are still
|
|
2668
|
+
* sent, so the write or reload that cleared it has done what it was for, its
|
|
2669
|
+
* caller hears that it did, and no state is left behind.
|
|
2670
|
+
*/
|
|
2671
|
+
private clearStateFor(was: GateConfig, next: GateConfig): { removed: string[]; cleared: string[] } {
|
|
2672
|
+
const behaviorsByName = (config: GateConfig): Map<string, string> => {
|
|
2673
|
+
const byName = new Map<string, string[]>();
|
|
2674
|
+
for (const p of config.policies) byName.set(p.name, [...(byName.get(p.name) ?? []), behaviorFingerprint(p.behavior)]);
|
|
2675
|
+
return new Map([...byName].map(([name, behaviors]) => [name, JSON.stringify(behaviors)]));
|
|
2676
|
+
};
|
|
2677
|
+
const before = behaviorsByName(was);
|
|
2678
|
+
const after = behaviorsByName(next);
|
|
2679
|
+
const removed = [...before.keys()].filter((name) => !after.has(name));
|
|
2680
|
+
for (const name of removed) {
|
|
2681
|
+
this.clearPolicyState(name);
|
|
2682
|
+
this.stats.delete(name);
|
|
2683
|
+
this.judgeRecords.delete(name);
|
|
2684
|
+
}
|
|
2685
|
+
const cleared = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
|
|
2686
|
+
for (const name of cleared) this.clearPolicyState(name);
|
|
2687
|
+
for (const name of [...removed, ...cleared]) this.fireDebounce(name);
|
|
2688
|
+
return { removed, cleared };
|
|
2689
|
+
}
|
|
2690
|
+
|
|
781
2691
|
private reloadIfChanged(): void {
|
|
782
2692
|
const now = Date.now();
|
|
783
|
-
if (now
|
|
2693
|
+
if (checkedRecently(now, this.lastReloadCheck)) return;
|
|
784
2694
|
this.lastReloadCheck = now;
|
|
785
2695
|
|
|
786
2696
|
try {
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (
|
|
793
|
-
|
|
794
|
-
// Capture old policies' behavior fingerprints before any state
|
|
795
|
-
// mutation — needed to detect kept policies whose parameters changed.
|
|
796
|
-
const oldBehaviorByName = new Map<string, string>();
|
|
797
|
-
for (const p of this.config.policies) {
|
|
798
|
-
oldBehaviorByName.set(p.name, behaviorFingerprint(p.behavior));
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// Clean up state for REMOVED policies (gone from new config entirely).
|
|
802
|
-
const newPolicyNames = new Set(newConfig.policies.map(p => p.name));
|
|
803
|
-
const removedPolicies: string[] = [];
|
|
804
|
-
for (const oldName of oldBehaviorByName.keys()) {
|
|
805
|
-
if (!newPolicyNames.has(oldName)) {
|
|
806
|
-
this.clearPolicyState(oldName, { deliverPendingDebounce: true });
|
|
807
|
-
this.stats.delete(oldName);
|
|
808
|
-
removedPolicies.push(oldName);
|
|
809
|
-
}
|
|
810
|
-
}
|
|
2697
|
+
// Missing, unchanged or unreadable: the rules in force stay.
|
|
2698
|
+
const read = this.readConfigFile({ unlessUnchanged: true });
|
|
2699
|
+
// gate.json holds the text in force, so none the start ran is unsaved,
|
|
2700
|
+
// unless what's in force is what the start ran beside it or in its place.
|
|
2701
|
+
if (read.state === 'unchanged' && this.configSource === 'file') this.startUnsaved = null;
|
|
2702
|
+
if (read.state !== 'loaded') return;
|
|
2703
|
+
const newConfig = read.config;
|
|
811
2704
|
|
|
812
|
-
|
|
813
|
-
// Without this, a tokens 100→5 edit (or keyBy switch, or every 10→3,
|
|
814
|
-
// etc.) wouldn't take effect until existing buckets/counters drained.
|
|
815
|
-
// Stats are preserved — matchCount is historical, not live state.
|
|
816
|
-
const stateClearedPolicies: string[] = [];
|
|
817
|
-
for (const newPolicy of newConfig.policies) {
|
|
818
|
-
const oldHash = oldBehaviorByName.get(newPolicy.name);
|
|
819
|
-
if (oldHash === undefined) continue; // new policy, no state to clear
|
|
820
|
-
const newHash = behaviorFingerprint(newPolicy.behavior);
|
|
821
|
-
if (oldHash !== newHash) {
|
|
822
|
-
this.clearPolicyState(newPolicy.name, { deliverPendingDebounce: true });
|
|
823
|
-
stateClearedPolicies.push(newPolicy.name);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
2705
|
+
const { removed: removedPolicies, cleared: stateClearedPolicies } = this.clearStateFor(this.config, newConfig);
|
|
826
2706
|
|
|
827
2707
|
this.config = newConfig;
|
|
828
2708
|
this.compiledPolicies = this.compilePolicies(newConfig);
|
|
2709
|
+
// Recorded only now that its rules are in force, so a reload that
|
|
2710
|
+
// throws before here is tried again at the next check.
|
|
2711
|
+
this.configText = read.text;
|
|
2712
|
+
this.configNote = read.note;
|
|
2713
|
+
this.configSource = 'file';
|
|
2714
|
+
if (read.note) console.error(`[gate] ${read.note}`);
|
|
2715
|
+
// The rules in force are now gate.json's, so none the start ran is unsaved.
|
|
2716
|
+
this.startUnsaved = null;
|
|
829
2717
|
this.lastReloadTimestamp = now;
|
|
830
2718
|
this.refreshShadowWarnings('reload');
|
|
831
2719
|
|
|
@@ -839,7 +2727,7 @@ export class EventGate {
|
|
|
839
2727
|
timestamp: now,
|
|
840
2728
|
});
|
|
841
2729
|
} catch {
|
|
842
|
-
//
|
|
2730
|
+
// The rules in force stay, and the next check tries again.
|
|
843
2731
|
}
|
|
844
2732
|
}
|
|
845
2733
|
|
|
@@ -850,55 +2738,87 @@ export class EventGate {
|
|
|
850
2738
|
/**
|
|
851
2739
|
* Add or replace (upsert) a single policy in gate.json and apply it live.
|
|
852
2740
|
*
|
|
853
|
-
* The raw policy is
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
2741
|
+
* The raw policy is read with the SAME `readRule` used when loading gate.json,
|
|
2742
|
+
* and since its caller is there to fix it, any problem it has throws, naming
|
|
2743
|
+
* every one (nothing is written), and the caller surfaces it as a tool error,
|
|
2744
|
+
* where a load reads past it (readConfig). On success the policy is persisted
|
|
2745
|
+
* to gate.json (so it survives restart and is visible to `workspace--edit _config/gate.json`)
|
|
857
2746
|
* and the in-memory config is swapped in immediately, so the rule takes effect
|
|
858
|
-
* without waiting for the throttled
|
|
2747
|
+
* without waiting for the throttled reload.
|
|
859
2748
|
*
|
|
860
2749
|
* The freshest on-disk config is re-read first, so a concurrent operator edit
|
|
861
|
-
* isn't clobbered
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
2750
|
+
* isn't clobbered, and the change edits gate.json's own rule list by name:
|
|
2751
|
+
* every rule it doesn't change is written back as it was written, problems and
|
|
2752
|
+
* fields the gate doesn't read included, and so is everything else gate.json
|
|
2753
|
+
* holds, such as a list of rules parked under another name, beside which a
|
|
2754
|
+
* change adds a "policies" list where there's none (rulesForChange). Over a
|
|
2755
|
+
* gate.json the gate doesn't use, only a change after which it loads is
|
|
2756
|
+
* written, such as one replacing or removing the rule that keeps it out, and
|
|
2757
|
+
* that leaves it rules in force, since it holds events back by default; any
|
|
2758
|
+
* other is refused, naming what does, and the file stays as written. When
|
|
2759
|
+
* gate.json is absent, the change edits the text whose rules are in force the
|
|
2760
|
+
* same way, or the rules in force where there's none. A gate.json that can't
|
|
2761
|
+
* be read or isn't JSON is refused (unusableRefusal), and stays as it is. A policy with the
|
|
2762
|
+
* same `name` is replaced IN PLACE when no `position` is given (order
|
|
2763
|
+
* preserved); WITH a `position` it is moved there — so re-issuing a rule is
|
|
2764
|
+
* also how you reposition it. New policies append by default; `'prepend'` puts
|
|
2765
|
+
* one at the very front (ahead of EVERYTHING, including addressed-message wake
|
|
2766
|
+
* rules — see the shadow lint), and the anchored forms `{before: name}` /
|
|
2767
|
+
* `{after: name}` insert relative to an existing rule, which is almost always
|
|
2768
|
+
* the safer way to order a sampler or debounce against a specific catch-all.
|
|
2769
|
+
* Fields of the policy the gate reads nowhere, and that aren't a slip of one
|
|
2770
|
+
* it does, are left out of what's written, and named to `onIgnoredFields` once
|
|
2771
|
+
* the change is written and traced. A throw from the callback is logged,
|
|
2772
|
+
* and the change stands (afterChange). A field it reads at another level is a
|
|
2773
|
+
* problem, like a slip.
|
|
2774
|
+
*
|
|
2775
|
+
* Throws, writing nothing, for an invalid policy, a placement it can't make,
|
|
2776
|
+
* a gate.json that can't be read or isn't JSON, one with no rule list it
|
|
2777
|
+
* could edit without writing over what it holds, one a start didn't use,
|
|
2778
|
+
* which runs as one without it does, while it's unchanged, or one the gate
|
|
2779
|
+
* doesn't use that the change doesn't mend or would leave with no rules in
|
|
2780
|
+
* force; and throws when the write fails, which may leave gate.json
|
|
2781
|
+
* part-written.
|
|
869
2782
|
*/
|
|
870
2783
|
addPolicy(
|
|
871
2784
|
rawPolicy: unknown,
|
|
872
|
-
options?: {
|
|
2785
|
+
options?: {
|
|
2786
|
+
position?: 'append' | 'prepend' | { before: string } | { after: string };
|
|
2787
|
+
onIgnoredFields?: (notes: string[]) => void;
|
|
2788
|
+
},
|
|
873
2789
|
): GatePolicy {
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
const
|
|
877
|
-
const
|
|
2790
|
+
const reading = readRule(rawPolicy, 'A policy');
|
|
2791
|
+
if (reading.problems.length > 0 || !reading.policy) throw new Error(refusalOf(reading));
|
|
2792
|
+
const policy = reading.policy;
|
|
2793
|
+
const ignored = notesOf(reading);
|
|
2794
|
+
const edit = this.rulesForChange();
|
|
2795
|
+
if ('refused' in edit) throw new Error(edit.refused);
|
|
2796
|
+
const { top, rules, remedy } = edit;
|
|
2797
|
+
const idx = rules.findIndex((rule) => nameOf(rule) === policy.name);
|
|
878
2798
|
const position = options?.position;
|
|
879
2799
|
|
|
880
2800
|
if (idx >= 0 && position === undefined) {
|
|
881
|
-
|
|
2801
|
+
rules[idx] = policy; // replace in place, order preserved
|
|
882
2802
|
} else {
|
|
883
|
-
if (idx >= 0)
|
|
2803
|
+
if (idx >= 0) rules.splice(idx, 1); // repositioning a known rule
|
|
884
2804
|
if (position === 'prepend') {
|
|
885
|
-
|
|
2805
|
+
rules.unshift(policy);
|
|
886
2806
|
} else if (position !== undefined && typeof position === 'object') {
|
|
887
2807
|
const anchorName = 'before' in position ? position.before : position.after;
|
|
888
|
-
const anchorIdx =
|
|
2808
|
+
const anchorIdx = rules.findIndex((rule) => nameOf(rule) === anchorName);
|
|
889
2809
|
if (anchorIdx < 0) {
|
|
890
2810
|
throw new Error(
|
|
891
2811
|
`addPolicy: no policy named "${anchorName}" to anchor on — `
|
|
892
|
-
+ `have: [${
|
|
2812
|
+
+ `have: [${rules.map(nameOf).filter((name) => name !== undefined).join(', ')}]`,
|
|
893
2813
|
);
|
|
894
2814
|
}
|
|
895
|
-
|
|
2815
|
+
rules.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
|
|
896
2816
|
} else {
|
|
897
|
-
|
|
2817
|
+
rules.push(policy);
|
|
898
2818
|
}
|
|
899
2819
|
}
|
|
900
2820
|
|
|
901
|
-
this.writeConfig(
|
|
2821
|
+
this.writeConfig(top, rules, remedy);
|
|
902
2822
|
this.emitTrace({
|
|
903
2823
|
type: 'gate:policy-added',
|
|
904
2824
|
configPath: this.configPath,
|
|
@@ -906,26 +2826,43 @@ export class EventGate {
|
|
|
906
2826
|
replaced: idx >= 0,
|
|
907
2827
|
timestamp: Date.now(),
|
|
908
2828
|
});
|
|
2829
|
+
if (ignored.length > 0) {
|
|
2830
|
+
// A caller that doesn't take the notes still gets them said, in the log.
|
|
2831
|
+
const onIgnoredFields = options?.onIgnoredFields;
|
|
2832
|
+
if (onIgnoredFields) afterChange('onIgnoredFields', () => onIgnoredFields(ignored));
|
|
2833
|
+
else console.error(`[gate] ${ignored.join(' ')}`);
|
|
2834
|
+
}
|
|
909
2835
|
return policy;
|
|
910
2836
|
}
|
|
911
2837
|
|
|
912
2838
|
/**
|
|
913
2839
|
* Remove a policy by name from gate.json and clear its live state. Any pending
|
|
914
|
-
* debounce batch is
|
|
915
|
-
* so buffered events aren't silently dropped
|
|
916
|
-
*
|
|
917
|
-
* (see addPolicy).
|
|
2840
|
+
* debounce batch is sent once the removal is written and in force, as its
|
|
2841
|
+
* timer would have sent it, so buffered events aren't silently dropped
|
|
2842
|
+
* (clearStateFor). Freshest on-disk config is re-read first, and every other
|
|
2843
|
+
* rule is written back as it was written (see addPolicy). A name that isn't
|
|
2844
|
+
* a non-empty string names no rule, so it returns false, writing nothing,
|
|
2845
|
+
* whatever gate.json holds. Otherwise it throws, writing nothing, for a
|
|
2846
|
+
* gate.json that can't be read or isn't JSON, one with no rule list it could
|
|
2847
|
+
* edit, or one a start didn't use, as addPolicy does, and returns false,
|
|
2848
|
+
* writing nothing, if no policy of that name exists, gate.json's own rules
|
|
2849
|
+
* included, so one the gate leaves out can be removed by name too. For a name
|
|
2850
|
+
* it holds, it throws, writing nothing, for a gate.json the gate doesn't use
|
|
2851
|
+
* that the removal doesn't mend or would leave with no rules in force, and
|
|
2852
|
+
* throws when the write fails, which may
|
|
2853
|
+
* leave gate.json part-written; the rules in force then stay as they were,
|
|
2854
|
+
* with their state.
|
|
918
2855
|
*/
|
|
919
2856
|
removePolicy(name: string): boolean {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
this.writeConfig(
|
|
2857
|
+
// nameOf gives undefined for a rule with no name, so a call from JavaScript
|
|
2858
|
+
// with no name would otherwise remove every such rule.
|
|
2859
|
+
if (typeof name !== 'string' || name === '') return false;
|
|
2860
|
+
const edit = this.rulesForChange();
|
|
2861
|
+
if ('refused' in edit) throw new Error(edit.refused);
|
|
2862
|
+
if (!edit.rules.some((rule) => nameOf(rule) === name)) return false;
|
|
2863
|
+
// The write clears the rule's state once it has succeeded (writeConfig),
|
|
2864
|
+
// so a refusal or a failed write leaves the rule in force with its state.
|
|
2865
|
+
this.writeConfig(edit.top, edit.rules.filter((rule) => nameOf(rule) !== name), edit.remedy);
|
|
929
2866
|
this.emitTrace({
|
|
930
2867
|
type: 'gate:policy-removed',
|
|
931
2868
|
configPath: this.configPath,
|
|
@@ -936,34 +2873,78 @@ export class EventGate {
|
|
|
936
2873
|
}
|
|
937
2874
|
|
|
938
2875
|
/**
|
|
939
|
-
* List the current policy names
|
|
2876
|
+
* List the current policy names: those of a newer gate.json that loads (the
|
|
2877
|
+
* freshest on-disk view), and otherwise the rules in force, which beside a
|
|
2878
|
+
* gate.json with no rule list are the host's starting rules. Cheap helper for
|
|
940
2879
|
* tools/UX that want to show what's active without the full status payload.
|
|
941
2880
|
*/
|
|
942
2881
|
listPolicyNames(): string[] {
|
|
943
|
-
const
|
|
2882
|
+
const read = this.readConfigFile({ unlessUnchanged: true });
|
|
2883
|
+
const current = read.state === 'loaded' ? read.config : this.config;
|
|
944
2884
|
return current.policies.map((p) => p.name);
|
|
945
2885
|
}
|
|
946
2886
|
|
|
947
2887
|
/**
|
|
948
|
-
* Persist
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
2888
|
+
* Persist gate.json and apply it in memory together: `top`, its own fields,
|
|
2889
|
+
* with `rules` as its rule list. Only what loads is written: a change to a
|
|
2890
|
+
* gate.json the gate doesn't use, for which `remedy` is given, what would
|
|
2891
|
+
* bring it into force, throws, naming that and `remedy`, and writes nothing,
|
|
2892
|
+
* where the change doesn't mend what keeps it out, or where it would leave
|
|
2893
|
+
* gate.json with no rules in force: such a gate.json holds events back by
|
|
2894
|
+
* default, so putting it in force would hold back every event, those the
|
|
2895
|
+
* rules in force wake on included. The rules in force are then what the text
|
|
2896
|
+
* written reads as, as at a load, so what it still holds that the gate reads
|
|
2897
|
+
* past or leaves out stays named; the text is kept as `configText`, so
|
|
2898
|
+
* `reloadIfChanged` doesn't parse it again. Once the text is written and its
|
|
2899
|
+
* rules are in force, a rule that was in force that it leaves out or gives
|
|
2900
|
+
* another behavior loses its state (clearStateFor), last, since a pending
|
|
2901
|
+
* batch it sends can't be taken back, and a write that fails leaves the
|
|
2902
|
+
* rules in force with their state; a batch that can't be delivered is
|
|
2903
|
+
* logged, not thrown. A write that fails throws, saying whether gate.json was
|
|
2904
|
+
* changed: one that fails before writing leaves it as it was, and one that
|
|
2905
|
+
* fails after its open may leave it part-written (beforeWriting). Callers
|
|
2906
|
+
* surface it as a tool error.
|
|
952
2907
|
*/
|
|
953
|
-
private writeConfig(
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
2908
|
+
private writeConfig(top: Record<string, unknown>, rules: unknown[], remedy?: string): void {
|
|
2909
|
+
const text = JSON.stringify({ ...top, policies: rules }, null, 2) + '\n';
|
|
2910
|
+
const read = readConfig(JSON.parse(text), 'gate.json');
|
|
2911
|
+
if ('refused' in read) {
|
|
2912
|
+
throw new Error(
|
|
2913
|
+
`Nothing was saved: with this change, gate.json wouldn't be used. ${sentence(read.refused)}${remedy ? ` ${remedy}` : ''}`,
|
|
2914
|
+
);
|
|
2915
|
+
}
|
|
2916
|
+
// One the gate doesn't use, and that a change can mend, holds back what a
|
|
2917
|
+
// rule it leaves out would wake on, so its default holds events back.
|
|
2918
|
+
if (remedy !== undefined && read.config.policies.length === 0) {
|
|
2919
|
+
throw new Error(
|
|
2920
|
+
'Nothing was saved: with this change, gate.json would have no rules the gate reads, and its default would hold back every '
|
|
2921
|
+
+ `event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`,
|
|
2922
|
+
);
|
|
2923
|
+
}
|
|
2924
|
+
try {
|
|
2925
|
+
mkdirSync(dirname(this.configPath), { recursive: true });
|
|
2926
|
+
writeFileSync(this.configPath, text);
|
|
2927
|
+
} catch (err) {
|
|
2928
|
+
throw new Error(beforeWriting(err)
|
|
2929
|
+
? `gate.json couldn't be written (${errorText(err)}), so nothing was saved: the rules in force and gate.json stay `
|
|
2930
|
+
+ 'as they were. Whoever runs this host can fix what keeps it from being written.'
|
|
2931
|
+
: `Writing gate.json failed partway (${errorText(err)}), so the change isn't in force: the rules in force stay as `
|
|
2932
|
+
+ 'they were. gate.json may hold the whole change, which the gate reads at its next reload; or it may hold part of it '
|
|
2933
|
+
+ 'or nothing, which the gate can\'t read, and then they stay until gate.json is fixed. '
|
|
2934
|
+
+ 'Whoever runs this host can fix what kept it from being written, and check what gate.json holds.', { cause: err });
|
|
2935
|
+
}
|
|
2936
|
+
const was = this.config;
|
|
2937
|
+
this.configText = text;
|
|
2938
|
+
this.configNote = loadNote(read.sentences);
|
|
2939
|
+
this.config = read.config;
|
|
2940
|
+
this.compiledPolicies = this.compilePolicies(read.config);
|
|
958
2941
|
this.refreshShadowWarnings('policy-mutation');
|
|
959
2942
|
this.configSource = 'file';
|
|
960
|
-
this.
|
|
2943
|
+
this.configError = this.configNote;
|
|
2944
|
+
this.configUnused = false;
|
|
2945
|
+
this.startUnsaved = null;
|
|
961
2946
|
this.lastReloadTimestamp = Date.now();
|
|
962
|
-
|
|
963
|
-
this.configMtime = statSync(this.configPath).mtimeMs;
|
|
964
|
-
} catch {
|
|
965
|
-
// A failed stat only risks one redundant reload; keep the old mtime.
|
|
966
|
-
}
|
|
2947
|
+
this.clearStateFor(was, read.config);
|
|
967
2948
|
}
|
|
968
2949
|
|
|
969
2950
|
// =========================================================================
|
|
@@ -984,9 +2965,16 @@ export class EventGate {
|
|
|
984
2965
|
return false;
|
|
985
2966
|
}
|
|
986
2967
|
|
|
987
|
-
// Channel check
|
|
2968
|
+
// Channel check: the event's own channel id, or the registered id the
|
|
2969
|
+
// gate resolves for its route. A push event carries the adapter's raw id
|
|
2970
|
+
// (`origin.channelId`, or none), and a channels/incoming message the
|
|
2971
|
+
// registered one, so a pattern in the registered form matches every
|
|
2972
|
+
// event in its channel whose push names the channel (by `mcplChannelId`,
|
|
2973
|
+
// or a Discord origin's guild and channel): its messages on both paths,
|
|
2974
|
+
// and its reactions, edits, voice reports and other pushes.
|
|
988
2975
|
if (compiled.channelRegex && !compiled.channelRegex.test(info.channelId)) {
|
|
989
|
-
|
|
2976
|
+
const registered = this.routeChannelFor(info);
|
|
2977
|
+
if (registered === undefined || !compiled.channelRegex.test(registered)) return false;
|
|
990
2978
|
}
|
|
991
2979
|
|
|
992
2980
|
// Mount check (workspace fs events)
|
|
@@ -1057,10 +3045,9 @@ export class EventGate {
|
|
|
1057
3045
|
// marker requires too: a `source` in an event's metadata is the server's
|
|
1058
3046
|
// own word, which any server can give.
|
|
1059
3047
|
if (this.sleepUntil > this.now()) {
|
|
1060
|
-
this.reloadPrivilegedIfChanged();
|
|
1061
3048
|
const isHeartbeat = info.serverId === 'heartbeat';
|
|
1062
3049
|
const authorId = this.extractAuthorId(info.metadata);
|
|
1063
|
-
const privileged =
|
|
3050
|
+
const privileged = this.isPrivilegedUser(authorId);
|
|
1064
3051
|
if (!isHeartbeat && !privileged) {
|
|
1065
3052
|
this.sleepSuppressed++;
|
|
1066
3053
|
this.totalEvaluations++;
|
|
@@ -1169,7 +3156,7 @@ export class EventGate {
|
|
|
1169
3156
|
}
|
|
1170
3157
|
|
|
1171
3158
|
if (typeof policy.behavior === 'object') {
|
|
1172
|
-
if ('debounce' in policy.behavior) {
|
|
3159
|
+
if ('debounce' in policy.behavior || 'judge' in policy.behavior) {
|
|
1173
3160
|
this.handleDebounce(policy, info);
|
|
1174
3161
|
return { trigger: false, policyName: policy.name, behavior: policy.behavior };
|
|
1175
3162
|
}
|
|
@@ -1190,10 +3177,17 @@ export class EventGate {
|
|
|
1190
3177
|
// rate_limit + passive_sample + resets
|
|
1191
3178
|
// =========================================================================
|
|
1192
3179
|
|
|
1193
|
-
/** Resolve the partition key for keyBy-style behaviors.
|
|
1194
|
-
|
|
3180
|
+
/** Resolve the partition key for keyBy-style behaviors. `keyBy:
|
|
3181
|
+
* "channelId"` takes the registered id the gate resolves for the event when
|
|
3182
|
+
* it has one, as channel matching does, so a channel's push events count
|
|
3183
|
+
* with its channels/incoming messages rather than under their raw id. */
|
|
3184
|
+
private resolveKey(info: GateEventInfo, keyBy: string | undefined): string {
|
|
1195
3185
|
if (!keyBy) return SHARED_BUCKET_KEY;
|
|
1196
|
-
|
|
3186
|
+
if (keyBy === 'channelId') {
|
|
3187
|
+
const registered = this.routeChannelFor(info);
|
|
3188
|
+
if (registered !== undefined) return registered;
|
|
3189
|
+
}
|
|
3190
|
+
const value = info.metadata?.[keyBy];
|
|
1197
3191
|
if (value === undefined || value === null || value === '') return SHARED_BUCKET_KEY;
|
|
1198
3192
|
return String(value);
|
|
1199
3193
|
}
|
|
@@ -1203,7 +3197,7 @@ export class EventGate {
|
|
|
1203
3197
|
info: GateEventInfo,
|
|
1204
3198
|
config: { tokens: number; refillIntervalMs: number; keyBy?: string },
|
|
1205
3199
|
): GateDecision {
|
|
1206
|
-
const key = this.resolveKey(info
|
|
3200
|
+
const key = this.resolveKey(info, config.keyBy);
|
|
1207
3201
|
let buckets = this.rateLimitBuckets.get(policy.name);
|
|
1208
3202
|
if (!buckets) {
|
|
1209
3203
|
buckets = new Map();
|
|
@@ -1217,7 +3211,14 @@ export class EventGate {
|
|
|
1217
3211
|
buckets.set(key, bucket);
|
|
1218
3212
|
} else if (bucket.tokens < config.tokens) {
|
|
1219
3213
|
const elapsed = now - bucket.lastRefill;
|
|
1220
|
-
|
|
3214
|
+
// A refill interval of 0, read from gate.json or the starting rules
|
|
3215
|
+
// (readRateLimit), refills at once; divided by, it refilled the bucket
|
|
3216
|
+
// once and left its clock at NaN. Under 0, no number of whole intervals
|
|
3217
|
+
// has passed, so it never refills.
|
|
3218
|
+
if (config.refillIntervalMs === 0) {
|
|
3219
|
+
bucket.tokens = config.tokens;
|
|
3220
|
+
bucket.lastRefill = now;
|
|
3221
|
+
} else if (elapsed > 0) {
|
|
1221
3222
|
const tokensToAdd = Math.floor(elapsed / config.refillIntervalMs);
|
|
1222
3223
|
if (tokensToAdd > 0) {
|
|
1223
3224
|
bucket.tokens = Math.min(config.tokens, bucket.tokens + tokensToAdd);
|
|
@@ -1245,7 +3246,7 @@ export class EventGate {
|
|
|
1245
3246
|
info: GateEventInfo,
|
|
1246
3247
|
config: { every: number; keyBy?: string },
|
|
1247
3248
|
): GateDecision {
|
|
1248
|
-
const key = this.resolveKey(info
|
|
3249
|
+
const key = this.resolveKey(info, config.keyBy);
|
|
1249
3250
|
let counters = this.passiveSampleCounters.get(policy.name);
|
|
1250
3251
|
if (!counters) {
|
|
1251
3252
|
counters = new Map();
|
|
@@ -1277,24 +3278,16 @@ export class EventGate {
|
|
|
1277
3278
|
}
|
|
1278
3279
|
|
|
1279
3280
|
/**
|
|
1280
|
-
* Clear
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
3281
|
+
* Clear one policy's live state (rate-limit buckets, passive-sample
|
|
3282
|
+
* counters, denial / fire counters) and stop its debounce timer, leaving
|
|
3283
|
+
* its pending batch for fireDebounce to send, as clearStateFor does once
|
|
3284
|
+
* every policy's state is cleared. It leaves stats (matchCount,
|
|
3285
|
+
* lastMatchTimestamp), which are history; clearStateFor deletes a removed
|
|
3286
|
+
* policy's.
|
|
1285
3287
|
*/
|
|
1286
|
-
private clearPolicyState(
|
|
1287
|
-
name: string,
|
|
1288
|
-
options: { deliverPendingDebounce: boolean },
|
|
1289
|
-
): void {
|
|
3288
|
+
private clearPolicyState(name: string): void {
|
|
1290
3289
|
const debounce = this.debounceTimers.get(name);
|
|
1291
|
-
if (debounce)
|
|
1292
|
-
clearTimeout(debounce.timer);
|
|
1293
|
-
if (options.deliverPendingDebounce && debounce.events.length > 0) {
|
|
1294
|
-
this.deliverEvents(debounce.events);
|
|
1295
|
-
}
|
|
1296
|
-
this.debounceTimers.delete(name);
|
|
1297
|
-
}
|
|
3290
|
+
if (debounce) clearTimeout(debounce.timer);
|
|
1298
3291
|
this.rateLimitBuckets.delete(name);
|
|
1299
3292
|
this.passiveSampleCounters.delete(name);
|
|
1300
3293
|
this.rateLimitDenied.delete(name);
|
|
@@ -1315,14 +3308,17 @@ export class EventGate {
|
|
|
1315
3308
|
if (b === 'always') return true;
|
|
1316
3309
|
if (b === 'defer' || b === 'skip') return false;
|
|
1317
3310
|
if (typeof b === 'object') {
|
|
1318
|
-
if ('debounce' in b) return false; // batches; never triggers inline
|
|
3311
|
+
if ('debounce' in b || 'judge' in b) return false; // batches; never triggers inline
|
|
1319
3312
|
if ('rate_limit' in b) {
|
|
1320
3313
|
const cfg = b.rate_limit;
|
|
1321
|
-
const key = this.resolveKey(info
|
|
3314
|
+
const key = this.resolveKey(info, cfg.keyBy);
|
|
1322
3315
|
const bucket = this.rateLimitBuckets.get(policy.name)?.get(key);
|
|
1323
3316
|
if (!bucket) return true; // fresh bucket starts full
|
|
3317
|
+
// As applyRateLimit refills: at once, for an interval of 0, and never
|
|
3318
|
+
// for one under 0.
|
|
3319
|
+
if (cfg.refillIntervalMs === 0) return true;
|
|
1324
3320
|
let tokens = bucket.tokens;
|
|
1325
|
-
if (tokens < cfg.tokens) {
|
|
3321
|
+
if (tokens < cfg.tokens && cfg.refillIntervalMs > 0) {
|
|
1326
3322
|
const elapsed = Math.max(0, this.now() - bucket.lastRefill);
|
|
1327
3323
|
tokens = Math.min(cfg.tokens, tokens + Math.floor(elapsed / cfg.refillIntervalMs));
|
|
1328
3324
|
}
|
|
@@ -1330,7 +3326,7 @@ export class EventGate {
|
|
|
1330
3326
|
}
|
|
1331
3327
|
if ('passive_sample' in b) {
|
|
1332
3328
|
const cfg = b.passive_sample;
|
|
1333
|
-
const key = this.resolveKey(info
|
|
3329
|
+
const key = this.resolveKey(info, cfg.keyBy);
|
|
1334
3330
|
const count = this.passiveSampleCounters.get(policy.name)?.get(key) ?? 0;
|
|
1335
3331
|
return count + 1 >= cfg.every;
|
|
1336
3332
|
}
|
|
@@ -1357,16 +3353,8 @@ export class EventGate {
|
|
|
1357
3353
|
return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
|
|
1358
3354
|
}
|
|
1359
3355
|
|
|
1360
|
-
/**
|
|
1361
|
-
|
|
1362
|
-
* rule wins each and whether it would wake. Backs the before/after table in
|
|
1363
|
-
* the wake_add_rule result — the point where a rule change's real semantics
|
|
1364
|
-
* must become legible (af#105; the Mythos stand-back-density incident would
|
|
1365
|
-
* have been visible as `dm: discord-direct-address → stand-back-density`).
|
|
1366
|
-
* Probes are representative shapes, not an exhaustive event space — rules
|
|
1367
|
-
* keyed on specific channels/mounts may not match any probe.
|
|
1368
|
-
*/
|
|
1369
|
-
probeTable(): GateProbeRow[] {
|
|
3356
|
+
/** The canonical synthetic events the probe table runs: chat shapes as MCPL delivers them, and a heartbeat. */
|
|
3357
|
+
private probeEvents(): Array<{ label: string; info: GateEventInfo }> {
|
|
1370
3358
|
const chat = (
|
|
1371
3359
|
label: string,
|
|
1372
3360
|
eventType: string,
|
|
@@ -1412,8 +3400,34 @@ export class EventGate {
|
|
|
1412
3400
|
} as GateEventInfo,
|
|
1413
3401
|
},
|
|
1414
3402
|
];
|
|
3403
|
+
return probes;
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
/**
|
|
3407
|
+
* The rules no probe event matches, such as one scoped to an event type
|
|
3408
|
+
* the probes don't model (`external-message`, how a module's messages are
|
|
3409
|
+
* gated): the probe table can't show what they do, so a result that shows
|
|
3410
|
+
* the table names them rather than let its rows read as their effect.
|
|
3411
|
+
*/
|
|
3412
|
+
probeUnreached(): string[] {
|
|
3413
|
+
const events = this.probeEvents().map(({ info }) => info);
|
|
3414
|
+
return this.compiledPolicies
|
|
3415
|
+
.filter((compiled) => !events.some((info) => this.compiledMatches(compiled, info)))
|
|
3416
|
+
.map((compiled) => compiled.policy.name);
|
|
3417
|
+
}
|
|
1415
3418
|
|
|
1416
|
-
|
|
3419
|
+
/**
|
|
3420
|
+
* Probe a canonical set of synthetic chat/heartbeat events and report which
|
|
3421
|
+
* rule wins each and whether it would wake. Backs the before/after table in
|
|
3422
|
+
* the wake_add_rule result — the point where a rule change's real semantics
|
|
3423
|
+
* must become legible (af#105; the Mythos stand-back-density incident would
|
|
3424
|
+
* have been visible as `dm: discord-direct-address → stand-back-density`).
|
|
3425
|
+
* Probes are representative shapes, not an exhaustive event space — rules
|
|
3426
|
+
* keyed on specific channels/mounts, or on other event types, may not match
|
|
3427
|
+
* any probe; `probeUnreached` names those.
|
|
3428
|
+
*/
|
|
3429
|
+
probeTable(): GateProbeRow[] {
|
|
3430
|
+
return this.probeEvents().map(({ label, info }) => {
|
|
1417
3431
|
const d = this.probe(info);
|
|
1418
3432
|
return {
|
|
1419
3433
|
probe: label,
|
|
@@ -1495,7 +3509,8 @@ export class EventGate {
|
|
|
1495
3509
|
}
|
|
1496
3510
|
|
|
1497
3511
|
private handleDebounce(policy: GatePolicy, info: GateEventInfo): void {
|
|
1498
|
-
const debounceMs = (policy.behavior
|
|
3512
|
+
const debounceMs = batchQuietMs(policy.behavior) ?? MIN_DEBOUNCE_MS;
|
|
3513
|
+
const judge = typeof policy.behavior === 'object' && 'judge' in policy.behavior ? policy.behavior.judge : undefined;
|
|
1499
3514
|
|
|
1500
3515
|
const event: PendingEvent = {
|
|
1501
3516
|
policyName: policy.name,
|
|
@@ -1514,16 +3529,28 @@ export class EventGate {
|
|
|
1514
3529
|
serverId: info.serverId || undefined,
|
|
1515
3530
|
addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'),
|
|
1516
3531
|
routeChannelId: this.routeChannelFor(info),
|
|
3532
|
+
...(typeof info.metadata?.messageId === 'string' ? { messageId: info.metadata.messageId } : {}),
|
|
3533
|
+
...(typeof info.metadata?.eventId === 'string' ? { eventId: info.metadata.eventId } : {}),
|
|
1517
3534
|
};
|
|
1518
3535
|
|
|
1519
3536
|
const existing = this.debounceTimers.get(policy.name);
|
|
3537
|
+
// A judge's maxWaitMs caps how far new events push the batch back: it's
|
|
3538
|
+
// judged by then, from its first event, quiet or not.
|
|
3539
|
+
const delay = (firstAt: number, batchJudge: JudgeConfig | undefined): number => {
|
|
3540
|
+
const cap = batchJudge?.maxWaitMs;
|
|
3541
|
+
return cap === undefined ? debounceMs : Math.max(0, Math.min(debounceMs, firstAt + cap - this.now()));
|
|
3542
|
+
};
|
|
1520
3543
|
if (existing) {
|
|
1521
3544
|
clearTimeout(existing.timer);
|
|
1522
3545
|
existing.events.push(event);
|
|
1523
|
-
|
|
3546
|
+
const ms = delay(existing.firstAt, existing.judge);
|
|
3547
|
+
existing.timer = setTimeout(() => this.fireDebounce(policy.name), ms);
|
|
3548
|
+
existing.dueAt = this.now() + ms;
|
|
1524
3549
|
} else {
|
|
1525
|
-
const
|
|
1526
|
-
|
|
3550
|
+
const firstAt = this.now();
|
|
3551
|
+
const ms = delay(firstAt, judge);
|
|
3552
|
+
const timer = setTimeout(() => this.fireDebounce(policy.name), ms);
|
|
3553
|
+
this.debounceTimers.set(policy.name, { timer, events: [event], firstAt, dueAt: firstAt + ms, ...(judge ? { judge } : {}) });
|
|
1527
3554
|
}
|
|
1528
3555
|
}
|
|
1529
3556
|
|
|
@@ -1537,6 +3564,14 @@ export class EventGate {
|
|
|
1537
3564
|
const events = state.events;
|
|
1538
3565
|
this.debounceTimers.delete(policyName);
|
|
1539
3566
|
|
|
3567
|
+
// A judge rule's batch goes to the reply judge, which delivers it, or
|
|
3568
|
+
// doesn't, once it has decided (judgeBatch). Without a judge wired it's
|
|
3569
|
+
// delivered below, as a debounce's batch is.
|
|
3570
|
+
if (state.judge && this.judgeFn) {
|
|
3571
|
+
this.judgeBatch(policyName, events, state.judge);
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
|
|
1540
3575
|
// If any agent is currently inferring — or the host is quiesced — buffer
|
|
1541
3576
|
// for later (with cap). While quiesced, delivering would write a
|
|
1542
3577
|
// "[Gate: N events]" message into the very context being operated on
|
|
@@ -1546,7 +3581,20 @@ export class EventGate {
|
|
|
1546
3581
|
return;
|
|
1547
3582
|
}
|
|
1548
3583
|
|
|
1549
|
-
|
|
3584
|
+
// A delivery that throws drops the batch, named in the log: thrown from
|
|
3585
|
+
// its timer, it would be uncaught, and stop the process, and thrown to a
|
|
3586
|
+
// write or reload that sends it (clearStateFor), it would report a change
|
|
3587
|
+
// that was made as failed.
|
|
3588
|
+
try {
|
|
3589
|
+
this.deliverEvents(events);
|
|
3590
|
+
} catch (err) {
|
|
3591
|
+
const one = events.length === 1;
|
|
3592
|
+
console.error(
|
|
3593
|
+
`[gate] Policy "${policyName}": its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
|
|
3594
|
+
+ `(${errorText(err)}), so the batch is dropped`,
|
|
3595
|
+
);
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
1550
3598
|
|
|
1551
3599
|
this.emitTrace({
|
|
1552
3600
|
type: 'gate:debounce-delivered',
|
|
@@ -1574,40 +3622,68 @@ export class EventGate {
|
|
|
1574
3622
|
const referenced = events.filter(e => e.inContext);
|
|
1575
3623
|
|
|
1576
3624
|
const lines: string[] = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`);
|
|
3625
|
+
// How each referenced channel is named in the line, in order.
|
|
3626
|
+
const shown: string[] = [];
|
|
1577
3627
|
if (referenced.length > 0) {
|
|
1578
|
-
|
|
3628
|
+
// By channel, its server and its id: two servers' channels can share
|
|
3629
|
+
// an id.
|
|
3630
|
+
const byChannel = new Map<string, { name: string; count: number; oldest: number; label?: string }>();
|
|
1579
3631
|
for (const e of referenced) {
|
|
1580
3632
|
// The registered id when known, so this line names the channel the
|
|
1581
3633
|
// same way routing notices and send tools do (a push event's own
|
|
1582
|
-
// channelId is the adapter's raw id)
|
|
1583
|
-
|
|
1584
|
-
|
|
3634
|
+
// channelId is the adapter's raw id), shown as the agent names it
|
|
3635
|
+
// back where the event names its server.
|
|
3636
|
+
// Only a registered id is a channel name; an adapter's raw id is
|
|
3637
|
+
// shown as it is.
|
|
3638
|
+
const id = e.routeChannelId ?? e.channelId;
|
|
3639
|
+
const name = id === undefined
|
|
3640
|
+
? '(unknown channel)'
|
|
3641
|
+
: e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn
|
|
3642
|
+
? this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId })
|
|
3643
|
+
: id;
|
|
3644
|
+
const key = JSON.stringify([e.serverId ?? null, id ?? null]);
|
|
3645
|
+
const entry = byChannel.get(key) ?? { name, count: 0, oldest: e.timestamp, label: e.channelLabel };
|
|
1585
3646
|
entry.count += 1;
|
|
1586
3647
|
entry.oldest = Math.min(entry.oldest, e.timestamp);
|
|
1587
3648
|
entry.label = entry.label ?? e.channelLabel;
|
|
1588
3649
|
byChannel.set(key, entry);
|
|
1589
3650
|
}
|
|
1590
|
-
for (const
|
|
3651
|
+
for (const { name, count, oldest, label } of byChannel.values()) {
|
|
1591
3652
|
// Prefer the human-readable name; keep the id parenthesized so the
|
|
1592
3653
|
// agent can still address tools that want the composite id. A name
|
|
1593
3654
|
// may carry its own `#`, so one is added only where the name lacks
|
|
1594
3655
|
// it, as at the other places that add a `#`.
|
|
1595
|
-
const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${
|
|
1596
|
-
|
|
1597
|
-
lines.push(
|
|
1598
|
-
`- ${count} message${count > 1 ? 's' : ''} in ${channel} ` +
|
|
1599
|
-
`(oldest ~${age}s ago) — already shown above at arrival; this is a ` +
|
|
1600
|
-
`batched wake, not new content`,
|
|
1601
|
-
);
|
|
3656
|
+
const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${name})` : name;
|
|
3657
|
+
shown.push(channel);
|
|
3658
|
+
lines.push(referenceLine(count, channel, oldest, Date.now()));
|
|
1602
3659
|
}
|
|
1603
3660
|
}
|
|
1604
3661
|
|
|
3662
|
+
// The line says which messages a wake is for. For one message that is
|
|
3663
|
+
// still the newest thing in the window it would be written to, it says
|
|
3664
|
+
// nothing the message right above doesn't. But the turn compiles later,
|
|
3665
|
+
// and what lands in between puts the message above other entries, so the
|
|
3666
|
+
// gate hands the line to the wake (WakeProvenance.line) and a fresh start
|
|
3667
|
+
// of the wake's turn decides it (WakeLine). Several messages, one with
|
|
3668
|
+
// anything after it, or content the line quotes, keep the line now.
|
|
1605
3669
|
const text = `[Gate: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
3670
|
+
const only = events.length === 1 && events[0].inContext ? events[0] : undefined;
|
|
3671
|
+
let line: WakeLine | undefined;
|
|
3672
|
+
if (only && this.newestEntry(only)) {
|
|
3673
|
+
const shownChannel = only.routeChannelId ?? only.channelId;
|
|
3674
|
+
line = {
|
|
3675
|
+
entry: entryRef(only),
|
|
3676
|
+
policyNames,
|
|
3677
|
+
shown: shown[0],
|
|
3678
|
+
at: only.timestamp,
|
|
3679
|
+
...(shownChannel !== undefined ? { channelId: shownChannel } : {}),
|
|
3680
|
+
};
|
|
3681
|
+
} else {
|
|
3682
|
+
this.addMessageFn('user', [{ type: 'text', text }], {
|
|
3683
|
+
source: 'gate:debounce',
|
|
3684
|
+
policies: policyNames,
|
|
3685
|
+
});
|
|
3686
|
+
}
|
|
1611
3687
|
|
|
1612
3688
|
// Provenance of the batched wake (who/where woke the agent): the newest
|
|
1613
3689
|
// ADDRESSED event first, else the newest event naming a channel or an
|
|
@@ -1615,19 +3691,323 @@ export class EventGate {
|
|
|
1615
3691
|
// event addressed the agent and its registered channel is known,
|
|
1616
3692
|
// `routeChannelId` names the turn's speech locus too — the reply goes
|
|
1617
3693
|
// back to whoever addressed the agent, not to whichever channel last saw
|
|
1618
|
-
// any traffic. Ambient-only batches
|
|
1619
|
-
|
|
3694
|
+
// any traffic. Ambient-only batches set no locus. A batch whose line the
|
|
3695
|
+
// gate left to the turn's start carries it too (`line`), one object
|
|
3696
|
+
// shared by every agent the batch wakes, so it is written once.
|
|
3697
|
+
const chosen = wakeProvenance(events);
|
|
3698
|
+
const provenance = line ? { ...chosen, line } : chosen;
|
|
1620
3699
|
for (const agentName of this.getAgentNamesFn()) {
|
|
1621
3700
|
this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance);
|
|
1622
3701
|
}
|
|
1623
3702
|
}
|
|
1624
3703
|
|
|
3704
|
+
/** isNewestEntry for one event; a callback fault keeps the line. */
|
|
3705
|
+
private newestEntry(event: PendingEvent): boolean {
|
|
3706
|
+
if (!this.isNewestEntryFn) return false;
|
|
3707
|
+
try {
|
|
3708
|
+
return this.isNewestEntryFn(entryRef(event)) === true;
|
|
3709
|
+
} catch {
|
|
3710
|
+
return false;
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
// =========================================================================
|
|
3715
|
+
// Reply judge (`judge` rules)
|
|
3716
|
+
// =========================================================================
|
|
3717
|
+
|
|
3718
|
+
/**
|
|
3719
|
+
* Send one of a judge rule's batches to the reply judge. While a resident's
|
|
3720
|
+
* turn runs or the host is quiesced the batch is held instead, merged with
|
|
3721
|
+
* any the rule already holds, and judged once both are clear
|
|
3722
|
+
* (flushJudgeHeldIfClear): the judge then reads the context the turn left,
|
|
3723
|
+
* which may already have answered it. While the judge decides one of the
|
|
3724
|
+
* rule's batches, a later one waits for that verdict (finishJudging), so the
|
|
3725
|
+
* judge never decides two of a rule's batches at once.
|
|
3726
|
+
*/
|
|
3727
|
+
private judgeBatch(policyName: string, events: PendingEvent[], judge: JudgeConfig): void {
|
|
3728
|
+
if (this.disposed) return;
|
|
3729
|
+
const deciding = this.judging.get(policyName);
|
|
3730
|
+
if (deciding) {
|
|
3731
|
+
deciding.queued.push(...events);
|
|
3732
|
+
this.capJudgeEvents(deciding.queued);
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
if (this.judgedTurnRunning() || this.quiesced) {
|
|
3736
|
+
this.holdForJudge(policyName, events, judge);
|
|
3737
|
+
return;
|
|
3738
|
+
}
|
|
3739
|
+
const fn = this.judgeFn;
|
|
3740
|
+
if (!fn) {
|
|
3741
|
+
this.deliverOrBuffer(events);
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
const abort = new AbortController();
|
|
3745
|
+
const agentNames = this.getAgentNamesFn();
|
|
3746
|
+
const state: JudgingState = { events, judge, started: Date.now(), abort, queued: [] };
|
|
3747
|
+
this.judging.set(policyName, state);
|
|
3748
|
+
const timedOut = new Promise<never>((_, reject) => {
|
|
3749
|
+
state.timer = setTimeout(() => {
|
|
3750
|
+
abort.abort();
|
|
3751
|
+
reject(new Error(`the judge took more than ${this.judgeTimeoutMs} ms`));
|
|
3752
|
+
}, this.judgeTimeoutMs);
|
|
3753
|
+
// Never keep the process alive on the judge's account.
|
|
3754
|
+
state.timer.unref?.();
|
|
3755
|
+
});
|
|
3756
|
+
let asked: Promise<JudgeVerdict>;
|
|
3757
|
+
try {
|
|
3758
|
+
asked = fn({
|
|
3759
|
+
policyName,
|
|
3760
|
+
...(judge.instructions !== undefined ? { instructions: judge.instructions } : {}),
|
|
3761
|
+
events: events.map((e) => this.judgeEventSummary(e)),
|
|
3762
|
+
agentNames,
|
|
3763
|
+
upcomingWakes: this.upcomingWakes(agentNames, policyName),
|
|
3764
|
+
signal: abort.signal,
|
|
3765
|
+
});
|
|
3766
|
+
} catch (err) {
|
|
3767
|
+
asked = Promise.reject(err);
|
|
3768
|
+
}
|
|
3769
|
+
Promise.race([asked, timedOut]).then(
|
|
3770
|
+
(verdict) => this.finishJudging(policyName, state, verdict),
|
|
3771
|
+
(err: unknown) => this.finishJudging(policyName, state, undefined, err),
|
|
3772
|
+
).finally(() => {
|
|
3773
|
+
if (state.timer) clearTimeout(state.timer);
|
|
3774
|
+
}).catch((err: unknown) => {
|
|
3775
|
+
console.error(`[gate] Policy "${policyName}": the judge's verdict couldn't be applied (${errorText(err)})`);
|
|
3776
|
+
});
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
/** Hold a batch for the judge until no turn changing the context it reads
|
|
3780
|
+
* runs and the host isn't quiesced, merged with what the rule already holds. */
|
|
3781
|
+
private holdForJudge(policyName: string, events: PendingEvent[], judge: JudgeConfig): void {
|
|
3782
|
+
const held = this.judgeHeld.get(policyName);
|
|
3783
|
+
if (held) {
|
|
3784
|
+
held.events.push(...events);
|
|
3785
|
+
held.judge = judge;
|
|
3786
|
+
this.capJudgeEvents(held.events);
|
|
3787
|
+
} else {
|
|
3788
|
+
const fresh = [...events];
|
|
3789
|
+
this.capJudgeEvents(fresh);
|
|
3790
|
+
this.judgeHeld.set(policyName, { events: fresh, judge });
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
/**
|
|
3795
|
+
* Keep a rule's events waiting on the judge to the inference buffer's cap,
|
|
3796
|
+
* dropping the oldest, counted and logged as bufferForInference counts its
|
|
3797
|
+
* drops: a turn that never ends (the wake-wedge) or a long quiesce on a busy
|
|
3798
|
+
* channel would otherwise grow them without bound. A queue beside a batch
|
|
3799
|
+
* the judge is deciding is capped on its own, so its newest events are never
|
|
3800
|
+
* the ones dropped for the batch ahead of it.
|
|
3801
|
+
*/
|
|
3802
|
+
private capJudgeEvents(events: PendingEvent[]): void {
|
|
3803
|
+
const drop = events.length - MAX_INFERENCE_BUFFER;
|
|
3804
|
+
if (drop <= 0) return;
|
|
3805
|
+
events.splice(0, drop);
|
|
3806
|
+
const before = this.droppedJudgeEvents;
|
|
3807
|
+
this.droppedJudgeEvents += drop;
|
|
3808
|
+
if (Math.floor(before / 50) !== Math.floor(this.droppedJudgeEvents / 50) || before === 0) {
|
|
3809
|
+
console.error(
|
|
3810
|
+
`[gate] events waiting on the reply judge reached the cap (${MAX_INFERENCE_BUFFER} per rule): dropped `
|
|
3811
|
+
+ `${this.droppedJudgeEvents} oldest so far${this.quiesced ? ' during quiesce' : ''}`,
|
|
3812
|
+
);
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
/** The rule's judge as it is now, when the rule is still a judge rule; else the one the batch was asked with. */
|
|
3817
|
+
private currentJudge(policyName: string, asked: JudgeConfig): JudgeConfig {
|
|
3818
|
+
const now = this.config.policies.find((p) => p.name === policyName)?.behavior;
|
|
3819
|
+
return typeof now === 'object' && 'judge' in now ? now.judge : asked;
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
/**
|
|
3823
|
+
* The gate's own timers that will wake `agentNames` with no new event, for
|
|
3824
|
+
* the judge to weigh what waiting costs: each agent's pending self-wake
|
|
3825
|
+
* (skip_reply's wake_in_seconds), the end of a sleep, and the pending
|
|
3826
|
+
* batches of rules other than `exceptPolicy` (a debounce's wakes the agent
|
|
3827
|
+
* when it fires; a judge rule's goes to the judge, so it only may).
|
|
3828
|
+
*/
|
|
3829
|
+
upcomingWakes(agentNames: string[], exceptPolicy?: string): UpcomingWake[] {
|
|
3830
|
+
const wakes: UpcomingWake[] = [];
|
|
3831
|
+
const now = this.now();
|
|
3832
|
+
for (const name of agentNames) {
|
|
3833
|
+
const due = this.selfWakeDue.get(name);
|
|
3834
|
+
if (due && this.selfWakeTimers.has(name)) {
|
|
3835
|
+
wakes.push({ at: due.at, certain: true, what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
if (this.sleepUntil > now && (this.sleepAgent === undefined || agentNames.includes(this.sleepAgent))) {
|
|
3839
|
+
wakes.push({ at: this.sleepUntil, certain: true, what: 'the end of its sleep' });
|
|
3840
|
+
}
|
|
3841
|
+
for (const [name, state] of this.debounceTimers) {
|
|
3842
|
+
if (name === exceptPolicy || state.events.length === 0) continue;
|
|
3843
|
+
const n = state.events.length;
|
|
3844
|
+
const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
|
|
3845
|
+
wakes.push(state.judge
|
|
3846
|
+
? { at: state.dueAt, certain: false, what: `rule "${name}"'s batch of ${batch} goes to the judge` }
|
|
3847
|
+
: { at: state.dueAt, certain: true, what: `rule "${name}"'s debounced batch of ${batch}` });
|
|
3848
|
+
}
|
|
3849
|
+
return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
/** A batch's event as the judge is shown it. */
|
|
3853
|
+
private judgeEventSummary(e: PendingEvent): JudgeEventSummary {
|
|
3854
|
+
const id = e.routeChannelId ?? e.channelId;
|
|
3855
|
+
let channel: string | undefined;
|
|
3856
|
+
if (id !== undefined) {
|
|
3857
|
+
let name = id;
|
|
3858
|
+
if (e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn) {
|
|
3859
|
+
try {
|
|
3860
|
+
name = this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId });
|
|
3861
|
+
} catch {
|
|
3862
|
+
name = id;
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
channel = e.channelLabel ? `${e.channelLabel.startsWith('#') ? e.channelLabel : `#${e.channelLabel}`} (${name})` : name;
|
|
3866
|
+
}
|
|
3867
|
+
return {
|
|
3868
|
+
eventType: e.eventType,
|
|
3869
|
+
...(channel !== undefined ? { channel } : {}),
|
|
3870
|
+
...(e.authorId !== undefined ? { authorId: e.authorId } : {}),
|
|
3871
|
+
addressed: e.addressed,
|
|
3872
|
+
content: e.content,
|
|
3873
|
+
at: e.timestamp,
|
|
3874
|
+
};
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
/**
|
|
3878
|
+
* Apply the judge's verdict on a batch. Reply: the batch, with any of the
|
|
3879
|
+
* rule's batches that fired meanwhile, is delivered as a debounce's is
|
|
3880
|
+
* (held for a turn that started meanwhile, as fireDebounce holds one). Wait:
|
|
3881
|
+
* nothing is delivered, and batches that fired meanwhile go to the judge
|
|
3882
|
+
* afresh. A judge that failed, timed out or answered nothing usable decides
|
|
3883
|
+
* by onError: "wake" delivers, "skip" doesn't. Each verdict is traced and
|
|
3884
|
+
* counted for gate_status, and a failure is logged.
|
|
3885
|
+
*/
|
|
3886
|
+
private finishJudging(policyName: string, state: JudgingState, verdict: JudgeVerdict | undefined, err?: unknown): void {
|
|
3887
|
+
// A verdict for a batch the gate let go of (dispose() clears what the
|
|
3888
|
+
// judge was deciding) applies to nothing, even after a start() since.
|
|
3889
|
+
if (this.judging.get(policyName) !== state) return;
|
|
3890
|
+
this.judging.delete(policyName);
|
|
3891
|
+
if (state.timer) clearTimeout(state.timer);
|
|
3892
|
+
const durationMs = Date.now() - state.started;
|
|
3893
|
+
const failed = verdict == null || typeof verdict !== 'object' || typeof verdict.reply !== 'boolean';
|
|
3894
|
+
if (failed && (err as { providerAdmission?: unknown } | undefined)?.providerAdmission === 'deferred') {
|
|
3895
|
+
const wakes = this.judgeOnError === 'wake';
|
|
3896
|
+
console.error(
|
|
3897
|
+
`[gate] Policy "${policyName}": the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
|
|
3898
|
+
+ `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
|
|
3899
|
+
);
|
|
3900
|
+
if (this.disposed) return;
|
|
3901
|
+
if (wakes) this.deliverOrBuffer([...state.events, ...state.queued]);
|
|
3902
|
+
else if (state.queued.length > 0) this.judgeBatch(policyName, state.queued, this.currentJudge(policyName, state.judge));
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
const outcome: 'reply' | 'wait' | 'error' = failed ? 'error' : verdict.reply ? 'reply' : 'wait';
|
|
3906
|
+
const reason = failed
|
|
3907
|
+
? (err !== undefined ? errorText(err) : 'the judge gave no verdict')
|
|
3908
|
+
: typeof verdict.reason === 'string' ? verdict.reason : '';
|
|
3909
|
+
const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0 } };
|
|
3910
|
+
record.verdicts[outcome]++;
|
|
3911
|
+
record.last = {
|
|
3912
|
+
at: Date.now(),
|
|
3913
|
+
verdict: outcome,
|
|
3914
|
+
reason: safeSlice(reason, 0, 300),
|
|
3915
|
+
eventCount: state.events.length,
|
|
3916
|
+
durationMs,
|
|
3917
|
+
...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
|
|
3918
|
+
};
|
|
3919
|
+
if (this.config.policies.some((p) => p.name === policyName)) this.judgeRecords.set(policyName, record);
|
|
3920
|
+
if (failed) {
|
|
3921
|
+
this.judgeFailStreak++;
|
|
3922
|
+
this.judgeLastFailure = safeSlice(reason, 0, 200);
|
|
3923
|
+
} else {
|
|
3924
|
+
this.judgeFailStreak = 0;
|
|
3925
|
+
}
|
|
3926
|
+
const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
|
|
3927
|
+
if (failed) {
|
|
3928
|
+
console.error(
|
|
3929
|
+
`[gate] Policy "${policyName}": the reply judge failed (${reason}), so its batch of ${state.events.length} `
|
|
3930
|
+
+ `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
|
|
3931
|
+
);
|
|
3932
|
+
}
|
|
3933
|
+
this.emitTrace({
|
|
3934
|
+
type: 'gate:judge-verdict',
|
|
3935
|
+
policyName,
|
|
3936
|
+
verdict: outcome,
|
|
3937
|
+
woke: wake,
|
|
3938
|
+
eventCount: state.events.length,
|
|
3939
|
+
durationMs,
|
|
3940
|
+
...(!failed && verdict.model ? { model: verdict.model } : {}),
|
|
3941
|
+
...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
|
|
3942
|
+
...(!failed && typeof verdict.cacheReadTokens === 'number' ? { cacheReadTokens: verdict.cacheReadTokens } : {}),
|
|
3943
|
+
...(!failed && typeof verdict.outputTokens === 'number' ? { outputTokens: verdict.outputTokens } : {}),
|
|
3944
|
+
timestamp: Date.now(),
|
|
3945
|
+
});
|
|
3946
|
+
if (this.disposed) return;
|
|
3947
|
+
const judge = this.currentJudge(policyName, state.judge);
|
|
3948
|
+
if (wake) {
|
|
3949
|
+
this.deliverOrBuffer([...state.events, ...state.queued]);
|
|
3950
|
+
return;
|
|
3951
|
+
}
|
|
3952
|
+
if (state.queued.length > 0) this.judgeBatch(policyName, state.queued, judge);
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
/** Deliver a batch now, or hold it for the agents' turns and the host's
|
|
3956
|
+
* quiesce to end, as fireDebounce does; a delivery that throws drops it,
|
|
3957
|
+
* named in the log. */
|
|
3958
|
+
private deliverOrBuffer(events: PendingEvent[]): void {
|
|
3959
|
+
if (events.length === 0) return;
|
|
3960
|
+
if (this.inferring.size > 0 || this.quiesced) {
|
|
3961
|
+
this.bufferForInference(events);
|
|
3962
|
+
return;
|
|
3963
|
+
}
|
|
3964
|
+
try {
|
|
3965
|
+
this.deliverEvents(events);
|
|
3966
|
+
} catch (err) {
|
|
3967
|
+
const one = events.length === 1;
|
|
3968
|
+
console.error(
|
|
3969
|
+
`[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
|
|
3970
|
+
+ `(${errorText(err)}), so the batch is dropped`,
|
|
3971
|
+
);
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
private changesJudgedContext(agentName: string): boolean {
|
|
3976
|
+
return this.changesJudgedContextFn?.(agentName) ?? true;
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
private judgedTurnRunning(): boolean {
|
|
3980
|
+
return [...this.inferring].some((name) => this.changesJudgedContext(name));
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
private stopJudging(): void {
|
|
3984
|
+
for (const [policyName, state] of [...this.judging]) {
|
|
3985
|
+
this.judging.delete(policyName);
|
|
3986
|
+
if (state.timer) clearTimeout(state.timer);
|
|
3987
|
+
state.abort.abort();
|
|
3988
|
+
this.holdForJudge(policyName, [...state.events, ...state.queued], state.judge);
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
/** Send the batches held for the judge once no turn changing the context it reads runs and the host isn't quiesced. */
|
|
3993
|
+
private flushJudgeHeldIfClear(): void {
|
|
3994
|
+
if (this.quiesced || this.judgedTurnRunning() || this.judgeHeld.size === 0) return;
|
|
3995
|
+
const held = [...this.judgeHeld];
|
|
3996
|
+
this.judgeHeld.clear();
|
|
3997
|
+
// Deferred, as the inference buffer's flush is, to stay out of the
|
|
3998
|
+
// turn-end callback that called this.
|
|
3999
|
+
queueMicrotask(() => {
|
|
4000
|
+
for (const [policyName, { events, judge }] of held) this.judgeBatch(policyName, events, this.currentJudge(policyName, judge));
|
|
4001
|
+
});
|
|
4002
|
+
}
|
|
4003
|
+
|
|
1625
4004
|
// =========================================================================
|
|
1626
4005
|
// Inference lifecycle
|
|
1627
4006
|
// =========================================================================
|
|
1628
4007
|
|
|
1629
4008
|
onInferenceStarted(agentName: string): void {
|
|
1630
4009
|
this.inferring.add(agentName);
|
|
4010
|
+
if (this.changesJudgedContext(agentName)) this.stopJudging();
|
|
1631
4011
|
// A turn is starting — whatever caused it supersedes any pending
|
|
1632
4012
|
// self-wake. (Arming happens MID-turn via skip_reply, i.e. after this
|
|
1633
4013
|
// ran for the arming turn, so a fresh arm always survives its own turn.)
|
|
@@ -1647,11 +4027,13 @@ export class EventGate {
|
|
|
1647
4027
|
* still running. */
|
|
1648
4028
|
onInferenceResumed(agentName: string): void {
|
|
1649
4029
|
this.inferring.add(agentName);
|
|
4030
|
+
if (this.changesJudgedContext(agentName)) this.stopJudging();
|
|
1650
4031
|
}
|
|
1651
4032
|
|
|
1652
4033
|
onInferenceEnded(agentName: string): void {
|
|
1653
4034
|
this.inferring.delete(agentName);
|
|
1654
4035
|
this.flushInferenceBufferIfClear();
|
|
4036
|
+
this.flushJudgeHeldIfClear();
|
|
1655
4037
|
}
|
|
1656
4038
|
|
|
1657
4039
|
/**
|
|
@@ -1666,15 +4048,35 @@ export class EventGate {
|
|
|
1666
4048
|
setQuiesced(quiesced: boolean): void {
|
|
1667
4049
|
if (this.quiesced === quiesced) return;
|
|
1668
4050
|
this.quiesced = quiesced;
|
|
1669
|
-
if (
|
|
4051
|
+
if (quiesced) {
|
|
4052
|
+
// A judge reading the context now would read it mid-surgery, and call
|
|
4053
|
+
// its provider while the host holds still: it's stopped, and its batch
|
|
4054
|
+
// held to be judged at resume.
|
|
4055
|
+
this.stopJudging();
|
|
4056
|
+
} else {
|
|
4057
|
+
this.flushInferenceBufferIfClear();
|
|
4058
|
+
this.flushJudgeHeldIfClear();
|
|
4059
|
+
}
|
|
1670
4060
|
}
|
|
1671
4061
|
|
|
1672
4062
|
private flushInferenceBufferIfClear(): void {
|
|
1673
4063
|
if (this.quiesced) return;
|
|
1674
4064
|
if (this.inferring.size === 0 && this.inferenceBuffer.length > 0) {
|
|
1675
4065
|
const events = this.inferenceBuffer.splice(0);
|
|
1676
|
-
// Defer to avoid re-entrancy inside trace callbacks
|
|
1677
|
-
|
|
4066
|
+
// Defer to avoid re-entrancy inside trace callbacks. A delivery that
|
|
4067
|
+
// throws drops what was held, named in the log, as fireDebounce drops a
|
|
4068
|
+
// batch: thrown in the microtask, it would be uncaught.
|
|
4069
|
+
queueMicrotask(() => {
|
|
4070
|
+
try {
|
|
4071
|
+
this.deliverEvents(events);
|
|
4072
|
+
} catch (err) {
|
|
4073
|
+
const one = events.length === 1;
|
|
4074
|
+
console.error(
|
|
4075
|
+
`[gate] ${events.length} ${one ? 'event' : 'events'} held for an agent's turn or the host's quiesce couldn't be `
|
|
4076
|
+
+ `delivered (${errorText(err)}), so ${one ? "it's" : "they're"} dropped`,
|
|
4077
|
+
);
|
|
4078
|
+
}
|
|
4079
|
+
});
|
|
1678
4080
|
}
|
|
1679
4081
|
}
|
|
1680
4082
|
|
|
@@ -1687,12 +4089,19 @@ export class EventGate {
|
|
|
1687
4089
|
bufferedEvents: number;
|
|
1688
4090
|
droppedBufferedEvents: number;
|
|
1689
4091
|
sleepUntil: number;
|
|
4092
|
+
/** Events held for the reply judge until the turn or quiesce ends, and the judge's own drops at the cap. */
|
|
4093
|
+
judgeHeldEvents: number;
|
|
4094
|
+
droppedJudgeEvents: number;
|
|
1690
4095
|
} {
|
|
4096
|
+
let judgeHeldEvents = 0;
|
|
4097
|
+
for (const held of this.judgeHeld.values()) judgeHeldEvents += held.events.length;
|
|
1691
4098
|
return {
|
|
1692
4099
|
inferring: [...this.inferring],
|
|
1693
4100
|
bufferedEvents: this.inferenceBuffer.length,
|
|
1694
4101
|
droppedBufferedEvents: this.droppedBufferedEvents,
|
|
1695
4102
|
sleepUntil: this.sleepUntil,
|
|
4103
|
+
judgeHeldEvents,
|
|
4104
|
+
droppedJudgeEvents: this.droppedJudgeEvents,
|
|
1696
4105
|
};
|
|
1697
4106
|
}
|
|
1698
4107
|
|
|
@@ -1782,10 +4191,9 @@ export class EventGate {
|
|
|
1782
4191
|
|
|
1783
4192
|
/**
|
|
1784
4193
|
* `agentName` is gone, as a conversation fork's end or an ephemeral run's
|
|
1785
|
-
* removes one: its pending self-wake is cancelled,
|
|
1786
|
-
*
|
|
1787
|
-
*
|
|
1788
|
-
* exists. A sleep another agent set, or one set with no agent, stays.
|
|
4194
|
+
* removes one: its pending self-wake is cancelled, rather than wake a name
|
|
4195
|
+
* that no longer exists. It holds no sleep: neither can set one (the
|
|
4196
|
+
* framework refuses their `sleep`).
|
|
1789
4197
|
*/
|
|
1790
4198
|
agentGone(agentName: string): void {
|
|
1791
4199
|
const selfWake = this.selfWakeTimers.get(agentName);
|
|
@@ -1793,7 +4201,6 @@ export class EventGate {
|
|
|
1793
4201
|
clearTimeout(selfWake);
|
|
1794
4202
|
this.selfWakeTimers.delete(agentName);
|
|
1795
4203
|
}
|
|
1796
|
-
if (this.sleepUntil !== 0 && this.sleepAgent === agentName) this.clearSleep();
|
|
1797
4204
|
}
|
|
1798
4205
|
|
|
1799
4206
|
// =========================================================================
|
|
@@ -1832,18 +4239,25 @@ export class EventGate {
|
|
|
1832
4239
|
const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
|
|
1833
4240
|
// Deliver the notice into the WAKING agent's window — a self-wake
|
|
1834
4241
|
// armed by a non-primary agent (e.g. a subconscious cadence) must not
|
|
1835
|
-
// narrate itself into the primary's context.
|
|
4242
|
+
// narrate itself into the primary's context. Its wake goes with it: an
|
|
4243
|
+
// agent that heard the notice during a turn was awake when its timer
|
|
4244
|
+
// fired, and the host drops the wake once a request that carried the
|
|
4245
|
+
// notice is answered.
|
|
4246
|
+
const placement: WritePlacement = {};
|
|
1836
4247
|
this.addMessageFn('user', [{
|
|
1837
4248
|
type: 'text',
|
|
1838
4249
|
text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
|
|
1839
|
-
}], { source: 'gate:self-wake' }, agentName);
|
|
4250
|
+
}], { source: 'gate:self-wake' }, agentName, placement);
|
|
1840
4251
|
this.requestInferenceFn(
|
|
1841
4252
|
agentName,
|
|
1842
4253
|
`self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`,
|
|
1843
4254
|
'self-wake',
|
|
4255
|
+
undefined,
|
|
4256
|
+
placement,
|
|
1844
4257
|
);
|
|
1845
4258
|
}, ms);
|
|
1846
4259
|
this.selfWakeTimers.set(agentName, timer);
|
|
4260
|
+
this.selfWakeDue.set(agentName, { at: this.now() + ms, source });
|
|
1847
4261
|
this.emitTrace({
|
|
1848
4262
|
type: 'gate:decision',
|
|
1849
4263
|
eventType: 'selfwake:set',
|
|
@@ -1918,7 +4332,6 @@ export class EventGate {
|
|
|
1918
4332
|
return null;
|
|
1919
4333
|
}
|
|
1920
4334
|
|
|
1921
|
-
/** Load the privileged-users file (bare array or { userIds: [...] }). */
|
|
1922
4335
|
/**
|
|
1923
4336
|
* Whether an author id is on the privileged-users list (hot-reloaded).
|
|
1924
4337
|
* Shared by sleep bypass and tune-out wake classification (#77: wakes are
|
|
@@ -1926,45 +4339,74 @@ export class EventGate {
|
|
|
1926
4339
|
*/
|
|
1927
4340
|
isPrivilegedUser(authorId: string | null | undefined): boolean {
|
|
1928
4341
|
if (!authorId) return false;
|
|
1929
|
-
this.
|
|
4342
|
+
this.reloadPrivilegedIfChanged();
|
|
1930
4343
|
return this.privilegedUserIds.has(authorId);
|
|
1931
4344
|
}
|
|
1932
4345
|
|
|
1933
|
-
|
|
4346
|
+
/**
|
|
4347
|
+
* Read the privileged-users file: a list of user ids, or `{"userIds": [...]}`,
|
|
4348
|
+
* each a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero). An absent file
|
|
4349
|
+
* means no one is privileged. A file that can't be read, isn't JSON, or
|
|
4350
|
+
* holds no list leaves the list that last loaded in this process in effect
|
|
4351
|
+
* (none, at startup), with its error in gate_status until the file is read
|
|
4352
|
+
* again. In a list, an entry that isn't a user id is skipped, and named in
|
|
4353
|
+
* the file's error, and the rest load. With `unlessUnchanged`, a file whose
|
|
4354
|
+
* text is the text last read isn't parsed again.
|
|
4355
|
+
*/
|
|
4356
|
+
private loadPrivileged(opts?: { unlessUnchanged?: boolean }): void {
|
|
1934
4357
|
if (!this.privilegedUsersPath) return;
|
|
4358
|
+
const failed = (why: string): string => `Failed to load privileged-users file: ${why}`;
|
|
4359
|
+
let raw: string;
|
|
1935
4360
|
try {
|
|
1936
|
-
|
|
4361
|
+
raw = readFileSync(this.privilegedUsersPath, 'utf8');
|
|
4362
|
+
} catch (err) {
|
|
4363
|
+
// Read again next time: a file can become readable with its text
|
|
4364
|
+
// unchanged, as a chmod makes it.
|
|
4365
|
+
this.privilegedText = undefined;
|
|
4366
|
+
if (isMissing(err)) {
|
|
1937
4367
|
this.privilegedUserIds = new Set();
|
|
1938
|
-
this.
|
|
1939
|
-
|
|
4368
|
+
this.privilegedError = null;
|
|
4369
|
+
} else {
|
|
4370
|
+
this.privilegedError = failed(err instanceof Error ? err.message : String(err));
|
|
1940
4371
|
}
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
4372
|
+
return;
|
|
4373
|
+
}
|
|
4374
|
+
if (opts?.unlessUnchanged && raw === this.privilegedText) return;
|
|
4375
|
+
this.privilegedText = raw;
|
|
4376
|
+
let ids: unknown;
|
|
4377
|
+
try {
|
|
4378
|
+
const parsed: unknown = parseGateJson(raw);
|
|
4379
|
+
ids = Array.isArray(parsed) ? parsed : (parsed as { userIds?: unknown } | null)?.userIds;
|
|
1945
4380
|
} catch (err) {
|
|
1946
|
-
this.
|
|
1947
|
-
|
|
1948
|
-
|
|
4381
|
+
this.privilegedError = failed(err instanceof Error ? err.message : String(err));
|
|
4382
|
+
return;
|
|
4383
|
+
}
|
|
4384
|
+
// A file that parses but holds no list hasn't loaded either: it used to
|
|
4385
|
+
// mean no one is privileged, without a word.
|
|
4386
|
+
if (!Array.isArray(ids)) {
|
|
4387
|
+
this.privilegedError = failed('expected a list of user ids, or {"userIds": [...]}');
|
|
4388
|
+
return;
|
|
4389
|
+
}
|
|
4390
|
+
const loaded = new Set<string>();
|
|
4391
|
+
const skipped: unknown[] = [];
|
|
4392
|
+
for (const id of ids) {
|
|
4393
|
+
if (typeof id === 'string' && id !== '') loaded.add(id);
|
|
4394
|
+
// JSON.parse has already rounded an integer past 2^53, and the rounded
|
|
4395
|
+
// value can be another user's id, so it's skipped rather than loaded.
|
|
4396
|
+
else if (typeof id === 'number' && Number.isSafeInteger(id)) loaded.add(String(id));
|
|
4397
|
+
else skipped.push(id);
|
|
1949
4398
|
}
|
|
4399
|
+
this.privilegedUserIds = loaded;
|
|
4400
|
+
this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size) : null;
|
|
1950
4401
|
}
|
|
1951
4402
|
|
|
1952
|
-
/**
|
|
4403
|
+
/** At most once a second, the privileged-users file read again, and loaded when its text has changed. */
|
|
1953
4404
|
private reloadPrivilegedIfChanged(): void {
|
|
1954
4405
|
if (!this.privilegedUsersPath) return;
|
|
1955
4406
|
const now = this.now();
|
|
1956
|
-
if (now
|
|
4407
|
+
if (checkedRecently(now, this.lastPrivilegedCheck)) return;
|
|
1957
4408
|
this.lastPrivilegedCheck = now;
|
|
1958
|
-
|
|
1959
|
-
if (!existsSync(this.privilegedUsersPath)) {
|
|
1960
|
-
if (this.privilegedUserIds.size > 0) this.privilegedUserIds = new Set();
|
|
1961
|
-
return;
|
|
1962
|
-
}
|
|
1963
|
-
const mtime = statSync(this.privilegedUsersPath).mtimeMs;
|
|
1964
|
-
if (mtime !== this.privilegedMtime) this.loadPrivileged();
|
|
1965
|
-
} catch {
|
|
1966
|
-
/* leave the last-known set in place on a transient stat/read error */
|
|
1967
|
-
}
|
|
4409
|
+
this.loadPrivileged({ unlessUnchanged: true });
|
|
1968
4410
|
}
|
|
1969
4411
|
|
|
1970
4412
|
getToolDefinition(): ToolDefinition {
|
|
@@ -1978,6 +4420,10 @@ export class EventGate {
|
|
|
1978
4420
|
}
|
|
1979
4421
|
|
|
1980
4422
|
async handleToolCall(): Promise<{ success: boolean; data?: GateStatus; error?: string }> {
|
|
4423
|
+
// What's in force now: each file read again if a check is due, as the
|
|
4424
|
+
// next event would.
|
|
4425
|
+
this.reloadIfChanged();
|
|
4426
|
+
this.reloadPrivilegedIfChanged();
|
|
1981
4427
|
const status = this.getStatus();
|
|
1982
4428
|
return { success: true, data: status };
|
|
1983
4429
|
}
|
|
@@ -2010,6 +4456,16 @@ export class EventGate {
|
|
|
2010
4456
|
fireCount: this.passiveSampleFires.get(p.name) ?? 0,
|
|
2011
4457
|
};
|
|
2012
4458
|
}
|
|
4459
|
+
if (typeof p.behavior === 'object' && 'judge' in p.behavior) {
|
|
4460
|
+
const record = this.judgeRecords.get(p.name);
|
|
4461
|
+
result.judgeState = {
|
|
4462
|
+
pendingCount: debounce?.events.length ?? 0,
|
|
4463
|
+
judging: this.judging.has(p.name),
|
|
4464
|
+
heldCount: this.judgeHeld.get(p.name)?.events.length ?? 0,
|
|
4465
|
+
verdicts: { ...(record?.verdicts ?? { reply: 0, wait: 0, error: 0 }) },
|
|
4466
|
+
...(record?.last ? { last: { ...record.last } } : {}),
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
2013
4469
|
return result;
|
|
2014
4470
|
});
|
|
2015
4471
|
|
|
@@ -2036,7 +4492,11 @@ export class EventGate {
|
|
|
2036
4492
|
default: this.config.default ?? 'always',
|
|
2037
4493
|
script: this.gateScript.status(),
|
|
2038
4494
|
policies,
|
|
2039
|
-
errors: [
|
|
4495
|
+
errors: [
|
|
4496
|
+
this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError,
|
|
4497
|
+
this.judgeUnwiredNotice(), this.judgeFailingNotice(),
|
|
4498
|
+
]
|
|
4499
|
+
.filter((e): e is string => e !== null),
|
|
2040
4500
|
shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
|
|
2041
4501
|
totalEvaluations: this.totalEvaluations,
|
|
2042
4502
|
defaultDecisions: {
|
|
@@ -2047,6 +4507,27 @@ export class EventGate {
|
|
|
2047
4507
|
};
|
|
2048
4508
|
}
|
|
2049
4509
|
|
|
4510
|
+
/** gate_status's word on judge rules in force with no judge wired. */
|
|
4511
|
+
private judgeUnwiredNotice(): string | null {
|
|
4512
|
+
if (this.judgeFn) return null;
|
|
4513
|
+
const names = this.config.policies
|
|
4514
|
+
.filter((p) => typeof p.behavior === 'object' && 'judge' in p.behavior)
|
|
4515
|
+
.map((p) => p.name);
|
|
4516
|
+
if (names.length === 0) return null;
|
|
4517
|
+
return `${quotedList(names)} ${names.length === 1 ? 'is a judge rule' : 'are judge rules'}, and this host wires no reply `
|
|
4518
|
+
+ `judge, so ${names.length === 1 ? 'its batches wake' : 'their batches wake'} the agent as a debounce of the same quiet period would`;
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
/** gate_status's word on a judge that keeps failing (JUDGE_FAILING_STREAK in a row). */
|
|
4522
|
+
private judgeFailingNotice(): string | null {
|
|
4523
|
+
if (this.judgeFailStreak < JUDGE_FAILING_STREAK) return null;
|
|
4524
|
+
return `the reply judge has failed its last ${this.judgeFailStreak} requests (the newest: ${this.judgeLastFailure}), `
|
|
4525
|
+
+ (this.judgeOnError === 'wake'
|
|
4526
|
+
? 'so judge rules wake the agent as debounces of the same quiet period would'
|
|
4527
|
+
: "so judge rules' batches don't wake the agent")
|
|
4528
|
+
+ "; the host's judge model or provider may not serve it (GateOptions.judge)";
|
|
4529
|
+
}
|
|
4530
|
+
|
|
2050
4531
|
// =========================================================================
|
|
2051
4532
|
// Cleanup
|
|
2052
4533
|
// =========================================================================
|
|
@@ -2057,10 +4538,19 @@ export class EventGate {
|
|
|
2057
4538
|
clearTimeout(state.timer);
|
|
2058
4539
|
}
|
|
2059
4540
|
this.debounceTimers.clear();
|
|
4541
|
+
// A judge deciding now is told to stop, and its verdict, when it comes,
|
|
4542
|
+
// delivers nothing (finishJudging); held batches go with the buffer.
|
|
4543
|
+
for (const state of this.judging.values()) {
|
|
4544
|
+
if (state.timer) clearTimeout(state.timer);
|
|
4545
|
+
state.abort.abort();
|
|
4546
|
+
}
|
|
4547
|
+
this.judging.clear();
|
|
4548
|
+
this.judgeHeld.clear();
|
|
2060
4549
|
for (const timer of this.selfWakeTimers.values()) {
|
|
2061
4550
|
clearTimeout(timer);
|
|
2062
4551
|
}
|
|
2063
4552
|
this.selfWakeTimers.clear();
|
|
4553
|
+
this.selfWakeDue.clear();
|
|
2064
4554
|
// The sleep stays, with its note and agent, and its timer goes: fired
|
|
2065
4555
|
// after a stop(), its wake reached a framework that had stopped, and the
|
|
2066
4556
|
// timer held the process until the sleep's end. The next start() arms it
|