@animalabs/agent-framework 0.29.0 → 0.31.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 +71 -8
- package/dist/src/admission/admit.d.ts +90 -0
- package/dist/src/admission/admit.d.ts.map +1 -0
- package/dist/src/admission/admit.js +43 -0
- package/dist/src/admission/admit.js.map +1 -0
- package/dist/src/agent.d.ts +50 -9
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +125 -23
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server-core.d.ts.map +1 -1
- package/dist/src/api/mcp-server-core.js +10 -3
- package/dist/src/api/mcp-server-core.js.map +1 -1
- package/dist/src/author-presentation.d.ts +50 -0
- package/dist/src/author-presentation.d.ts.map +1 -0
- package/dist/src/author-presentation.js +58 -0
- package/dist/src/author-presentation.js.map +1 -0
- package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/render.js +7 -6
- package/dist/src/code-execution/isolate/render.js.map +1 -1
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +5 -4
- package/dist/src/code-execution/isolate/scripts.js.map +1 -1
- package/dist/src/dendrite/index.d.ts +6 -0
- package/dist/src/dendrite/index.d.ts.map +1 -0
- package/dist/src/dendrite/index.js +3 -0
- package/dist/src/dendrite/index.js.map +1 -0
- package/dist/src/dendrite/presets.d.ts +60 -0
- package/dist/src/dendrite/presets.d.ts.map +1 -0
- package/dist/src/dendrite/presets.js +117 -0
- package/dist/src/dendrite/presets.js.map +1 -0
- package/dist/src/dendrite/registry.d.ts +260 -0
- package/dist/src/dendrite/registry.d.ts.map +1 -0
- package/dist/src/dendrite/registry.js +742 -0
- package/dist/src/dendrite/registry.js.map +1 -0
- package/dist/src/dendrite/types.d.ts +392 -0
- package/dist/src/dendrite/types.d.ts.map +1 -0
- package/dist/src/dendrite/types.js +24 -0
- package/dist/src/dendrite/types.js.map +1 -0
- package/dist/src/framework.d.ts +806 -118
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +4180 -1121
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +36 -21
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +223 -149
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
- package/dist/src/gate/recipe-reconcile.js +6 -5
- package/dist/src/gate/recipe-reconcile.js.map +1 -1
- package/dist/src/host-presentation.d.ts +4 -0
- package/dist/src/host-presentation.d.ts.map +1 -1
- package/dist/src/host-presentation.js +12 -10
- package/dist/src/host-presentation.js.map +1 -1
- package/dist/src/index.d.ts +6 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/inference-bounds.d.ts +37 -0
- package/dist/src/inference-bounds.d.ts.map +1 -0
- package/dist/src/inference-bounds.js +84 -0
- package/dist/src/inference-bounds.js.map +1 -0
- package/dist/src/line-reader.d.ts +7 -1
- package/dist/src/line-reader.d.ts.map +1 -1
- package/dist/src/line-reader.js +17 -6
- package/dist/src/line-reader.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +84 -9
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +136 -58
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/errors.d.ts +1 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -1
- package/dist/src/mcpl/errors.js +3 -0
- package/dist/src/mcpl/errors.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +2 -2
- package/dist/src/mcpl/inbound-source.js +2 -2
- package/dist/src/mcpl/inference-router.d.ts +11 -1
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +65 -9
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +10 -7
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
- package/dist/src/mcpl/modern-connection.js +85 -74
- package/dist/src/mcpl/modern-connection.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts +1 -1
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +45 -3
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +11 -7
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +21 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +1 -2
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +3 -3
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +14 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +26 -22
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +1 -0
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
- package/dist/src/mcpl/tool-signature.js +16 -11
- package/dist/src/mcpl/tool-signature.js.map +1 -1
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
- package/dist/src/mcpl/toolsets-module.js +7 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +44 -21
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +283 -64
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +17 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +32 -4
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +189 -25
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +24 -16
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/sender-name.d.ts +5 -4
- package/dist/src/modules/sender-name.d.ts.map +1 -1
- package/dist/src/modules/sender-name.js +7 -5
- package/dist/src/modules/sender-name.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +33 -12
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +6 -3
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +61 -15
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +9 -1
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +50 -10
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts +11 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +19 -8
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +8 -0
- 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 +7 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +7 -5
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/notice-tags.d.ts +2 -0
- package/dist/src/notice-tags.d.ts.map +1 -1
- package/dist/src/notice-tags.js +2 -0
- package/dist/src/notice-tags.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +1 -9
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +4 -2
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +7 -4
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stored-injections.d.ts +4 -0
- package/dist/src/stored-injections.d.ts.map +1 -1
- package/dist/src/stored-injections.js +6 -0
- package/dist/src/stored-injections.js.map +1 -1
- package/dist/src/supplied-text.d.ts +2 -0
- package/dist/src/supplied-text.d.ts.map +1 -1
- package/dist/src/supplied-text.js +16 -7
- package/dist/src/supplied-text.js.map +1 -1
- package/dist/src/tool-result-guard.js +1 -1
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +368 -16
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +1224 -136
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +115 -7
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/tune-out/tools.js +55 -18
- package/dist/src/tune-out/tools.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -1
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +153 -15
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +42 -3
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +140 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/admission/admit.ts +113 -0
- package/src/agent.ts +138 -31
- package/src/api/mcp-server-core.ts +10 -3
- package/src/author-presentation.ts +81 -0
- package/src/code-execution/isolate/render.ts +7 -6
- package/src/code-execution/isolate/scripts.ts +5 -4
- package/src/dendrite/index.ts +32 -0
- package/src/dendrite/presets.ts +149 -0
- package/src/dendrite/registry.ts +808 -0
- package/src/dendrite/types.ts +397 -0
- package/src/framework.ts +4344 -1052
- package/src/gate/event-gate.ts +251 -150
- package/src/gate/recipe-reconcile.ts +7 -5
- package/src/host-presentation.ts +14 -10
- package/src/index.ts +16 -2
- package/src/inference-bounds.ts +111 -0
- package/src/line-reader.ts +22 -5
- package/src/mcpl/channel-registry.ts +170 -60
- package/src/mcpl/errors.ts +4 -0
- package/src/mcpl/inbound-source.ts +2 -2
- package/src/mcpl/inference-router.ts +67 -10
- package/src/mcpl/modern-connection.ts +83 -68
- package/src/mcpl/protocol-family.ts +37 -4
- package/src/mcpl/push-coalescer.ts +21 -7
- package/src/mcpl/references.ts +3 -4
- package/src/mcpl/server-connection.ts +30 -21
- package/src/mcpl/tool-signature.ts +18 -11
- package/src/mcpl/toolsets-module.ts +7 -0
- package/src/mcpl/transport.ts +261 -60
- package/src/mcpl/types.ts +18 -1
- package/src/module-registry.ts +178 -25
- package/src/modules/discord/discord-js-client.ts +33 -29
- package/src/modules/sender-name.ts +8 -5
- package/src/modules/workspace/disk-agreement.ts +39 -15
- package/src/modules/workspace/index.ts +65 -14
- package/src/modules/workspace/observe.ts +51 -10
- package/src/modules/workspace/reconcile.ts +22 -8
- package/src/modules/workspace/types.ts +5 -0
- package/src/modules/workspace/watcher.ts +8 -6
- package/src/notice-tags.ts +2 -0
- package/src/recovery/discord-awareness-outbox.ts +1 -11
- package/src/silent-heartbeat.ts +7 -4
- package/src/stored-injections.ts +7 -0
- package/src/supplied-text.ts +17 -6
- package/src/tool-result-guard.ts +1 -1
- package/src/tune-out/coordinator.ts +1374 -141
- package/src/tune-out/tools.ts +166 -13
- package/src/types/agent.ts +30 -1
- package/src/types/framework.ts +157 -15
- package/src/types/module.ts +42 -3
- package/src/types/trace.ts +154 -3
package/src/gate/event-gate.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { NOTICE_TAG } from '../notice-tags.js';
|
|
15
|
-
import { cutToFit, suppliedValue } from '../supplied-text.js';
|
|
15
|
+
import { cutToFit, quotedJson, quotedValue, suppliedValue } from '../supplied-text.js';
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
17
17
|
import { basename, dirname, join } from 'node:path';
|
|
18
18
|
import { GateScript } from './gate-script.js';
|
|
@@ -483,10 +483,10 @@ function beforeWriting(err: unknown): boolean {
|
|
|
483
483
|
* "policies" that isn't a list and holds something. `runs` is what the gate
|
|
484
484
|
* runs meanwhile (rulesRun).
|
|
485
485
|
*/
|
|
486
|
-
function noListRefusal(noList: string, runs: string): string {
|
|
486
|
+
function noListRefusal(noList: string, runs: string, path: string): string {
|
|
487
487
|
return `${noList}, so it has no list of rules a change could edit without writing over what it holds, and nothing `
|
|
488
|
-
+ `was saved. Meanwhile the gate runs ${runs}. Until gate.json is an object whose "policies" is a list
|
|
489
|
-
+ 'rules, a change is refused the same way.';
|
|
488
|
+
+ `was saved. Meanwhile the gate runs ${runs}. Until gate.json, at ${path}, is an object whose "policies" is a list `
|
|
489
|
+
+ 'holding its rules, a change is refused the same way.';
|
|
490
490
|
}
|
|
491
491
|
|
|
492
492
|
/**
|
|
@@ -495,15 +495,15 @@ function noListRefusal(noList: string, runs: string): string {
|
|
|
495
495
|
* written over it, so what it holds, often an operator's unfinished edit, stays
|
|
496
496
|
* as it is. `runs` is what the gate runs meanwhile (rulesRun).
|
|
497
497
|
*/
|
|
498
|
-
function unusableRefusal(unusable: { error: string; notJson?: true }, runs: string): string {
|
|
498
|
+
function unusableRefusal(unusable: { error: string; notJson?: true }, runs: string, path: string): string {
|
|
499
499
|
if (!unusable.notJson) {
|
|
500
|
-
return `gate.json couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written
|
|
501
|
-
+ `Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json
|
|
502
|
-
+ 'refused the same way.';
|
|
500
|
+
return `gate.json, at ${path}, couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written `
|
|
501
|
+
+ `over it. Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json a file the gate can read; `
|
|
502
|
+
+ 'until then, a change is refused the same way.';
|
|
503
503
|
}
|
|
504
|
-
return `gate.json isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without
|
|
505
|
-
+ `over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change
|
|
506
|
-
+ 'refused the same way.';
|
|
504
|
+
return `gate.json, at ${path}, isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without `
|
|
505
|
+
+ `writing over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change `
|
|
506
|
+
+ 'is refused the same way.';
|
|
507
507
|
}
|
|
508
508
|
|
|
509
509
|
/**
|
|
@@ -514,13 +514,19 @@ function unusableRefusal(unusable: { error: string; notJson?: true }, runs: stri
|
|
|
514
514
|
* (notUsedClause, describeStart) say which rules keep gate.json out of use
|
|
515
515
|
* and each fix.
|
|
516
516
|
*/
|
|
517
|
-
function notUsedRefusal(runs: string): string {
|
|
518
|
-
return
|
|
517
|
+
function notUsedRefusal(runs: string, path: string): string {
|
|
518
|
+
return `gate.json, at ${path}, isn't used at this start, which runs as one without it does, and a change would put gate.json's rules in `
|
|
519
519
|
+ `force in place of what that runs, so nothing was saved. Meanwhile the gate runs ${runs}. gate_status says what keeps `
|
|
520
520
|
+ 'gate.json out of use and what would put it in use, such as giving it rules under "policies", or fixing the starting '
|
|
521
521
|
+ 'rules, which whoever runs this host can do, for the next start; until then, a change is refused the same way.';
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
function savedOnlyInForce(path: string, remedy: string | undefined): string {
|
|
525
|
+
return remedy === undefined
|
|
526
|
+
? ''
|
|
527
|
+
: ` gate.json, at ${path}, isn't in force, and a change is saved only if it brings it into force. ${remedy}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
524
530
|
/**
|
|
525
531
|
* A value as a message quotes it: its JSON, cut short when long, never inside a
|
|
526
532
|
* surrogate pair, since the message reaches the agent's context.
|
|
@@ -531,7 +537,8 @@ export function shownValue(value: unknown): string {
|
|
|
531
537
|
if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
|
|
532
538
|
let json: string;
|
|
533
539
|
try {
|
|
534
|
-
|
|
540
|
+
const stringified = JSON.stringify(value);
|
|
541
|
+
json = stringified === undefined ? String(value) : quotedJson(stringified);
|
|
535
542
|
} catch {
|
|
536
543
|
json = String(value);
|
|
537
544
|
}
|
|
@@ -548,9 +555,22 @@ function joinedList(items: readonly string[], joiner: 'and' | 'or' = 'and'): str
|
|
|
548
555
|
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} ${joiner} ${items[items.length - 1]}`;
|
|
549
556
|
}
|
|
550
557
|
|
|
558
|
+
const asGateNames = (path: string): string => path;
|
|
559
|
+
|
|
560
|
+
function namedBy(names: Readonly<Record<string, string>> | undefined): (path: string) => string {
|
|
561
|
+
if (!names) return asGateNames;
|
|
562
|
+
return (path) => {
|
|
563
|
+
for (let end = path.length; end > 0; end = path.lastIndexOf('.', end - 1)) {
|
|
564
|
+
const head = path.slice(0, end);
|
|
565
|
+
if (Object.hasOwn(names, head)) return `${names[head]}${path.slice(end)}`;
|
|
566
|
+
}
|
|
567
|
+
return path;
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
551
571
|
/** `"a"`, `"a" and "b"`, `"a", "b" and "c"` (or "or"). */
|
|
552
572
|
function quotedList(names: readonly string[], joiner: 'and' | 'or' = 'and'): string {
|
|
553
|
-
return joinedList(names.map((n) =>
|
|
573
|
+
return joinedList(names.map((n) => quotedValue(n)), joiner);
|
|
554
574
|
}
|
|
555
575
|
|
|
556
576
|
/** 1st, 2nd, 3rd, 4th, 11th, 21st. */
|
|
@@ -611,7 +631,7 @@ function fieldNames(
|
|
|
611
631
|
|
|
612
632
|
/** `"tags" (did you mean "tagsAny", "tagsAll" or "tagsNone"?)` */
|
|
613
633
|
function slipShown(slip: Slip): string {
|
|
614
|
-
return
|
|
634
|
+
return `${quotedValue(slip.key)} (did you mean ${quotedList(slip.meant, 'or')}?)`;
|
|
615
635
|
}
|
|
616
636
|
|
|
617
637
|
/** Names an object has that are fields read at another level, with where that is (`homes`). */
|
|
@@ -651,22 +671,22 @@ function misplacedShown(m: Misplaced): string {
|
|
|
651
671
|
* The levels of a gate.json the gate reads fields at, each as a message names
|
|
652
672
|
* it, `file` naming the file itself.
|
|
653
673
|
*/
|
|
654
|
-
function fieldLevels(file: string): ReadonlyArray<{ name: string; fields: readonly string[] }> {
|
|
674
|
+
function fieldLevels(file: string, named: (path: string) => string = asGateNames): ReadonlyArray<{ name: string; fields: readonly string[] }> {
|
|
655
675
|
return [
|
|
656
676
|
{ name: file, fields: CONFIG_FIELDS },
|
|
657
677
|
{ name: 'a rule', fields: POLICY_FIELDS },
|
|
658
678
|
{ name: '"match"', fields: MATCH_FIELDS },
|
|
659
679
|
{ name: '"match.filter"', fields: FILTER_FIELDS },
|
|
660
680
|
{ name: '"behavior"', fields: BEHAVIOR_KINDS },
|
|
661
|
-
{ name: '
|
|
662
|
-
{ name: '
|
|
663
|
-
{ name: '
|
|
681
|
+
{ name: `"${named('behavior.rate_limit')}"`, fields: RATE_LIMIT_FIELDS },
|
|
682
|
+
{ name: `"${named('behavior.passive_sample')}"`, fields: PASSIVE_SAMPLE_FIELDS },
|
|
683
|
+
{ name: `"${named('behavior.judge')}"`, fields: JUDGE_FIELDS },
|
|
664
684
|
];
|
|
665
685
|
}
|
|
666
686
|
|
|
667
687
|
/** Where a name is a field the gate reads, at a level other than the one whose fields are `here`. */
|
|
668
|
-
function homesOf(file: string, here: readonly string[]): (key: string) => string[] {
|
|
669
|
-
return (key) => fieldLevels(file)
|
|
688
|
+
function homesOf(file: string, here: readonly string[], named?: (path: string) => string): (key: string) => string[] {
|
|
689
|
+
return (key) => fieldLevels(file, named)
|
|
670
690
|
.filter((level) => level.fields !== here && level.fields.includes(key))
|
|
671
691
|
.map((level) => level.name);
|
|
672
692
|
}
|
|
@@ -798,7 +818,7 @@ interface RuleProblem {
|
|
|
798
818
|
|
|
799
819
|
/** A rule as the gate reads it. */
|
|
800
820
|
interface RuleReading {
|
|
801
|
-
/** How messages name it: `
|
|
821
|
+
/** How messages name it: `Rule "x"`, or its place when it has no name. */
|
|
802
822
|
where: string;
|
|
803
823
|
/** The rule as read, unless a problem has no reading. */
|
|
804
824
|
policy?: GatePolicy;
|
|
@@ -833,6 +853,7 @@ interface Reader {
|
|
|
833
853
|
where: string;
|
|
834
854
|
/** How messages name the file the rule is in, for a field of the file's own put in the rule. */
|
|
835
855
|
file: string;
|
|
856
|
+
named: (path: string) => string;
|
|
836
857
|
problems: RuleProblem[];
|
|
837
858
|
notes: string[];
|
|
838
859
|
/** Set where its rate limit's tokens are a number not over 0, so it wakes on none of what it matches. */
|
|
@@ -859,14 +880,15 @@ function readFieldNames(
|
|
|
859
880
|
misplacedReadOf: (m: Misplaced) => string | undefined = () => undefined,
|
|
860
881
|
): void {
|
|
861
882
|
const { slips, unread } = fieldNames(obj, fields);
|
|
862
|
-
const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields));
|
|
863
|
-
|
|
883
|
+
const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields, r.named));
|
|
884
|
+
const shown = level === null ? null : r.named(level);
|
|
885
|
+
noteUnread(shown ? `${r.where}: ${shown}` : r.where, rest, fields, r.notes);
|
|
864
886
|
for (const slip of slips) {
|
|
865
|
-
r.problems.push({ text: `${
|
|
887
|
+
r.problems.push({ text: `${shown ?? 'it'} has ${slipShown(slip)}, which the gate doesn't read`, read: readOf(slip) });
|
|
866
888
|
}
|
|
867
889
|
for (const m of misplaced) {
|
|
868
890
|
r.problems.push({
|
|
869
|
-
text: `${
|
|
891
|
+
text: `${shown ?? 'it'} has ${misplacedShown(m)}, which the gate doesn't read here`,
|
|
870
892
|
read: misplacedReadOf(m) ?? (m.keys.length === 1 ? SLIP_READ : 'so they do nothing'),
|
|
871
893
|
});
|
|
872
894
|
}
|
|
@@ -881,7 +903,7 @@ function droppedEntries(count: number): string {
|
|
|
881
903
|
* A rule, read as written, or past each problem it has. `unnamed` names a rule
|
|
882
904
|
* without a name in messages, and `file` the file it's in.
|
|
883
905
|
*/
|
|
884
|
-
function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReading {
|
|
906
|
+
function readRule(raw: unknown, unnamed: string, file = 'gate.json', named: (path: string) => string = asGateNames): RuleReading {
|
|
885
907
|
if (!isPlainObject(raw)) {
|
|
886
908
|
return {
|
|
887
909
|
where: unnamed,
|
|
@@ -891,7 +913,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReadin
|
|
|
891
913
|
};
|
|
892
914
|
}
|
|
893
915
|
const name = nameOf(raw);
|
|
894
|
-
const r: Reader = { where: name ? `
|
|
916
|
+
const r: Reader = { where: name ? `Rule ${quotedValue(name)}` : unnamed, file, named, problems: [], notes: [] };
|
|
895
917
|
const obj = withoutEmpty(r.where, raw, r.notes, RULE_LEFT_OUT);
|
|
896
918
|
// Field names first, so a slip of one is named before what its absence does.
|
|
897
919
|
readFieldNames(r, null, obj, POLICY_FIELDS, (slip) => {
|
|
@@ -914,7 +936,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReadin
|
|
|
914
936
|
const { behavior, kind } = readBehavior(r, obj.behavior);
|
|
915
937
|
const match = readMatch(r, obj.match);
|
|
916
938
|
const resets = readResets(r, obj.resets);
|
|
917
|
-
const passthrough = readPassthrough(r, obj.passthrough,
|
|
939
|
+
const passthrough = readPassthrough(r, obj.passthrough, kind);
|
|
918
940
|
const reading: RuleReading = {
|
|
919
941
|
where: r.where, problems: r.problems, notes: r.notes, quiet: kind === 'skip' || kind === 'defer' || r.noTokens === true,
|
|
920
942
|
};
|
|
@@ -969,7 +991,7 @@ function readBehavior(r: Reader, value: unknown): { behavior?: GateBehavior | un
|
|
|
969
991
|
}
|
|
970
992
|
const behavior = READ_KIND[kind](r, b[kind]);
|
|
971
993
|
for (const other of kinds.slice(1)) {
|
|
972
|
-
const unread: Reader = { where: r.where, file: r.file, problems: [], notes: [] };
|
|
994
|
+
const unread: Reader = { where: r.where, file: r.file, named: r.named, problems: [], notes: [] };
|
|
973
995
|
READ_KIND[other](unread, b[other]);
|
|
974
996
|
for (const p of unread.problems) r.problems.push({ text: p.text, read: `which goes unread with the rest of "${other}"` });
|
|
975
997
|
}
|
|
@@ -985,18 +1007,18 @@ function readBehavior(r: Reader, value: unknown): { behavior?: GateBehavior | un
|
|
|
985
1007
|
*/
|
|
986
1008
|
function readDebounce(r: Reader, ms: unknown): GateBehavior | undefined {
|
|
987
1009
|
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
988
|
-
r.problems.push({ text:
|
|
1010
|
+
r.problems.push({ text: `${r.named('behavior.debounce')} is ${shownValue(ms)}, where a number of milliseconds is needed, such as 60000` });
|
|
989
1011
|
return undefined;
|
|
990
1012
|
}
|
|
991
1013
|
if (ms < MIN_DEBOUNCE_MS) {
|
|
992
1014
|
r.problems.push({
|
|
993
|
-
text:
|
|
1015
|
+
text: `${r.named('behavior.debounce')} is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
|
|
994
1016
|
});
|
|
995
1017
|
return { debounce: MIN_DEBOUNCE_MS };
|
|
996
1018
|
}
|
|
997
1019
|
if (ms > MAX_DEBOUNCE_MS) {
|
|
998
1020
|
r.problems.push({
|
|
999
|
-
text:
|
|
1021
|
+
text: `${r.named('behavior.debounce')} is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
|
|
1000
1022
|
});
|
|
1001
1023
|
return { debounce: MAX_DEBOUNCE_MS };
|
|
1002
1024
|
}
|
|
@@ -1019,27 +1041,27 @@ function readDebounce(r: Reader, ms: unknown): GateBehavior | undefined {
|
|
|
1019
1041
|
function readRateLimit(r: Reader, value: unknown): GateBehavior | undefined {
|
|
1020
1042
|
if (!isPlainObject(value)) {
|
|
1021
1043
|
r.problems.push({
|
|
1022
|
-
text:
|
|
1044
|
+
text: `${r.named('behavior.rate_limit')} is ${shownValue(value)}, where an object is needed, such as {"tokens": 3, "refillIntervalMs": 60000}`,
|
|
1023
1045
|
});
|
|
1024
1046
|
return undefined;
|
|
1025
1047
|
}
|
|
1026
|
-
const conf = withoutEmpty(`${r.where}: behavior.rate_limit`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
|
|
1048
|
+
const conf = withoutEmpty(`${r.where}: ${r.named('behavior.rate_limit')}`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
|
|
1027
1049
|
readFieldNames(r, 'behavior.rate_limit', conf, RATE_LIMIT_FIELDS);
|
|
1028
1050
|
const tokens = conf.tokens;
|
|
1029
1051
|
const refill = conf.refillIntervalMs;
|
|
1030
1052
|
const tokensUsable = typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0;
|
|
1031
1053
|
if (typeof tokens === 'number' && !(tokens > 0)) r.noTokens = true;
|
|
1032
1054
|
if (!tokensUsable) {
|
|
1033
|
-
r.problems.push({ text:
|
|
1055
|
+
r.problems.push({ text: `${r.named('behavior.rate_limit.tokens')} is ${shownValue(tokens)}, where a positive number is needed` });
|
|
1034
1056
|
}
|
|
1035
1057
|
const refillUsable = typeof refill === 'number' && Number.isFinite(refill);
|
|
1036
1058
|
if (!refillUsable) {
|
|
1037
1059
|
r.problems.push({
|
|
1038
|
-
text:
|
|
1060
|
+
text: `${r.named('behavior.rate_limit.refillIntervalMs')} is ${shownValue(refill)}, where a positive number of milliseconds is needed`,
|
|
1039
1061
|
});
|
|
1040
1062
|
} else if (refill <= 0) {
|
|
1041
1063
|
r.problems.push({
|
|
1042
|
-
text:
|
|
1064
|
+
text: `${r.named('behavior.rate_limit.refillIntervalMs')} is ${refill}, where a positive number of milliseconds is needed`,
|
|
1043
1065
|
read: refill === 0
|
|
1044
1066
|
? 'so its tokens refill at once, and it limits nothing it matches'
|
|
1045
1067
|
: "so its tokens never refill, and once they're spent it wakes the agent on nothing it matches",
|
|
@@ -1059,21 +1081,21 @@ function readRateLimit(r: Reader, value: unknown): GateBehavior | undefined {
|
|
|
1059
1081
|
*/
|
|
1060
1082
|
function readPassiveSample(r: Reader, value: unknown): GateBehavior | undefined {
|
|
1061
1083
|
if (!isPlainObject(value)) {
|
|
1062
|
-
r.problems.push({ text:
|
|
1084
|
+
r.problems.push({ text: `${r.named('behavior.passive_sample')} is ${shownValue(value)}, where an object is needed, such as {"every": 10}` });
|
|
1063
1085
|
return undefined;
|
|
1064
1086
|
}
|
|
1065
|
-
const conf = withoutEmpty(`${r.where}: behavior.passive_sample`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
|
|
1087
|
+
const conf = withoutEmpty(`${r.where}: ${r.named('behavior.passive_sample')}`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
|
|
1066
1088
|
readFieldNames(r, 'behavior.passive_sample', conf, PASSIVE_SAMPLE_FIELDS);
|
|
1067
1089
|
const given = conf.every;
|
|
1068
1090
|
let every: number | undefined;
|
|
1069
1091
|
if (typeof given !== 'number' || !Number.isFinite(given)) {
|
|
1070
|
-
r.problems.push({ text:
|
|
1092
|
+
r.problems.push({ text: `${r.named('behavior.passive_sample.every')} is ${shownValue(given)}, where a whole number of 1 or more is needed` });
|
|
1071
1093
|
} else if (Number.isInteger(given) && given > 0) {
|
|
1072
1094
|
every = given;
|
|
1073
1095
|
} else {
|
|
1074
1096
|
every = given <= 0 ? 1 : Math.ceil(given);
|
|
1075
1097
|
r.problems.push({
|
|
1076
|
-
text:
|
|
1098
|
+
text: `${r.named('behavior.passive_sample.every')} is ${given}, ${given <= 0 ? 'under 1' : 'not a whole number'}`,
|
|
1077
1099
|
read: `so it's read as ${every}`,
|
|
1078
1100
|
});
|
|
1079
1101
|
}
|
|
@@ -1096,11 +1118,11 @@ function readPassiveSample(r: Reader, value: unknown): GateBehavior | undefined
|
|
|
1096
1118
|
function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
|
|
1097
1119
|
if (!isPlainObject(value)) {
|
|
1098
1120
|
r.problems.push({
|
|
1099
|
-
text:
|
|
1121
|
+
text: `${r.named('behavior.judge')} is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
|
|
1100
1122
|
});
|
|
1101
1123
|
return undefined;
|
|
1102
1124
|
}
|
|
1103
|
-
const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
|
|
1125
|
+
const conf = withoutEmpty(`${r.where}: ${r.named('behavior.judge')}`, value, r.notes, {
|
|
1104
1126
|
maxWaitMs: 'a batch is judged only after its quiet period',
|
|
1105
1127
|
instructions: 'the judge decides without the agent\'s word',
|
|
1106
1128
|
});
|
|
@@ -1109,16 +1131,16 @@ function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
|
|
|
1109
1131
|
const ms = conf.debounceMs;
|
|
1110
1132
|
let debounceMs: number | undefined;
|
|
1111
1133
|
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
1112
|
-
r.problems.push({ text:
|
|
1134
|
+
r.problems.push({ text: `${r.named('behavior.judge.debounceMs')} is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
|
|
1113
1135
|
ok = false;
|
|
1114
1136
|
} else if (ms < MIN_DEBOUNCE_MS) {
|
|
1115
1137
|
r.problems.push({
|
|
1116
|
-
text:
|
|
1138
|
+
text: `${r.named('behavior.judge.debounceMs')} is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
|
|
1117
1139
|
});
|
|
1118
1140
|
debounceMs = MIN_DEBOUNCE_MS;
|
|
1119
1141
|
} else if (ms > MAX_DEBOUNCE_MS) {
|
|
1120
1142
|
r.problems.push({
|
|
1121
|
-
text:
|
|
1143
|
+
text: `${r.named('behavior.judge.debounceMs')} is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
|
|
1122
1144
|
});
|
|
1123
1145
|
debounceMs = MAX_DEBOUNCE_MS;
|
|
1124
1146
|
} else {
|
|
@@ -1128,19 +1150,19 @@ function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
|
|
|
1128
1150
|
const wait = conf.maxWaitMs;
|
|
1129
1151
|
if (wait !== undefined) {
|
|
1130
1152
|
if (typeof wait !== 'number' || !Number.isFinite(wait)) {
|
|
1131
|
-
r.problems.push({ text:
|
|
1153
|
+
r.problems.push({ text: `${r.named('behavior.judge.maxWaitMs')} is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
|
|
1132
1154
|
ok = false;
|
|
1133
1155
|
} else {
|
|
1134
1156
|
const floor = debounceMs ?? MIN_DEBOUNCE_MS;
|
|
1135
1157
|
if (wait < floor) {
|
|
1136
1158
|
r.problems.push({
|
|
1137
|
-
text:
|
|
1159
|
+
text: `${r.named('behavior.judge.maxWaitMs')} is ${wait} ms, under the rule's ${floor} ms quiet period`,
|
|
1138
1160
|
read: `so it's read as ${floor} ms`,
|
|
1139
1161
|
});
|
|
1140
1162
|
maxWaitMs = floor;
|
|
1141
1163
|
} else if (wait > MAX_JUDGE_WAIT_MS) {
|
|
1142
1164
|
r.problems.push({
|
|
1143
|
-
text:
|
|
1165
|
+
text: `${r.named('behavior.judge.maxWaitMs')} is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
|
|
1144
1166
|
read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
|
|
1145
1167
|
});
|
|
1146
1168
|
maxWaitMs = MAX_JUDGE_WAIT_MS;
|
|
@@ -1153,11 +1175,11 @@ function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
|
|
|
1153
1175
|
let instructions: string | undefined;
|
|
1154
1176
|
if (said !== undefined) {
|
|
1155
1177
|
if (typeof said !== 'string') {
|
|
1156
|
-
r.problems.push({ text:
|
|
1178
|
+
r.problems.push({ text: `${r.named('behavior.judge.instructions')} is ${shownValue(said)}, where text is needed` });
|
|
1157
1179
|
ok = false;
|
|
1158
1180
|
} else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
|
|
1159
1181
|
r.problems.push({
|
|
1160
|
-
text:
|
|
1182
|
+
text: `${r.named('behavior.judge.instructions')} is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
|
|
1161
1183
|
});
|
|
1162
1184
|
ok = false;
|
|
1163
1185
|
} else {
|
|
@@ -1184,7 +1206,7 @@ function readKeyBy(r: Reader, kind: 'rate_limit' | 'passive_sample', value: unkn
|
|
|
1184
1206
|
if (value === undefined) return undefined;
|
|
1185
1207
|
if (typeof value === 'string') return value;
|
|
1186
1208
|
r.problems.push({
|
|
1187
|
-
text: `behavior.${kind}.keyBy is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
|
|
1209
|
+
text: `${r.named(`behavior.${kind}.keyBy`)} is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
|
|
1188
1210
|
read: `so it does nothing, and every event the rule matches shares one ${kind === 'rate_limit' ? 'bucket' : 'count'}`,
|
|
1189
1211
|
});
|
|
1190
1212
|
return undefined;
|
|
@@ -1299,7 +1321,10 @@ function readMatch(r: Reader, value: unknown): GatePolicyMatch {
|
|
|
1299
1321
|
new RegExp(f.pattern);
|
|
1300
1322
|
match.filter = { type, pattern: f.pattern };
|
|
1301
1323
|
} catch (e) {
|
|
1302
|
-
r.problems.push({
|
|
1324
|
+
r.problems.push({
|
|
1325
|
+
text: `match.filter.pattern isn't a regular expression the gate can read (${quotedValue(errorText(e))}), so fix it, `
|
|
1326
|
+
+ 'or give match.filter.type "text" to look for the pattern as plain text',
|
|
1327
|
+
});
|
|
1303
1328
|
}
|
|
1304
1329
|
}
|
|
1305
1330
|
}
|
|
@@ -1341,18 +1366,17 @@ function readResets(r: Reader, value: unknown): string[] | undefined {
|
|
|
1341
1366
|
* always, defer, skip or a debounce it would be a no-op at best and a
|
|
1342
1367
|
* misleading promise at worst, so it's refused rather than ignored.
|
|
1343
1368
|
*/
|
|
1344
|
-
function readPassthrough(
|
|
1345
|
-
r: Reader, value: unknown, behavior: GateBehavior | undefined, kind: BehaviorKind | undefined,
|
|
1346
|
-
): boolean {
|
|
1369
|
+
function readPassthrough(r: Reader, value: unknown, kind: BehaviorKind | undefined): boolean {
|
|
1347
1370
|
if (value === undefined || value === false) return false;
|
|
1348
1371
|
if (value !== true) {
|
|
1349
1372
|
r.problems.push({ text: `passthrough is ${shownValue(value)}, where true or false is needed` });
|
|
1350
1373
|
return false;
|
|
1351
1374
|
}
|
|
1352
1375
|
if (kind === undefined || kind === 'rate_limit' || kind === 'passive_sample') return true;
|
|
1376
|
+
const given = kind === 'always' || kind === 'defer' || kind === 'skip' ? `behavior "${kind}"` : r.named(`behavior.${kind}`);
|
|
1353
1377
|
r.problems.push({
|
|
1354
|
-
text: `passthrough is only
|
|
1355
|
-
+
|
|
1378
|
+
text: `passthrough is true, and only a ${r.named('behavior.rate_limit')} or ${r.named('behavior.passive_sample')} rule passes on `
|
|
1379
|
+
+ `an event it matches without waking on it; with ${given}, leave passthrough out`,
|
|
1356
1380
|
});
|
|
1357
1381
|
return false;
|
|
1358
1382
|
}
|
|
@@ -1518,7 +1542,7 @@ function readConfig(
|
|
|
1518
1542
|
}
|
|
1519
1543
|
|
|
1520
1544
|
const rules = policies.map((rule, i) => {
|
|
1521
|
-
const reading = readRule(rule, `The ${ordinal(i + 1)}
|
|
1545
|
+
const reading = readRule(rule, `The ${ordinal(i + 1)} rule`, file);
|
|
1522
1546
|
return { raw: rule, reading, outcome: ruleOutcome(reading) };
|
|
1523
1547
|
});
|
|
1524
1548
|
if (defaultBehavior !== 'always') {
|
|
@@ -1604,7 +1628,7 @@ function readLastRecipe(path: string): { recipe: WrittenConfig | null; problem?:
|
|
|
1604
1628
|
if (read.refusedWhole !== undefined) return { recipe: null, problem: "it isn't read as the starting rules are" };
|
|
1605
1629
|
const rules = read.rules.map((rule) => rule.raw);
|
|
1606
1630
|
const twice = duplicateName(rules);
|
|
1607
|
-
if (twice !== undefined) return { recipe: null, problem: `it names the rule
|
|
1631
|
+
if (twice !== undefined) return { recipe: null, problem: `it names the rule ${quotedValue(twice)} twice` };
|
|
1608
1632
|
return { recipe: { rules, default: isPlainObject(raw) ? raw.default : undefined } };
|
|
1609
1633
|
}
|
|
1610
1634
|
|
|
@@ -1614,7 +1638,7 @@ function readLastRecipe(path: string): { recipe: WrittenConfig | null; problem?:
|
|
|
1614
1638
|
* wrote as read agrees with the same rule written more briefly.
|
|
1615
1639
|
*/
|
|
1616
1640
|
function ruleMeaning(rule: unknown): string {
|
|
1617
|
-
const reading = readRule(rule, 'A
|
|
1641
|
+
const reading = readRule(rule, 'A rule');
|
|
1618
1642
|
return reading.policy ? `read ${canonical(reading.policy)}` : `written ${canonical(rule)}`;
|
|
1619
1643
|
}
|
|
1620
1644
|
|
|
@@ -1725,7 +1749,7 @@ function notUsedClause(write: Extract<StartWrite, { kind: 'start-without' }>, st
|
|
|
1725
1749
|
*/
|
|
1726
1750
|
function outOfForce(write: Extract<StartWrite, { kind: 'start-without' }>): { said: string; fix: string; nextStart: boolean } | null {
|
|
1727
1751
|
const nameless = write.nameless ?? 0;
|
|
1728
|
-
const left = [...(write.leftOut ?? []).map((name) =>
|
|
1752
|
+
const left = [...(write.leftOut ?? []).map((name) => quotedValue(name)), ...(nameless === 0 ? [] : [nameless === 1 ? 'one with no name' : `${nameless} with no name`])];
|
|
1729
1753
|
const held = write.heldOut ?? [];
|
|
1730
1754
|
const said: string[] = [];
|
|
1731
1755
|
const fix: string[] = [];
|
|
@@ -1954,14 +1978,14 @@ export function findShadowedPolicies(policies: GatePolicy[], eventTypesFrom?: Ev
|
|
|
1954
1978
|
* three it is (the rule never applies; it misses some of its events; it
|
|
1955
1979
|
* may not be meant for those events at all) and what to do. */
|
|
1956
1980
|
export function formatShadowWarning(w: ShadowWarning): string {
|
|
1957
|
-
const later =
|
|
1958
|
-
const earlier =
|
|
1959
|
-
const move = `move ${later} above ${earlier} (wake_add_rule with the whole rule and insertBefore:
|
|
1981
|
+
const later = quotedValue(w.later);
|
|
1982
|
+
const earlier = quotedValue(w.earlier);
|
|
1983
|
+
const move = `move ${later} above ${earlier} (wake_add_rule with the whole rule and insertBefore: ${quotedValue(w.earlier)})`;
|
|
1960
1984
|
if (w.whole) {
|
|
1961
1985
|
return `${later} never applies: ${earlier} comes first and matches every event ${later} would. `
|
|
1962
1986
|
+ `If ${later} should apply, ${move}, or narrow ${earlier}.`;
|
|
1963
1987
|
}
|
|
1964
|
-
const types = w.eventTypes === 'all' ? 'all' : w.eventTypes.join(', ');
|
|
1988
|
+
const types = w.eventTypes === 'all' ? 'all' : w.eventTypes.map((type) => quotedValue(type).slice(1, -1)).join(', ');
|
|
1965
1989
|
if (w.known) {
|
|
1966
1990
|
return `${earlier} comes first and takes ${later}'s ${types} events, so ${later} applies only to its others. `
|
|
1967
1991
|
+ `If it should apply to those too, ${move}.`;
|
|
@@ -1994,20 +2018,23 @@ function afterChange(name: string, callback: () => void): void {
|
|
|
1994
2018
|
}
|
|
1995
2019
|
|
|
1996
2020
|
/** The privileged-users file's error when some entries of its list aren't user ids. */
|
|
1997
|
-
function skippedPrivilegedIds(skipped: unknown[], loaded: number): string {
|
|
2021
|
+
function skippedPrivilegedIds(skipped: unknown[], loaded: number, path: string): string {
|
|
1998
2022
|
const one = skipped.length === 1;
|
|
1999
2023
|
const shown = skipped.slice(0, 5).map((x) => {
|
|
2000
2024
|
if (typeof x === 'number' && Number.isInteger(x)) {
|
|
2001
2025
|
return `${x} (a number this large can't be read exactly: write the id as a string, in quotes)`;
|
|
2002
2026
|
}
|
|
2003
|
-
const json =
|
|
2027
|
+
const json = quotedValue(x);
|
|
2004
2028
|
return json.length > 40 ? `${safeSlice(json, 0, 39)}…` : json;
|
|
2005
2029
|
});
|
|
2006
2030
|
const more = skipped.length > shown.length ? `, and ${skipped.length - shown.length} more` : '';
|
|
2007
|
-
const rest = loaded === 0
|
|
2008
|
-
|
|
2031
|
+
const rest = loaded === 0
|
|
2032
|
+
? 'no id loaded, so no one is privileged'
|
|
2033
|
+
: `the ${loaded === 1 ? 'one id that did load is' : `${loaded} ids that did load are`} privileged`;
|
|
2034
|
+
return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file, at ${path}, ${one ? "isn't a user id" : "aren't user ids"}, `
|
|
2009
2035
|
+ `so ${one ? 'it was' : 'they were'} skipped: ${shown.join(', ')}${more}. A user id is a non-empty string, or a safe integer `
|
|
2010
|
-
+ `(a whole number within 2^53 - 1 of zero); ${rest}
|
|
2036
|
+
+ `(a whole number within 2^53 - 1 of zero); ${rest}. Once ${one ? 'that entry is a user id' : 'those entries are user ids'}, the gate `
|
|
2037
|
+
+ 'reads the file again at its next check for changes.';
|
|
2011
2038
|
}
|
|
2012
2039
|
|
|
2013
2040
|
// ---------------------------------------------------------------------------
|
|
@@ -2039,6 +2066,7 @@ export class EventGate {
|
|
|
2039
2066
|
/** Whether gate.json's error is one that keeps what it holds out of force
|
|
2040
2067
|
* (configUnreadable), so gate_status says what's in force instead. */
|
|
2041
2068
|
private configUnused = false;
|
|
2069
|
+
private configFix: string | null = null;
|
|
2042
2070
|
private privilegedError: string | null = null;
|
|
2043
2071
|
/** What the start did with the host's starting rules that it couldn't use
|
|
2044
2072
|
* or add (describeStart), kept apart from gate.json's own error, until the
|
|
@@ -2119,8 +2147,8 @@ export class EventGate {
|
|
|
2119
2147
|
private sleepSuppressed = 0;
|
|
2120
2148
|
private sleepTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2121
2149
|
private sleepAgent: string | undefined;
|
|
2122
|
-
// From dispose()
|
|
2123
|
-
//
|
|
2150
|
+
// From dispose() on, no sleep or self-wake timer is armed, though a turn a
|
|
2151
|
+
// failed stop() left running can still ask for one.
|
|
2124
2152
|
private disposed = false;
|
|
2125
2153
|
|
|
2126
2154
|
// Self-wake timers per agent (skip_reply's wake_in_seconds). Unlike sleep,
|
|
@@ -2156,6 +2184,8 @@ export class EventGate {
|
|
|
2156
2184
|
) => unknown;
|
|
2157
2185
|
private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
|
|
2158
2186
|
private getAgentNamesFn: () => string[];
|
|
2187
|
+
/** Delivery-time re-check: is this channel held from the recipients now? */
|
|
2188
|
+
private isHeldFn: (serverId: string | undefined, channelId: string | undefined) => boolean;
|
|
2159
2189
|
private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined;
|
|
2160
2190
|
private channelShownFn?: (channel: { serverId: string; channelId: string }) => string;
|
|
2161
2191
|
private isNewestEntryFn?: (entry: GateEntryRef) => boolean;
|
|
@@ -2180,6 +2210,13 @@ export class EventGate {
|
|
|
2180
2210
|
* message is answered. */
|
|
2181
2211
|
requestInference: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
|
|
2182
2212
|
getAgentNames: () => string[];
|
|
2213
|
+
/**
|
|
2214
|
+
* Whether a channel is held from the recipients (a tune-out epoch in
|
|
2215
|
+
* force). A held event never enters a batch — admission runs before the
|
|
2216
|
+
* gate — but a hold can begin after an event was batched; at delivery
|
|
2217
|
+
* the batch is re-checked and held events are dropped (#232 H3).
|
|
2218
|
+
*/
|
|
2219
|
+
isHeld?: (serverId: string | undefined, channelId: string | undefined) => boolean;
|
|
2183
2220
|
/** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
|
|
2184
2221
|
now?: () => number;
|
|
2185
2222
|
/** Per-event timeout (ms) for the optional gate.js script. Default 50. */
|
|
@@ -2228,6 +2265,7 @@ export class EventGate {
|
|
|
2228
2265
|
this.addMessageFn = opts.addMessage;
|
|
2229
2266
|
this.requestInferenceFn = opts.requestInference;
|
|
2230
2267
|
this.getAgentNamesFn = opts.getAgentNames;
|
|
2268
|
+
this.isHeldFn = opts.isHeld ?? (() => false);
|
|
2231
2269
|
this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
|
|
2232
2270
|
this.channelShownFn = opts.channelShown;
|
|
2233
2271
|
this.isNewestEntryFn = opts.isNewestEntry;
|
|
@@ -2356,7 +2394,7 @@ export class EventGate {
|
|
|
2356
2394
|
}
|
|
2357
2395
|
const twice = duplicateName(starting.rules.map((rule) => rule.raw));
|
|
2358
2396
|
if (twice !== undefined) {
|
|
2359
|
-
this.startMergeNote = `At start, the recipe names the rule
|
|
2397
|
+
this.startMergeNote = `At start, the recipe names the rule ${quotedValue(twice)} twice, so gate.json was seeded with it as given and it `
|
|
2360
2398
|
+ "wasn't recorded as the recipe last applied: a later start merges the recipe by name, once gate.json and the recipe "
|
|
2361
2399
|
+ 'name each rule once.';
|
|
2362
2400
|
} else {
|
|
@@ -2550,7 +2588,13 @@ export class EventGate {
|
|
|
2550
2588
|
try {
|
|
2551
2589
|
raw = readFileSync(this.configPath, 'utf-8');
|
|
2552
2590
|
} catch (err) {
|
|
2553
|
-
if (!isMissing(err))
|
|
2591
|
+
if (!isMissing(err)) {
|
|
2592
|
+
const why = quotedValue(errorText(err));
|
|
2593
|
+
return this.configUnreadable(
|
|
2594
|
+
why, `gate.json, at ${this.configPath}, can't be read: ${why}`,
|
|
2595
|
+
'Whoever runs this host can make it a file the gate can read, and the gate reads it again at its next check for changes.',
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2554
2598
|
// No file is no problem with it: the rules in force stay.
|
|
2555
2599
|
this.configError = null;
|
|
2556
2600
|
this.configUnused = false;
|
|
@@ -2569,12 +2613,25 @@ export class EventGate {
|
|
|
2569
2613
|
try {
|
|
2570
2614
|
parsed = parseGateJson(raw);
|
|
2571
2615
|
} catch (err) {
|
|
2572
|
-
|
|
2616
|
+
const why = quotedValue(errorText(err));
|
|
2617
|
+
return {
|
|
2618
|
+
...this.configUnreadable(
|
|
2619
|
+
why, `gate.json, at ${this.configPath}, isn't JSON: ${why}`,
|
|
2620
|
+
"Once it's JSON, the gate reads it again at its next check for changes.",
|
|
2621
|
+
),
|
|
2622
|
+
notJson: true,
|
|
2623
|
+
};
|
|
2573
2624
|
}
|
|
2574
2625
|
const read = readConfig(parsed, 'gate.json');
|
|
2575
2626
|
// JSON the gate doesn't use is still the operator's file, edited as written.
|
|
2576
2627
|
if ('refused' in read) {
|
|
2577
|
-
|
|
2628
|
+
const problem = read.refused.startsWith('gate.json ')
|
|
2629
|
+
? `gate.json, at ${this.configPath},${read.refused.slice('gate.json'.length)}`
|
|
2630
|
+
: read.refused;
|
|
2631
|
+
return {
|
|
2632
|
+
...this.configUnreadable(read.refused, problem, 'Once gate.json is mended, the gate reads it again at its next check for changes.'),
|
|
2633
|
+
state: 'refused', listed: read.listed, silenced: read.silenced,
|
|
2634
|
+
};
|
|
2578
2635
|
}
|
|
2579
2636
|
// A field the gate doesn't read does nothing, and a rule is read past a
|
|
2580
2637
|
// problem or left out, so the file loads, and each is named.
|
|
@@ -2588,20 +2645,20 @@ export class EventGate {
|
|
|
2588
2645
|
* gate.json's current error, replacing the last one, and traced and logged
|
|
2589
2646
|
* when it's new, so an edit that doesn't load is in the log once.
|
|
2590
2647
|
*/
|
|
2591
|
-
private configUnreadable(
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
console.error(`[gate] ${this.configPath} couldn't be used (${msg}), so what it holds isn't in force`);
|
|
2648
|
+
private configUnreadable(why: string, problem: string, fix: string | null = null): { state: 'unreadable'; error: string } {
|
|
2649
|
+
if (problem !== this.configError) {
|
|
2650
|
+
console.error(`[gate] ${this.configPath} couldn't be used (${why}), so what it holds isn't in force${fix ? `. ${fix}` : ''}`);
|
|
2595
2651
|
this.emitTrace({
|
|
2596
2652
|
type: 'gate:config-error',
|
|
2597
|
-
error:
|
|
2653
|
+
error: why,
|
|
2598
2654
|
configPath: this.configPath,
|
|
2599
2655
|
timestamp: Date.now(),
|
|
2600
2656
|
});
|
|
2601
2657
|
}
|
|
2602
|
-
this.configError =
|
|
2658
|
+
this.configError = problem;
|
|
2659
|
+
this.configFix = fix;
|
|
2603
2660
|
this.configUnused = true;
|
|
2604
|
-
return { state: 'unreadable', error:
|
|
2661
|
+
return { state: 'unreadable', error: why };
|
|
2605
2662
|
}
|
|
2606
2663
|
|
|
2607
2664
|
/**
|
|
@@ -2610,7 +2667,8 @@ export class EventGate {
|
|
|
2610
2667
|
*/
|
|
2611
2668
|
private configErrorShown(): string | null {
|
|
2612
2669
|
if (this.configError === null || !this.configUnused) return this.configError;
|
|
2613
|
-
return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}
|
|
2670
|
+
return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}.`
|
|
2671
|
+
+ (this.configFix ? ` ${this.configFix}` : '');
|
|
2614
2672
|
}
|
|
2615
2673
|
|
|
2616
2674
|
/** What the gate runs, by where its rules came from, for a text that says so: "the host's starting rules". */
|
|
@@ -2655,13 +2713,13 @@ export class EventGate {
|
|
|
2655
2713
|
| { top: Record<string, unknown>; rules: unknown[]; remedy?: string }
|
|
2656
2714
|
| { refused: string } {
|
|
2657
2715
|
const read = this.readConfigFile();
|
|
2658
|
-
if (read.state === 'unreadable') return { refused: unusableRefusal(read, this.rulesRun()) };
|
|
2716
|
+
if (read.state === 'unreadable') return { refused: unusableRefusal(read, this.rulesRun(), this.configPath) };
|
|
2659
2717
|
if (read.state === 'loaded' || read.state === 'refused') {
|
|
2660
|
-
if ('noList' in read.listed) return { refused: noListRefusal(read.listed.noList, this.rulesRun()) };
|
|
2718
|
+
if ('noList' in read.listed) return { refused: noListRefusal(read.listed.noList, this.rulesRun(), this.configPath) };
|
|
2661
2719
|
// Rules not gate.json's in force while it holds the text in force: what a
|
|
2662
2720
|
// start ran in place of one with no rules of its own, until it changes.
|
|
2663
2721
|
if (read.state === 'loaded' && read.text === this.configText && this.configSource !== 'file') {
|
|
2664
|
-
return { refused: notUsedRefusal(this.rulesRun()) };
|
|
2722
|
+
return { refused: notUsedRefusal(this.rulesRun(), this.configPath) };
|
|
2665
2723
|
}
|
|
2666
2724
|
const edit = { top: read.listed.top, rules: [...read.listed.rules] };
|
|
2667
2725
|
if (read.state === 'loaded') return edit;
|
|
@@ -2678,9 +2736,9 @@ export class EventGate {
|
|
|
2678
2736
|
return {
|
|
2679
2737
|
...edit,
|
|
2680
2738
|
remedy: sharing === 1
|
|
2681
|
-
? `${removes ? 'Replacing or removing' : 'Replacing'}
|
|
2739
|
+
? `${removes ? 'Replacing or removing' : 'Replacing'} ${quotedValue(only)} by name would bring gate.json into force.`
|
|
2682
2740
|
: sharing > 1
|
|
2683
|
-
? `gate.json has ${sharing} rules named
|
|
2741
|
+
? `gate.json has ${sharing} rules named ${quotedValue(only)}, and a change by that name replaces the first of them`
|
|
2684
2742
|
+ `${removes ? ' or removes them all' : ''}.`
|
|
2685
2743
|
: 'No change through the gate can bring it into force, so gate.json has to be fixed by an edit of its own; '
|
|
2686
2744
|
+ 'until then, a change is refused the same way.',
|
|
@@ -2854,9 +2912,18 @@ export class EventGate {
|
|
|
2854
2912
|
options?: {
|
|
2855
2913
|
position?: 'append' | 'prepend' | { before: string } | { after: string };
|
|
2856
2914
|
onIgnoredFields?: (notes: string[]) => void;
|
|
2915
|
+
/** The caller's own name for a field a refusal names, keyed by gate.json's name for it: one of
|
|
2916
|
+
* `behavior.debounce`, `behavior.rate_limit`, `behavior.passive_sample` and `behavior.judge`, or
|
|
2917
|
+
* `position.before` and `position.after` for a placement. A key names the fields under it too:
|
|
2918
|
+
* `behavior.rate_limit` gives `behavior.rate_limit.tokens` its name, and a longer key wins.
|
|
2919
|
+
* Other fields keep gate.json's names.
|
|
2920
|
+
*
|
|
2921
|
+
* @internal For `wake_add_rule`'s refusals; not a host API. */
|
|
2922
|
+
fieldNames?: Readonly<Record<string, string>>;
|
|
2857
2923
|
},
|
|
2858
2924
|
): GatePolicy {
|
|
2859
|
-
const
|
|
2925
|
+
const named = namedBy(options?.fieldNames);
|
|
2926
|
+
const reading = readRule(rawPolicy, 'A rule', 'gate.json', named);
|
|
2860
2927
|
if (reading.problems.length > 0 || !reading.policy) throw new Error(refusalOf(reading));
|
|
2861
2928
|
const policy = reading.policy;
|
|
2862
2929
|
const ignored = notesOf(reading);
|
|
@@ -2876,9 +2943,26 @@ export class EventGate {
|
|
|
2876
2943
|
const anchorName = 'before' in position ? position.before : position.after;
|
|
2877
2944
|
const anchorIdx = rules.findIndex((rule) => nameOf(rule) === anchorName);
|
|
2878
2945
|
if (anchorIdx < 0) {
|
|
2946
|
+
const field = named('before' in position ? 'position.before' : 'position.after');
|
|
2947
|
+
const others = [...new Set(rules.map(nameOf).filter((name): name is string => name !== undefined))];
|
|
2948
|
+
if (idx >= 0 && anchorName === policy.name) {
|
|
2949
|
+
throw new Error(
|
|
2950
|
+
`${reading.where} wasn't saved: ${field} is ${quotedValue(anchorName)}, the rule being saved, and a rule can't be `
|
|
2951
|
+
+ 'placed beside itself. '
|
|
2952
|
+
+ (others.length > 0
|
|
2953
|
+
? `The other rules are ${quotedList(others)}. Give ${field} one of those to place the rule beside it, or leave it out`
|
|
2954
|
+
: `There are no other rules, so leave ${field} out`)
|
|
2955
|
+
+ ' to replace the rule where it is.'
|
|
2956
|
+
+ savedOnlyInForce(this.configPath, remedy),
|
|
2957
|
+
);
|
|
2958
|
+
}
|
|
2879
2959
|
throw new Error(
|
|
2880
|
-
|
|
2881
|
-
+
|
|
2960
|
+
`${reading.where} wasn't saved: ${field} is ${quotedValue(anchorName)}, and no rule has that name. `
|
|
2961
|
+
+ (others.length > 0
|
|
2962
|
+
? `The other rules are ${quotedList(others)}. Give ${field} one of those to place the rule beside it, or leave it out`
|
|
2963
|
+
: `There are no other rules, so leave ${field} out`)
|
|
2964
|
+
+ ' to add the rule at the end, or, where a rule already has its name, to replace that rule where it is.'
|
|
2965
|
+
+ savedOnlyInForce(this.configPath, remedy),
|
|
2882
2966
|
);
|
|
2883
2967
|
}
|
|
2884
2968
|
rules.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
|
|
@@ -2953,6 +3037,16 @@ export class EventGate {
|
|
|
2953
3037
|
return current.policies.map((p) => p.name);
|
|
2954
3038
|
}
|
|
2955
3039
|
|
|
3040
|
+
/** @internal For `wake_add_rule`'s and `wake_remove_rule`'s refusals; not a host API. */
|
|
3041
|
+
ruleNamesForChange(): { names: string[]; note: string } | { refused: string } {
|
|
3042
|
+
const edit = this.rulesForChange();
|
|
3043
|
+
if ('refused' in edit) return { refused: edit.refused };
|
|
3044
|
+
return {
|
|
3045
|
+
names: [...new Set(edit.rules.map(nameOf).filter((name): name is string => name !== undefined))],
|
|
3046
|
+
note: savedOnlyInForce(this.configPath, edit.remedy),
|
|
3047
|
+
};
|
|
3048
|
+
}
|
|
3049
|
+
|
|
2956
3050
|
/**
|
|
2957
3051
|
* Persist gate.json and apply it in memory together: `top`, its own fields,
|
|
2958
3052
|
* with `rules` as its rule list. Only what loads is written: a change to a
|
|
@@ -2979,15 +3073,16 @@ export class EventGate {
|
|
|
2979
3073
|
const read = readConfig(JSON.parse(text), 'gate.json');
|
|
2980
3074
|
if ('refused' in read) {
|
|
2981
3075
|
throw new Error(
|
|
2982
|
-
`Nothing was saved: with this change, gate.json wouldn't be used. ${sentence(read.refused)}
|
|
3076
|
+
`Nothing was saved: with this change, gate.json, at ${this.configPath}, wouldn't be used. ${sentence(read.refused)}`
|
|
3077
|
+
+ (remedy ? ` ${remedy}` : ''),
|
|
2983
3078
|
);
|
|
2984
3079
|
}
|
|
2985
3080
|
// One the gate doesn't use, and that a change can mend, holds back what a
|
|
2986
3081
|
// rule it leaves out would wake on, so its default holds events back.
|
|
2987
3082
|
if (remedy !== undefined && read.config.policies.length === 0) {
|
|
2988
3083
|
throw new Error(
|
|
2989
|
-
|
|
2990
|
-
+ `event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`,
|
|
3084
|
+
`Nothing was saved: with this change, gate.json, at ${this.configPath}, would have no rules the gate reads, and its default `
|
|
3085
|
+
+ `would hold back every event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`,
|
|
2991
3086
|
);
|
|
2992
3087
|
}
|
|
2993
3088
|
try {
|
|
@@ -3669,7 +3764,7 @@ export class EventGate {
|
|
|
3669
3764
|
} catch (err) {
|
|
3670
3765
|
const one = events.length === 1;
|
|
3671
3766
|
console.error(
|
|
3672
|
-
`[gate]
|
|
3767
|
+
`[gate] Rule ${quotedValue(policyName)}: its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
|
|
3673
3768
|
+ `(${errorText(err)}), so the batch is dropped`,
|
|
3674
3769
|
);
|
|
3675
3770
|
return;
|
|
@@ -3687,7 +3782,23 @@ export class EventGate {
|
|
|
3687
3782
|
// Event delivery
|
|
3688
3783
|
// =========================================================================
|
|
3689
3784
|
|
|
3690
|
-
private deliverEvents(
|
|
3785
|
+
private deliverEvents(batched: PendingEvent[]): void {
|
|
3786
|
+
// Delivery-time re-check: a channel tuned out after its events were
|
|
3787
|
+
// batched is held now, and "already shown above at arrival" would be
|
|
3788
|
+
// untrue of them. They are dropped; the hold's own reader has them.
|
|
3789
|
+
const heldAt = (e: PendingEvent): string | undefined => e.routeChannelId ?? e.channelId;
|
|
3790
|
+
const events = batched.filter((e) => {
|
|
3791
|
+
const channel = heldAt(e);
|
|
3792
|
+
return !(channel && this.isHeldFn(e.serverId, channel));
|
|
3793
|
+
});
|
|
3794
|
+
if (events.length < batched.length) {
|
|
3795
|
+
this.emitTrace({
|
|
3796
|
+
type: 'gate:held-dropped',
|
|
3797
|
+
dropped: batched.length - events.length,
|
|
3798
|
+
channels: [...new Set(batched.filter((e) => !events.includes(e)).map(heldAt))],
|
|
3799
|
+
timestamp: this.now(),
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3691
3802
|
if (events.length === 0) return;
|
|
3692
3803
|
|
|
3693
3804
|
const policyNames = [...new Set(events.map(e => e.policyName))];
|
|
@@ -3851,7 +3962,7 @@ export class EventGate {
|
|
|
3851
3962
|
).finally(() => {
|
|
3852
3963
|
if (state.timer) clearTimeout(state.timer);
|
|
3853
3964
|
}).catch((err: unknown) => {
|
|
3854
|
-
console.error(`[gate]
|
|
3965
|
+
console.error(`[gate] Rule ${quotedValue(policyName)}: the judge's verdict couldn't be applied (${errorText(err)})`);
|
|
3855
3966
|
});
|
|
3856
3967
|
}
|
|
3857
3968
|
|
|
@@ -3922,8 +4033,8 @@ export class EventGate {
|
|
|
3922
4033
|
const n = state.events.length;
|
|
3923
4034
|
const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
|
|
3924
4035
|
wakes.push(state.judge
|
|
3925
|
-
? { at: state.dueAt, certain: false, what: `rule
|
|
3926
|
-
: { at: state.dueAt, certain: true, what: `rule
|
|
4036
|
+
? { at: state.dueAt, certain: false, what: `rule ${quotedValue(name)}'s batch of ${batch} goes to the judge` }
|
|
4037
|
+
: { at: state.dueAt, certain: true, what: `rule ${quotedValue(name)}'s debounced batch of ${batch}` });
|
|
3927
4038
|
}
|
|
3928
4039
|
return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
|
|
3929
4040
|
}
|
|
@@ -3973,7 +4084,7 @@ export class EventGate {
|
|
|
3973
4084
|
if (failed && (err as { providerAdmission?: unknown } | undefined)?.providerAdmission === 'deferred') {
|
|
3974
4085
|
const wakes = this.judgeOnError === 'wake';
|
|
3975
4086
|
console.error(
|
|
3976
|
-
`[gate]
|
|
4087
|
+
`[gate] Rule ${quotedValue(policyName)}: the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
|
|
3977
4088
|
+ `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
|
|
3978
4089
|
);
|
|
3979
4090
|
if (this.disposed) return;
|
|
@@ -4005,7 +4116,7 @@ export class EventGate {
|
|
|
4005
4116
|
const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
|
|
4006
4117
|
if (failed) {
|
|
4007
4118
|
console.error(
|
|
4008
|
-
`[gate]
|
|
4119
|
+
`[gate] Rule ${quotedValue(policyName)}: the reply judge failed (${reason}), so its batch of ${state.events.length} `
|
|
4009
4120
|
+ `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
|
|
4010
4121
|
);
|
|
4011
4122
|
}
|
|
@@ -4099,11 +4210,10 @@ export class EventGate {
|
|
|
4099
4210
|
|
|
4100
4211
|
/** A turn that continues another (a budget restart, a tool-result-guard
|
|
4101
4212
|
* retry) holds the entry its predecessor kept for it, and takes it again
|
|
4102
|
-
* where that entry ended in between
|
|
4103
|
-
*
|
|
4104
|
-
*
|
|
4105
|
-
*
|
|
4106
|
-
* still running. */
|
|
4213
|
+
* where that entry ended in between, as at a provider hold that kept the
|
|
4214
|
+
* turn from starting, whose end queues it again. Unlike
|
|
4215
|
+
* onInferenceStarted it leaves a pending self-wake alone: the logical
|
|
4216
|
+
* turn that armed it is the one still running. */
|
|
4107
4217
|
onInferenceResumed(agentName: string): void {
|
|
4108
4218
|
this.inferring.add(agentName);
|
|
4109
4219
|
if (this.changesJudgedContext(agentName)) this.stopJudging();
|
|
@@ -4228,7 +4338,7 @@ export class EventGate {
|
|
|
4228
4338
|
// Arm the self-wake: when the window elapses, clear sleep and request an
|
|
4229
4339
|
// inference so the agent resumes on its own (not dependent on a heartbeat
|
|
4230
4340
|
// tick or an incoming message). Cancelled by clearSleep()/`wake`, and by
|
|
4231
|
-
// dispose(), after which
|
|
4341
|
+
// dispose(), after which nothing arms it.
|
|
4232
4342
|
this.armSleepWake();
|
|
4233
4343
|
this.reloadPrivilegedIfChanged();
|
|
4234
4344
|
this.emitTrace({
|
|
@@ -4242,24 +4352,11 @@ export class EventGate {
|
|
|
4242
4352
|
return { until: this.sleepUntil };
|
|
4243
4353
|
}
|
|
4244
4354
|
|
|
4245
|
-
/**
|
|
4246
|
-
* Arm timers again for a lifecycle that starts after a stop(), and the
|
|
4247
|
-
* sleep's wake first: dispose() clears its timer and keeps the sleep. A
|
|
4248
|
-
* sleep still ahead wakes the agent at its end; one that ended while
|
|
4249
|
-
* stopped wakes it now, since that wake is what the agent asked for. With
|
|
4250
|
-
* no sleep, wakeFromSleep does nothing.
|
|
4251
|
-
*/
|
|
4252
|
-
resumeSleep(): void {
|
|
4253
|
-
this.disposed = false;
|
|
4254
|
-
if (this.sleepUntil > this.now()) this.armSleepWake();
|
|
4255
|
-
else this.wakeFromSleep('sleep-expired');
|
|
4256
|
-
}
|
|
4257
|
-
|
|
4258
4355
|
/**
|
|
4259
4356
|
* Arm the sleep's wake for its end by the gate's own clock. One timer holds
|
|
4260
4357
|
* at most MAX_TIMER_MS, about 24.8 days, so a longer sleep waits in steps,
|
|
4261
4358
|
* and wakeFromSleep reads the clock as each one fires. Nothing is armed
|
|
4262
|
-
*
|
|
4359
|
+
* once the gate is disposed.
|
|
4263
4360
|
*/
|
|
4264
4361
|
private armSleepWake(): void {
|
|
4265
4362
|
if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; }
|
|
@@ -4269,10 +4366,9 @@ export class EventGate {
|
|
|
4269
4366
|
}
|
|
4270
4367
|
|
|
4271
4368
|
/**
|
|
4272
|
-
* `agentName` is gone, as
|
|
4273
|
-
*
|
|
4274
|
-
*
|
|
4275
|
-
* framework refuses their `sleep`).
|
|
4369
|
+
* `agentName` is gone, as an agent's end removes one: its pending
|
|
4370
|
+
* self-wake is cancelled, and a sleep it set ends, rather than go on
|
|
4371
|
+
* quieting every agent and then wake a name that no longer exists.
|
|
4276
4372
|
*/
|
|
4277
4373
|
agentGone(agentName: string): void {
|
|
4278
4374
|
const selfWake = this.selfWakeTimers.get(agentName);
|
|
@@ -4280,6 +4376,7 @@ export class EventGate {
|
|
|
4280
4376
|
clearTimeout(selfWake);
|
|
4281
4377
|
this.selfWakeTimers.delete(agentName);
|
|
4282
4378
|
}
|
|
4379
|
+
if (this.sleepAgent === agentName) this.clearSleep();
|
|
4283
4380
|
}
|
|
4284
4381
|
|
|
4285
4382
|
// =========================================================================
|
|
@@ -4368,9 +4465,7 @@ export class EventGate {
|
|
|
4368
4465
|
* fires early (Node's can, by a millisecond), or a clock set back.
|
|
4369
4466
|
*/
|
|
4370
4467
|
private wakeFromSleep(reason: string): void {
|
|
4371
|
-
// The timer that called it has fired
|
|
4372
|
-
// already over, one may still be armed, and it goes, so no stale step of
|
|
4373
|
-
// it can fire later.
|
|
4468
|
+
// The timer that called it has fired.
|
|
4374
4469
|
if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; }
|
|
4375
4470
|
if (this.sleepUntil === 0) return;
|
|
4376
4471
|
if (this.sleepUntil > this.now()) {
|
|
@@ -4427,14 +4522,20 @@ export class EventGate {
|
|
|
4427
4522
|
* each a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero). An absent file
|
|
4428
4523
|
* means no one is privileged. A file that can't be read, isn't JSON, or
|
|
4429
4524
|
* holds no list leaves the list that last loaded in this process in effect
|
|
4430
|
-
* (none, at startup), with its error in gate_status until
|
|
4431
|
-
*
|
|
4432
|
-
* the file's error, and the rest load. With `unlessUnchanged`, a file whose
|
|
4525
|
+
* (none, at startup), with its error in gate_status until a later read
|
|
4526
|
+
* loads it, finds it gone, or fails another way. In a list, an entry that isn't a user id is skipped, and named
|
|
4527
|
+
* in the file's error, and the rest load. With `unlessUnchanged`, a file whose
|
|
4433
4528
|
* text is the text last read isn't parsed again.
|
|
4434
4529
|
*/
|
|
4435
4530
|
private loadPrivileged(opts?: { unlessUnchanged?: boolean }): void {
|
|
4436
4531
|
if (!this.privilegedUsersPath) return;
|
|
4437
|
-
const
|
|
4532
|
+
const file = `The privileged-users file, at ${this.privilegedUsersPath},`;
|
|
4533
|
+
const held = this.privilegedUserIds.size;
|
|
4534
|
+
const meanwhile = held === 0
|
|
4535
|
+
? 'no one is privileged'
|
|
4536
|
+
: `${held === 1 ? 'the one id of the list that last loaded stays' : `the ${held} ids of the list that last loaded stay`} privileged`;
|
|
4537
|
+
const revoke = 'Replacing it with a file holding [], or removing it, makes no one privileged at the gate\'s next check for changes.';
|
|
4538
|
+
const failed = (problem: string, fix: string): string => `${file} ${problem}, so ${meanwhile}. ${fix} ${revoke}`;
|
|
4438
4539
|
let raw: string;
|
|
4439
4540
|
try {
|
|
4440
4541
|
raw = readFileSync(this.privilegedUsersPath, 'utf8');
|
|
@@ -4446,24 +4547,28 @@ export class EventGate {
|
|
|
4446
4547
|
this.privilegedUserIds = new Set();
|
|
4447
4548
|
this.privilegedError = null;
|
|
4448
4549
|
} else {
|
|
4449
|
-
this.privilegedError = failed(
|
|
4550
|
+
this.privilegedError = failed(
|
|
4551
|
+
`can't be read: ${quotedValue(errorText(err))}`,
|
|
4552
|
+
'Whoever runs this host can make it a file the gate can read, and the gate reads it again at its next check for changes.',
|
|
4553
|
+
);
|
|
4450
4554
|
}
|
|
4451
4555
|
return;
|
|
4452
4556
|
}
|
|
4453
4557
|
if (opts?.unlessUnchanged && raw === this.privilegedText) return;
|
|
4454
4558
|
this.privilegedText = raw;
|
|
4559
|
+
const fixed = 'Once it holds a list of user ids, the gate loads it at its next check for changes.';
|
|
4455
4560
|
let ids: unknown;
|
|
4456
4561
|
try {
|
|
4457
4562
|
const parsed: unknown = parseGateJson(raw);
|
|
4458
4563
|
ids = Array.isArray(parsed) ? parsed : (parsed as { userIds?: unknown } | null)?.userIds;
|
|
4459
4564
|
} catch (err) {
|
|
4460
|
-
this.privilegedError = failed(
|
|
4565
|
+
this.privilegedError = failed(`isn't JSON: ${quotedValue(errorText(err))}`, fixed);
|
|
4461
4566
|
return;
|
|
4462
4567
|
}
|
|
4463
4568
|
// A file that parses but holds no list hasn't loaded either: it used to
|
|
4464
4569
|
// mean no one is privileged, without a word.
|
|
4465
4570
|
if (!Array.isArray(ids)) {
|
|
4466
|
-
this.privilegedError = failed('
|
|
4571
|
+
this.privilegedError = failed('holds no list of user ids, where one is needed, such as ["123"] or {"userIds": ["123"]}', fixed);
|
|
4467
4572
|
return;
|
|
4468
4573
|
}
|
|
4469
4574
|
const loaded = new Set<string>();
|
|
@@ -4476,7 +4581,7 @@ export class EventGate {
|
|
|
4476
4581
|
else skipped.push(id);
|
|
4477
4582
|
}
|
|
4478
4583
|
this.privilegedUserIds = loaded;
|
|
4479
|
-
this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size) : null;
|
|
4584
|
+
this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size, this.privilegedUsersPath) : null;
|
|
4480
4585
|
}
|
|
4481
4586
|
|
|
4482
4587
|
/** At most once a second, the privileged-users file read again, and loaded when its text has changed. */
|
|
@@ -4634,9 +4739,9 @@ export class EventGate {
|
|
|
4634
4739
|
this.selfWakeDue.clear();
|
|
4635
4740
|
// The sleep stays, with its note and agent, and its timer goes: fired
|
|
4636
4741
|
// after a stop(), its wake reached a framework that had stopped, and the
|
|
4637
|
-
// timer held the process until the sleep's end.
|
|
4638
|
-
//
|
|
4639
|
-
//
|
|
4742
|
+
// timer held the process until the sleep's end. Nothing arms one after
|
|
4743
|
+
// this, nor a self-wake: a turn a failed stop() left running can still
|
|
4744
|
+
// call sleep or skip_reply.
|
|
4640
4745
|
if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; }
|
|
4641
4746
|
this.disposed = true;
|
|
4642
4747
|
this.rateLimitBuckets.clear();
|
|
@@ -4646,13 +4751,9 @@ export class EventGate {
|
|
|
4646
4751
|
this.inferenceBuffer = [];
|
|
4647
4752
|
// stop() disposes the gate once its streams and event loop have ended (a
|
|
4648
4753
|
// turn still compiling then is dropped at its next check, before it sends
|
|
4649
|
-
// anything)
|
|
4650
|
-
//
|
|
4651
|
-
//
|
|
4652
|
-
// agent's next turn ended. A turn that continues after the start() takes
|
|
4653
|
-
// its entry again (onInferenceResumed). A start() made during the stop()
|
|
4654
|
-
// is refused, so no turn of a new lifecycle can have begun before this
|
|
4655
|
-
// runs.
|
|
4754
|
+
// anything). An agent left here by a turn whose exit didn't end it (a
|
|
4755
|
+
// retry stop() cancelled, a successor stop() refused) would make every
|
|
4756
|
+
// debounced batch buffer until that agent's next turn ended.
|
|
4656
4757
|
this.inferring.clear();
|
|
4657
4758
|
}
|
|
4658
4759
|
}
|