opencode-crosstalk 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +460 -2
- package/assets/CursorGuard.png +0 -0
- package/assets/Wren.png +0 -0
- package/assets/avatars/ATTRIBUTION.md +18 -0
- package/assets/avatars/female/01-explorer-eeb4a4.png +0 -0
- package/assets/avatars/female/02-manager-b16a5b.png +0 -0
- package/assets/avatars/female/02-manager-e7a391.png +0 -0
- package/assets/avatars/female/03-coder-92594b.png +0 -0
- package/assets/avatars/female/03-coder-e5a07e.png +0 -0
- package/assets/avatars/female/05-analyst-b16a5b.png +0 -0
- package/assets/avatars/female/05-analyst-eeb4a4.png +0 -0
- package/assets/avatars/female/08-tester-d78774.png +0 -0
- package/assets/avatars/female/08-tester-eeb4a4.png +0 -0
- package/assets/avatars/female/10-builder-92594b.png +0 -0
- package/assets/avatars/female/10-builder-e5a07e.png +0 -0
- package/assets/avatars/female/13-researcher-92594b.png +0 -0
- package/assets/avatars/female/13-researcher-e7a391.png +0 -0
- package/assets/avatars/female/14-writer-623d36.png +0 -0
- package/assets/avatars/female/14-writer-e5a07e.png +0 -0
- package/assets/avatars/female/16-fixer-e7a391.png +0 -0
- package/assets/avatars/female/17-deployer-92594b.png +0 -0
- package/assets/avatars/female/17-deployer-e5a07e.png +0 -0
- package/assets/avatars/female/23-mentor-b16a5b.png +0 -0
- package/assets/avatars/female/23-mentor-e7a391.png +0 -0
- package/assets/avatars/female/25-pioneer-d78774.png +0 -0
- package/assets/avatars/female/26-strategist-b16a5b.png +0 -0
- package/assets/avatars/female/31-gardener-e5a07e.png +0 -0
- package/assets/avatars/female/33-simplifier-b16a5b.png +0 -0
- package/assets/avatars/female/33-simplifier-eeb4a4.png +0 -0
- package/assets/avatars/female/34-optimizer-92594b.png +0 -0
- package/assets/avatars/female/34-optimizer-e7a391.png +0 -0
- package/assets/avatars/female/36-integrator-d78774.png +0 -0
- package/assets/avatars/female/36-integrator-eeb4a4.png +0 -0
- package/assets/avatars/female/37-compiler-b16a5b.png +0 -0
- package/assets/avatars/female/37-compiler-e7a391.png +0 -0
- package/assets/avatars/female/40-formatter-b16a5b.png +0 -0
- package/assets/avatars/female/42-shipper-623d36.png +0 -0
- package/assets/avatars/female/46-steward-623d36.png +0 -0
- package/assets/avatars/female/46-steward-d78774.png +0 -0
- package/assets/avatars/female/47-shepherd-b16a5b.png +0 -0
- package/assets/avatars/female/47-shepherd-eeb4a4.png +0 -0
- package/assets/avatars/female/48-tinkerer-92594b.png +0 -0
- package/assets/avatars/female/50-wrangler-eeb4a4.png +0 -0
- package/assets/avatars/index.html +4 -0
- package/assets/avatars/male/01-explorer-d78774.png +0 -0
- package/assets/avatars/male/06-planner-92594b.png +0 -0
- package/assets/avatars/male/06-planner-e7a391.png +0 -0
- package/assets/avatars/male/07-reviewer-623d36.png +0 -0
- package/assets/avatars/male/07-reviewer-e5a07e.png +0 -0
- package/assets/avatars/male/09-migrator-b16a5b.png +0 -0
- package/assets/avatars/male/09-migrator-e7a391.png +0 -0
- package/assets/avatars/male/11-debugger-623d36.png +0 -0
- package/assets/avatars/male/11-debugger-d78774.png +0 -0
- package/assets/avatars/male/12-designer-b16a5b.png +0 -0
- package/assets/avatars/male/12-designer-eeb4a4.png +0 -0
- package/assets/avatars/male/15-editor-d78774.png +0 -0
- package/assets/avatars/male/15-editor-eeb4a4.png +0 -0
- package/assets/avatars/male/16-fixer-b16a5b.png +0 -0
- package/assets/avatars/male/18-scout-623d36.png +0 -0
- package/assets/avatars/male/18-scout-d78774.png +0 -0
- package/assets/avatars/male/19-architect-b16a5b.png +0 -0
- package/assets/avatars/male/19-architect-eeb4a4.png +0 -0
- package/assets/avatars/male/20-operator-92594b.png +0 -0
- package/assets/avatars/male/20-operator-e7a391.png +0 -0
- package/assets/avatars/male/21-coordinator-623d36.png +0 -0
- package/assets/avatars/male/21-coordinator-e5a07e.png +0 -0
- package/assets/avatars/male/22-auditor-d78774.png +0 -0
- package/assets/avatars/male/22-auditor-eeb4a4.png +0 -0
- package/assets/avatars/male/25-pioneer-623d36.png +0 -0
- package/assets/avatars/male/26-strategist-eeb4a4.png +0 -0
- package/assets/avatars/male/28-librarian-623d36.png +0 -0
- package/assets/avatars/male/28-librarian-e5a07e.png +0 -0
- package/assets/avatars/male/30-tracker-b16a5b.png +0 -0
- package/assets/avatars/male/30-tracker-e7a391.png +0 -0
- package/assets/avatars/male/31-gardener-92594b.png +0 -0
- package/assets/avatars/male/32-refactorer-623d36.png +0 -0
- package/assets/avatars/male/32-refactorer-d78774.png +0 -0
- package/assets/avatars/male/35-profiler-623d36.png +0 -0
- package/assets/avatars/male/35-profiler-e5a07e.png +0 -0
- package/assets/avatars/male/38-parser-92594b.png +0 -0
- package/assets/avatars/male/38-parser-e5a07e.png +0 -0
- package/assets/avatars/male/39-linter-623d36.png +0 -0
- package/assets/avatars/male/39-linter-d78774.png +0 -0
- package/assets/avatars/male/40-formatter-eeb4a4.png +0 -0
- package/assets/avatars/male/41-patcher-92594b.png +0 -0
- package/assets/avatars/male/41-patcher-e7a391.png +0 -0
- package/assets/avatars/male/42-shipper-e5a07e.png +0 -0
- package/assets/avatars/male/43-maintainer-d78774.png +0 -0
- package/assets/avatars/male/43-maintainer-eeb4a4.png +0 -0
- package/assets/avatars/male/44-guardian-b16a5b.png +0 -0
- package/assets/avatars/male/44-guardian-e7a391.png +0 -0
- package/assets/avatars/male/45-sentinel-92594b.png +0 -0
- package/assets/avatars/male/45-sentinel-e5a07e.png +0 -0
- package/assets/avatars/male/48-tinkerer-e7a391.png +0 -0
- package/assets/avatars/male/49-hacker-623d36.png +0 -0
- package/assets/avatars/male/49-hacker-e5a07e.png +0 -0
- package/assets/avatars/male/50-wrangler-d78774.png +0 -0
- package/assets/avatars/manifest.json +1202 -0
- package/index.ts +11 -0
- package/package.json +65 -4
- package/src/config.ts +176 -0
- package/src/core/avatar.ts +92 -0
- package/src/core/claims.ts +302 -0
- package/src/core/clock.ts +31 -0
- package/src/core/directory.ts +48 -0
- package/src/core/format.ts +382 -0
- package/src/core/mailbox.ts +239 -0
- package/src/core/mesh.ts +781 -0
- package/src/core/registry.ts +306 -0
- package/src/deliverer.ts +86 -0
- package/src/events.ts +66 -0
- package/src/index.ts +323 -0
- package/src/rpc.ts +52 -0
- package/src/storage.ts +105 -0
- package/src/tools/args.ts +132 -0
- package/src/tools/claim.ts +87 -0
- package/src/tools/heartbeat.ts +152 -0
- package/src/tools/inbox.ts +76 -0
- package/src/tools/index.ts +62 -0
- package/src/tools/peers.ts +101 -0
- package/src/tools/send.ts +84 -0
- package/src/tools/status.ts +137 -0
- package/src/tools/types.ts +72 -0
- package/src/tools/wait.ts +96 -0
- package/src/tui/avatar.ts +188 -0
- package/src/tui/index.tsx +216 -0
- package/src/types.ts +147 -0
- package/tui.ts +8 -0
package/src/core/mesh.ts
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The mesh: presence, mailboxes, and leases behind one façade.
|
|
3
|
+
*
|
|
4
|
+
* `Mesh` owns no I/O beyond the injected clock and an optional `Deliverer`, so
|
|
5
|
+
* every behaviour here is exercised directly in tests. `src/index.ts` supplies
|
|
6
|
+
* the real deliverer (OpenCode's `session.synthetic`) and the real event
|
|
7
|
+
* stream.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Claim,
|
|
12
|
+
ClaimOutcome,
|
|
13
|
+
CrosstalkMessage,
|
|
14
|
+
Declared,
|
|
15
|
+
Delivery,
|
|
16
|
+
MeshSnapshot,
|
|
17
|
+
MessageKind,
|
|
18
|
+
Peer,
|
|
19
|
+
PeerView,
|
|
20
|
+
Scope,
|
|
21
|
+
} from "../types.ts"
|
|
22
|
+
import { hasTask, portraitFor, type AvatarEntry } from "./avatar.ts"
|
|
23
|
+
import { delay, systemClock, type Clock } from "./clock.ts"
|
|
24
|
+
import { ClaimTable } from "./claims.ts"
|
|
25
|
+
import { Mailbox, type ListOptions, type WaitResult } from "./mailbox.ts"
|
|
26
|
+
import { Registry, type CrosstalkEvent, type ListFilter } from "./registry.ts"
|
|
27
|
+
|
|
28
|
+
export interface MeshOptions {
|
|
29
|
+
scope: Scope
|
|
30
|
+
staleAfterMs: number
|
|
31
|
+
evictAfterMs: number
|
|
32
|
+
maxMessages: number
|
|
33
|
+
messageTtlMs: number
|
|
34
|
+
claimTtlMs: number
|
|
35
|
+
maxWaitMs: number
|
|
36
|
+
pollMs: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Deliverer {
|
|
40
|
+
deliver(message: CrosstalkMessage): Promise<{ delivered: boolean; reason?: string }>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MeshDeps {
|
|
44
|
+
options: MeshOptions
|
|
45
|
+
clock?: Clock
|
|
46
|
+
deliverer?: Deliverer
|
|
47
|
+
/** Plugin location, used when a peer record carries no project or directory. */
|
|
48
|
+
defaults?: { projectID?: string; directory?: string }
|
|
49
|
+
/** Bundled Personas; when present, each session freezes one portrait per task. */
|
|
50
|
+
avatars?: readonly AvatarEntry[]
|
|
51
|
+
idFactory?(seq: number): string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SendInput {
|
|
55
|
+
to?: string
|
|
56
|
+
role?: string
|
|
57
|
+
all?: boolean
|
|
58
|
+
topic?: string
|
|
59
|
+
text: string
|
|
60
|
+
kind?: MessageKind
|
|
61
|
+
delivery?: Delivery
|
|
62
|
+
/** Set when the caller is answering another message. */
|
|
63
|
+
replyTo?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SendOutcome {
|
|
67
|
+
recipients: Array<{ sessionID: string; delivered: boolean; reason?: string }>
|
|
68
|
+
skipped: Array<{ target: string; reason: string }>
|
|
69
|
+
messages: CrosstalkMessage[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface InboxInput extends ListOptions {
|
|
73
|
+
waitMs?: number
|
|
74
|
+
markRead?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface InboxOutcome {
|
|
78
|
+
messages: CrosstalkMessage[]
|
|
79
|
+
reason: "immediate" | WaitResult["reason"]
|
|
80
|
+
marked: number
|
|
81
|
+
/** Unread remaining after this call. */
|
|
82
|
+
unread: number
|
|
83
|
+
/** Unread as it stood before this call acknowledged anything. */
|
|
84
|
+
unreadBefore: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ClaimInput {
|
|
88
|
+
action: "claim" | "release" | "renew" | "list"
|
|
89
|
+
resources?: readonly string[]
|
|
90
|
+
ttlMs?: number
|
|
91
|
+
note?: string
|
|
92
|
+
force?: boolean
|
|
93
|
+
atomic?: boolean
|
|
94
|
+
includeExpired?: boolean
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ClaimResult {
|
|
98
|
+
outcome: ClaimOutcome
|
|
99
|
+
held: Claim[]
|
|
100
|
+
ttlMs: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface PeerIdleResult {
|
|
104
|
+
reason: "done" | "timeout" | "aborted" | "gone" | "outside"
|
|
105
|
+
elapsedMs: number
|
|
106
|
+
status?: Peer["status"]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ClaimFreeResult {
|
|
110
|
+
reason: "done" | "timeout" | "aborted"
|
|
111
|
+
elapsedMs: number
|
|
112
|
+
holder?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface DeclareResult {
|
|
116
|
+
view: PeerView
|
|
117
|
+
/**
|
|
118
|
+
* The requested name is already used by another visible session; nothing was
|
|
119
|
+
* changed. `holder` is the session that owns the name.
|
|
120
|
+
*/
|
|
121
|
+
nameConflict?: { name: string; holder: string }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Bumped to 2 when twin plugin instances could each persist to the same key
|
|
126
|
+
* and clobber each other's records. v1 is still migrated rather than dropped:
|
|
127
|
+
* its peer records were written under the same freeze contract, so a long-run
|
|
128
|
+
* session carries its declarations and frozen portrait across the upgrade —
|
|
129
|
+
* discarding them would change art the user already sees, breaking the never-
|
|
130
|
+
* change rule. Only v1's claims are discarded, because the lease table is what
|
|
131
|
+
* the twins corrupted; a snapshot from any other era is ignored whole.
|
|
132
|
+
*/
|
|
133
|
+
export const SNAPSHOT_VERSION = 2
|
|
134
|
+
|
|
135
|
+
/** The one older format whose peer records `restore` still carries forward. */
|
|
136
|
+
export const LEGACY_SNAPSHOT_VERSION = 1
|
|
137
|
+
|
|
138
|
+
export class Mesh {
|
|
139
|
+
readonly registry: Registry
|
|
140
|
+
readonly mailbox: Mailbox
|
|
141
|
+
readonly claims: ClaimTable
|
|
142
|
+
readonly options: MeshOptions
|
|
143
|
+
|
|
144
|
+
readonly #clock: Clock
|
|
145
|
+
readonly #deliverer: Deliverer | undefined
|
|
146
|
+
readonly #defaults: { projectID?: string; directory?: string }
|
|
147
|
+
readonly #avatars: readonly AvatarEntry[]
|
|
148
|
+
readonly #idFactory: ((seq: number) => string) | undefined
|
|
149
|
+
/**
|
|
150
|
+
* Messages that are in a mailbox but were never injected into the live
|
|
151
|
+
* session, keyed by the session that should receive them. A `synthetic` call
|
|
152
|
+
* can fail across locations; without this the recipient would only ever find
|
|
153
|
+
* the message by polling, so every tool call retries them first.
|
|
154
|
+
*/
|
|
155
|
+
readonly #pendingInjection = new Map<string, CrosstalkMessage[]>()
|
|
156
|
+
|
|
157
|
+
/** Mesh-mutation observers (the RPC bridge); a listener must never throw. */
|
|
158
|
+
readonly #listeners = new Set<() => void>()
|
|
159
|
+
|
|
160
|
+
constructor(deps: MeshDeps) {
|
|
161
|
+
this.options = deps.options
|
|
162
|
+
this.#clock = deps.clock ?? systemClock
|
|
163
|
+
this.#deliverer = deps.deliverer
|
|
164
|
+
this.#defaults = deps.defaults ?? {}
|
|
165
|
+
this.#avatars = deps.avatars ?? []
|
|
166
|
+
this.#idFactory = deps.idFactory
|
|
167
|
+
this.registry = new Registry({
|
|
168
|
+
now: () => this.#clock.now(),
|
|
169
|
+
staleAfterMs: deps.options.staleAfterMs,
|
|
170
|
+
evictAfterMs: deps.options.evictAfterMs,
|
|
171
|
+
})
|
|
172
|
+
this.mailbox = new Mailbox({
|
|
173
|
+
now: () => this.#clock.now(),
|
|
174
|
+
maxPerSession: deps.options.maxMessages,
|
|
175
|
+
ttlMs: deps.options.messageTtlMs,
|
|
176
|
+
...(this.#idFactory ? { idFactory: this.#idFactory } : {}),
|
|
177
|
+
})
|
|
178
|
+
this.claims = new ClaimTable({ now: () => this.#clock.now(), defaultTtlMs: deps.options.claimTtlMs })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
now(): number {
|
|
182
|
+
return this.#clock.now()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Observe declarations and presence changes. The RPC bridge subscribes so it
|
|
187
|
+
* can push directory updates without polling.
|
|
188
|
+
*/
|
|
189
|
+
subscribe(listener: () => void): () => void {
|
|
190
|
+
this.#listeners.add(listener)
|
|
191
|
+
return () => {
|
|
192
|
+
this.#listeners.delete(listener)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#notify(): void {
|
|
197
|
+
for (const listener of this.#listeners) {
|
|
198
|
+
try {
|
|
199
|
+
listener()
|
|
200
|
+
} catch {
|
|
201
|
+
// A broken observer must never break a tool call.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Fold a server event in and evict lapsed peers. */
|
|
207
|
+
applyEvent(event: CrosstalkEvent): boolean {
|
|
208
|
+
const applied = this.registry.apply(event)
|
|
209
|
+
if (applied) {
|
|
210
|
+
this.registry.prune(this.#clock.now())
|
|
211
|
+
this.#notify()
|
|
212
|
+
}
|
|
213
|
+
return applied
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Guarantee a record for the calling session. A session that started before
|
|
218
|
+
* the plugin loaded may never emit `session.created` at us.
|
|
219
|
+
*/
|
|
220
|
+
ensureSelf(sessionID: string, agent?: string): Peer {
|
|
221
|
+
return this.registry.ensure(sessionID, {
|
|
222
|
+
projectID: this.#defaults.projectID,
|
|
223
|
+
directory: this.#defaults.directory,
|
|
224
|
+
...(agent ? { agent } : {}),
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The directory claims and relative paths resolve against for a session. */
|
|
229
|
+
directoryOf(sessionID: string): string | undefined {
|
|
230
|
+
return this.registry.get(sessionID)?.directory ?? this.#defaults.directory
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#filter(selfID: string, overrides: Partial<ListFilter> = {}): ListFilter {
|
|
234
|
+
return {
|
|
235
|
+
selfID,
|
|
236
|
+
scope: this.options.scope,
|
|
237
|
+
selfProjectID: this.#defaults.projectID,
|
|
238
|
+
selfDirectory: this.#defaults.directory,
|
|
239
|
+
...overrides,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Peer records visible to `selfID`, decorated with mesh state. */
|
|
244
|
+
peers(selfID: string, filter: Partial<ListFilter> = {}): PeerView[] {
|
|
245
|
+
this.ensureSelf(selfID)
|
|
246
|
+
const now = this.#clock.now()
|
|
247
|
+
return this.registry.list(this.#filter(selfID, filter)).map((peer) =>
|
|
248
|
+
this.registry.view(
|
|
249
|
+
peer,
|
|
250
|
+
{
|
|
251
|
+
isSelf: peer.sessionID === selfID,
|
|
252
|
+
unread: this.mailbox.unreadCount(peer.sessionID),
|
|
253
|
+
claims: this.claims.heldBy(peer.sessionID),
|
|
254
|
+
addressable: this.#inScope(selfID, peer),
|
|
255
|
+
},
|
|
256
|
+
now,
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
view(selfID: string): PeerView {
|
|
262
|
+
const self = this.ensureSelf(selfID)
|
|
263
|
+
return this.registry.view(
|
|
264
|
+
self,
|
|
265
|
+
{ isSelf: true, unread: this.mailbox.unreadCount(selfID), claims: this.claims.heldBy(selfID), addressable: true },
|
|
266
|
+
this.#clock.now(),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Record what a session says it is doing. Returns the updated self view. */
|
|
271
|
+
declare(sessionID: string, declared: Declared): DeclareResult {
|
|
272
|
+
const id = sessionIDOrThrow(sessionID)
|
|
273
|
+
const peer = this.ensureSelf(id)
|
|
274
|
+
// A name is a nickname, not identity: it must be unique among the peers
|
|
275
|
+
// the session can see, and a taken name is refused without touching the
|
|
276
|
+
// rest of the declaration.
|
|
277
|
+
if (declared.name !== undefined && declared.name !== peer.declared?.name) {
|
|
278
|
+
const holder = this.#nameHolder(declared.name, id)
|
|
279
|
+
if (holder) return { view: this.view(id), nameConflict: { name: declared.name, holder } }
|
|
280
|
+
}
|
|
281
|
+
const previous = peer.declared
|
|
282
|
+
const merged: Declared = {
|
|
283
|
+
name: declared.name ?? previous?.name,
|
|
284
|
+
role: declared.role ?? previous?.role,
|
|
285
|
+
goal: declared.goal ?? previous?.goal,
|
|
286
|
+
summary: declared.summary ?? previous?.summary,
|
|
287
|
+
summaryAt: declared.summary !== undefined ? this.#clock.now() : previous?.summaryAt,
|
|
288
|
+
note: declared.note ?? previous?.note,
|
|
289
|
+
avatar: declared.avatar ?? previous?.avatar,
|
|
290
|
+
workingOn: declared.workingOn ?? previous?.workingOn,
|
|
291
|
+
updatedAt: this.#clock.now(),
|
|
292
|
+
}
|
|
293
|
+
peer.declared = merged
|
|
294
|
+
peer.lastSeen = this.#clock.now()
|
|
295
|
+
// The portrait is a one-shot decision: it is assigned when a task first
|
|
296
|
+
// shows up and frozen afterwards, so no later declaration — different
|
|
297
|
+
// role, name, or avatar hint — can move the artwork.
|
|
298
|
+
if (peer.portrait === undefined && hasTask(merged)) {
|
|
299
|
+
const portrait = portraitFor(this.#avatars, {
|
|
300
|
+
sessionID: id,
|
|
301
|
+
name: merged.name,
|
|
302
|
+
role: merged.role,
|
|
303
|
+
avatar: merged.avatar,
|
|
304
|
+
})
|
|
305
|
+
if (portrait !== undefined) peer.portrait = portrait
|
|
306
|
+
}
|
|
307
|
+
this.#notify()
|
|
308
|
+
return { view: this.view(id) }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The visible session that already declared `name`, if any. */
|
|
312
|
+
#nameHolder(name: string, selfID: string): string | undefined {
|
|
313
|
+
const wanted = name.toLowerCase()
|
|
314
|
+
for (const peer of this.registry.list(this.#filter(selfID))) {
|
|
315
|
+
if (peer.sessionID === selfID) continue
|
|
316
|
+
if (peer.declared?.name?.toLowerCase() === wanted) return peer.sessionID
|
|
317
|
+
}
|
|
318
|
+
return undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Freeze portraits for sessions that already carry a task but have no
|
|
323
|
+
* portrait — the ones that declared before the portrait existed, or records
|
|
324
|
+
* restored from an older snapshot. One pass at startup; returns how many
|
|
325
|
+
* were assigned.
|
|
326
|
+
*/
|
|
327
|
+
assignPortraits(): number {
|
|
328
|
+
let assigned = 0
|
|
329
|
+
for (const peer of this.registry.list({ scope: "server" })) {
|
|
330
|
+
if (peer.portrait !== undefined || !hasTask(peer.declared)) continue
|
|
331
|
+
const portrait = portraitFor(this.#avatars, {
|
|
332
|
+
sessionID: peer.sessionID,
|
|
333
|
+
name: peer.declared?.name,
|
|
334
|
+
role: peer.declared?.role,
|
|
335
|
+
avatar: peer.declared?.avatar,
|
|
336
|
+
})
|
|
337
|
+
if (portrait !== undefined) {
|
|
338
|
+
peer.portrait = portrait
|
|
339
|
+
assigned += 1
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (assigned > 0) this.#notify()
|
|
343
|
+
return assigned
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Send a message to peers, then try to inject it into each recipient's live
|
|
348
|
+
* session. A failed injection is not an error: the message stays in the
|
|
349
|
+
* recipient's mailbox and surfaces on their next `crosstalk_inbox`.
|
|
350
|
+
*/
|
|
351
|
+
async send(selfID: string, input: SendInput, signal?: AbortSignal): Promise<SendOutcome> {
|
|
352
|
+
const sender = this.ensureSelf(selfID)
|
|
353
|
+
const targets = this.#resolveTargets(selfID, input)
|
|
354
|
+
if (targets.recipients.length === 0) {
|
|
355
|
+
return { recipients: [], skipped: targets.skipped, messages: [] }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const meta: Record<string, unknown> = {}
|
|
359
|
+
if (input.replyTo) meta.replyTo = input.replyTo
|
|
360
|
+
meta.requested = sender.declared?.role
|
|
361
|
+
|
|
362
|
+
const messages = this.mailbox.send({
|
|
363
|
+
from: selfID,
|
|
364
|
+
...(sender.declared?.name ? { fromName: sender.declared.name } : {}),
|
|
365
|
+
...(sender.declared?.role ? { fromRole: sender.declared.role } : {}),
|
|
366
|
+
to: targets.recipients,
|
|
367
|
+
text: input.text,
|
|
368
|
+
...(input.topic ? { topic: input.topic } : {}),
|
|
369
|
+
kind: input.kind ?? "message",
|
|
370
|
+
delivery: input.delivery ?? "steer",
|
|
371
|
+
meta,
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
const recipients: SendOutcome["recipients"] = []
|
|
375
|
+
for (const message of messages) {
|
|
376
|
+
if (signal?.aborted) {
|
|
377
|
+
recipients.push({ sessionID: message.to, delivered: false, reason: "cancelled" })
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
const peer = this.registry.get(message.to)
|
|
381
|
+
if (!peer) {
|
|
382
|
+
recipients.push({ sessionID: message.to, delivered: false, reason: "session not known to the mesh" })
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
// Defense in depth: the targets were resolved inside the wall, but a
|
|
386
|
+
// session whose location changed between resolution and delivery must
|
|
387
|
+
// not receive mail across it.
|
|
388
|
+
if (!this.#inScope(selfID, peer)) {
|
|
389
|
+
recipients.push({ sessionID: message.to, delivered: false, reason: `outside the ${this.options.scope} scope` })
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
// `session.synthetic` on a foreign location may throw; the mailbox copy
|
|
393
|
+
// is the durable path, so a failure only downgrades delivery and is
|
|
394
|
+
// retried the next time the recipient touches a crosstalk tool.
|
|
395
|
+
const result = this.#deliverer
|
|
396
|
+
? await this.#deliverer.deliver(message)
|
|
397
|
+
: { delivered: false, reason: "no deliverer configured" }
|
|
398
|
+
if (!result.delivered) this.#queuePending(message)
|
|
399
|
+
recipients.push({ sessionID: message.to, delivered: result.delivered, reason: result.reason })
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { recipients, skipped: targets.skipped, messages }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Retry injecting messages that were queued for `sessionID` but never made it
|
|
407
|
+
* into its live turn. Called before every tool execution, so a peer recovers
|
|
408
|
+
* from a failed injection as soon as it does anything at all. Returns how
|
|
409
|
+
* many were delivered by this attempt.
|
|
410
|
+
*/
|
|
411
|
+
async flushPendingInjection(sessionID: string, signal?: AbortSignal): Promise<number> {
|
|
412
|
+
const queue = this.#pendingInjection.get(sessionID)
|
|
413
|
+
if (!queue || queue.length === 0) return 0
|
|
414
|
+
|
|
415
|
+
const cutoff = this.#clock.now() - this.options.messageTtlMs
|
|
416
|
+
const remaining: CrosstalkMessage[] = []
|
|
417
|
+
let delivered = 0
|
|
418
|
+
|
|
419
|
+
for (const message of queue) {
|
|
420
|
+
if (message.created < cutoff) continue
|
|
421
|
+
const peer = this.registry.get(message.to)
|
|
422
|
+
if (signal?.aborted || !peer) {
|
|
423
|
+
remaining.push(message)
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
// The wall holds on retries too: a session that moved out of this
|
|
427
|
+
// location's project must not receive cross-project mail. The message
|
|
428
|
+
// stays queued in case the record catches up; it can never be injected.
|
|
429
|
+
if (!this.#deliverable(peer)) {
|
|
430
|
+
remaining.push(message)
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
const result = this.#deliverer ? await this.#deliverer.deliver(message) : { delivered: false }
|
|
434
|
+
if (result.delivered) delivered += 1
|
|
435
|
+
else remaining.push(message)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (remaining.length === 0) this.#pendingInjection.delete(sessionID)
|
|
439
|
+
else this.#pendingInjection.set(sessionID, remaining)
|
|
440
|
+
return delivered
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A queued injection may only reach a session that is still provably inside
|
|
445
|
+
* this location's scope. Unknown locations pass (they were in scope when the
|
|
446
|
+
* message was queued, and an event may simply be missing); a provably
|
|
447
|
+
* different project or directory is refused.
|
|
448
|
+
*/
|
|
449
|
+
#deliverable(target: Peer): boolean {
|
|
450
|
+
if (this.options.scope === "server") return true
|
|
451
|
+
if (this.options.scope === "project") {
|
|
452
|
+
const selfProject = this.#defaults.projectID
|
|
453
|
+
return selfProject === undefined || target.projectID === undefined || target.projectID === selfProject
|
|
454
|
+
}
|
|
455
|
+
const selfDirectory = this.#defaults.directory
|
|
456
|
+
return selfDirectory === undefined || target.directory === undefined || target.directory === selfDirectory
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Messages still waiting to be injected into a session's live turn. */
|
|
460
|
+
pendingInjectionCount(sessionID: string): number {
|
|
461
|
+
return this.#pendingInjection.get(sessionID)?.length ?? 0
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#queuePending(message: CrosstalkMessage): void {
|
|
465
|
+
const queue = this.#pendingInjection.get(message.to) ?? []
|
|
466
|
+
// Bounded: a recipient that never opens a tool call must not grow the
|
|
467
|
+
// server's memory without limit. Oldest queued injection goes first.
|
|
468
|
+
while (queue.length >= 50) queue.shift()
|
|
469
|
+
queue.push(message)
|
|
470
|
+
this.#pendingInjection.set(message.to, queue)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#resolveTargets(selfID: string, input: SendInput): { recipients: string[]; skipped: SendOutcome["skipped"] } {
|
|
474
|
+
const skipped: SendOutcome["skipped"] = []
|
|
475
|
+
if (input.all) {
|
|
476
|
+
// `all` reaches every peer inside the communication wall; a session
|
|
477
|
+
// whose project cannot be proven is never on the distribution list.
|
|
478
|
+
return {
|
|
479
|
+
recipients: this.registry.list(this.#filter(selfID, { strict: true })).map((p) => p.sessionID),
|
|
480
|
+
skipped,
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (input.to) {
|
|
484
|
+
const target = input.to.trim()
|
|
485
|
+
if (target === "") {
|
|
486
|
+
skipped.push({ target: "(empty)", reason: "no session id given" })
|
|
487
|
+
return { recipients: [], skipped }
|
|
488
|
+
}
|
|
489
|
+
if (target === selfID) {
|
|
490
|
+
skipped.push({ target, reason: "that is you" })
|
|
491
|
+
return { recipients: [], skipped }
|
|
492
|
+
}
|
|
493
|
+
if (this.registry.get(target)) {
|
|
494
|
+
if (!this.#visible(selfID, target)) {
|
|
495
|
+
skipped.push({ target, reason: `outside the ${this.options.scope} scope` })
|
|
496
|
+
return { recipients: [], skipped }
|
|
497
|
+
}
|
|
498
|
+
return { recipients: [target], skipped }
|
|
499
|
+
}
|
|
500
|
+
// Not a session id: try a declared human name, case-insensitively,
|
|
501
|
+
// among addressable peers only — names never cross the wall.
|
|
502
|
+
const wanted = target.toLowerCase()
|
|
503
|
+
const byName = this.registry
|
|
504
|
+
.list(this.#filter(selfID, { strict: true }))
|
|
505
|
+
.filter((peer) => peer.declared?.name?.toLowerCase() === wanted)
|
|
506
|
+
.map((peer) => peer.sessionID)
|
|
507
|
+
if (byName.length === 1) return { recipients: [byName[0] as string], skipped }
|
|
508
|
+
if (byName.length > 1) {
|
|
509
|
+
skipped.push({ target, reason: `"${target}" is claimed by ${byName.length} sessions; use a session id` })
|
|
510
|
+
return { recipients: [], skipped }
|
|
511
|
+
}
|
|
512
|
+
skipped.push({ target, reason: "session unknown; check crosstalk_peers for session ids and names" })
|
|
513
|
+
return { recipients: [], skipped }
|
|
514
|
+
}
|
|
515
|
+
if (input.role) {
|
|
516
|
+
const wanted = input.role.trim().toLowerCase()
|
|
517
|
+
const matches = this.registry
|
|
518
|
+
.list(this.#filter(selfID, { strict: true }))
|
|
519
|
+
.filter((peer) => peer.declared?.role?.toLowerCase() === wanted)
|
|
520
|
+
.map((peer) => peer.sessionID)
|
|
521
|
+
if (matches.length === 0) skipped.push({ target: `role:${input.role}`, reason: "no peer declared that role" })
|
|
522
|
+
return { recipients: matches, skipped }
|
|
523
|
+
}
|
|
524
|
+
skipped.push({ target: "(none)", reason: "give to, role, or all" })
|
|
525
|
+
return { recipients: [], skipped }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* True when `target` is a peer this session may address: provably within the
|
|
530
|
+
* configured scope. An unknown location is a refusal, not a maybe.
|
|
531
|
+
*/
|
|
532
|
+
#visible(selfID: string, target: string): boolean {
|
|
533
|
+
const peer = this.registry.get(target)
|
|
534
|
+
return peer !== undefined && this.#inScope(selfID, peer)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The communication wall. `scope: "server"` has no wall; otherwise the peer
|
|
539
|
+
* must carry a location that provably equals the caller's — a session whose
|
|
540
|
+
* project (or directory) is unknown cannot be messaged or waited on.
|
|
541
|
+
*/
|
|
542
|
+
#inScope(selfID: string, peer: Peer): boolean {
|
|
543
|
+
const scope = this.options.scope
|
|
544
|
+
if (peer.sessionID === selfID || scope === "server") return true
|
|
545
|
+
const projectID = this.registry.get(selfID)?.projectID ?? this.#defaults.projectID
|
|
546
|
+
if (scope === "project") return projectID !== undefined && peer.projectID === projectID
|
|
547
|
+
const directory = this.registry.get(selfID)?.directory ?? this.#defaults.directory
|
|
548
|
+
return directory !== undefined && peer.directory === directory
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Read, optionally block for, and optionally ack the caller's mail. */
|
|
552
|
+
async inbox(selfID: string, input: InboxInput, signal?: AbortSignal): Promise<InboxOutcome> {
|
|
553
|
+
this.ensureSelf(selfID)
|
|
554
|
+
const waitMs = Math.min(Math.max(0, input.waitMs ?? 0), this.options.maxWaitMs)
|
|
555
|
+
|
|
556
|
+
let messages: CrosstalkMessage[]
|
|
557
|
+
let reason: InboxOutcome["reason"] = "immediate"
|
|
558
|
+
if (waitMs > 0) {
|
|
559
|
+
const result = await this.mailbox.wait(selfID, waitMs, signal)
|
|
560
|
+
messages = result.messages
|
|
561
|
+
reason = result.reason
|
|
562
|
+
if (reason === "messages") {
|
|
563
|
+
// Re-read through the caller's filters; `wait` only filtered unread.
|
|
564
|
+
messages = this.mailbox.list(selfID, input)
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
messages = this.mailbox.list(selfID, input)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Counted before acknowledging, so the header can say how much was unread
|
|
571
|
+
// even though this call is about to mark it read.
|
|
572
|
+
const unreadBefore = messages.filter((message) => message.readAt === undefined).length
|
|
573
|
+
const marked = input.markRead === false ? 0 : this.mailbox.markRead(selfID, messages.map((m) => m.id))
|
|
574
|
+
return {
|
|
575
|
+
messages,
|
|
576
|
+
reason,
|
|
577
|
+
marked,
|
|
578
|
+
unread: this.mailbox.unreadCount(selfID),
|
|
579
|
+
unreadBefore,
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Lease bookkeeping for the caller's session. */
|
|
584
|
+
claims_for(selfID: string): ClaimResult {
|
|
585
|
+
return { outcome: emptyOutcome(), held: this.claims.list({ holder: selfID }), ttlMs: this.#claimsExpiry(selfID) }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
claim(selfID: string, input: ClaimInput): ClaimResult {
|
|
589
|
+
this.ensureSelf(selfID)
|
|
590
|
+
const baseDir = this.directoryOf(selfID)
|
|
591
|
+
const ttlMs = Math.min(Math.max(1, input.ttlMs ?? this.options.claimTtlMs), 86_400_000)
|
|
592
|
+
const resources = (input.resources ?? []).filter((r) => r.trim() !== "")
|
|
593
|
+
|
|
594
|
+
let outcome: ClaimOutcome
|
|
595
|
+
switch (input.action) {
|
|
596
|
+
case "claim":
|
|
597
|
+
outcome = this.claims.claim({
|
|
598
|
+
holder: selfID,
|
|
599
|
+
resources,
|
|
600
|
+
ttlMs,
|
|
601
|
+
baseDir,
|
|
602
|
+
...(input.note ? { note: input.note } : {}),
|
|
603
|
+
...(input.force !== undefined ? { force: input.force } : {}),
|
|
604
|
+
...(input.atomic !== undefined ? { atomic: input.atomic } : {}),
|
|
605
|
+
})
|
|
606
|
+
break
|
|
607
|
+
case "renew":
|
|
608
|
+
outcome = this.claims.renew({
|
|
609
|
+
holder: selfID,
|
|
610
|
+
resources,
|
|
611
|
+
ttlMs,
|
|
612
|
+
baseDir,
|
|
613
|
+
...(input.note ? { note: input.note } : {}),
|
|
614
|
+
...(input.atomic !== undefined ? { atomic: input.atomic } : {}),
|
|
615
|
+
})
|
|
616
|
+
break
|
|
617
|
+
case "release":
|
|
618
|
+
outcome = this.claims.release({
|
|
619
|
+
holder: selfID,
|
|
620
|
+
resources,
|
|
621
|
+
baseDir,
|
|
622
|
+
...(input.force !== undefined ? { force: input.force } : {}),
|
|
623
|
+
})
|
|
624
|
+
break
|
|
625
|
+
case "list": {
|
|
626
|
+
const held = this.claims.list({
|
|
627
|
+
holder: selfID,
|
|
628
|
+
baseDir,
|
|
629
|
+
...(input.includeExpired ? { includeExpired: true } : {}),
|
|
630
|
+
})
|
|
631
|
+
return { outcome: emptyOutcome(), held, ttlMs }
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return { outcome, held: this.claims.list({ holder: selfID }), ttlMs }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#claimsExpiry(selfID: string): number {
|
|
639
|
+
const held = this.claims.list({ holder: selfID })
|
|
640
|
+
const soonest = held.reduce((min, claim) => Math.min(min, claim.expires), Number.POSITIVE_INFINITY)
|
|
641
|
+
return Number.isFinite(soonest) ? Math.max(0, soonest - this.#clock.now()) : this.options.claimTtlMs
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Block until a peer stops being busy, disappears, or the wait expires. */
|
|
645
|
+
async waitForPeerIdle(
|
|
646
|
+
selfID: string,
|
|
647
|
+
target: string,
|
|
648
|
+
timeoutMs: number,
|
|
649
|
+
signal?: AbortSignal,
|
|
650
|
+
): Promise<PeerIdleResult> {
|
|
651
|
+
const start = this.#clock.now()
|
|
652
|
+
const limit = Math.min(Math.max(0, timeoutMs), this.options.maxWaitMs)
|
|
653
|
+
if (target === selfID) return { reason: "gone", elapsedMs: 0 }
|
|
654
|
+
|
|
655
|
+
const peer = this.registry.get(target)
|
|
656
|
+
if (!peer) return { reason: "gone", elapsedMs: 0 }
|
|
657
|
+
// Waiting on a peer is communication: the wall applies. A session outside
|
|
658
|
+
// the scope resolves at once instead of blocking until the timeout.
|
|
659
|
+
if (!this.#inScope(selfID, peer)) return { reason: "outside", elapsedMs: 0, status: peer.status }
|
|
660
|
+
|
|
661
|
+
for (;;) {
|
|
662
|
+
if (peer.status === "idle" || peer.status === "unknown") {
|
|
663
|
+
return { reason: "done", elapsedMs: this.#clock.now() - start, status: peer.status }
|
|
664
|
+
}
|
|
665
|
+
if (this.#clock.now() - start >= limit) {
|
|
666
|
+
return { reason: signal?.aborted ? "aborted" : "timeout", elapsedMs: this.#clock.now() - start, status: peer.status }
|
|
667
|
+
}
|
|
668
|
+
await delay(this.options.pollMs, signal)
|
|
669
|
+
if (signal?.aborted) return { reason: "aborted", elapsedMs: this.#clock.now() - start }
|
|
670
|
+
const current = this.registry.get(target)
|
|
671
|
+
if (!current) return { reason: "gone", elapsedMs: this.#clock.now() - start }
|
|
672
|
+
if (!this.#inScope(selfID, current)) return { reason: "outside", elapsedMs: this.#clock.now() - start, status: current.status }
|
|
673
|
+
if (current.status === "idle" || current.status === "unknown") {
|
|
674
|
+
return { reason: "done", elapsedMs: this.#clock.now() - start, status: current.status }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Block until a resource has no live lease. */
|
|
680
|
+
async waitForClaimFree(
|
|
681
|
+
_selfID: string,
|
|
682
|
+
resource: string,
|
|
683
|
+
timeoutMs: number,
|
|
684
|
+
baseDir?: string,
|
|
685
|
+
signal?: AbortSignal,
|
|
686
|
+
): Promise<ClaimFreeResult> {
|
|
687
|
+
const start = this.#clock.now()
|
|
688
|
+
const limit = Math.min(Math.max(0, timeoutMs), this.options.maxWaitMs)
|
|
689
|
+
const key = this.claims.normalize(resource, baseDir)
|
|
690
|
+
const holderOf = (): string | undefined => {
|
|
691
|
+
const claim = this.claims.list({ resource: key, includeExpired: true })[0]
|
|
692
|
+
if (!claim || claim.expires <= this.#clock.now()) return undefined
|
|
693
|
+
return claim.holder
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for (;;) {
|
|
697
|
+
const holder = holderOf()
|
|
698
|
+
if (holder === undefined) return { reason: "done", elapsedMs: this.#clock.now() - start }
|
|
699
|
+
if (this.#clock.now() - start >= limit) {
|
|
700
|
+
return {
|
|
701
|
+
reason: signal?.aborted ? "aborted" : "timeout",
|
|
702
|
+
elapsedMs: this.#clock.now() - start,
|
|
703
|
+
holder,
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
await delay(this.options.pollMs, signal)
|
|
707
|
+
if (signal?.aborted) return { reason: "aborted", elapsedMs: this.#clock.now() - start, holder }
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Serializable state. Messages are intentionally left out. */
|
|
712
|
+
snapshot(): MeshSnapshot {
|
|
713
|
+
return {
|
|
714
|
+
version: SNAPSHOT_VERSION,
|
|
715
|
+
savedAt: this.#clock.now(),
|
|
716
|
+
peers: [...this.registry.list({ scope: "server" })].map((peer) => ({ ...peer })),
|
|
717
|
+
claims: this.claims.entries().map((claim) => ({ ...claim })),
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Merge a snapshot back in. A session the live stream has already created
|
|
723
|
+
* keeps its freshness and only receives fields the stream never carried
|
|
724
|
+
* (declarations, project, directory); a session the registry has not seen is
|
|
725
|
+
* restored wholesale. Restored claims are dropped when the same key is
|
|
726
|
+
* already held. A v1 snapshot is migrated rather than rejected: peers (with
|
|
727
|
+
* their declarations and frozen portraits) are carried forward, but none of
|
|
728
|
+
* its claims are adopted — that lease table is exactly what the twin
|
|
729
|
+
* instances corrupted, and a resurrected ghost lease would harm live peers.
|
|
730
|
+
*/
|
|
731
|
+
restore(snapshot: MeshSnapshot): void {
|
|
732
|
+
const legacy = snapshot.version === LEGACY_SNAPSHOT_VERSION
|
|
733
|
+
if (!legacy && snapshot.version !== SNAPSHOT_VERSION) return
|
|
734
|
+
for (const peer of snapshot.peers) {
|
|
735
|
+
if (!peer || typeof peer.sessionID !== "string") continue
|
|
736
|
+
const live = this.registry.get(peer.sessionID)
|
|
737
|
+
if (live) {
|
|
738
|
+
if (live.title === undefined && peer.title !== undefined) live.title = peer.title
|
|
739
|
+
if (live.agent === undefined && peer.agent !== undefined) live.agent = peer.agent
|
|
740
|
+
if (live.model === undefined && peer.model !== undefined) live.model = peer.model
|
|
741
|
+
if (live.parentID === undefined && peer.parentID !== undefined) live.parentID = peer.parentID
|
|
742
|
+
if (live.projectID === undefined && peer.projectID !== undefined) live.projectID = peer.projectID
|
|
743
|
+
if (live.directory === undefined && peer.directory !== undefined) live.directory = peer.directory
|
|
744
|
+
if (live.created === undefined && peer.created !== undefined) live.created = peer.created
|
|
745
|
+
if (live.declared === undefined && peer.declared !== undefined) live.declared = peer.declared
|
|
746
|
+
if (live.portrait === undefined && peer.portrait !== undefined) live.portrait = peer.portrait
|
|
747
|
+
continue
|
|
748
|
+
}
|
|
749
|
+
const restored = this.registry.ensure(peer.sessionID)
|
|
750
|
+
restored.status = peer.status ?? "unknown"
|
|
751
|
+
restored.lastSeen = typeof peer.lastSeen === "number" ? peer.lastSeen : this.#clock.now()
|
|
752
|
+
if (peer.title !== undefined) restored.title = peer.title
|
|
753
|
+
if (peer.agent !== undefined) restored.agent = peer.agent
|
|
754
|
+
if (peer.model !== undefined) restored.model = peer.model
|
|
755
|
+
if (peer.parentID !== undefined) restored.parentID = peer.parentID
|
|
756
|
+
if (peer.projectID !== undefined) restored.projectID = peer.projectID
|
|
757
|
+
if (peer.directory !== undefined) restored.directory = peer.directory
|
|
758
|
+
if (peer.created !== undefined) restored.created = peer.created
|
|
759
|
+
if (peer.declared !== undefined) restored.declared = peer.declared
|
|
760
|
+
if (peer.portrait !== undefined) restored.portrait = peer.portrait
|
|
761
|
+
}
|
|
762
|
+
if (legacy) return
|
|
763
|
+
const held = new Set(this.claims.entries().map((claim) => claim.key))
|
|
764
|
+
for (const claim of snapshot.claims) {
|
|
765
|
+
if (!claim || typeof claim.key !== "string" || held.has(claim.key)) continue
|
|
766
|
+
if (claim.expires <= this.#clock.now()) continue
|
|
767
|
+
this.claims.adopt(claim)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function emptyOutcome(): ClaimOutcome {
|
|
773
|
+
return { claimed: [], renewed: [], released: [], stolen: [], conflicts: [], rolledBack: false }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function sessionIDOrThrow(sessionID: string): string {
|
|
777
|
+
if (typeof sessionID !== "string" || sessionID === "") {
|
|
778
|
+
throw new Error("crosstalk: session id is required")
|
|
779
|
+
}
|
|
780
|
+
return sessionID
|
|
781
|
+
}
|