omp-onlyne 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/src/runtime.ts ADDED
@@ -0,0 +1,676 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { dirname, join } from "node:path";
3
+ import { OnlyneError, trustPhase, type DiscoveryCandidate, type DiscoveryRecord, type KeyExchange, type KeyHook, type MaintenanceClock, type NotifyEvent, type OnlyneNode, type Outbound, type Page, type TrustEntry } from "@onlyne/lib";
4
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
5
+ import { ensureKeyDir } from "@onlyne/node-host/keys";
6
+ import { NetworkDiscovery } from "@onlyne/node-host/network";
7
+ import { resolveLocale, setLocale } from "@onlyne/node-host/i18n";
8
+ import { acquisitionOf } from "./acquire.ts";
9
+ import { AcquisitionLog, approvePeer, beginPeer, handshakeEntry, isTrustThread, judgeApproval, judgementFacts, revokePeer, trustThread, TRUST_THREAD, type ApprovalDeps, type ApprovalJudgement, type ApprovalRequest } from "./handshake.ts";
10
+ import { commandText as t } from "./ui/command-text.ts";
11
+ import { registerMsgTool } from "./tool.ts";
12
+ import { currentDecision, JudgeDecider } from "./decider.ts";
13
+ import { loadConfig, normalizeServiceUrl, saveConfig, shortNodeId } from "./config.ts";
14
+ import { createRuntimeBridge } from "./messages.ts";
15
+ import { advertisedHost, createOnlyneNode } from "./node.ts";
16
+ import { canAnswer, ContextRegistry, SqliteStorage } from "./store.ts";
17
+ import { resourceQuery } from "./resources.ts";
18
+ import type { OnlyneConfig, OnlyneStatus, QueueItem, ViewerContact, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "./types.ts";
19
+
20
+ /** The trust transition of RFC 0017 §5, as the host receives it. */
21
+ type TrustNotify = Extract<NotifyEvent, { event: "trust" }>;
22
+
23
+ export interface PluginRuntime {
24
+ readonly config: OnlyneConfig;
25
+ readonly endpoint: string;
26
+ readonly mainAgentId: string;
27
+ readonly main: ExtensionContext;
28
+ readonly pi: ExtensionAPI;
29
+ readonly contexts: ContextRegistry;
30
+ readonly node: OnlyneNode;
31
+ readonly storage: SqliteStorage;
32
+ readonly decider: JudgeDecider;
33
+ snapshot(): Promise<ViewerSnapshot>;
34
+ status(): Promise<OnlyneStatus>;
35
+ onChange(listener: () => void): () => void;
36
+ /** The library's one notification channel: trust transitions, inbound entries, pending-work flips. */
37
+ notify(event: NotifyEvent): void;
38
+ contact(endpoint: string): Promise<void>;
39
+ /** Stage one of one exchange; the returned session carries the fingerprint the peer proved. */
40
+ trustBegin(node: string): Promise<KeyExchange>;
41
+ /** A human's confirmation in the panel: RFC 0018 §2 keeps that gate outside the policy. */
42
+ trustAccept(node: string, fingerprint: string): Promise<void>;
43
+ trustRevoke(node: string): Promise<void>;
44
+ trustRotate(): Promise<{ fingerprint: string }>;
45
+ /** RFC 0018 §2/§3.1: an agent's approval, judged against the acquisition policy first. */
46
+ acquireAccept(node: string, fingerprint: string): Promise<TrustEntry>;
47
+ /** RFC 0018 §2/§3.1: an agent's revocation. The policy never applies to dropping a peer. */
48
+ acquireRevoke(node: string): Promise<void>;
49
+ /** RFC 0018 §2/§3.1: an agent's initiation, judged before the key read that would begin it. */
50
+ acquireBegin(node: string): Promise<TrustEntry>;
51
+ refreshDiscovery(): Promise<void>;
52
+ updateConfig(next: OnlyneConfig): Promise<void>;
53
+ close(): Promise<void>;
54
+ }
55
+ let active: PluginRuntime | undefined;
56
+ export interface RuntimeHost {
57
+ config?: () => Promise<OnlyneConfig>;
58
+ keys?: KeyHook;
59
+ }
60
+ export function getRuntime(): PluginRuntime | undefined { return active; }
61
+ export function setRuntime(runtime: PluginRuntime | undefined): void { active = runtime; }
62
+ export async function startRuntime(pi: ExtensionAPI, ctx: ExtensionContext, host: RuntimeHost = {}): Promise<PluginRuntime> {
63
+ const config = await (host.config === undefined ? loadConfig(ctx.cwd) : host.config());
64
+ setLocale(resolveLocale(config.language));
65
+ if (active && active.config.path === config.path && JSON.stringify(active.config) === JSON.stringify(config)) return active;
66
+ return restartRuntime(config, pi, ctx, host);
67
+ }
68
+ export async function restartRuntime(config: OnlyneConfig, pi: ExtensionAPI, ctx: ExtensionContext, host: RuntimeHost = {}): Promise<PluginRuntime> {
69
+ setLocale(resolveLocale(config.language));
70
+ await active?.close();
71
+ const runtime = new OmpRuntime(config, pi, ctx, host);
72
+ try { await runtime.start(); } catch (error) { await runtime.close(); throw error; }
73
+ active = runtime;
74
+ registerMsgTool(pi);
75
+ return runtime;
76
+ }
77
+ export async function stopRuntime(): Promise<void> {
78
+ const closing = active;
79
+ active = undefined;
80
+ await closing?.close();
81
+ }
82
+
83
+ /** How often the discovery catalog is re-observed; well inside the library's 120 s freshness window. */
84
+ const DISCOVERY_REFRESH_MS = 30_000;
85
+
86
+ /** The alias the context registry gives this node's main session (`ContextRegistry.ensureMain`). */
87
+ const MAIN_CONTEXT = "main";
88
+
89
+ /** One mutation's identity for the library: a fresh key and a window that outlives this call. */
90
+ function mutation(): { idempotency_key: string; retry_until: string } {
91
+ return { idempotency_key: crypto.randomUUID(), retry_until: new Date(Date.now() + 60_000).toISOString() };
92
+ }
93
+
94
+ /** The port a configured public_url names, defaulting by scheme; undefined when it names none. */
95
+ function publicPort(value: string | undefined): number | undefined {
96
+ if (value === undefined) return undefined;
97
+ try {
98
+ const url = new URL(value);
99
+ if (url.port !== "") return Number(url.port);
100
+ return url.protocol === "https:" ? 443 : 80;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ class OmpRuntime implements PluginRuntime {
107
+ readonly contexts: ContextRegistry;
108
+ readonly storage: SqliteStorage;
109
+ readonly mainAgentId: string;
110
+ node!: OnlyneNode;
111
+ decider!: JudgeDecider;
112
+ endpoint = "";
113
+ /** RFC 0018 §2.1: where the connection being served came from, for the library's own call into `notify`. */
114
+ private readonly connection = new AsyncLocalStorage<string | undefined>();
115
+ private readonly log: AcquisitionLog;
116
+ /** The host side of one approval, built once with the node in `start`. */
117
+ private peers!: ApprovalDeps;
118
+ private server?: ReturnType<typeof Bun.serve>;
119
+ private keyDir?: string;
120
+ private network?: NetworkDiscovery;
121
+ /** The host's one periodic job: re-observing discovery. Delivery cadence belongs to the library. */
122
+ private timer?: Timer;
123
+ /** A round that outlives its interval must not overlap the next one. */
124
+ private rediscovering = false;
125
+ private discoveryError?: string;
126
+ private controller = new AbortController();
127
+ private listeners = new Set<() => void>();
128
+ private closed = false;
129
+ constructor(readonly config: OnlyneConfig, readonly pi: ExtensionAPI, readonly main: ExtensionContext, readonly host: RuntimeHost = {}) {
130
+ this.mainAgentId = main.agent.id;
131
+ this.storage = new SqliteStorage(join(dirname(config.path), "omp.db"));
132
+ this.contexts = new ContextRegistry(this.storage, config.node.id, main.agent.id);
133
+ this.log = new AcquisitionLog(this.storage);
134
+ }
135
+ async start(): Promise<void> {
136
+ let handler: (request: Request) => Promise<Response> = async () => new Response(t("initializing"), { status: 503 });
137
+ // RFC 0018 §2.1: the address a peer statement arrived from is known only here. The
138
+ // library calls `notify` synchronously from inside the request it is serving, so the
139
+ // connection's own store is still in scope when the trust event is handled.
140
+ const serve = (port: number) => Bun.serve({
141
+ hostname: this.config.server.bind, port,
142
+ fetch: (request, server) => this.connection.run(server.requestIP(request)?.address, () => handler(request)),
143
+ });
144
+ // A configured public_url is a promise about where this node answers. Advertising it
145
+ // without listening there leaves every peer that pinned the address calling a port
146
+ // nobody serves, so the port is taken unless it is already in use. Without a public_url
147
+ // the port this node last bound is reused: an address that changes on every start is one
148
+ // every peer has to be taught again, and the operator's only recourse is to pin it by hand.
149
+ const wanted = publicPort(this.config.server.public_url) ?? this.config.server.port;
150
+ let fellBack = false;
151
+ // `server.port` is optional because Bun omits it for a unix socket; this listener is TCP.
152
+ let bound: number;
153
+ try {
154
+ this.server = serve(wanted ?? 0);
155
+ bound = this.server.port!;
156
+ } catch (error) {
157
+ if (wanted === undefined) throw error;
158
+ fellBack = true;
159
+ this.server = serve(0);
160
+ bound = this.server.port!;
161
+ this.pi.logger.warn("onlyne.port.taken", { wanted, bound, error: errorText(error) });
162
+ }
163
+ const host = advertisedHost(this.config.server.bind);
164
+ const actual = `http://${host.includes(":") ? `[${host}]` : host}:${bound}/onlyne/v3`;
165
+ this.endpoint = this.config.server.public_url === undefined || fellBack ? actual : normalizeServiceUrl(this.config.server.public_url);
166
+ // The port the system chose is this node's address for as long as it keeps it. Writing it
167
+ // back is what makes the next start answer where the peers already look, and it costs one
168
+ // file write on the first start and none afterwards.
169
+ if (this.config.server.public_url === undefined && this.config.server.port !== bound) {
170
+ await this.rememberPort(bound);
171
+ }
172
+ const instanceId = crypto.randomUUID();
173
+ // The decider probes peers for their execution contexts, so it must use the node's own
174
+ // transport: an unsigned probe is refused by every keyed peer.
175
+ let transport: OnlyneNode["transport"] | undefined;
176
+ this.decider = new JudgeDecider({
177
+ config: this.config, contexts: this.contexts, mainAgentId: this.mainAgentId,
178
+ resourceQuery: meta => resourceQuery(this.node, meta),
179
+ log: (message, fields) => {
180
+ // Judge decisions and their trims are the tuning record; everything else stays debug.
181
+ if (message.startsWith("onlyne.judge.")) this.pi.logger.info(message, fields);
182
+ else this.pi.logger.debug(message, fields);
183
+ },
184
+ listPeers: async () => (await this.node.discovery.listRecords()).filter(record => record.verified_at !== undefined && Date.parse(record.expires_at) > Date.now()),
185
+ listTrust: async () => (await this.node.trust.list(this.node.auth)).map(row => ({
186
+ node_id: row.node_id, phase: trustPhase(row), base_url: row.base_url,
187
+ })),
188
+ listReceiverContexts: async id => id === this.config.node.id
189
+ ? [
190
+ { alias: "main", summary: t("main"), state: this.main.isIdle() ? "idle" : "running" },
191
+ // A lost or never-bound record cannot receive a continuation; advertising it
192
+ // would invite the sender to pick a context that fails on arrival.
193
+ ...await this.contexts.list().then(records => records
194
+ .filter(record => record.state !== "lost" && record.state !== "unbound" && canAnswer(record))
195
+ .map(record => ({ alias: record.alias, summary: record.summary, state: record.state }))),
196
+ ]
197
+ : transport!.request(id, "GET", "/contexts", undefined, this.controller.signal),
198
+ // RFC 0018 §3.2: the handshake thread is this host's own record, never a candidate
199
+ // for a conversation the agent routes or reads by name.
200
+ listThreads: async () => (await allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor })))
201
+ .filter(thread => !isTrustThread(this.config.node.id, thread.thread_ref)),
202
+ listJoinedThreads: async () => (await allPages(cursor => this.node.threads.joined(this.node.auth, { page_size: 100, cursor })))
203
+ .filter(thread => !isTrustThread(this.config.node.id, thread.thread_ref)),
204
+ listMessages: (thread, cursor) => this.node.messages.list(this.node.auth, thread, { attention: "thread", page_size: 100, cursor }),
205
+ listInbox: async () => (await allPages(cursor => this.node.messages.inbox(this.node.auth, { page_size: 100, cursor })))
206
+ .map(row => ({ context: row.context, attention: row.attention })),
207
+ threadMembers: async thread => (await this.node.threads.describe(this.node.auth, thread)).members.map(member => member.peer),
208
+ });
209
+ const bridge = createRuntimeBridge({ pi: this.pi, main: this.main, decider: this.decider, contexts: this.contexts, nodeId: this.config.node.id });
210
+ const projectRoot = dirname(dirname(this.config.path));
211
+ // `node.key_scope` names where the identity lives. Without it the historic global
212
+ // store stays in effect; `none` runs the keyless loopback development binding. A
213
+ // refused project directory (git work tree without an ignore rule) fails the start.
214
+ const keyScope = this.config.node.key_scope ?? "global";
215
+ const keyDir = this.host.keys !== undefined || keyScope === "none" ? undefined : await ensureKeyDir(projectRoot, this.config.node.id, keyScope);
216
+ this.keyDir = keyDir;
217
+ // The library owns the maintenance cadence; the host only lends it one-shot session
218
+ // timers. The loop re-arms after every pass, so a repeating timer would double it.
219
+ const clock: MaintenanceClock = {
220
+ setTimer: (ms, run) => this.main.setTimeout(run, ms),
221
+ clearTimer: handle => this.main.clearTimer(handle as Timer),
222
+ };
223
+ this.node = createOnlyneNode({ config: this.config, endpoint: this.endpoint, instanceId, storage: this.storage, contexts: this.contexts, runtime: bridge, clock,
224
+ ...(keyDir === undefined ? {} : { keyDir }),
225
+ ...(this.host.keys === undefined ? {} : { keys: this.host.keys }),
226
+ decide: input => {
227
+ const scope = currentDecision();
228
+ if (!scope) throw new OnlyneError("decision_unavailable", "No OMP session is bound to this decision.");
229
+ scope.input = input;
230
+ return this.decider.decide(scope);
231
+ },
232
+ });
233
+ transport = this.node.transport;
234
+ this.peers = {
235
+ policy: acquisitionOf(this.config),
236
+ log: this.log,
237
+ observed: async (nodeId, fingerprint) => (await this.log.observation(nodeId, fingerprint))?.observed,
238
+ declared: async nodeId => (await this.node.trust.entry(nodeId))?.base_url,
239
+ recorded: nodeId => this.recorded(nodeId),
240
+ audit: judgement => this.pi.logger.info("onlyne.acquire.decided", judgementFacts(judgement)),
241
+ accept: (nodeId, fingerprint) => this.node.trust.accept(this.node.auth, nodeId, fingerprint),
242
+ revoke: nodeId => this.node.trust.revoke(this.node.auth, nodeId),
243
+ now: () => new Date().toISOString(),
244
+ };
245
+ const memberships = (await this.node.domains.list(this.node.auth)).items;
246
+ const current = memberships[0];
247
+ if (current && current.domain_id !== this.config.region) await this.node.domains.leave(this.node.auth, current.domain_id, { ...mutation(), expected_revision: current.revision });
248
+ if (!current || current.domain_id !== this.config.region) {
249
+ let expected_revision: string | undefined;
250
+ try { expected_revision = (await this.node.domains.describe(this.node.auth, this.config.region)).revision; }
251
+ catch (error) { if (!(error instanceof OnlyneError && error.error.code === "not_found")) throw error; }
252
+ await this.node.domains.join(this.node.auth, this.config.region, { ...mutation(), expected_revision });
253
+ }
254
+ handler = request => this.node.fetch(request);
255
+ this.network = new NetworkDiscovery({ node_id: this.config.node.id, instance_id: instanceId, endpoint: this.endpoint, bind: this.config.server.bind,
256
+ onCandidate: async (url, source, expectedNode) => {
257
+ if (expectedNode === this.config.node.id) return;
258
+ await this.node.discovery.observe(url, source, expectedNode, this.controller.signal);
259
+ this.discoveryError = undefined;
260
+ this.changed();
261
+ },
262
+ onError: error => { this.discoveryError = errorText(error); this.pi.logger.warn("onlyne.discovery.failed", { error: errorText(error) }); },
263
+ });
264
+ try { await this.network.start(); } catch (error) { this.pi.logger.warn("onlyne.discovery.unavailable", { error: errorText(error) }); }
265
+ await this.contexts.ensureMain();
266
+ await this.node.client.recover(this.controller.signal);
267
+ // A saved seed is reached on every start, so a node that moved ports is found again even
268
+ // where mDNS never hears it. One unreachable seed is a logged fact, never a failed start,
269
+ // and the seeds are read together so a dead address costs one wait rather than one each.
270
+ await Promise.all((this.config.discovery?.seeds ?? []).map(async seed => {
271
+ try { await this.follow(seed); }
272
+ catch (error) { this.pi.logger.warn("onlyne.seed.failed", { seed, error: errorText(error) }); }
273
+ }));
274
+ this.changed();
275
+ this.node.client.runMaintenance();
276
+ this.rediscover();
277
+ this.timer = this.main.setInterval(() => this.rediscover(), DISCOVERY_REFRESH_MS);
278
+ this.pi.logger.info("onlyne.listening", { endpoint: this.endpoint, node: this.config.node.id, region: this.config.region });
279
+ }
280
+ /**
281
+ * RFC 0017 §5: the library reports every committed trust transition, every inbound entry
282
+ * and every pending-work flip here. The viewer repaints from it; it never polls storage
283
+ * for these.
284
+ */
285
+ notify(event: NotifyEvent): void {
286
+ if (event.event === "trust") {
287
+ this.pi.logger.info(`onlyne.trust.${event.cause}`, {
288
+ node: event.entry.node_id, phase: event.phase, fingerprint: event.entry.fingerprint,
289
+ });
290
+ // RFC 0018 §2.1: the address this statement arrived from exists only inside the
291
+ // call the library is making right now, and nothing after it can recover it.
292
+ this.trustEvent(event, this.connection.getStore());
293
+ }
294
+ this.changed();
295
+ }
296
+ onChange(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
297
+ private changed(): void { for (const listener of this.listeners) listener(); }
298
+ /**
299
+ * RFC 0018 §2/§3: one trust transition's host work — the observation its statement's
300
+ * connection left, the judgement of an invitation this host may approve itself, and the
301
+ * handshake entry the agent reads. Everything is reported by the library's own call, so it
302
+ * runs beside it; a failure here is logged and is never a reason to doubt the committed row.
303
+ */
304
+ private trustEvent(event: TrustNotify, observed: string | undefined): void {
305
+ void this.handleTrust(event, observed).catch(error => {
306
+ this.pi.logger.warn("onlyne.trust.handling_failed", { node: event.entry.node_id, phase: event.phase, error: errorText(error) });
307
+ });
308
+ }
309
+ private async handleTrust(event: TrustNotify, observed: string | undefined): Promise<void> {
310
+ const node = event.entry.node_id;
311
+ const fingerprint = event.entry.fingerprint;
312
+ // The address is recorded for every peer statement, before its verdict. Local
313
+ // transitions never replace it with the address of the approval call (which has none),
314
+ // so an approval decided later still reads the statement's original connection.
315
+ const at = new Date().toISOString();
316
+ if (event.cause === "peer-statement") {
317
+ await this.log.observe(node, fingerprint, {
318
+ ...(observed === undefined ? {} : { observed }), at, cause: event.cause,
319
+ });
320
+ } else await this.log.transition(node, fingerprint, at, event.cause);
321
+ // RFC 0018 §2.2: the verdict reads the catalog as it stood when the statement arrived.
322
+ // The record this host derives from that statement is not part of the judgement, or the
323
+ // two conditions would collapse into one.
324
+ const recorded = await this.recorded(node);
325
+ // The statement's own address is also a discovery candidate here, as the tern host's
326
+ // bridge records it.
327
+ if (event.entry.base_url) {
328
+ await this.node.discovery.observe(event.entry.base_url, "trust-statement", node).catch(() => undefined);
329
+ }
330
+ // §2.2: only an invitation whose statement covered a connection this host observed, and
331
+ // whose declared and recorded addresses all stay inside the allow set, is committed
332
+ // without a human deciding first.
333
+ const judgement = event.phase === "invited" && event.cause === "peer-statement"
334
+ ? await judgeApproval(this.peers, { node_id: node, fingerprint, observed, recorded })
335
+ : undefined;
336
+ if (judgement?.verdict.allowed === true) this.approveDeferred(node, fingerprint);
337
+ await this.deliverHandshake(event, observed, judgement);
338
+ }
339
+ /**
340
+ * RFC 0018 §2: the approval an invitation earned is committed outside the library's call,
341
+ * so the admission that notified this host is never re-entered.
342
+ */
343
+ private approveDeferred(node: string, fingerprint: string): void {
344
+ queueMicrotask(() => {
345
+ void this.peers.accept(node, fingerprint)
346
+ .catch(error => this.pi.logger.warn("onlyne.acquire.approve_failed", { node, error: errorText(error) }));
347
+ });
348
+ }
349
+ /**
350
+ * RFC 0018 §3: one handshake entry per `(node, fingerprint, phase)`, written into this
351
+ * node's own thread under a plan bound to the main session. The thread has this node as its
352
+ * only member, so the entry never leaves the node.
353
+ */
354
+ private async deliverHandshake(event: TrustNotify, observed: string | undefined, judgement: ApprovalJudgement | undefined): Promise<void> {
355
+ const node = event.entry.node_id;
356
+ const fingerprint = event.entry.fingerprint;
357
+ if (await this.log.delivered(node, fingerprint, event.phase)) return;
358
+ const handshake = handshakeEntry({
359
+ node_id: node, fingerprint, phase: event.phase, declared: event.entry.base_url, observed,
360
+ policy: this.peers.policy, judgement, approved: judgement?.verdict.allowed === true,
361
+ });
362
+ await this.ensureTrustThread();
363
+ const outcome = await this.node.client.send(this.config.node.id, {
364
+ id: crypto.randomUUID(),
365
+ sender: this.config.node.id,
366
+ delivery_kind: "trust",
367
+ msg: handshake.msg,
368
+ // The mention is what puts the entry in this node's own inbox: the delivery plan
369
+ // names the main context, and the bridge reads the facts for its todo hook.
370
+ meta: { thread: trustThread(this.config.node.id), mentions: [this.config.node.id], data: { trust: handshake.trust } },
371
+ }, { response: "none", execution: "existing", context: MAIN_CONTEXT });
372
+ if (outcome.admission !== "accepted") {
373
+ this.pi.logger.warn("onlyne.trust.delivery_failed", { node, phase: event.phase, error: outcome.error?.message });
374
+ return;
375
+ }
376
+ await this.log.markDelivered(node, fingerprint, event.phase, new Date().toISOString());
377
+ }
378
+ /** RFC 0018 §3.2: the handshake thread, created on first use. Its id is fixed, so it is one thread for the node's lifetime. */
379
+ private async ensureTrustThread(): Promise<void> {
380
+ const ref = trustThread(this.config.node.id);
381
+ try {
382
+ await this.node.threads.describe(this.node.auth, ref);
383
+ return;
384
+ } catch (error) {
385
+ if (!(error instanceof OnlyneError && error.error.code === "not_found")) throw error;
386
+ }
387
+ await this.node.threads.create(this.node.auth, { title: TRUST_THREAD, id: ref.id, ...mutation() });
388
+ }
389
+ /** RFC 0018 §2.2: every address discovery holds for this node is an outbound candidate the policy must allow. */
390
+ private async recorded(node: string): Promise<string[]> {
391
+ const records = await this.node.discovery.listRecords();
392
+ return [...new Set(records.filter(record => record.node_id === node).map(record => record.base_url))];
393
+ }
394
+ /**
395
+ * A user-typed address. It is normalized once, read for its own identity and everything it
396
+ * declares, and only a successful read becomes a seed. The node keeps running: a seed is a
397
+ * routing hint, not a second binding.
398
+ */
399
+ async contact(endpoint: string): Promise<void> {
400
+ const seed = normalizeServiceUrl(endpoint);
401
+ await this.follow(seed);
402
+ await this.rememberSeed(seed);
403
+ this.changed();
404
+ }
405
+ /**
406
+ * Reads one address into the discovery catalog. The peer's own directory is a bonus: a
407
+ * node that answers `/description` but not `/discovery` is still a usable neighbor.
408
+ */
409
+ private async follow(seed: string): Promise<void> {
410
+ await this.node.discovery.observe(seed, "bootstrap", undefined, this.controller.signal);
411
+ await this.followDirectory(seed).catch(error => {
412
+ this.pi.logger.debug("onlyne.seed.directory", { seed, error: errorText(error) });
413
+ });
414
+ }
415
+ /** Every peer the address declares, each read on its own: one failure does not stop the rest. */
416
+ private async followDirectory(seed: string): Promise<void> {
417
+ const url = new URL(`${seed}/discovery`);
418
+ let cursor: string | undefined;
419
+ do {
420
+ url.search = "";
421
+ if (cursor) url.searchParams.set("cursor", cursor);
422
+ const response = await fetch(url, { signal: this.controller.signal });
423
+ if (!response.ok) throw new OnlyneError("unavailable", `Discovery directory answered HTTP ${response.status}.`);
424
+ const page = await response.json() as Page<DiscoveryCandidate>;
425
+ if (!Array.isArray(page.items)) throw new OnlyneError("invalid_request", "The discovery page has no items.");
426
+ for (const candidate of page.items) {
427
+ if (candidate.node_id === this.config.node.id) continue;
428
+ await this.node.discovery.observe(candidate.base_url, seed, candidate.node_id, this.controller.signal).catch(() => undefined);
429
+ }
430
+ cursor = page.next_cursor ?? undefined;
431
+ } while (cursor);
432
+ }
433
+ /** The seed list is configuration: one write per new address, and none on a start. */
434
+ private async rememberSeed(seed: string): Promise<void> {
435
+ const seeds = this.config.discovery?.seeds ?? [];
436
+ if (seeds.includes(seed)) return;
437
+ const saved = await saveConfig({ ...this.config, discovery: { seeds: [...seeds, seed] } });
438
+ this.config.discovery = saved.discovery;
439
+ }
440
+ /**
441
+ * The listening port is configuration for the same reason a seed is: a peer holds the
442
+ * address it last reached, and a node that answers somewhere new has moved without telling
443
+ * anyone. One write on the first start, and one more only after a port conflict moved it.
444
+ */
445
+ private async rememberPort(port: number): Promise<void> {
446
+ const saved = await saveConfig({ ...this.config, server: { ...this.config.server, port } });
447
+ this.config.server = saved.server;
448
+ this.pi.logger.info("onlyne.port.persisted", { port, path: this.config.path });
449
+ }
450
+ /** Stage one of one exchange: the peer's proved fingerprint, and the session that commits it. */
451
+ async trustBegin(node: string): Promise<KeyExchange> {
452
+ return this.node.trust.begin(this.node.auth, node);
453
+ }
454
+ async trustAccept(node: string, fingerprint: string): Promise<void> {
455
+ await this.node.trust.accept(this.node.auth, node, fingerprint);
456
+ }
457
+ async trustRevoke(node: string): Promise<void> {
458
+ await this.node.trust.revoke(this.node.auth, node);
459
+ }
460
+ /** RFC 0018 §2/§3.1: the agent's approval, refused with `policy_denied` when the policy does not allow it. */
461
+ async acquireAccept(node: string, fingerprint: string): Promise<TrustEntry> {
462
+ return approvePeer(this.peers, node, fingerprint);
463
+ }
464
+ /** RFC 0018 §2/§3.1: the agent's revocation, which no policy judges. */
465
+ async acquireRevoke(node: string): Promise<void> {
466
+ await revokePeer(this.peers, node);
467
+ }
468
+ /**
469
+ * RFC 0018 §2/§3.1: the agent's initiation of one exchange. The judgement reads only what this
470
+ * host already stores; the peer's key is read after it, so a refusal sends nothing.
471
+ */
472
+ async acquireBegin(node: string): Promise<TrustEntry> {
473
+ const row = await this.node.trust.entry(node);
474
+ const statement = row === undefined ? undefined : (await this.log.observation(node, row.fingerprint))?.observed;
475
+ const records = (await this.node.discovery.listRecords()).filter(record => record.node_id === node);
476
+ const observed = outboundObserved({ statement, records, declared: row?.base_url });
477
+ const request: ApprovalRequest = {
478
+ node_id: node,
479
+ ...(row === undefined ? {} : { fingerprint: row.fingerprint }),
480
+ ...(observed === undefined ? {} : { observed }),
481
+ };
482
+ const committed = await beginPeer(this.peers, target => this.node.trust.begin(this.node.auth, target), request);
483
+ return committed.entry;
484
+ }
485
+ async trustRotate(): Promise<{ fingerprint: string }> {
486
+ const identity = await this.node.trust.rotate(this.node.auth);
487
+ return { fingerprint: identity.fingerprint };
488
+ }
489
+ async refreshDiscovery(): Promise<void> {
490
+ await this.node.discovery.refresh(this.controller.signal);
491
+ this.changed();
492
+ }
493
+ /** One discovery round on the host's timer; a failed round keeps the last good catalog. */
494
+ private rediscover(): void {
495
+ if (this.rediscovering) return;
496
+ this.rediscovering = true;
497
+ void this.refreshDiscovery()
498
+ .catch(error => { if (!this.closed) this.pi.logger.warn("onlyne.discovery.failed", { error: errorText(error) }); })
499
+ .finally(() => { this.rediscovering = false; });
500
+ }
501
+
502
+ async status(): Promise<OnlyneStatus> {
503
+ const records = await this.node.discovery.listRecords();
504
+ const inbox = await allPages(cursor => this.node.messages.inbox(this.node.auth, { page_size: 100, cursor }));
505
+ const at = Date.now();
506
+ // Reachable now: a fresh direct observation, never an expired or never-verified clue.
507
+ const reachable = new Set(records
508
+ .filter(record => record.verified_at !== undefined && Date.parse(record.expires_at) > at)
509
+ .map(record => record.node_id));
510
+ reachable.add(this.config.node.id);
511
+ // One hop is the set this node can actually address: peers it has verified and, when
512
+ // keys are in use, trusted. A clue from a relay is not a neighbor.
513
+ const peers = new Set(records
514
+ .filter(record => record.node_id !== this.config.node.id && record.verified_at !== undefined && Date.parse(record.expires_at) > at)
515
+ .map(record => record.node_id));
516
+ if (this.node.keys.enabled) {
517
+ const trusted = new Set((await this.node.trust.list(this.node.auth)).filter(entry => trustPhase(entry) === "exchanged").map(entry => entry.node_id));
518
+ for (const node of [...peers]) if (!trusted.has(node)) peers.delete(node);
519
+ }
520
+ // Live attention only: an acknowledged or consumed item is history, not an alert.
521
+ const alerts = inbox.filter(item => !item.attention.acked && !item.injected).length;
522
+ return {
523
+ region: this.config.region, node_id: this.config.node.id, nodes: reachable.size, neighbors: peers.size,
524
+ alerts, unread: inbox.filter(item => !item.injected).length,
525
+ ...(this.discoveryError === undefined ? {} : { discovery_error: this.discoveryError }),
526
+ };
527
+ }
528
+ async snapshot(): Promise<ViewerSnapshot> {
529
+ const contexts = await this.contexts.list();
530
+ const records = await this.node.discovery.listRecords();
531
+ const inbox = await allPages(cursor => this.node.messages.inbox(this.node.auth, { page_size: 100, cursor }));
532
+ const views: ViewerThread[] = [];
533
+ // Hosted threads are read locally. A thread this node only belongs to lives on its
534
+ // owner, so it is shown from the attention rows already held here; the viewer never
535
+ // reaches across the network.
536
+ const hosted = new Set<string>();
537
+ for (const thread of await allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor }))) {
538
+ const ref = thread.thread_ref;
539
+ hosted.add(`${ref.owner}/${ref.id}`);
540
+ // RFC 0018 §3.2: the handshake entries stay out of the conversation tree. The
541
+ // thread is still recorded above, so its own inbox rows are not re-rendered here.
542
+ if (isTrustThread(this.config.node.id, ref)) continue;
543
+ const entries = await allPages(cursor => this.node.messages.list(this.node.auth, ref, { attention: "thread", page_size: 100, cursor }));
544
+ views.push({ ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: thread.title, state: thread.state,
545
+ unread: inbox.filter(item => !item.injected && item.attention.thread_ref.owner === ref.owner && item.attention.thread_ref.id === ref.id).length,
546
+ last_seq: entries.at(-1)?.seq ?? "0", messages: entries.map(entry => ({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation })),
547
+ });
548
+ }
549
+ // A member's own message never comes back through its inbox, so the message that
550
+ // opened a thread it only takes part in survives only in the outbound record that
551
+ // carried it. Read those here and merge them below the inbox rows, or the member
552
+ // sees the reply and not the question that started it.
553
+ const outbound = await this.storage.transaction(tx => tx.scan<Outbound>("outbound/"));
554
+ const seen = new Map<string, ViewerThread>();
555
+ // A grant can arrive before any message, and a thread this node was invited into is a row
556
+ // the viewer must show. The local joined listing supplies those rows; the owner is never
557
+ // asked here, so the viewer stays a read of this node's own records.
558
+ for (const row of await allPages(cursor => this.node.threads.joined(this.node.auth, { page_size: 100, cursor }))) {
559
+ const ref = row.thread_ref;
560
+ const key = `${ref.owner}/${ref.id}`;
561
+ if (hosted.has(key) || seen.has(key)) continue;
562
+ seen.set(key, { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: row.last_seq, messages: [], read_only: true });
563
+ }
564
+ for (const item of inbox) {
565
+ const ref = item.attention.thread_ref;
566
+ const key = `${ref.owner}/${ref.id}`;
567
+ if (hosted.has(key)) continue;
568
+ const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [], read_only: true };
569
+ seen.set(key, view);
570
+ if (!item.injected) view.unread++;
571
+ const entry = item.attention.entry;
572
+ if (entry !== undefined) {
573
+ view.messages.push({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation });
574
+ view.last_seq = entry.seq;
575
+ }
576
+ }
577
+ for (const row of outbound) {
578
+ const receipt = row.value.receipt;
579
+ if (receipt === undefined) continue;
580
+ const ref = receipt.thread_ref;
581
+ const key = `${ref.owner}/${ref.id}`;
582
+ if (hosted.has(key)) continue;
583
+ const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [], read_only: true };
584
+ seen.set(key, view);
585
+ const message = row.value.request.message;
586
+ view.messages.push({ ref: { sender: message.sender, id: message.id }, seq: receipt.seq, direction: "out", author: message.sender, at: row.value.created_at ?? "", text: message.msg, relation: message.meta?.relation });
587
+ if (Number(receipt.seq) > Number(view.last_seq)) view.last_seq = receipt.seq;
588
+ }
589
+ for (const view of seen.values()) view.messages.sort((left, right) => Number(left.seq) - Number(right.seq));
590
+ views.push(...seen.values());
591
+ const queue: QueueItem[] = inbox.filter(item => !item.injected).map(item => ({ id: `inbound:${item.attention.obligation_id}`, kind: "inbound", label: `${item.attention.thread_ref.id}/${item.attention.seq}`, detail: t("pending", { author: item.attention.authenticated_author }), at: item.attention.entry?.at }));
592
+ queue.push(...contexts.filter(record => record.state === "running" || record.state === "unbound").map(record => ({ id: `context:${record.alias}`, kind: "context" as const, label: record.alias, detail: `${record.summary} · ${record.state}`, at: record.created_at })));
593
+ queue.push(...outbound.filter(row => row.value.state === "unknown" || row.value.state === "pending").map(row => ({ id: row.key, kind: "outbound" as const, label: row.value.request.target_endpoint, detail: row.value.state })));
594
+ const at = Date.now();
595
+ // One row per node: a node that moved ports would otherwise appear once per address.
596
+ // A verified record beats an unverified one; within each class the newest wins.
597
+ const byNode = new Map<string, DiscoveryRecord>();
598
+ for (const record of records) {
599
+ const kept = byNode.get(record.node_id);
600
+ if (kept === undefined || newerRecord(record, kept)) byNode.set(record.node_id, record);
601
+ }
602
+ const discovery: ViewerDiscovery[] = [...byNode.values()].map(record => ({
603
+ node_id: record.node_id, instance_id: record.instance_id, base_url: record.base_url, source: record.source,
604
+ ...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
605
+ ...(record.membership?.state === undefined ? {} : { membership_state: record.membership.state }),
606
+ ...(record.verified_at === undefined ? {} : { verified_at: record.verified_at }),
607
+ expires_at: record.expires_at, last_seen: record.last_seen,
608
+ ...(record.error === undefined ? {} : { error: record.error.message }),
609
+ }));
610
+ const newest = new Map<string, DiscoveryRecord>();
611
+ for (const record of records) {
612
+ if (record.node_id === this.config.node.id || record.verified_at === undefined || Date.parse(record.expires_at) <= at) continue;
613
+ const kept = newest.get(record.node_id);
614
+ if (kept === undefined || newerRecord(record, kept)) newest.set(record.node_id, record);
615
+ }
616
+ const contacts: ViewerContact[] = [...newest.values()].map(record => ({
617
+ node_id: record.node_id, instance_id: record.instance_id, base_url: record.base_url,
618
+ ...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
619
+ last_seen: record.last_seen,
620
+ }));
621
+ const trust = this.node.keys.enabled ? await this.node.trust.list(this.node.auth) : [];
622
+ const identity = this.node.keys.enabled ? await this.node.keys.describe() : undefined;
623
+ return { region: this.config.region, endpoint: this.endpoint, contexts, queue, threads: views, discovery, contacts,
624
+ trust: trust.map(row => ({ node_id: row.node_id, fingerprint: row.fingerprint, phase: trustPhase(row), base_url: row.base_url })),
625
+ ...(identity === undefined ? {} : { identity: { public_key: identity.public_key, fingerprint: identity.fingerprint } }),
626
+ ...(this.discoveryError === undefined ? {} : { discovery_error: this.discoveryError }) };
627
+ }
628
+ async updateConfig(next: OnlyneConfig): Promise<void> { await saveConfig(next); await restartRuntime(next, this.pi, this.main, this.host); }
629
+ async close(): Promise<void> {
630
+ if (this.closed) return;
631
+ this.closed = true;
632
+ this.controller.abort();
633
+ if (this.timer) this.main.clearTimer(this.timer);
634
+ // A start that failed before the node existed still comes here to release the server.
635
+ this.node?.client.stopMaintenance();
636
+ await this.network?.close();
637
+ this.server?.stop(true);
638
+ this.storage.close();
639
+ this.listeners.clear();
640
+ if (active === this) active = undefined;
641
+ }
642
+ }
643
+ export async function allPages<T>(read: (cursor?: string) => Promise<Page<T>>): Promise<T[]> {
644
+ const items: T[] = [];
645
+ let cursor: string | undefined;
646
+ do { const page = await read(cursor); items.push(...page.items); cursor = page.next_cursor ?? undefined; } while (cursor);
647
+ return items;
648
+ }
649
+
650
+ function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }
651
+
652
+ /**
653
+ * RFC 0018 §2.1–2.2: the address an outbound judgement reads for a peer this host may initiate
654
+ * with. The arrival address this host recorded for the peer's last statement is the strongest
655
+ * fact it holds; a peer that never sent one is judged at an address discovery verified by
656
+ * probing, and with no record at all at the address the library's own fallback would connect to.
657
+ */
658
+ export function outboundObserved(facts: {
659
+ statement?: string;
660
+ records: readonly DiscoveryRecord[];
661
+ declared?: string;
662
+ }): string | undefined {
663
+ if (facts.statement !== undefined && facts.statement !== "") return facts.statement;
664
+ const verified = facts.records
665
+ .filter(record => record.verified_at !== undefined)
666
+ .sort((left, right) => right.received_at.localeCompare(left.received_at));
667
+ return verified[0]?.base_url ?? (facts.declared === "" ? undefined : facts.declared);
668
+ }
669
+
670
+ /** A verified record beats an unverified one; within each class the newest observation wins. */
671
+ function newerRecord(candidate: DiscoveryRecord, kept: DiscoveryRecord): boolean {
672
+ const candidateVerified = candidate.verified_at !== undefined;
673
+ const keptVerified = kept.verified_at !== undefined;
674
+ if (candidateVerified !== keptVerified) return candidateVerified;
675
+ return Date.parse(candidate.last_seen) > Date.parse(kept.last_seen);
676
+ }