@jr2/orchestrator 0.5.1 → 0.6.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/docs/agents-block.md +2 -2
- package/docs/concepts/agents-and-turns.md +56 -20
- package/docs/concepts/architecture.md +30 -17
- package/docs/concepts/charts.md +6 -3
- package/docs/concepts/converge.md +3 -0
- package/docs/concepts/effects.md +86 -31
- package/docs/concepts/machines-and-workflows.md +9 -0
- package/docs/concepts/pools-and-sources.md +6 -6
- package/docs/concepts/secrets-and-trust.md +126 -42
- package/docs/concepts/the-menu.md +3 -0
- package/docs/concepts/workspaces.md +129 -51
- package/docs/get-started/an-agent-in-a-sandbox.md +21 -19
- package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
- package/docs/get-started/install.md +4 -0
- package/docs/guides/add-tools-to-the-sandbox.md +17 -4
- package/docs/guides/bind-open-parts.md +5 -4
- package/docs/guides/cut-a-worktree.md +560 -0
- package/docs/guides/fix-a-failed-converge.md +1 -1
- package/docs/guides/let-an-agent-write-a-chart.md +5 -1
- package/docs/guides/look-inside-a-sandbox.md +18 -15
- package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
- package/docs/guides/push-and-sign-a-branch.md +67 -21
- package/docs/guides/run-a-command-or-call-a-url.md +84 -41
- package/docs/guides/run-one-worker-per-item.md +19 -18
- package/docs/guides/set-an-agents-access-and-dials.md +67 -22
- package/docs/guides/size-a-workspace.md +19 -6
- package/docs/guides/use-a-hosted-provider.md +6 -0
- package/docs/guides/watch-a-run.md +47 -24
- package/docs/guides/write-a-workflow.md +1 -1
- package/docs/llms.txt +33 -12
- package/docs/reference/authoring-surface/agent.md +34 -11
- package/docs/reference/authoring-surface/answer.md +19 -3
- package/docs/reference/authoring-surface/arg.md +27 -5
- package/docs/reference/authoring-surface/branchname.md +15 -0
- package/docs/reference/authoring-surface/command.md +36 -21
- package/docs/reference/authoring-surface/fail.md +17 -4
- package/docs/reference/authoring-surface/file.md +36 -16
- package/docs/reference/authoring-surface/http.md +108 -8
- package/docs/reference/authoring-surface/isready.md +14 -0
- package/docs/reference/authoring-surface/jr2setup.md +1 -1
- package/docs/reference/authoring-surface/open.md +36 -5
- package/docs/reference/authoring-surface/paramsof.md +22 -4
- package/docs/reference/authoring-surface/pool.md +4 -4
- package/docs/reference/authoring-surface/push.md +30 -12
- package/docs/reference/authoring-surface/readyof.md +21 -0
- package/docs/reference/authoring-surface/secret.md +25 -4
- package/docs/reference/authoring-surface/source.md +3 -3
- package/docs/reference/authoring-surface/type-agentdefinition.md +2 -2
- package/docs/reference/authoring-surface/type-agentslots.md +4 -1
- package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
- package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
- package/docs/reference/authoring-surface/type-bindactors.md +14 -0
- package/docs/reference/authoring-surface/type-binding.md +5 -6
- package/docs/reference/authoring-surface/type-commandinput.md +4 -3
- package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
- package/docs/reference/authoring-surface/type-commandoutput.md +22 -4
- package/docs/reference/authoring-surface/type-customize.md +4 -2
- package/docs/reference/authoring-surface/type-dispatchoutput.md +8 -1
- package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
- package/docs/reference/authoring-surface/type-filebody.md +4 -8
- package/docs/reference/authoring-surface/type-framepath.md +14 -0
- package/docs/reference/authoring-surface/type-hostinjectedinput.md +2 -4
- package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
- package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
- package/docs/reference/authoring-surface/type-httpoutput.md +7 -1
- package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
- package/docs/reference/authoring-surface/type-namestart.md +77 -0
- package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
- package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
- package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
- package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
- package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
- package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
- package/docs/reference/authoring-surface/type-poolspec.md +2 -2
- package/docs/reference/authoring-surface/type-pushinput.md +22 -3
- package/docs/reference/authoring-surface/type-pushoutput.md +32 -7
- package/docs/reference/authoring-surface/type-reposlot.md +5 -5
- package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
- package/docs/reference/authoring-surface/type-slothandles.md +5 -13
- package/docs/reference/authoring-surface/type-sourcespec.md +10 -10
- package/docs/reference/authoring-surface/type-workspaced.md +29 -8
- package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
- package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
- package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
- package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
- package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
- package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
- package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
- package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
- package/docs/reference/authoring-surface/workspace.md +65 -37
- package/docs/reference/authoring-surface/worktree.md +71 -0
- package/docs/reference/authoring-surface.md +30 -10
- package/docs/reference/cli.md +1 -1
- package/docs/reference/config.md +13 -4
- package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +17 -10
- package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +29 -19
- package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
- package/docs/reference/decisions/0012-workspace-wrapper-machine.md +30 -30
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +35 -17
- package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +8 -2
- package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +6 -4
- package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +21 -9
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +2 -1
- package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +8 -9
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +62 -65
- package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +9 -1
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +13 -12
- package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +4 -1
- package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +18 -19
- package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +8 -8
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +4 -4
- package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
- package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
- package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
- package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +24 -24
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +5 -3
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +110 -67
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +101 -74
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +5 -6
- package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +2 -0
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +181 -153
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +74 -63
- package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +31 -20
- package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
- package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
- package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
- package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
- package/docs/reference/decisions.md +3 -0
- package/docs/reference/glossary.md +51 -12
- package/docs/reference/http-api.md +45 -7
- package/docs/reference/machines.md +0 -1
- package/docs/why.md +5 -4
- package/package.json +3 -3
- package/src/actor.ts +161 -35
- package/src/agent.ts +6 -6
- package/src/ambient.ts +3 -5
- package/src/chart.ts +186 -19
- package/src/charts.ts +9 -2
- package/src/config.ts +16 -6
- package/src/custodian.lua +17 -5
- package/src/custodian.ts +251 -77
- package/src/customize.ts +11 -3
- package/src/effect-checks.ts +50 -3
- package/src/effect.ts +343 -127
- package/src/effector-wire.ts +6 -3
- package/src/fail.ts +17 -4
- package/src/harness-client.ts +74 -31
- package/src/harness-env.ts +33 -0
- package/src/held-secrets.ts +125 -16
- package/src/holds.ts +3 -0
- package/src/http-effect.ts +88 -32
- package/src/http.ts +56 -17
- package/src/index.ts +15 -1
- package/src/internal.ts +1 -0
- package/src/kube-client.ts +6 -3
- package/src/machine-doc.ts +3 -4
- package/src/model-keys.ts +24 -0
- package/src/names.ts +19 -6
- package/src/notices.ts +443 -54
- package/src/open-body.ts +85 -19
- package/src/open.ts +36 -5
- package/src/parts.ts +40 -28
- package/src/party.ts +33 -0
- package/src/pick.ts +41 -7
- package/src/pool.ts +44 -23
- package/src/registration.ts +53 -11
- package/src/repo-identity.ts +7 -3
- package/src/repos.ts +20 -2
- package/src/run-host.ts +106 -20
- package/src/sandbox-effect.ts +217 -66
- package/src/sandbox-kube.ts +63 -16
- package/src/secret-placement.ts +9 -2
- package/src/setup.ts +86 -6
- package/src/tokens.ts +107 -32
- package/src/vocabulary.ts +23 -4
- package/src/wire.ts +10 -10
- package/src/workspace.ts +264 -253
- package/src/worktree.ts +249 -0
- package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
- package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
package/src/notices.ts
CHANGED
|
@@ -1,66 +1,155 @@
|
|
|
1
1
|
// The Orchestrator's notices (ADR-0062): what happened that the next Turn cannot find out for
|
|
2
2
|
// itself — a memory kill in its Workspace, a conversation that is new — kept as plain data in the
|
|
3
3
|
// run's persisted state (ADR-0007), beside the admission and epoch ledgers, and handed to the next
|
|
4
|
-
// admission in scope. The Harness writes every word; this is
|
|
4
|
+
// admission in scope, or to an open Turn at a step boundary. The Harness writes every word; this is
|
|
5
|
+
// only the keeping.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// A conversation notice reaches one conversation, so it leaves the pending set when it is heard. A
|
|
8
|
+
// Workspace notice goes to an AUDIENCE that the ledger fixes when it raises it — "an Agent hears a
|
|
9
|
+
// kill if it was there for it":
|
|
10
|
+
//
|
|
11
|
+
// 1. each conversation admitted in that Workspace before the kill that can still take a Turn: an
|
|
12
|
+
// open Turn's, or a continued conversation's (`members`);
|
|
13
|
+
// 2. when the kill cut specific Turns, each one's Agent's NEXT Turn, fresh or continued (`retry`:
|
|
14
|
+
// one kill can cut several Turns, and each Agent retries its own work);
|
|
15
|
+
// 3. minus each conversation whose own `bash` answer returned the kill's verdict (`told`). A
|
|
16
|
+
// retry in a told conversation — the next Turn of a continued one — already has the verdict
|
|
17
|
+
// in its context: it is the retry, and hears nothing.
|
|
18
|
+
//
|
|
19
|
+
// Each member hears it once (`heardBy`), and it RETIRES when every member and every retry has
|
|
20
|
+
// heard it. A conversation that starts after the kill, other than a retry, does not hear it. A
|
|
21
|
+
// fresh Turn that ends leaves the audience, because it can take no Turn again. "Before the kill"
|
|
22
|
+
// allows for the clocks (`AUDIENCE_SKEW_MS`): a kernel kill's time is the kubelet's, to the second.
|
|
23
|
+
//
|
|
24
|
+
// "Heard" means LEDGERED: a notice is heard by a conversation in the same save that records the
|
|
25
|
+
// admission it rode there, or the Harness's acknowledgment of a step boundary that appended it.
|
|
26
|
+
// Between `take` and that save the notice is RESERVED for the taking conversation, in memory only, so
|
|
27
|
+
// a second read in the same conversation cannot carry it too — and a restart forgets reservations
|
|
28
|
+
// along with everything else in memory. jr2 keeps no "handled" state: whether an Agent acted on a
|
|
29
|
+
// notice is not the ledger's concern.
|
|
11
30
|
|
|
12
31
|
import type { Notice } from "./wire.ts";
|
|
13
32
|
|
|
14
|
-
/** Where an admission may hear notices: its continued conversation's base
|
|
15
|
-
* and its Workspace's Sandbox name. Either may be absent — a fresh Turn has no
|
|
16
|
-
* told about, and a Menu-only Agent works in no Workspace. `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
33
|
+
/** Where an admission, or a step boundary, may hear notices: its continued conversation's base
|
|
34
|
+
* Instance ID (ADR-0057) and its Workspace's Sandbox name. Either may be absent — a fresh Turn has no
|
|
35
|
+
* conversation to be told about, and a Menu-only Agent works in no Workspace. `taker` is the
|
|
36
|
+
* conversation that takes a Workspace notice — the Instance ID the admission rides, which a fresh
|
|
37
|
+
* Turn has too, as a new conversation; absent, `conversation` is the taker, and a scope with neither
|
|
38
|
+
* takes no Workspace notice. `slot` is the taker's Agent (ADR-0076), and `turn` says this admission
|
|
39
|
+
* starts a Turn: together they let a cut Turn's retry hear its kill. `only`, when set, narrows the
|
|
40
|
+
* take to the notices raised by these sources: what a nudge or a wake carries (ADR-0062). `except`
|
|
41
|
+
* leaves out the notices raised by these: what a re-admission of the same Turn was told already;
|
|
19
42
|
* `exceptPrefix` every notice whose source starts so — all guard kills, when that is not known. */
|
|
20
43
|
export type NoticeScope = {
|
|
21
44
|
conversation?: string;
|
|
22
45
|
workspace?: string;
|
|
46
|
+
taker?: string;
|
|
47
|
+
slot?: string;
|
|
48
|
+
turn?: boolean;
|
|
23
49
|
only?: readonly string[];
|
|
24
50
|
except?: readonly string[];
|
|
25
51
|
exceptPrefix?: string;
|
|
26
52
|
};
|
|
27
53
|
|
|
54
|
+
/** A cut Turn's retry (D4 rule 2): the next Turn of `slot` — in any conversation, the cut Turn's
|
|
55
|
+
* own `cut` included when it continues. `retried`: that Turn took it. */
|
|
56
|
+
export type NoticeRetry = { slot: string; cut?: string; retried?: true };
|
|
57
|
+
|
|
58
|
+
/** How far a member's first admission may come after a kill's time and still count as before it:
|
|
59
|
+
* a kernel kill's time is the kubelet's `finishedAt`, which has whole seconds, and a guard kill's is
|
|
60
|
+
* read on a stream; a member's is this process's clock. Two seconds holds the truncation and a
|
|
61
|
+
* second of skew between the clocks. Wider is a notice to a conversation that started just after
|
|
62
|
+
* the kill; narrower is a member that never hears it. */
|
|
63
|
+
export const AUDIENCE_SKEW_MS = 2_000;
|
|
64
|
+
|
|
28
65
|
/** One pending notice: the notice, and the conversation or Workspace it goes to. `source` names
|
|
29
66
|
* the event that raised it, so the same event raised twice — two Turns lost to one kernel kill, a
|
|
30
|
-
* stream read replayed after a restore — is one notice.
|
|
31
|
-
|
|
67
|
+
* stream read replayed after a restore — is one notice. On a Workspace notice: `audience`, the
|
|
68
|
+
* conversations fixed at the raise; `retry`, each cut Turn's next Turn; `heardBy`, the
|
|
69
|
+
* conversations it was delivered to, which do not take it again; `told`, the conversations whose
|
|
70
|
+
* own `bash` answer told them. */
|
|
71
|
+
export type PendingNotice = {
|
|
72
|
+
id: string;
|
|
73
|
+
notice: Notice;
|
|
74
|
+
to: string;
|
|
75
|
+
source?: string;
|
|
76
|
+
audience?: string[];
|
|
77
|
+
retry?: NoticeRetry[];
|
|
78
|
+
heardBy?: string[];
|
|
79
|
+
told?: string[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** One conversation admitted in a Workspace (ADR-0062): who can be in a Workspace notice's audience.
|
|
83
|
+
* `open`: a Turn of it is live. `continued`: it continues across Turns, so it can take another Turn
|
|
84
|
+
* after this one ends. `since`: its first admission there, which a kill must come after. */
|
|
85
|
+
export type NoticeMember = { taker: string; slot: string; continued: boolean; open: boolean; since: string };
|
|
32
86
|
|
|
33
87
|
/** The persisted form (`RunBlob.notices`). `seen` is every source ever raised, so a delivered
|
|
34
|
-
* notice is not raised again by a late or replayed witness of the same event.
|
|
35
|
-
|
|
88
|
+
* notice is not raised again by a late or replayed witness of the same event. `members`: by
|
|
89
|
+
* Workspace, the conversations admitted there that can still take a Turn. */
|
|
90
|
+
export type NoticeLedgerState = {
|
|
91
|
+
pending: PendingNotice[];
|
|
92
|
+
seen: string[];
|
|
93
|
+
/** Each retry ever recorded, by source and slot: a replayed raise records it once. */
|
|
94
|
+
retriesSeen?: string[];
|
|
95
|
+
members?: Record<string, NoticeMember[]>;
|
|
96
|
+
};
|
|
36
97
|
|
|
37
98
|
/** What one admission takes: the notices it carries, and their ids for the ledger write. */
|
|
38
99
|
export type TakenNotices = {
|
|
39
100
|
ids: string[];
|
|
40
101
|
notices: Notice[];
|
|
41
|
-
/** Every notice taken, with the source it was raised with — what `restore`
|
|
42
|
-
* model heard the admission. */
|
|
102
|
+
/** Every notice taken, in the order of `ids`, with the source it was raised with — what `restore`
|
|
103
|
+
* puts back when no model heard the admission. */
|
|
43
104
|
raised?: Array<{ notice: Notice; source?: string }>;
|
|
44
105
|
};
|
|
45
106
|
|
|
107
|
+
/** What a Workspace notice's raise knows of its kill: the cut Turn's retry, the conversations its
|
|
108
|
+
* `bash` answer told, and when it happened — a conversation first admitted after it was not there. */
|
|
109
|
+
export type RaiseOptions = { retry?: NoticeRetry; told?: readonly string[]; at?: string };
|
|
110
|
+
|
|
111
|
+
/** A reservation: a conversation notice's id alone (it has one taker), a Workspace notice's id
|
|
112
|
+
* with the conversation that took it (each conversation takes it once). */
|
|
113
|
+
const reservation = (p: PendingNotice, taker: string | undefined): string =>
|
|
114
|
+
p.notice.scope === "workspace" ? `${p.id}\u0000${taker ?? ""}` : p.id;
|
|
115
|
+
|
|
116
|
+
/** The mark of a retry recorded for a source: a replayed raise records it once. */
|
|
117
|
+
const retryMark = (source: string, slot: string): string => `${source}\u0000retry\u0000${slot}`;
|
|
118
|
+
|
|
119
|
+
const copyRetry = (r: readonly NoticeRetry[] | undefined) =>
|
|
120
|
+
r === undefined ? {} : { retry: r.map((x) => ({ ...x })) };
|
|
121
|
+
|
|
46
122
|
export class NoticeLedger {
|
|
47
123
|
readonly #pending: PendingNotice[];
|
|
48
124
|
readonly #seen: Set<string>;
|
|
125
|
+
readonly #retriesSeen: Set<string>;
|
|
126
|
+
readonly #members: Map<string, NoticeMember[]>;
|
|
49
127
|
readonly #reserved = new Set<string>();
|
|
128
|
+
/** The reservations a cut Turn's retry holds, by the slot it retries: delivering one marks that
|
|
129
|
+
* retry `retried`. */
|
|
130
|
+
readonly #asRetry = new Map<string, string>();
|
|
50
131
|
readonly #newId: () => string;
|
|
51
132
|
|
|
52
133
|
constructor(state: NoticeLedgerState | undefined, newId: () => string) {
|
|
53
|
-
this.#pending =
|
|
134
|
+
this.#pending = (state?.pending ?? []).map((p) => ({
|
|
135
|
+
...p,
|
|
136
|
+
...(p.audience === undefined ? {} : { audience: [...p.audience] }),
|
|
137
|
+
...copyRetry(p.retry),
|
|
138
|
+
...(p.heardBy === undefined ? {} : { heardBy: [...p.heardBy] }),
|
|
139
|
+
...(p.told === undefined ? {} : { told: [...p.told] }),
|
|
140
|
+
}));
|
|
54
141
|
this.#seen = new Set(state?.seen ?? []);
|
|
142
|
+
this.#retriesSeen = new Set(state?.retriesSeen ?? []);
|
|
143
|
+
this.#members = new Map(Object.entries(state?.members ?? {}).map(([ws, ms]) => [ws, ms.map((m) => ({ ...m }))]));
|
|
55
144
|
this.#newId = newId;
|
|
56
145
|
}
|
|
57
146
|
|
|
58
147
|
/** Raise a notice for `to` (a conversation's base id, or a Sandbox name — the notice's own
|
|
59
148
|
* `scope` says which). `older` are the sources of earlier events that this one tells the same as —
|
|
60
149
|
* a burst of guard kills, read at once: they are marked raised and give no notice of their own.
|
|
61
|
-
*
|
|
62
|
-
* only then. */
|
|
63
|
-
raise(notice: Notice, to: string, source?: string, older: readonly string[] = []): boolean {
|
|
150
|
+
* A Workspace notice's audience is fixed here, from `opts`. Answers whether anything changed — a
|
|
151
|
+
* notice pending, or a source marked — so the caller saves only then. */
|
|
152
|
+
raise(notice: Notice, to: string, source?: string, older: readonly string[] = [], opts: RaiseOptions = {}): boolean {
|
|
64
153
|
let changed = false;
|
|
65
154
|
for (const seen of older) {
|
|
66
155
|
if (this.#seen.has(seen)) continue;
|
|
@@ -68,33 +157,73 @@ export class NoticeLedger {
|
|
|
68
157
|
changed = true;
|
|
69
158
|
}
|
|
70
159
|
if (source !== undefined) {
|
|
71
|
-
|
|
160
|
+
// A second witness of one kill: a Turn it cut that the first did not name still retries.
|
|
161
|
+
if (this.#seen.has(source)) return this.#addRetry(notice, to, source, opts.retry) || changed;
|
|
72
162
|
this.#seen.add(source);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
163
|
+
if (opts.retry !== undefined) this.#retriesSeen.add(retryMark(source, opts.retry.slot));
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
const workspace = notice.scope === "workspace";
|
|
167
|
+
const audience = workspace ? this.#audienceOf(to, opts) : undefined;
|
|
168
|
+
if (source === undefined) {
|
|
169
|
+
// A notice with no source is one notice with a pending twin that has none either — while no
|
|
170
|
+
// conversation has heard or reserved that twin: a later event is news to each conversation
|
|
171
|
+
// the first one reached. A notice WITH a source keeps its own entry, so a take by source
|
|
172
|
+
// (`only`, `except`) finds it; equal ones are one notice in the admission (`take`).
|
|
173
|
+
const twin = this.#twin(notice, to);
|
|
174
|
+
if (twin !== undefined) {
|
|
175
|
+
if (audience === undefined) return changed;
|
|
176
|
+
const before = twin.audience?.length ?? 0;
|
|
177
|
+
twin.audience = [...new Set([...(twin.audience ?? []), ...audience])];
|
|
178
|
+
const retried = opts.retry !== undefined && !(twin.retry ?? []).some((r) => r.slot === opts.retry!.slot);
|
|
179
|
+
if (retried) twin.retry = [...(twin.retry ?? []), { ...opts.retry! }];
|
|
180
|
+
return changed || twin.audience.length !== before || retried;
|
|
80
181
|
}
|
|
81
182
|
}
|
|
82
|
-
|
|
183
|
+
// A Workspace notice no one was there for, and that no Turn retries, tells no one.
|
|
184
|
+
if (audience !== undefined && audience.length === 0 && opts.retry === undefined) return changed;
|
|
185
|
+
this.#pending.push({
|
|
186
|
+
id: this.#newId(),
|
|
187
|
+
notice,
|
|
188
|
+
to,
|
|
189
|
+
...(source === undefined ? {} : { source }),
|
|
190
|
+
...(audience === undefined ? {} : { audience }),
|
|
191
|
+
...(opts.retry === undefined ? {} : { retry: [{ ...opts.retry }] }),
|
|
192
|
+
...(workspace && opts.told !== undefined && opts.told.length > 0 ? { told: [...opts.told] } : {}),
|
|
193
|
+
});
|
|
83
194
|
return true;
|
|
84
195
|
}
|
|
85
196
|
|
|
86
|
-
/**
|
|
87
|
-
*
|
|
197
|
+
/** A raise of a seen source that names a cut Turn: its retry joins the notice, or is pending on a
|
|
198
|
+
* notice of its own when the first witness gave none or every member has heard it. Once for each
|
|
199
|
+
* slot and source, so a replayed read adds nothing. */
|
|
200
|
+
#addRetry(notice: Notice, to: string, source: string, retry: NoticeRetry | undefined): boolean {
|
|
201
|
+
if (retry === undefined || notice.scope !== "workspace") return false;
|
|
202
|
+
const mark = retryMark(source, retry.slot);
|
|
203
|
+
if (this.#retriesSeen.has(mark)) return false;
|
|
204
|
+
this.#retriesSeen.add(mark);
|
|
205
|
+
const pending = this.#pending.find((p) => p.source === source && p.to === to);
|
|
206
|
+
if (pending !== undefined) {
|
|
207
|
+
pending.retry = [...(pending.retry ?? []), { ...retry }];
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
this.#pending.push({ id: this.#newId(), notice, to, source, audience: [], retry: [{ ...retry }] });
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Reserve every notice in this scope that this conversation may hear and has neither heard nor
|
|
215
|
+
* reserved, in the order raised. Equal notices — two kills that tell the same thing — are said
|
|
216
|
+
* once, and every id is reserved. */
|
|
88
217
|
take(scope: NoticeScope): TakenNotices {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
218
|
+
const taker = scope.taker ?? scope.conversation;
|
|
219
|
+
this.#retryTold(scope, taker);
|
|
220
|
+
const taken = this.#eligible(scope, taker);
|
|
221
|
+
for (const p of taken) {
|
|
222
|
+
const key = reservation(p, taker);
|
|
223
|
+
this.#reserved.add(key);
|
|
224
|
+
const retry = this.#retryFor(p, scope, taker);
|
|
225
|
+
if (retry !== undefined) this.#asRetry.set(key, retry.slot);
|
|
226
|
+
}
|
|
98
227
|
const said = new Set<string>();
|
|
99
228
|
const notices = taken.flatMap((p) => {
|
|
100
229
|
const key = JSON.stringify(p.notice);
|
|
@@ -109,33 +238,293 @@ export class NoticeLedger {
|
|
|
109
238
|
};
|
|
110
239
|
}
|
|
111
240
|
|
|
241
|
+
/** What {@link take} would reserve in this scope, reserving nothing: a step boundary asks first
|
|
242
|
+
* whether a guard kill is among them, and so whether it must learn who was told. */
|
|
243
|
+
peek(scope: NoticeScope): Array<{ id: string; notice: Notice; source?: string }> {
|
|
244
|
+
return this.#eligible(scope, scope.taker ?? scope.conversation).map((p) => ({
|
|
245
|
+
id: p.id,
|
|
246
|
+
notice: p.notice,
|
|
247
|
+
...(p.source === undefined ? {} : { source: p.source }),
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
|
|
112
251
|
/** Put back a notice an admission carried that no model heard, with the source it was raised
|
|
113
|
-
* with — `seen` already holds it, so a raise would drop it.
|
|
114
|
-
*
|
|
115
|
-
|
|
252
|
+
* with — `seen` already holds it, so a raise would drop it. A Workspace notice is still pending
|
|
253
|
+
* for the other conversations: it is pending again for `taker`, the conversation that did not hear
|
|
254
|
+
* it, which is a member of its audience again. Nothing changes when the notice is pending for that
|
|
255
|
+
* conversation already. Answers whether anything changed. */
|
|
256
|
+
restore(notice: Notice, to: string, source?: string, taker?: string): boolean {
|
|
257
|
+
const pending =
|
|
258
|
+
source === undefined
|
|
259
|
+
? notice.scope === "workspace" && taker !== undefined
|
|
260
|
+
? this.#equal(notice, to, taker)
|
|
261
|
+
: this.#twin(notice, to)
|
|
262
|
+
: this.#pending.find((p) => p.source === source && p.to === to);
|
|
263
|
+
if (pending !== undefined) {
|
|
264
|
+
if (pending.notice.scope !== "workspace" || taker === undefined) return false;
|
|
265
|
+
const heardBy = pending.heardBy ?? [];
|
|
266
|
+
const inAudience = pending.audience === undefined || pending.audience.includes(taker);
|
|
267
|
+
if (!heardBy.includes(taker) && inAudience) return false;
|
|
268
|
+
pending.heardBy = heardBy.filter((c) => c !== taker);
|
|
269
|
+
if (!inAudience) pending.audience = [...(pending.audience ?? []), taker];
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (source !== undefined) this.#seen.add(source);
|
|
273
|
+
if (notice.scope === "workspace") {
|
|
274
|
+
// Retired, or never pending for anyone else: pending again for the one that did not hear it.
|
|
275
|
+
if (taker === undefined) return false;
|
|
276
|
+
this.#pending.push({
|
|
277
|
+
id: this.#newId(),
|
|
278
|
+
notice,
|
|
279
|
+
to,
|
|
280
|
+
...(source === undefined ? {} : { source }),
|
|
281
|
+
audience: [taker],
|
|
282
|
+
});
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
116
285
|
if (source === undefined) return this.raise(notice, to);
|
|
117
|
-
if (this.#pending.some((p) => p.source === source && p.to === to)) return false;
|
|
118
|
-
this.#seen.add(source);
|
|
119
286
|
this.#pending.push({ id: this.#newId(), notice, to, source });
|
|
120
287
|
return true;
|
|
121
288
|
}
|
|
122
289
|
|
|
123
|
-
/** The admission that carried them was ledgered
|
|
124
|
-
|
|
290
|
+
/** The admission that carried them, in `taker`'s conversation, was ledgered — or the Harness
|
|
291
|
+
* acknowledged the step boundary that appended them: they are heard there. A conversation notice
|
|
292
|
+
* leaves the pending set; a Workspace notice stays for the rest of its audience, and `taker` does
|
|
293
|
+
* not hear it again. It retires once every member has heard it. */
|
|
294
|
+
delivered(ids: readonly string[], taker?: string): void {
|
|
125
295
|
for (const id of ids) {
|
|
126
|
-
this.#reserved.delete(id);
|
|
127
296
|
const at = this.#pending.findIndex((p) => p.id === id);
|
|
128
|
-
|
|
297
|
+
const p = this.#pending[at];
|
|
298
|
+
if (p === undefined) {
|
|
299
|
+
this.#reserved.delete(id);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const key = reservation(p, taker);
|
|
303
|
+
this.#reserved.delete(key);
|
|
304
|
+
if (p.notice.scope !== "workspace") {
|
|
305
|
+
this.#pending.splice(at, 1);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const slot = this.#asRetry.get(key);
|
|
309
|
+
if (slot !== undefined) {
|
|
310
|
+
this.#asRetry.delete(key);
|
|
311
|
+
for (const r of p.retry ?? []) if (r.slot === slot) r.retried = true;
|
|
312
|
+
}
|
|
313
|
+
if (taker !== undefined && !(p.heardBy ?? []).includes(taker)) p.heardBy = [...(p.heardBy ?? []), taker];
|
|
314
|
+
this.#retireIfHeard(p);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The Harness acknowledged the step boundary that appended these (ADR-0062): each one that
|
|
319
|
+
* `taker` holds a reservation on is heard there. Answers how many were. */
|
|
320
|
+
acknowledge(ids: readonly string[], taker: string): number {
|
|
321
|
+
const mine = ids.filter((id) => {
|
|
322
|
+
const p = this.#pending.find((q) => q.id === id);
|
|
323
|
+
return p !== undefined && this.#reserved.has(reservation(p, taker));
|
|
324
|
+
});
|
|
325
|
+
this.delivered(mine, taker);
|
|
326
|
+
return mine.length;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The admission that carried them, in `taker`'s conversation, failed — or the Turn ended with a
|
|
330
|
+
* step boundary's notices unacknowledged: they are pending again there, for the next one. */
|
|
331
|
+
release(ids: readonly string[], taker?: string): void {
|
|
332
|
+
for (const id of ids) {
|
|
333
|
+
const p = this.#pending.find((q) => q.id === id);
|
|
334
|
+
const key = p === undefined ? id : reservation(p, taker);
|
|
335
|
+
this.#reserved.delete(key);
|
|
336
|
+
this.#asRetry.delete(key);
|
|
129
337
|
}
|
|
130
338
|
}
|
|
131
339
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
340
|
+
/** A conversation was admitted in `workspace` (ADR-0062): it is there for every kill from now on,
|
|
341
|
+
* while it can take a Turn. Answers whether anything changed. */
|
|
342
|
+
admitted(workspace: string, member: Omit<NoticeMember, "open">): boolean {
|
|
343
|
+
const members = this.#members.get(workspace) ?? [];
|
|
344
|
+
const known = members.find((m) => m.taker === member.taker);
|
|
345
|
+
if (known !== undefined) {
|
|
346
|
+
if (known.open && known.continued === member.continued) return false;
|
|
347
|
+
known.open = true;
|
|
348
|
+
known.continued = member.continued;
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
members.push({ ...member, open: true });
|
|
352
|
+
this.#members.set(workspace, members);
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A Turn of these conversations in `workspace` ended. A continued one can take another Turn and
|
|
357
|
+
* stays a member; a fresh one cannot, so it leaves every audience it has not heard yet — which can
|
|
358
|
+
* retire a notice. Answers whether anything changed. */
|
|
359
|
+
ended(workspace: string, takers: readonly string[]): boolean {
|
|
360
|
+
let changed = false;
|
|
361
|
+
const members = this.#members.get(workspace) ?? [];
|
|
362
|
+
for (const taker of takers) {
|
|
363
|
+
const at = members.findIndex((m) => m.taker === taker);
|
|
364
|
+
const m = members[at];
|
|
365
|
+
if (m === undefined) continue;
|
|
366
|
+
changed = true;
|
|
367
|
+
if (m.continued) {
|
|
368
|
+
m.open = false;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
members.splice(at, 1);
|
|
372
|
+
this.#leave(taker, workspace);
|
|
373
|
+
}
|
|
374
|
+
if (members.length === 0) this.#members.delete(workspace);
|
|
375
|
+
return changed;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** A conversation that can take no Turn again — a continued one jr2 burned (ADR-0057): it leaves
|
|
379
|
+
* every Workspace and every audience. Answers whether anything changed. */
|
|
380
|
+
forget(taker: string): boolean {
|
|
381
|
+
let changed = false;
|
|
382
|
+
for (const [workspace, members] of this.#members) {
|
|
383
|
+
const at = members.findIndex((m) => m.taker === taker);
|
|
384
|
+
if (at < 0) continue;
|
|
385
|
+
members.splice(at, 1);
|
|
386
|
+
if (members.length === 0) this.#members.delete(workspace);
|
|
387
|
+
this.#leave(taker, workspace);
|
|
388
|
+
changed = true;
|
|
389
|
+
}
|
|
390
|
+
return changed;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The conversations whose own `bash` answer returned the verdict of the kill `source` raised
|
|
394
|
+
* (D4 rule 3): that answer told them, so they leave its audience. Answers whether anything
|
|
395
|
+
* changed. */
|
|
396
|
+
told(source: string, takers: readonly string[]): boolean {
|
|
397
|
+
if (takers.length === 0) return false;
|
|
398
|
+
const p = this.#pending.find((q) => q.source === source);
|
|
399
|
+
if (p === undefined || p.audience === undefined) return false;
|
|
400
|
+
const before = p.audience.length;
|
|
401
|
+
const toldBefore = p.told?.length ?? 0;
|
|
402
|
+
p.told = [...new Set([...(p.told ?? []), ...takers])];
|
|
403
|
+
p.audience = p.audience.filter((t) => !takers.includes(t) || (p.heardBy ?? []).includes(t));
|
|
404
|
+
if (p.audience.length === before && p.told.length === toldBefore) return false;
|
|
405
|
+
this.#retireIfHeard(p);
|
|
406
|
+
return true;
|
|
135
407
|
}
|
|
136
408
|
|
|
137
409
|
/** The persisted form — reserved notices included, because reserved is not delivered. */
|
|
138
410
|
state(): NoticeLedgerState {
|
|
139
|
-
return {
|
|
411
|
+
return {
|
|
412
|
+
pending: this.#pending.map((p) => ({
|
|
413
|
+
...p,
|
|
414
|
+
...(p.audience === undefined ? {} : { audience: [...p.audience] }),
|
|
415
|
+
...copyRetry(p.retry),
|
|
416
|
+
...(p.heardBy === undefined ? {} : { heardBy: [...p.heardBy] }),
|
|
417
|
+
...(p.told === undefined ? {} : { told: [...p.told] }),
|
|
418
|
+
})),
|
|
419
|
+
seen: [...this.#seen],
|
|
420
|
+
...(this.#retriesSeen.size === 0 ? {} : { retriesSeen: [...this.#retriesSeen] }),
|
|
421
|
+
...(this.#members.size === 0
|
|
422
|
+
? {}
|
|
423
|
+
: { members: Object.fromEntries([...this.#members].map(([ws, ms]) => [ws, ms.map((m) => ({ ...m }))])) }),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The notices this scope may take for `taker`, unreserved and unheard, in the order raised. */
|
|
428
|
+
#eligible(scope: NoticeScope, taker: string | undefined): PendingNotice[] {
|
|
429
|
+
return this.#pending.filter(
|
|
430
|
+
(p) =>
|
|
431
|
+
!this.#reserved.has(reservation(p, taker)) &&
|
|
432
|
+
(scope.only === undefined || (p.source !== undefined && scope.only.includes(p.source))) &&
|
|
433
|
+
(scope.except === undefined || p.source === undefined || !scope.except.includes(p.source)) &&
|
|
434
|
+
(scope.exceptPrefix === undefined || p.source === undefined || !p.source.startsWith(scope.exceptPrefix)) &&
|
|
435
|
+
(p.notice.scope === "conversation"
|
|
436
|
+
? p.to === scope.conversation
|
|
437
|
+
: taker !== undefined &&
|
|
438
|
+
p.to === scope.workspace &&
|
|
439
|
+
!(p.heardBy ?? []).includes(taker) &&
|
|
440
|
+
(p.audience === undefined || p.audience.includes(taker) || this.#retryFor(p, scope, taker) !== undefined)),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The cut Turn's retry that this take is (D4 rule 2), or undefined: a Turn's admission of the cut
|
|
445
|
+
* Agent's slot, in any conversation that its own `bash` answer did not tell — the cut Turn's own,
|
|
446
|
+
* when it continues, too. */
|
|
447
|
+
#retryFor(p: PendingNotice, scope: NoticeScope, taker: string | undefined): NoticeRetry | undefined {
|
|
448
|
+
if (scope.turn !== true || taker === undefined || (p.told ?? []).includes(taker)) return undefined;
|
|
449
|
+
return (p.retry ?? []).find((r) => r.retried !== true && r.slot === scope.slot);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** A Turn of a retried slot in a conversation that its `bash` answer told: the next Turn of a
|
|
453
|
+
* continued conversation, which has the verdict in its context. It is the retry, and hears
|
|
454
|
+
* nothing more — so the notice can retire. */
|
|
455
|
+
#retryTold(scope: NoticeScope, taker: string | undefined): void {
|
|
456
|
+
if (scope.turn !== true || taker === undefined || scope.workspace === undefined) return;
|
|
457
|
+
for (const p of [...this.#pending]) {
|
|
458
|
+
if (p.to !== scope.workspace || !(p.told ?? []).includes(taker)) continue;
|
|
459
|
+
let changed = false;
|
|
460
|
+
for (const r of p.retry ?? []) {
|
|
461
|
+
if (r.retried === true || r.slot !== scope.slot) continue;
|
|
462
|
+
r.retried = true;
|
|
463
|
+
changed = true;
|
|
464
|
+
}
|
|
465
|
+
if (changed) this.#retireIfHeard(p);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Who of a Workspace's members is there for a kill now: each that can take a Turn, first
|
|
470
|
+
* admitted before the kill, and not told by its own `bash` answer. */
|
|
471
|
+
#audienceOf(workspace: string, opts: RaiseOptions): string[] {
|
|
472
|
+
const at = opts.at === undefined ? undefined : Date.parse(opts.at);
|
|
473
|
+
return (this.#members.get(workspace) ?? [])
|
|
474
|
+
.filter((m) => m.open || m.continued)
|
|
475
|
+
.filter((m) => at === undefined || Number.isNaN(at) || Date.parse(m.since) <= at + AUDIENCE_SKEW_MS)
|
|
476
|
+
.map((m) => m.taker)
|
|
477
|
+
.filter((t) => !(opts.told ?? []).includes(t));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** `taker` can hear no more in `workspace`: it leaves each audience there it has not heard. */
|
|
481
|
+
#leave(taker: string, workspace: string): void {
|
|
482
|
+
for (const p of [...this.#pending]) {
|
|
483
|
+
if (p.notice.scope !== "workspace" || p.to !== workspace || p.audience === undefined) continue;
|
|
484
|
+
if ((p.heardBy ?? []).includes(taker) || !p.audience.includes(taker)) continue;
|
|
485
|
+
p.audience = p.audience.filter((t) => t !== taker);
|
|
486
|
+
this.#retireIfHeard(p);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** A Workspace notice every member has heard, and every retry has, is done. */
|
|
491
|
+
#retireIfHeard(p: PendingNotice): void {
|
|
492
|
+
if (p.notice.scope !== "workspace" || p.audience === undefined) return;
|
|
493
|
+
const heard = p.heardBy ?? [];
|
|
494
|
+
if (!p.audience.every((t) => heard.includes(t))) return;
|
|
495
|
+
if ((p.retry ?? []).some((r) => r.retried !== true)) return;
|
|
496
|
+
const at = this.#pending.indexOf(p);
|
|
497
|
+
if (at >= 0) this.#pending.splice(at, 1);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The pending notice with no source that tells the same as `notice`, to the same place, and
|
|
501
|
+
* that a new event of the same words can fold into: a conversation notice always, a Workspace
|
|
502
|
+
* notice only while no conversation has heard or reserved it. */
|
|
503
|
+
#twin(notice: Notice, to: string): PendingNotice | undefined {
|
|
504
|
+
return this.#sameAs(notice, to).find(
|
|
505
|
+
(p) => p.notice.scope !== "workspace" || ((p.heardBy ?? []).length === 0 && !this.#reservedByAny(p)),
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** The pending Workspace notice with no source that tells the same as `notice` and that `taker`
|
|
510
|
+
* restores: one still pending for it — then nothing changes — or else the last one it heard. */
|
|
511
|
+
#equal(notice: Notice, to: string, taker: string): PendingNotice | undefined {
|
|
512
|
+
const same = this.#sameAs(notice, to);
|
|
513
|
+
return (
|
|
514
|
+
same.find(
|
|
515
|
+
(p) => !(p.heardBy ?? []).includes(taker) && (p.audience === undefined || p.audience.includes(taker)),
|
|
516
|
+
) ?? same.at(-1)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#sameAs(notice: Notice, to: string): PendingNotice[] {
|
|
521
|
+
const same = JSON.stringify(notice);
|
|
522
|
+
return this.#pending.filter((p) => p.source === undefined && p.to === to && JSON.stringify(p.notice) === same);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Whether any conversation holds a reservation on this notice. */
|
|
526
|
+
#reservedByAny(p: PendingNotice): boolean {
|
|
527
|
+
for (const key of this.#reserved) if (key === p.id || key.startsWith(`${p.id}\u0000`)) return true;
|
|
528
|
+
return false;
|
|
140
529
|
}
|
|
141
530
|
}
|