omp-onlyne 0.0.0-stage → 0.1.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/AGENTS.sample.md +27 -0
- package/LICENSE +21 -0
- package/README.md +132 -2
- package/index.ts +55 -0
- package/package.json +62 -5
- package/src/binding.ts +132 -0
- package/src/bridge-text.ts +109 -0
- package/src/config.ts +300 -0
- package/src/decider-text.ts +420 -0
- package/src/decider.ts +1982 -0
- package/src/drafts.ts +57 -0
- package/src/host-text.ts +86 -0
- package/src/messages.ts +327 -0
- package/src/node.ts +127 -0
- package/src/resources.ts +36 -0
- package/src/runtime.ts +416 -0
- package/src/store.ts +189 -0
- package/src/tern.ts +66 -0
- package/src/todo-native.ts +230 -0
- package/src/todo.ts +896 -0
- package/src/tool-text.ts +133 -0
- package/src/tool.ts +236 -0
- package/src/types.ts +136 -0
- package/src/ui/bootstrap-text.ts +125 -0
- package/src/ui/bootstrap.ts +358 -0
- package/src/ui/command-text.ts +38 -0
- package/src/ui/format.ts +56 -0
- package/src/ui/index.ts +156 -0
- package/src/ui/mail-message.ts +263 -0
- package/src/ui/status.ts +213 -0
- package/src/ui/viewer-text.ts +305 -0
- package/src/ui/viewer.ts +1386 -0
- package/src/urls-text.ts +286 -0
- package/src/urls.ts +1347 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { OnlyneError, trustPhase, type DiscoveryCandidate, type DiscoveryRecord, type KeyExchange, type KeyHook, type MaintenanceClock, type NotifyEvent, type OnlyneNode, type Outbound, type Page } from "@onlyne/lib";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
4
|
+
import { ensureKeyDir } from "@onlyne/node-host/keys";
|
|
5
|
+
import { NetworkDiscovery } from "@onlyne/node-host/network";
|
|
6
|
+
import { resolveLocale, setLocale } from "@onlyne/node-host/i18n";
|
|
7
|
+
import { commandText as t } from "./ui/command-text.ts";
|
|
8
|
+
import { registerMsgTool } from "./tool.ts";
|
|
9
|
+
import { currentDecision, JudgeDecider } from "./decider.ts";
|
|
10
|
+
import { loadConfig, normalizeServiceUrl, saveConfig, shortNodeId } from "./config.ts";
|
|
11
|
+
import { createRuntimeBridge } from "./messages.ts";
|
|
12
|
+
import { advertisedHost, createOnlyneNode } from "./node.ts";
|
|
13
|
+
import { canAnswer, ContextRegistry, SqliteStorage } from "./store.ts";
|
|
14
|
+
import { resourceQuery } from "./resources.ts";
|
|
15
|
+
import type { OnlyneConfig, OnlyneStatus, QueueItem, ViewerContact, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export interface PluginRuntime {
|
|
18
|
+
readonly config: OnlyneConfig;
|
|
19
|
+
readonly endpoint: string;
|
|
20
|
+
readonly mainAgentId: string;
|
|
21
|
+
readonly main: ExtensionContext;
|
|
22
|
+
readonly pi: ExtensionAPI;
|
|
23
|
+
readonly contexts: ContextRegistry;
|
|
24
|
+
readonly node: OnlyneNode;
|
|
25
|
+
readonly storage: SqliteStorage;
|
|
26
|
+
readonly decider: JudgeDecider;
|
|
27
|
+
snapshot(): Promise<ViewerSnapshot>;
|
|
28
|
+
status(): Promise<OnlyneStatus>;
|
|
29
|
+
onChange(listener: () => void): () => void;
|
|
30
|
+
/** The library's one notification channel: trust transitions, inbound entries, pending-work flips. */
|
|
31
|
+
notify(event: NotifyEvent): void;
|
|
32
|
+
contact(endpoint: string): Promise<void>;
|
|
33
|
+
/** Stage one of one exchange; the returned session carries the fingerprint the peer proved. */
|
|
34
|
+
trustBegin(node: string): Promise<KeyExchange>;
|
|
35
|
+
trustAccept(node: string, fingerprint: string): Promise<void>;
|
|
36
|
+
trustRevoke(node: string): Promise<void>;
|
|
37
|
+
trustRotate(): Promise<{ fingerprint: string }>;
|
|
38
|
+
refreshDiscovery(): Promise<void>;
|
|
39
|
+
updateConfig(next: OnlyneConfig): Promise<void>;
|
|
40
|
+
close(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
let active: PluginRuntime | undefined;
|
|
43
|
+
export interface RuntimeHost {
|
|
44
|
+
config?: () => Promise<OnlyneConfig>;
|
|
45
|
+
keys?: KeyHook;
|
|
46
|
+
}
|
|
47
|
+
export function getRuntime(): PluginRuntime | undefined { return active; }
|
|
48
|
+
export function setRuntime(runtime: PluginRuntime | undefined): void { active = runtime; }
|
|
49
|
+
export async function startRuntime(pi: ExtensionAPI, ctx: ExtensionContext, host: RuntimeHost = {}): Promise<PluginRuntime> {
|
|
50
|
+
const config = await (host.config === undefined ? loadConfig(ctx.cwd) : host.config());
|
|
51
|
+
setLocale(resolveLocale(config.language));
|
|
52
|
+
if (active && active.config.path === config.path && JSON.stringify(active.config) === JSON.stringify(config)) return active;
|
|
53
|
+
return restartRuntime(config, pi, ctx, host);
|
|
54
|
+
}
|
|
55
|
+
export async function restartRuntime(config: OnlyneConfig, pi: ExtensionAPI, ctx: ExtensionContext, host: RuntimeHost = {}): Promise<PluginRuntime> {
|
|
56
|
+
setLocale(resolveLocale(config.language));
|
|
57
|
+
await active?.close();
|
|
58
|
+
const runtime = new OmpRuntime(config, pi, ctx, host);
|
|
59
|
+
try { await runtime.start(); } catch (error) { await runtime.close(); throw error; }
|
|
60
|
+
active = runtime;
|
|
61
|
+
registerMsgTool(pi);
|
|
62
|
+
return runtime;
|
|
63
|
+
}
|
|
64
|
+
export async function stopRuntime(): Promise<void> { await active?.close(); }
|
|
65
|
+
|
|
66
|
+
/** How often the discovery catalog is re-observed; well inside the library's 120 s freshness window. */
|
|
67
|
+
const DISCOVERY_REFRESH_MS = 30_000;
|
|
68
|
+
|
|
69
|
+
class OmpRuntime implements PluginRuntime {
|
|
70
|
+
readonly contexts: ContextRegistry;
|
|
71
|
+
readonly storage: SqliteStorage;
|
|
72
|
+
readonly mainAgentId: string;
|
|
73
|
+
node!: OnlyneNode;
|
|
74
|
+
decider!: JudgeDecider;
|
|
75
|
+
endpoint = "";
|
|
76
|
+
private server?: ReturnType<typeof Bun.serve>;
|
|
77
|
+
private keyDir?: string;
|
|
78
|
+
private network?: NetworkDiscovery;
|
|
79
|
+
/** The host's one periodic job: re-observing discovery. Delivery cadence belongs to the library. */
|
|
80
|
+
private timer?: Timer;
|
|
81
|
+
/** A round that outlives its interval must not overlap the next one. */
|
|
82
|
+
private rediscovering = false;
|
|
83
|
+
private discoveryError?: string;
|
|
84
|
+
private controller = new AbortController();
|
|
85
|
+
private listeners = new Set<() => void>();
|
|
86
|
+
private closed = false;
|
|
87
|
+
constructor(readonly config: OnlyneConfig, readonly pi: ExtensionAPI, readonly main: ExtensionContext, readonly host: RuntimeHost = {}) {
|
|
88
|
+
this.mainAgentId = main.agent.id;
|
|
89
|
+
this.storage = new SqliteStorage(join(dirname(config.path), "omp.db"));
|
|
90
|
+
this.contexts = new ContextRegistry(this.storage, config.node.id, main.agent.id);
|
|
91
|
+
}
|
|
92
|
+
async start(): Promise<void> {
|
|
93
|
+
let handler: (request: Request) => Promise<Response> = async () => new Response(t("initializing"), { status: 503 });
|
|
94
|
+
this.server = Bun.serve({ hostname: this.config.server.bind, port: 0, fetch: request => handler(request) });
|
|
95
|
+
const host = advertisedHost(this.config.server.bind);
|
|
96
|
+
this.endpoint = `http://${host.includes(":") ? `[${host}]` : host}:${this.server.port}/onlyne/v3`;
|
|
97
|
+
const instanceId = crypto.randomUUID();
|
|
98
|
+
// The decider probes peers for their execution contexts, so it must use the node's own
|
|
99
|
+
// transport: an unsigned probe is refused by every keyed peer.
|
|
100
|
+
let transport: OnlyneNode["transport"] | undefined;
|
|
101
|
+
this.decider = new JudgeDecider({
|
|
102
|
+
config: this.config, contexts: this.contexts, mainAgentId: this.mainAgentId,
|
|
103
|
+
resourceQuery: meta => resourceQuery(this.node, meta),
|
|
104
|
+
log: (message, fields) => {
|
|
105
|
+
// Judge decisions and their trims are the tuning record; everything else stays debug.
|
|
106
|
+
if (message.startsWith("onlyne.judge.")) this.pi.logger.info(message, fields);
|
|
107
|
+
else this.pi.logger.debug(message, fields);
|
|
108
|
+
},
|
|
109
|
+
listPeers: async () => (await this.node.discovery.listRecords()).filter(record => record.verified_at !== undefined && Date.parse(record.expires_at) > Date.now()),
|
|
110
|
+
listReceiverContexts: async id => id === this.config.node.id
|
|
111
|
+
? [
|
|
112
|
+
{ alias: "main", summary: t("main"), state: this.main.isIdle() ? "idle" : "running" },
|
|
113
|
+
// A lost or never-bound record cannot receive a continuation; advertising it
|
|
114
|
+
// would invite the sender to pick a context that fails on arrival.
|
|
115
|
+
...await this.contexts.list().then(records => records
|
|
116
|
+
.filter(record => record.state !== "lost" && record.state !== "unbound" && canAnswer(record))
|
|
117
|
+
.map(record => ({ alias: record.alias, summary: record.summary, state: record.state }))),
|
|
118
|
+
]
|
|
119
|
+
: transport!.request(id, "GET", "/contexts", undefined, this.controller.signal),
|
|
120
|
+
listThreads: () => allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor })),
|
|
121
|
+
listMessages: (thread, cursor) => this.node.messages.list(this.node.auth, thread, { attention: "thread", page_size: 100, cursor }),
|
|
122
|
+
});
|
|
123
|
+
const bridge = createRuntimeBridge({ pi: this.pi, main: this.main, decider: this.decider, contexts: this.contexts, nodeId: this.config.node.id });
|
|
124
|
+
const projectRoot = dirname(dirname(this.config.path));
|
|
125
|
+
// `node.key_scope` names where the identity lives. Without it the historic global
|
|
126
|
+
// store stays in effect; `none` runs the keyless loopback development binding. A
|
|
127
|
+
// refused project directory (git work tree without an ignore rule) fails the start.
|
|
128
|
+
const keyScope = this.config.node.key_scope ?? "global";
|
|
129
|
+
const keyDir = this.host.keys !== undefined || keyScope === "none" ? undefined : await ensureKeyDir(projectRoot, this.config.node.id, keyScope);
|
|
130
|
+
this.keyDir = keyDir;
|
|
131
|
+
// The library owns the maintenance cadence; the host only lends it one-shot session
|
|
132
|
+
// timers. The loop re-arms after every pass, so a repeating timer would double it.
|
|
133
|
+
const clock: MaintenanceClock = {
|
|
134
|
+
setTimer: (ms, run) => this.main.setTimeout(run, ms),
|
|
135
|
+
clearTimer: handle => this.main.clearTimer(handle as Timer),
|
|
136
|
+
};
|
|
137
|
+
this.node = createOnlyneNode({ config: this.config, endpoint: this.endpoint, instanceId, storage: this.storage, contexts: this.contexts, runtime: bridge, clock,
|
|
138
|
+
...(keyDir === undefined ? {} : { keyDir }),
|
|
139
|
+
...(this.host.keys === undefined ? {} : { keys: this.host.keys }),
|
|
140
|
+
decide: input => {
|
|
141
|
+
const scope = currentDecision();
|
|
142
|
+
if (!scope) throw new OnlyneError("decision_unavailable", "No OMP session is bound to this decision.");
|
|
143
|
+
scope.input = input;
|
|
144
|
+
return this.decider.decide(scope);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
transport = this.node.transport;
|
|
148
|
+
const memberships = (await this.node.domains.list(this.node.auth)).items;
|
|
149
|
+
const current = memberships[0];
|
|
150
|
+
const mutation = () => ({ idempotency_key: crypto.randomUUID(), retry_until: new Date(Date.now() + 60_000).toISOString() });
|
|
151
|
+
if (current && current.domain_id !== this.config.region) await this.node.domains.leave(this.node.auth, current.domain_id, { ...mutation(), expected_revision: current.revision });
|
|
152
|
+
if (!current || current.domain_id !== this.config.region) {
|
|
153
|
+
let expected_revision: string | undefined;
|
|
154
|
+
try { expected_revision = (await this.node.domains.describe(this.node.auth, this.config.region)).revision; }
|
|
155
|
+
catch (error) { if (!(error instanceof OnlyneError && error.error.code === "not_found")) throw error; }
|
|
156
|
+
await this.node.domains.join(this.node.auth, this.config.region, { ...mutation(), expected_revision });
|
|
157
|
+
}
|
|
158
|
+
handler = request => this.node.fetch(request);
|
|
159
|
+
this.network = new NetworkDiscovery({ node_id: this.config.node.id, instance_id: instanceId, endpoint: this.endpoint, bind: this.config.server.bind,
|
|
160
|
+
onCandidate: async (url, source, expectedNode) => {
|
|
161
|
+
if (expectedNode === this.config.node.id) return;
|
|
162
|
+
await this.node.discovery.observe(url, source, expectedNode, this.controller.signal);
|
|
163
|
+
this.discoveryError = undefined;
|
|
164
|
+
this.changed();
|
|
165
|
+
},
|
|
166
|
+
onError: error => { this.discoveryError = errorText(error); this.pi.logger.warn("onlyne.discovery.failed", { error: errorText(error) }); },
|
|
167
|
+
});
|
|
168
|
+
try { await this.network.start(); } catch (error) { this.pi.logger.warn("onlyne.discovery.unavailable", { error: errorText(error) }); }
|
|
169
|
+
await this.contexts.ensureMain();
|
|
170
|
+
await this.node.client.recover(this.controller.signal);
|
|
171
|
+
// A saved seed is reached on every start, so a node that moved ports is found again even
|
|
172
|
+
// where mDNS never hears it. One unreachable seed is a logged fact, never a failed start,
|
|
173
|
+
// and the seeds are read together so a dead address costs one wait rather than one each.
|
|
174
|
+
await Promise.all((this.config.discovery?.seeds ?? []).map(async seed => {
|
|
175
|
+
try { await this.follow(seed); }
|
|
176
|
+
catch (error) { this.pi.logger.warn("onlyne.seed.failed", { seed, error: errorText(error) }); }
|
|
177
|
+
}));
|
|
178
|
+
this.changed();
|
|
179
|
+
this.node.client.runMaintenance();
|
|
180
|
+
this.rediscover();
|
|
181
|
+
this.timer = this.main.setInterval(() => this.rediscover(), DISCOVERY_REFRESH_MS);
|
|
182
|
+
this.pi.logger.info("onlyne.listening", { endpoint: this.endpoint, node: this.config.node.id, region: this.config.region });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* RFC 0017 §5: the library reports every committed trust transition, every inbound entry
|
|
186
|
+
* and every pending-work flip here. The viewer repaints from it; it never polls storage
|
|
187
|
+
* for these.
|
|
188
|
+
*/
|
|
189
|
+
notify(event: NotifyEvent): void {
|
|
190
|
+
if (event.event === "trust") {
|
|
191
|
+
this.pi.logger.info(`onlyne.trust.${event.cause}`, {
|
|
192
|
+
node: event.entry.node_id, phase: event.phase, fingerprint: event.entry.fingerprint,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
this.changed();
|
|
196
|
+
}
|
|
197
|
+
onChange(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
198
|
+
private changed(): void { for (const listener of this.listeners) listener(); }
|
|
199
|
+
/**
|
|
200
|
+
* A user-typed address. It is normalized once, read for its own identity and everything it
|
|
201
|
+
* declares, and only a successful read becomes a seed. The node keeps running: a seed is a
|
|
202
|
+
* routing hint, not a second binding.
|
|
203
|
+
*/
|
|
204
|
+
async contact(endpoint: string): Promise<void> {
|
|
205
|
+
const seed = normalizeServiceUrl(endpoint);
|
|
206
|
+
await this.follow(seed);
|
|
207
|
+
await this.rememberSeed(seed);
|
|
208
|
+
this.changed();
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Reads one address into the discovery catalog. The peer's own directory is a bonus: a
|
|
212
|
+
* node that answers `/description` but not `/discovery` is still a usable neighbor.
|
|
213
|
+
*/
|
|
214
|
+
private async follow(seed: string): Promise<void> {
|
|
215
|
+
await this.node.discovery.observe(seed, "bootstrap", undefined, this.controller.signal);
|
|
216
|
+
await this.followDirectory(seed).catch(error => {
|
|
217
|
+
this.pi.logger.debug("onlyne.seed.directory", { seed, error: errorText(error) });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Every peer the address declares, each read on its own: one failure does not stop the rest. */
|
|
221
|
+
private async followDirectory(seed: string): Promise<void> {
|
|
222
|
+
const url = new URL(`${seed}/discovery`);
|
|
223
|
+
let cursor: string | undefined;
|
|
224
|
+
do {
|
|
225
|
+
url.search = "";
|
|
226
|
+
if (cursor) url.searchParams.set("cursor", cursor);
|
|
227
|
+
const response = await fetch(url, { signal: this.controller.signal });
|
|
228
|
+
if (!response.ok) throw new OnlyneError("unavailable", `Discovery directory answered HTTP ${response.status}.`);
|
|
229
|
+
const page = await response.json() as Page<DiscoveryCandidate>;
|
|
230
|
+
if (!Array.isArray(page.items)) throw new OnlyneError("invalid_request", "The discovery page has no items.");
|
|
231
|
+
for (const candidate of page.items) {
|
|
232
|
+
if (candidate.node_id === this.config.node.id) continue;
|
|
233
|
+
await this.node.discovery.observe(candidate.base_url, seed, candidate.node_id, this.controller.signal).catch(() => undefined);
|
|
234
|
+
}
|
|
235
|
+
cursor = page.next_cursor ?? undefined;
|
|
236
|
+
} while (cursor);
|
|
237
|
+
}
|
|
238
|
+
/** The seed list is configuration: one write per new address, and none on a start. */
|
|
239
|
+
private async rememberSeed(seed: string): Promise<void> {
|
|
240
|
+
const seeds = this.config.discovery?.seeds ?? [];
|
|
241
|
+
if (seeds.includes(seed)) return;
|
|
242
|
+
const saved = await saveConfig({ ...this.config, discovery: { seeds: [...seeds, seed] } });
|
|
243
|
+
this.config.discovery = saved.discovery;
|
|
244
|
+
}
|
|
245
|
+
/** Stage one of one exchange: the peer's proved fingerprint, and the session that commits it. */
|
|
246
|
+
async trustBegin(node: string): Promise<KeyExchange> {
|
|
247
|
+
return this.node.trust.begin(this.node.auth, node);
|
|
248
|
+
}
|
|
249
|
+
async trustAccept(node: string, fingerprint: string): Promise<void> {
|
|
250
|
+
await this.node.trust.accept(this.node.auth, node, fingerprint);
|
|
251
|
+
}
|
|
252
|
+
async trustRevoke(node: string): Promise<void> {
|
|
253
|
+
await this.node.trust.revoke(this.node.auth, node);
|
|
254
|
+
}
|
|
255
|
+
async trustRotate(): Promise<{ fingerprint: string }> {
|
|
256
|
+
const identity = await this.node.trust.rotate(this.node.auth);
|
|
257
|
+
return { fingerprint: identity.fingerprint };
|
|
258
|
+
}
|
|
259
|
+
async refreshDiscovery(): Promise<void> {
|
|
260
|
+
await this.node.discovery.refresh(this.controller.signal);
|
|
261
|
+
this.changed();
|
|
262
|
+
}
|
|
263
|
+
/** One discovery round on the host's timer; a failed round keeps the last good catalog. */
|
|
264
|
+
private rediscover(): void {
|
|
265
|
+
if (this.rediscovering) return;
|
|
266
|
+
this.rediscovering = true;
|
|
267
|
+
void this.refreshDiscovery()
|
|
268
|
+
.catch(error => { if (!this.closed) this.pi.logger.warn("onlyne.discovery.failed", { error: errorText(error) }); })
|
|
269
|
+
.finally(() => { this.rediscovering = false; });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async status(): Promise<OnlyneStatus> {
|
|
273
|
+
const records = await this.node.discovery.listRecords();
|
|
274
|
+
const inbox = await allPages(cursor => this.node.messages.inbox(this.node.auth, { page_size: 100, cursor }));
|
|
275
|
+
const at = Date.now();
|
|
276
|
+
// Reachable now: a fresh direct observation, never an expired or never-verified clue.
|
|
277
|
+
const reachable = new Set(records
|
|
278
|
+
.filter(record => record.verified_at !== undefined && Date.parse(record.expires_at) > at)
|
|
279
|
+
.map(record => record.node_id));
|
|
280
|
+
reachable.add(this.config.node.id);
|
|
281
|
+
// One hop is the set this node can actually address: peers it has verified and, when
|
|
282
|
+
// keys are in use, trusted. A clue from a relay is not a neighbor.
|
|
283
|
+
const peers = new Set(records
|
|
284
|
+
.filter(record => record.node_id !== this.config.node.id && record.verified_at !== undefined && Date.parse(record.expires_at) > at)
|
|
285
|
+
.map(record => record.node_id));
|
|
286
|
+
if (this.node.keys.enabled) {
|
|
287
|
+
const trusted = new Set((await this.node.trust.list(this.node.auth)).filter(entry => trustPhase(entry) === "exchanged").map(entry => entry.node_id));
|
|
288
|
+
for (const node of [...peers]) if (!trusted.has(node)) peers.delete(node);
|
|
289
|
+
}
|
|
290
|
+
// Live attention only: an acknowledged or consumed item is history, not an alert.
|
|
291
|
+
const alerts = inbox.filter(item => !item.attention.acked && !item.injected).length;
|
|
292
|
+
return {
|
|
293
|
+
region: this.config.region, node_id: this.config.node.id, nodes: reachable.size, neighbors: peers.size,
|
|
294
|
+
alerts, unread: inbox.filter(item => !item.injected).length,
|
|
295
|
+
...(this.discoveryError === undefined ? {} : { discovery_error: this.discoveryError }),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async snapshot(): Promise<ViewerSnapshot> {
|
|
299
|
+
const contexts = await this.contexts.list();
|
|
300
|
+
const records = await this.node.discovery.listRecords();
|
|
301
|
+
const inbox = await allPages(cursor => this.node.messages.inbox(this.node.auth, { page_size: 100, cursor }));
|
|
302
|
+
const views: ViewerThread[] = [];
|
|
303
|
+
// Hosted threads are read locally. A thread this node only belongs to lives on its
|
|
304
|
+
// owner, so it is shown from the attention rows already held here; the viewer never
|
|
305
|
+
// reaches across the network.
|
|
306
|
+
const hosted = new Set<string>();
|
|
307
|
+
for (const thread of await allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor }))) {
|
|
308
|
+
const ref = thread.thread_ref;
|
|
309
|
+
hosted.add(`${ref.owner}/${ref.id}`);
|
|
310
|
+
const entries = await allPages(cursor => this.node.messages.list(this.node.auth, ref, { attention: "thread", page_size: 100, cursor }));
|
|
311
|
+
views.push({ ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: thread.title, state: thread.state,
|
|
312
|
+
unread: inbox.filter(item => !item.injected && item.attention.thread_ref.owner === ref.owner && item.attention.thread_ref.id === ref.id).length,
|
|
313
|
+
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 })),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// A member's own message never comes back through its inbox, so the message that
|
|
317
|
+
// opened a thread it only takes part in survives only in the outbound record that
|
|
318
|
+
// carried it. Read those here and merge them below the inbox rows, or the member
|
|
319
|
+
// sees the reply and not the question that started it.
|
|
320
|
+
const outbound = await this.storage.transaction(tx => tx.scan<Outbound>("outbound/"));
|
|
321
|
+
const seen = new Map<string, ViewerThread>();
|
|
322
|
+
for (const item of inbox) {
|
|
323
|
+
const ref = item.attention.thread_ref;
|
|
324
|
+
const key = `${ref.owner}/${ref.id}`;
|
|
325
|
+
if (hosted.has(key)) continue;
|
|
326
|
+
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: [] };
|
|
327
|
+
seen.set(key, view);
|
|
328
|
+
if (!item.injected) view.unread++;
|
|
329
|
+
const entry = item.attention.entry;
|
|
330
|
+
if (entry !== undefined) {
|
|
331
|
+
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 });
|
|
332
|
+
view.last_seq = entry.seq;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const row of outbound) {
|
|
336
|
+
const receipt = row.value.receipt;
|
|
337
|
+
if (receipt === undefined) continue;
|
|
338
|
+
const ref = receipt.thread_ref;
|
|
339
|
+
const key = `${ref.owner}/${ref.id}`;
|
|
340
|
+
if (hosted.has(key)) continue;
|
|
341
|
+
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: [] };
|
|
342
|
+
seen.set(key, view);
|
|
343
|
+
const message = row.value.request.message;
|
|
344
|
+
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 });
|
|
345
|
+
if (Number(receipt.seq) > Number(view.last_seq)) view.last_seq = receipt.seq;
|
|
346
|
+
}
|
|
347
|
+
for (const view of seen.values()) view.messages.sort((left, right) => Number(left.seq) - Number(right.seq));
|
|
348
|
+
views.push(...seen.values());
|
|
349
|
+
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 }));
|
|
350
|
+
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 })));
|
|
351
|
+
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 })));
|
|
352
|
+
const at = Date.now();
|
|
353
|
+
// One row per node: a node that moved ports would otherwise appear once per address.
|
|
354
|
+
// A verified record beats an unverified one; within each class the newest wins.
|
|
355
|
+
const byNode = new Map<string, DiscoveryRecord>();
|
|
356
|
+
for (const record of records) {
|
|
357
|
+
const kept = byNode.get(record.node_id);
|
|
358
|
+
if (kept === undefined || newerRecord(record, kept)) byNode.set(record.node_id, record);
|
|
359
|
+
}
|
|
360
|
+
const discovery: ViewerDiscovery[] = [...byNode.values()].map(record => ({
|
|
361
|
+
node_id: record.node_id, instance_id: record.instance_id, base_url: record.base_url, source: record.source,
|
|
362
|
+
...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
|
|
363
|
+
...(record.membership?.state === undefined ? {} : { membership_state: record.membership.state }),
|
|
364
|
+
...(record.verified_at === undefined ? {} : { verified_at: record.verified_at }),
|
|
365
|
+
expires_at: record.expires_at, last_seen: record.last_seen,
|
|
366
|
+
...(record.error === undefined ? {} : { error: record.error.message }),
|
|
367
|
+
}));
|
|
368
|
+
const newest = new Map<string, DiscoveryRecord>();
|
|
369
|
+
for (const record of records) {
|
|
370
|
+
if (record.node_id === this.config.node.id || record.verified_at === undefined || Date.parse(record.expires_at) <= at) continue;
|
|
371
|
+
const kept = newest.get(record.node_id);
|
|
372
|
+
if (kept === undefined || newerRecord(record, kept)) newest.set(record.node_id, record);
|
|
373
|
+
}
|
|
374
|
+
const contacts: ViewerContact[] = [...newest.values()].map(record => ({
|
|
375
|
+
node_id: record.node_id, instance_id: record.instance_id, base_url: record.base_url,
|
|
376
|
+
...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
|
|
377
|
+
last_seen: record.last_seen,
|
|
378
|
+
}));
|
|
379
|
+
const trust = this.node.keys.enabled ? await this.node.trust.list(this.node.auth) : [];
|
|
380
|
+
const identity = this.node.keys.enabled ? await this.node.keys.describe() : undefined;
|
|
381
|
+
return { region: this.config.region, endpoint: this.endpoint, contexts, queue, threads: views, discovery, contacts,
|
|
382
|
+
trust: trust.map(row => ({ node_id: row.node_id, fingerprint: row.fingerprint, phase: trustPhase(row), base_url: row.base_url })),
|
|
383
|
+
...(identity === undefined ? {} : { identity: { public_key: identity.public_key, fingerprint: identity.fingerprint } }),
|
|
384
|
+
...(this.discoveryError === undefined ? {} : { discovery_error: this.discoveryError }) };
|
|
385
|
+
}
|
|
386
|
+
async updateConfig(next: OnlyneConfig): Promise<void> { await saveConfig(next); await restartRuntime(next, this.pi, this.main, this.host); }
|
|
387
|
+
async close(): Promise<void> {
|
|
388
|
+
if (this.closed) return;
|
|
389
|
+
this.closed = true;
|
|
390
|
+
this.controller.abort();
|
|
391
|
+
if (this.timer) this.main.clearTimer(this.timer);
|
|
392
|
+
// A start that failed before the node existed still comes here to release the server.
|
|
393
|
+
this.node?.client.stopMaintenance();
|
|
394
|
+
await this.network?.close();
|
|
395
|
+
this.server?.stop(true);
|
|
396
|
+
this.storage.close();
|
|
397
|
+
this.listeners.clear();
|
|
398
|
+
if (active === this) active = undefined;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
export async function allPages<T>(read: (cursor?: string) => Promise<Page<T>>): Promise<T[]> {
|
|
402
|
+
const items: T[] = [];
|
|
403
|
+
let cursor: string | undefined;
|
|
404
|
+
do { const page = await read(cursor); items.push(...page.items); cursor = page.next_cursor ?? undefined; } while (cursor);
|
|
405
|
+
return items;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }
|
|
409
|
+
|
|
410
|
+
/** A verified record beats an unverified one; within each class the newest observation wins. */
|
|
411
|
+
function newerRecord(candidate: DiscoveryRecord, kept: DiscoveryRecord): boolean {
|
|
412
|
+
const candidateVerified = candidate.verified_at !== undefined;
|
|
413
|
+
const keptVerified = kept.verified_at !== undefined;
|
|
414
|
+
if (candidateVerified !== keptVerified) return candidateVerified;
|
|
415
|
+
return Date.parse(candidate.last_seen) > Date.parse(kept.last_seen);
|
|
416
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import type { Entry, Storage, ThreadRef, Transaction } from "@onlyne/lib";
|
|
5
|
+
import type { ContextRecord } from "./types.ts";
|
|
6
|
+
import { bridgeText as t } from "./bridge-text.ts";
|
|
7
|
+
|
|
8
|
+
/** Key/value rows under one SQLite file; the library's durable state and the plugin's registry share it. */
|
|
9
|
+
export class SqliteStorage implements Storage {
|
|
10
|
+
readonly guarantee = "durable" as const;
|
|
11
|
+
readonly #db: Database;
|
|
12
|
+
#tail: Promise<unknown> = Promise.resolve();
|
|
13
|
+
#closed = false;
|
|
14
|
+
|
|
15
|
+
constructor(path: string) {
|
|
16
|
+
// The store carries trust rows and, while a rotation is unacknowledged, the old private
|
|
17
|
+
// key that proves it. It is secret material: 0700 for the directory, 0600 for the files,
|
|
18
|
+
// including the WAL and shared-memory sidecars SQLite creates beside them.
|
|
19
|
+
const dir = dirname(path);
|
|
20
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
chmodSync(dir, 0o700);
|
|
22
|
+
this.#db = new Database(path, { create: true });
|
|
23
|
+
this.#db.exec("PRAGMA journal_mode = WAL");
|
|
24
|
+
this.#db.exec("PRAGMA synchronous = FULL");
|
|
25
|
+
this.#db.exec("PRAGMA busy_timeout = 5000");
|
|
26
|
+
this.#db.exec("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
27
|
+
for (const file of [path, `${path}-wal`, `${path}-shm`]) {
|
|
28
|
+
if (existsSync(file)) chmodSync(file, 0o600);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Serialize every transaction; SQLite's own BEGIN IMMEDIATE would otherwise race across awaits. */
|
|
33
|
+
transaction<T>(work: (tx: Transaction) => Promise<T>): Promise<T> {
|
|
34
|
+
const run = () => this.#execute(work);
|
|
35
|
+
const result = this.#tail.then(run, run);
|
|
36
|
+
this.#tail = result.then(
|
|
37
|
+
() => undefined,
|
|
38
|
+
() => undefined,
|
|
39
|
+
);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async #execute<T>(work: (tx: Transaction) => Promise<T>): Promise<T> {
|
|
44
|
+
if (this.#closed) throw new Error(t("storeClosed"));
|
|
45
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
46
|
+
const tx: Transaction = {
|
|
47
|
+
get: async <V>(key: string) => this.#get<V>(key),
|
|
48
|
+
put: async <V>(key: string, value: V) => this.#put(key, value),
|
|
49
|
+
delete: async (key: string) => {
|
|
50
|
+
this.#db.query("DELETE FROM kv WHERE key = ?").run(key);
|
|
51
|
+
},
|
|
52
|
+
scan: async <V>(prefix: string) => this.#scan<V>(prefix),
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
const value = await work(tx);
|
|
56
|
+
this.#db.exec("COMMIT");
|
|
57
|
+
return value;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
try {
|
|
60
|
+
this.#db.exec("ROLLBACK");
|
|
61
|
+
} catch {
|
|
62
|
+
// The transaction may already be gone; preserve the original failure.
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#get<T>(key: string): T | undefined {
|
|
69
|
+
const row = this.#db.query<{ value: string }, [string]>("SELECT value FROM kv WHERE key = ?").get(key);
|
|
70
|
+
return row === null ? undefined : (JSON.parse(row.value) as T);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#put(key: string, value: unknown): void {
|
|
74
|
+
this.#db
|
|
75
|
+
.query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
76
|
+
.run(key, JSON.stringify(value));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#scan<T>(prefix: string): Array<{ key: string; value: T }> {
|
|
80
|
+
const rows = this.#db
|
|
81
|
+
.query<{ key: string; value: string }, [number, string]>(
|
|
82
|
+
"SELECT key, value FROM kv WHERE substr(key, 1, ?) = ? ORDER BY key",
|
|
83
|
+
)
|
|
84
|
+
.all(prefix.length, prefix);
|
|
85
|
+
return rows.map(row => ({ key: row.key, value: JSON.parse(row.value) as T }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
close(): void {
|
|
89
|
+
if (this.#closed) return;
|
|
90
|
+
this.#closed = true;
|
|
91
|
+
this.#db.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const COUNTER_KEY = "meta/context-counter";
|
|
96
|
+
const CONTEXT_PREFIX = "context/";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a registered context can answer a delivery. Only the session holding this node's
|
|
100
|
+
* own send right can: a subagent context reads and drafts, never sends. Advertising one as a
|
|
101
|
+
* continuation target hands the sender a message nobody there can reply to, and the sender
|
|
102
|
+
* has no way to see that from the alias (caerulea report 4, problem 7).
|
|
103
|
+
*/
|
|
104
|
+
export function canAnswer(record: ContextRecord): boolean {
|
|
105
|
+
return record.agent_id !== undefined && record.agent_id === record.main_agent_id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Alias allocation and binding for execution contexts; the store is the authority across restarts. */
|
|
109
|
+
export class ContextRegistry {
|
|
110
|
+
constructor(private readonly storage: Storage, private readonly nodeId: string, private readonly mainAgentId: string) {}
|
|
111
|
+
|
|
112
|
+
/** The main OMP session is addressable like a registered execution context. */
|
|
113
|
+
async ensureMain(): Promise<ContextRecord> {
|
|
114
|
+
return this.storage.transaction(async tx => {
|
|
115
|
+
const key = `${CONTEXT_PREFIX}main`;
|
|
116
|
+
const current = await tx.get<ContextRecord>(key);
|
|
117
|
+
if (current !== undefined) return current;
|
|
118
|
+
const record: ContextRecord = {
|
|
119
|
+
alias: "main", node_id: this.nodeId, main_agent_id: this.mainAgentId, agent_id: this.mainAgentId,
|
|
120
|
+
summary: t("mainSummary"), state: "idle", created_at: new Date().toISOString(),
|
|
121
|
+
};
|
|
122
|
+
await tx.put(key, record);
|
|
123
|
+
return record;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async allocate(thread_ref: ThreadRef | undefined, summary: string, source?: Entry["message"]): Promise<ContextRecord> {
|
|
128
|
+
return this.storage.transaction(async tx => {
|
|
129
|
+
const counter = (await tx.get<number>(COUNTER_KEY)) ?? 0;
|
|
130
|
+
const next = counter + 1;
|
|
131
|
+
await tx.put(COUNTER_KEY, next);
|
|
132
|
+
const alias = `c-${String(next)}`;
|
|
133
|
+
const record: ContextRecord = {
|
|
134
|
+
alias,
|
|
135
|
+
node_id: this.nodeId,
|
|
136
|
+
main_agent_id: this.mainAgentId,
|
|
137
|
+
...(source === undefined ? {} : { source: { sender: source.sender, id: source.id } }),
|
|
138
|
+
...(thread_ref === undefined ? {} : { thread_ref }),
|
|
139
|
+
summary,
|
|
140
|
+
state: "unbound",
|
|
141
|
+
created_at: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
await tx.put(`${CONTEXT_PREFIX}${alias}`, record);
|
|
144
|
+
return record;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async bind(
|
|
149
|
+
alias: string,
|
|
150
|
+
binding: { agent_id: string; session_file?: string; parent_id?: string },
|
|
151
|
+
): Promise<ContextRecord> {
|
|
152
|
+
return this.storage.transaction(async tx => {
|
|
153
|
+
const key = `${CONTEXT_PREFIX}${alias}`;
|
|
154
|
+
const current = await tx.get<ContextRecord>(key);
|
|
155
|
+
if (current === undefined) throw new Error(t("contextNotRegistered", { alias }));
|
|
156
|
+
const next: ContextRecord = {
|
|
157
|
+
...current,
|
|
158
|
+
agent_id: binding.agent_id,
|
|
159
|
+
...(binding.session_file === undefined ? {} : { session_file: binding.session_file }),
|
|
160
|
+
...(binding.parent_id === undefined ? {} : { parent_id: binding.parent_id }),
|
|
161
|
+
state: current.agent_id === binding.agent_id ? current.state : "idle",
|
|
162
|
+
bound_at: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
await tx.put(key, next);
|
|
165
|
+
return next;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async find(alias: string): Promise<ContextRecord | undefined> {
|
|
170
|
+
return this.storage.transaction(tx => tx.get<ContextRecord>(`${CONTEXT_PREFIX}${alias}`));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async list(): Promise<ContextRecord[]> {
|
|
174
|
+
return this.storage.transaction(async tx =>
|
|
175
|
+
(await tx.scan<ContextRecord>(CONTEXT_PREFIX)).map(row => row.value),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async updateState(alias: string, state: ContextRecord["state"]): Promise<ContextRecord | undefined> {
|
|
180
|
+
return this.storage.transaction(async tx => {
|
|
181
|
+
const key = `${CONTEXT_PREFIX}${alias}`;
|
|
182
|
+
const current = await tx.get<ContextRecord>(key);
|
|
183
|
+
if (current === undefined) return undefined;
|
|
184
|
+
const next = { ...current, state };
|
|
185
|
+
await tx.put(key, next);
|
|
186
|
+
return next;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/tern.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tern identity for the bootstrap defaults.
|
|
3
|
+
*
|
|
4
|
+
* `TERN_PANE` is the block id the process runs in. `tern ls --json` is the
|
|
5
|
+
* supported query for the session that owns a block; a missing binary, a
|
|
6
|
+
* non-zero exit, malformed JSON or an unknown block all return `undefined`.
|
|
7
|
+
* The caller may fall back to its own default, but this module never guesses a
|
|
8
|
+
* session name from the cwd or from a prefix.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface TernBlock {
|
|
12
|
+
id?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface TernTab {
|
|
16
|
+
blocks?: TernBlock[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TernSession {
|
|
20
|
+
name?: unknown;
|
|
21
|
+
tabs?: TernTab[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TernList {
|
|
25
|
+
sessions?: TernSession[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The Tern block this process runs in, when Tern set it. */
|
|
29
|
+
export function detectTernBlock(): string | undefined {
|
|
30
|
+
const block = process.env.TERN_PANE?.trim();
|
|
31
|
+
return block === undefined || block.length === 0 ? undefined : block;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The session name owning `block` (default: this process's block), `onlyne:` stripped. */
|
|
35
|
+
export function detectTernSession(block: string | undefined = detectTernBlock()): string | undefined {
|
|
36
|
+
if (block === undefined) return undefined;
|
|
37
|
+
let output: string;
|
|
38
|
+
try {
|
|
39
|
+
const result = Bun.spawnSync([process.env.TERN_ACP ?? "tern", "ls", "--json"], {
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "ignore",
|
|
42
|
+
});
|
|
43
|
+
if (result.exitCode !== 0) return undefined;
|
|
44
|
+
output = new TextDecoder().decode(result.stdout);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
let parsed: TernList;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(output) as TernList;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : [];
|
|
55
|
+
for (const session of sessions) {
|
|
56
|
+
const tabs = Array.isArray(session.tabs) ? session.tabs : [];
|
|
57
|
+
for (const tab of tabs) {
|
|
58
|
+
const blocks = Array.isArray(tab.blocks) ? tab.blocks : [];
|
|
59
|
+
if (!blocks.some(candidate => String(candidate.id) === block)) continue;
|
|
60
|
+
if (typeof session.name !== "string") return undefined;
|
|
61
|
+
const name = session.name.replace(/^onlyne:/, "").trim();
|
|
62
|
+
return name.length === 0 ? undefined : name;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|