@scp-dev/hub 0.3.1 → 0.5.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/dist/index.d.ts +43 -2
- package/dist/index.js +95 -8
- package/package.json +2 -2
- package/src/federation.ts +5 -3
- package/src/hub.ts +107 -4
- package/src/index.ts +5 -0
package/dist/index.d.ts
CHANGED
|
@@ -583,16 +583,41 @@ type GraphEdge = {
|
|
|
583
583
|
count: number;
|
|
584
584
|
last_at: string;
|
|
585
585
|
};
|
|
586
|
+
/** a machine as the graph draws it: `actor` is the member who bound it, null for the personal account's owner or a revoked name */
|
|
587
|
+
interface GraphMachine extends MachineRow {
|
|
588
|
+
actor: string | null;
|
|
589
|
+
}
|
|
586
590
|
interface HubGraph {
|
|
587
591
|
/** the window message edges count over */
|
|
588
592
|
minutes: number;
|
|
589
|
-
machines:
|
|
593
|
+
machines: GraphMachine[];
|
|
590
594
|
nodes: GraphNode[];
|
|
591
595
|
edges: GraphEdge[];
|
|
592
596
|
}
|
|
593
597
|
/** the default and widest window of message edges; the core keeps an hour of sender and recipient pairs */
|
|
594
598
|
declare const GRAPH_MINUTES = 15;
|
|
595
599
|
declare const GRAPH_MINUTES_MAX = 60;
|
|
600
|
+
/** what a read-only dashboard stream sees as it happens: addresses, kinds and presence, never a body, note, summary or label */
|
|
601
|
+
type DashboardEvent = {
|
|
602
|
+
type: "presence";
|
|
603
|
+
at: string;
|
|
604
|
+
machine: string | null;
|
|
605
|
+
endpoint: string;
|
|
606
|
+
presence: Presence;
|
|
607
|
+
} | {
|
|
608
|
+
type: "message";
|
|
609
|
+
at: string;
|
|
610
|
+
kind: string;
|
|
611
|
+
from: string;
|
|
612
|
+
to: string[];
|
|
613
|
+
local?: true;
|
|
614
|
+
} | {
|
|
615
|
+
type: "endpoints";
|
|
616
|
+
at: string;
|
|
617
|
+
machine: string;
|
|
618
|
+
};
|
|
619
|
+
/** a watcher's fleet event as a dashboard sees it, field by field; null for the kinds a dashboard does not stream */
|
|
620
|
+
declare function dashboardEvent(event: Record<string, unknown>, at: string): DashboardEvent | null;
|
|
596
621
|
interface HubSocket {
|
|
597
622
|
conn: HubConn;
|
|
598
623
|
send(text: string): void;
|
|
@@ -611,6 +636,8 @@ interface HubDeps {
|
|
|
611
636
|
save(socket: HubSocket): void;
|
|
612
637
|
/** structured log line, e.g. relay_action; never bodies or tokens */
|
|
613
638
|
log?: (event: string, fields: Record<string, unknown>) => void;
|
|
639
|
+
/** live presence, endpoint and message metadata for a read-only dashboard; never a body */
|
|
640
|
+
dashboard?: (event: DashboardEvent) => void;
|
|
614
641
|
/** overrides of {@link HUB_LIMITS}; `Infinity` disables a cap, count or byte limit, durations stay finite */
|
|
615
642
|
limits?: Partial<HubLimits>;
|
|
616
643
|
/** the link to other accounts' relays; without it an address of another account is not found */
|
|
@@ -646,6 +673,14 @@ declare const HUB_LIMITS: {
|
|
|
646
673
|
type HubLimits = {
|
|
647
674
|
[K in keyof typeof HUB_LIMITS]: number;
|
|
648
675
|
};
|
|
676
|
+
/** kinds a bridge reports for a delivery its broker made on its own machine */
|
|
677
|
+
declare const LOCAL_EVENT_KINDS: readonly ["message", "request", "reply", "status", "handback", "spawn"];
|
|
678
|
+
/** events per `local_events` frame, recipients per event, and how far back an event's `at` may lie */
|
|
679
|
+
declare const LOCAL_EVENTS: {
|
|
680
|
+
readonly perFrame: 200;
|
|
681
|
+
readonly recipients: 64;
|
|
682
|
+
readonly maxAgeMs: 300000;
|
|
683
|
+
};
|
|
649
684
|
/** the hosted relay of one account: machines, bridges, direct clients, push and replay over a FederatedCore; pure, sockets are injected */
|
|
650
685
|
declare class RelayHub {
|
|
651
686
|
private readonly deps;
|
|
@@ -777,6 +812,11 @@ declare class RelayHub {
|
|
|
777
812
|
private deliverOn;
|
|
778
813
|
private readonly deliverJudged;
|
|
779
814
|
private delivered;
|
|
815
|
+
/**
|
|
816
|
+
* a bridge's metadata for deliveries its broker made on its own machine: counted for the graph and streamed to watchers and the
|
|
817
|
+
* dashboard as `local`; unknown endpoints and malformed events are skipped
|
|
818
|
+
*/
|
|
819
|
+
private localEvents;
|
|
780
820
|
/** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
|
|
781
821
|
private room;
|
|
782
822
|
/** bytes of message bodies held, and of the messages from other accounts */
|
|
@@ -793,6 +833,7 @@ declare class RelayHub {
|
|
|
793
833
|
/** the machines of other members than the grant's, which a bridge shows under the account handle */
|
|
794
834
|
private othersOf;
|
|
795
835
|
private fleet;
|
|
836
|
+
private dash;
|
|
796
837
|
private write;
|
|
797
838
|
private save;
|
|
798
839
|
private waitMs;
|
|
@@ -842,4 +883,4 @@ declare class RelayHub {
|
|
|
842
883
|
xnotice(endpoint: string, text: string): void;
|
|
843
884
|
}
|
|
844
885
|
|
|
845
|
-
export { type Deliver, type Delivery, FederatedCore, type FederatedOptions, GRAPH_MINUTES, GRAPH_MINUTES_MAX, type GraphEdge, type GraphNode, HUB_LIMITS, type HoldView, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubGraph, type HubJudgeOptions, type HubLimits, type HubRole, type HubSocket, JUDGE_LIMITS, type JudgeClientFactory, JudgeGate, type JudgeGateOptions, type JudgeLimits, type Origin as JudgeOrigin, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint };
|
|
886
|
+
export { type DashboardEvent, type Deliver, type Delivery, FederatedCore, type FederatedOptions, GRAPH_MINUTES, GRAPH_MINUTES_MAX, type GraphEdge, type GraphMachine, type GraphNode, HUB_LIMITS, type HoldView, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubGraph, type HubJudgeOptions, type HubLimits, type HubRole, type HubSocket, JUDGE_LIMITS, type JudgeClientFactory, JudgeGate, type JudgeGateOptions, type JudgeLimits, type Origin as JudgeOrigin, LOCAL_EVENTS, LOCAL_EVENT_KINDS, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint, dashboardEvent };
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
SYSTEM_HANDLE,
|
|
21
21
|
seqList,
|
|
22
22
|
unreachable,
|
|
23
|
+
harnessFits,
|
|
24
|
+
keptHarness,
|
|
23
25
|
validHandle,
|
|
24
26
|
validHarness,
|
|
25
27
|
validSelector,
|
|
@@ -497,9 +499,9 @@ var FederatedCore = class extends RelayCore {
|
|
|
497
499
|
this.checkCap();
|
|
498
500
|
row = this.byId(this.insertRemote(`${machine}/${handle}`, "agent", harness, auth, now));
|
|
499
501
|
created = true;
|
|
500
|
-
} else if (held.kind === "agent" && held.connections === 0 && held.harness
|
|
502
|
+
} else if (held.kind === "agent" && held.connections === 0 && harnessFits(held.harness, harness)) {
|
|
501
503
|
row = held;
|
|
502
|
-
} else if (exact && held.kind === "agent" && held.token_hash !== null && held.process === null && held.session === null && held.parent_ep === null && held.harness
|
|
504
|
+
} else if (exact && held.kind === "agent" && held.token_hash !== null && held.process === null && held.session === null && held.parent_ep === null && harnessFits(held.harness, harness)) {
|
|
503
505
|
row = held;
|
|
504
506
|
joined = true;
|
|
505
507
|
} else if (held.kind === "agent" && held.connections === 0 && (held.last_seen ?? held.created_at) < now - LIMITS.handleBoundS * 1e3) {
|
|
@@ -519,7 +521,7 @@ var FederatedCore = class extends RelayCore {
|
|
|
519
521
|
const token = joined ? void 0 : `kre_${randomHex(32)}`;
|
|
520
522
|
this.sql.all(
|
|
521
523
|
`UPDATE endpoints SET harness = ?, model = ?, cwd = ?, parent = ?, labels = ?, token_hash = ${token ? "?" : "token_hash"}, auth = ?, connections = connections + 1, last_seen = ? WHERE id = ?`,
|
|
522
|
-
harness,
|
|
524
|
+
keptHarness(row.harness, harness),
|
|
523
525
|
fields.model,
|
|
524
526
|
fields.cwd,
|
|
525
527
|
fields.parent,
|
|
@@ -2012,6 +2014,21 @@ function heldResult(id, params) {
|
|
|
2012
2014
|
// src/hub.ts
|
|
2013
2015
|
var GRAPH_MINUTES = 15;
|
|
2014
2016
|
var GRAPH_MINUTES_MAX = 60;
|
|
2017
|
+
var str = (v) => typeof v === "string" ? v : null;
|
|
2018
|
+
function dashboardEvent(event, at) {
|
|
2019
|
+
if (event.type === "message") {
|
|
2020
|
+
const from = str(event.from);
|
|
2021
|
+
const to = Array.isArray(event.to) ? event.to.filter((a) => typeof a === "string") : [];
|
|
2022
|
+
if (!from || !to.length) return null;
|
|
2023
|
+
return { type: "message", at, kind: str(event.kind) ?? "message", from, to, ...event.local === true ? { local: true } : {} };
|
|
2024
|
+
}
|
|
2025
|
+
if (event.type === "presence") {
|
|
2026
|
+
const endpoint = str(event.endpoint);
|
|
2027
|
+
const presence = event.presence === "online" || event.presence === "idle" || event.presence === "offline" ? event.presence : null;
|
|
2028
|
+
return endpoint && presence ? { type: "presence", at, machine: str(event.machine), endpoint, presence } : null;
|
|
2029
|
+
}
|
|
2030
|
+
return null;
|
|
2031
|
+
}
|
|
2015
2032
|
var HUB_LIMITS = {
|
|
2016
2033
|
machines: 16,
|
|
2017
2034
|
endpoints: 512,
|
|
@@ -2041,8 +2058,14 @@ var TOUCH_MS = 1e4;
|
|
|
2041
2058
|
var HUB_MIGRATION = [
|
|
2042
2059
|
`CREATE TABLE IF NOT EXISTS machines (name TEXT PRIMARY KEY, token_id TEXT NOT NULL, kind TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER)`,
|
|
2043
2060
|
`CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
|
|
2044
|
-
`CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)
|
|
2061
|
+
`CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)`,
|
|
2062
|
+
`CREATE TABLE IF NOT EXISTS local_events (from_ep TEXT NOT NULL, to_ep TEXT NOT NULL, at INTEGER NOT NULL)`,
|
|
2063
|
+
`CREATE INDEX IF NOT EXISTS local_events_at ON local_events (at)`
|
|
2045
2064
|
];
|
|
2065
|
+
var LOCAL_EVENT_KINDS = ["message", "request", "reply", "status", "handback", "spawn"];
|
|
2066
|
+
var LOCAL_EVENTS = { perFrame: 200, recipients: 64, maxAgeMs: 3e5 };
|
|
2067
|
+
var LOCAL_KEEP_MS = 36e5;
|
|
2068
|
+
var HASH_ID = /^[0-9a-f]{8,64}$/;
|
|
2046
2069
|
var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"]);
|
|
2047
2070
|
var CLIENT_METHODS = /* @__PURE__ */ new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS]);
|
|
2048
2071
|
var iso2 = (ms) => ms === null ? null : new Date(ms).toISOString();
|
|
@@ -2321,6 +2344,7 @@ var RelayHub = class _RelayHub {
|
|
|
2321
2344
|
this.setState("last_validated", now);
|
|
2322
2345
|
if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
|
|
2323
2346
|
this.relay.sweep();
|
|
2347
|
+
this.deps.sql.all(`DELETE FROM local_events WHERE at <= ?`, now - LOCAL_KEEP_MS);
|
|
2324
2348
|
this.expireMachines(now);
|
|
2325
2349
|
this.forgetTombstones(now);
|
|
2326
2350
|
const denied = this.deniedGrants();
|
|
@@ -2776,6 +2800,8 @@ var RelayHub = class _RelayHub {
|
|
|
2776
2800
|
this.fleet({ type: "activity", machine, ...row });
|
|
2777
2801
|
return row;
|
|
2778
2802
|
}
|
|
2803
|
+
case "local_events":
|
|
2804
|
+
return { recorded: this.localEvents(machine, params.events) };
|
|
2779
2805
|
case "list":
|
|
2780
2806
|
if (params.contacts === true) return { contacts: await this.contactList() };
|
|
2781
2807
|
return { endpoints: core.list(listFilters(params)) };
|
|
@@ -3123,6 +3149,47 @@ var RelayHub = class _RelayHub {
|
|
|
3123
3149
|
hop: result.hop
|
|
3124
3150
|
});
|
|
3125
3151
|
}
|
|
3152
|
+
/**
|
|
3153
|
+
* a bridge's metadata for deliveries its broker made on its own machine: counted for the graph and streamed to watchers and the
|
|
3154
|
+
* dashboard as `local`; unknown endpoints and malformed events are skipped
|
|
3155
|
+
*/
|
|
3156
|
+
localEvents(machine, value) {
|
|
3157
|
+
if (!Array.isArray(value) || value.length > LOCAL_EVENTS.perFrame)
|
|
3158
|
+
throw new RelayError3("invalid_params", `events is a list of at most ${LOCAL_EVENTS.perFrame}`);
|
|
3159
|
+
const core = this.relay;
|
|
3160
|
+
const now = this.now();
|
|
3161
|
+
const resolve = (handle) => {
|
|
3162
|
+
try {
|
|
3163
|
+
return core.remoteEndpoint(machine, handle);
|
|
3164
|
+
} catch {
|
|
3165
|
+
return null;
|
|
3166
|
+
}
|
|
3167
|
+
};
|
|
3168
|
+
let recorded = 0;
|
|
3169
|
+
for (const raw of value) {
|
|
3170
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
3171
|
+
const event = raw;
|
|
3172
|
+
const kind = LOCAL_EVENT_KINDS.includes(event.kind) ? event.kind : null;
|
|
3173
|
+
const at = typeof event.at === "number" && Number.isFinite(event.at) ? Math.min(Math.max(event.at, now - LOCAL_EVENTS.maxAgeMs), now) : null;
|
|
3174
|
+
const from = resolve(event.from);
|
|
3175
|
+
if (!kind || at === null || !from || !Array.isArray(event.to) || event.to.length > LOCAL_EVENTS.recipients) continue;
|
|
3176
|
+
const to = [...new Set(event.to.map(resolve).filter((id) => id !== null && id !== from))];
|
|
3177
|
+
if (!to.length) continue;
|
|
3178
|
+
for (const ep of to) this.deps.sql.all(`INSERT INTO local_events (from_ep, to_ep, at) VALUES (?, ?, ?)`, from, ep, at);
|
|
3179
|
+
this.fleet({
|
|
3180
|
+
type: "message",
|
|
3181
|
+
machine,
|
|
3182
|
+
local: true,
|
|
3183
|
+
...typeof event.id === "string" && HASH_ID.test(event.id) ? { id: event.id } : {},
|
|
3184
|
+
...typeof event.thread === "string" && HASH_ID.test(event.thread) ? { thread: event.thread } : {},
|
|
3185
|
+
kind,
|
|
3186
|
+
from: core.addressOf(from),
|
|
3187
|
+
to: to.map((ep) => core.addressOf(ep))
|
|
3188
|
+
});
|
|
3189
|
+
recorded++;
|
|
3190
|
+
}
|
|
3191
|
+
return recorded;
|
|
3192
|
+
}
|
|
3126
3193
|
/** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
|
|
3127
3194
|
room(sender, body) {
|
|
3128
3195
|
if (typeof body !== "string") return 0;
|
|
@@ -3170,6 +3237,7 @@ var RelayHub = class _RelayHub {
|
|
|
3170
3237
|
this.fleet({ type: "presence", machine, endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
|
|
3171
3238
|
if (change.status && !change.added) this.fleet({ type: "status", machine, endpoint: view.address, status: view.status });
|
|
3172
3239
|
}
|
|
3240
|
+
if (full || changes.some((c) => c.added || c.was !== void 0)) this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine });
|
|
3173
3241
|
}
|
|
3174
3242
|
/** a direct client's endpoint changed: the other bridges mirror it */
|
|
3175
3243
|
announceEndpoint(endpoint) {
|
|
@@ -3194,6 +3262,15 @@ var RelayHub = class _RelayHub {
|
|
|
3194
3262
|
fleet(event) {
|
|
3195
3263
|
const at = new Date(this.now()).toISOString();
|
|
3196
3264
|
for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } });
|
|
3265
|
+
const dash = this.deps.dashboard ? dashboardEvent(event, at) : null;
|
|
3266
|
+
if (dash) this.dash(dash);
|
|
3267
|
+
}
|
|
3268
|
+
dash(event) {
|
|
3269
|
+
try {
|
|
3270
|
+
this.deps.dashboard?.(event);
|
|
3271
|
+
} catch {
|
|
3272
|
+
this.deps.log?.("relay_dashboard_failed", { account: this.account });
|
|
3273
|
+
}
|
|
3197
3274
|
}
|
|
3198
3275
|
write(socket, value) {
|
|
3199
3276
|
if (this.gone.has(socket.conn.id)) return;
|
|
@@ -3330,9 +3407,13 @@ var RelayHub = class _RelayHub {
|
|
|
3330
3407
|
const edges = nodes.flatMap(
|
|
3331
3408
|
(n) => n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn", from: n.parent, to: n.address }] : []
|
|
3332
3409
|
);
|
|
3410
|
+
const since = this.now() - window * 6e4;
|
|
3333
3411
|
const pairs = this.deps.sql.all(
|
|
3334
|
-
`SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM
|
|
3335
|
-
|
|
3412
|
+
`SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM (
|
|
3413
|
+
SELECT from_ep, to_ep, at FROM pair_events WHERE at > ? UNION ALL SELECT from_ep, to_ep, at FROM local_events WHERE at > ?
|
|
3414
|
+
) GROUP BY from_ep, to_ep`,
|
|
3415
|
+
since,
|
|
3416
|
+
since
|
|
3336
3417
|
);
|
|
3337
3418
|
const traffic = pairs.flatMap((row) => {
|
|
3338
3419
|
const from = byId.get(row.from_ep);
|
|
@@ -3340,7 +3421,9 @@ var RelayHub = class _RelayHub {
|
|
|
3340
3421
|
return from && to && from !== to ? [{ kind: "message", from, to, count: row.n, last_at: iso2(row.last) }] : [];
|
|
3341
3422
|
});
|
|
3342
3423
|
edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)));
|
|
3343
|
-
|
|
3424
|
+
const actors = new Map(this.deps.sql.all(`SELECT name, actor_id FROM machines`).map((r) => [r.name, r.actor_id]));
|
|
3425
|
+
const machines = this.machineList().map((m) => ({ ...m, actor: actors.get(m.name) ?? null }));
|
|
3426
|
+
return { minutes: window, machines, nodes, edges };
|
|
3344
3427
|
}
|
|
3345
3428
|
machineList() {
|
|
3346
3429
|
const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
|
|
@@ -3386,6 +3469,7 @@ var RelayHub = class _RelayHub {
|
|
|
3386
3469
|
});
|
|
3387
3470
|
this.stored = null;
|
|
3388
3471
|
this.toBridges(name, { machine: name, endpoints: [], full: true });
|
|
3472
|
+
this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name });
|
|
3389
3473
|
this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" });
|
|
3390
3474
|
return { removed: name, endpoints: dropped };
|
|
3391
3475
|
}
|
|
@@ -3497,6 +3581,9 @@ export {
|
|
|
3497
3581
|
HUB_LIMITS,
|
|
3498
3582
|
JUDGE_LIMITS,
|
|
3499
3583
|
JudgeGate,
|
|
3584
|
+
LOCAL_EVENTS,
|
|
3585
|
+
LOCAL_EVENT_KINDS,
|
|
3500
3586
|
MACHINE_NAME,
|
|
3501
|
-
RelayHub
|
|
3587
|
+
RelayHub,
|
|
3588
|
+
dashboardEvent
|
|
3502
3589
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Swarm Communication Protocol federation and account hub: mirrored endpoints across machines, bridges, direct clients and cross-account delivery.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://scp.dev",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc -p ."
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@scp-dev/core": "0.1.
|
|
32
|
+
"@scp-dev/core": "0.1.5"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"tsup": "^8.5.1"
|
package/src/federation.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
SYSTEM_HANDLE,
|
|
41
41
|
seqList,
|
|
42
42
|
unreachable,
|
|
43
|
+
harnessFits,
|
|
44
|
+
keptHarness,
|
|
43
45
|
validHandle,
|
|
44
46
|
validHarness,
|
|
45
47
|
validSelector,
|
|
@@ -734,7 +736,7 @@ export class FederatedCore extends RelayCore {
|
|
|
734
736
|
this.checkCap()
|
|
735
737
|
row = this.byId(this.insertRemote(`${machine}/${handle}`, "agent", harness, auth, now)) as FederatedRow
|
|
736
738
|
created = true
|
|
737
|
-
} else if (held.kind === "agent" && held.connections === 0 && held.harness
|
|
739
|
+
} else if (held.kind === "agent" && held.connections === 0 && harnessFits(held.harness, harness)) {
|
|
738
740
|
row = held
|
|
739
741
|
} else if (
|
|
740
742
|
exact &&
|
|
@@ -743,7 +745,7 @@ export class FederatedCore extends RelayCore {
|
|
|
743
745
|
held.process === null &&
|
|
744
746
|
held.session === null &&
|
|
745
747
|
held.parent_ep === null &&
|
|
746
|
-
held.harness
|
|
748
|
+
harnessFits(held.harness, harness)
|
|
747
749
|
) {
|
|
748
750
|
row = held
|
|
749
751
|
joined = true
|
|
@@ -764,7 +766,7 @@ export class FederatedCore extends RelayCore {
|
|
|
764
766
|
const token = joined ? undefined : `kre_${randomHex(32)}`
|
|
765
767
|
this.sql.all(
|
|
766
768
|
`UPDATE endpoints SET harness = ?, model = ?, cwd = ?, parent = ?, labels = ?, token_hash = ${token ? "?" : "token_hash"}, auth = ?, connections = connections + 1, last_seen = ? WHERE id = ?`,
|
|
767
|
-
harness,
|
|
769
|
+
keptHarness(row.harness, harness),
|
|
768
770
|
fields.model,
|
|
769
771
|
fields.cwd,
|
|
770
772
|
fields.parent,
|
package/src/hub.ts
CHANGED
|
@@ -111,10 +111,15 @@ export interface GraphNode {
|
|
|
111
111
|
/** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
|
|
112
112
|
export type GraphEdge = { kind: "spawn"; from: string; to: string } | { kind: "message"; from: string; to: string; count: number; last_at: string }
|
|
113
113
|
|
|
114
|
+
/** a machine as the graph draws it: `actor` is the member who bound it, null for the personal account's owner or a revoked name */
|
|
115
|
+
export interface GraphMachine extends MachineRow {
|
|
116
|
+
actor: string | null
|
|
117
|
+
}
|
|
118
|
+
|
|
114
119
|
export interface HubGraph {
|
|
115
120
|
/** the window message edges count over */
|
|
116
121
|
minutes: number
|
|
117
|
-
machines:
|
|
122
|
+
machines: GraphMachine[]
|
|
118
123
|
nodes: GraphNode[]
|
|
119
124
|
edges: GraphEdge[]
|
|
120
125
|
}
|
|
@@ -123,6 +128,30 @@ export interface HubGraph {
|
|
|
123
128
|
export const GRAPH_MINUTES = 15
|
|
124
129
|
export const GRAPH_MINUTES_MAX = 60
|
|
125
130
|
|
|
131
|
+
/** what a read-only dashboard stream sees as it happens: addresses, kinds and presence, never a body, note, summary or label */
|
|
132
|
+
export type DashboardEvent =
|
|
133
|
+
| { type: "presence"; at: string; machine: string | null; endpoint: string; presence: Presence }
|
|
134
|
+
| { type: "message"; at: string; kind: string; from: string; to: string[]; local?: true }
|
|
135
|
+
| { type: "endpoints"; at: string; machine: string }
|
|
136
|
+
|
|
137
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null)
|
|
138
|
+
|
|
139
|
+
/** a watcher's fleet event as a dashboard sees it, field by field; null for the kinds a dashboard does not stream */
|
|
140
|
+
export function dashboardEvent(event: Record<string, unknown>, at: string): DashboardEvent | null {
|
|
141
|
+
if (event.type === "message") {
|
|
142
|
+
const from = str(event.from)
|
|
143
|
+
const to = Array.isArray(event.to) ? event.to.filter((a): a is string => typeof a === "string") : []
|
|
144
|
+
if (!from || !to.length) return null
|
|
145
|
+
return { type: "message", at, kind: str(event.kind) ?? "message", from, to, ...(event.local === true ? { local: true as const } : {}) }
|
|
146
|
+
}
|
|
147
|
+
if (event.type === "presence") {
|
|
148
|
+
const endpoint = str(event.endpoint)
|
|
149
|
+
const presence = event.presence === "online" || event.presence === "idle" || event.presence === "offline" ? event.presence : null
|
|
150
|
+
return endpoint && presence ? { type: "presence", at, machine: str(event.machine), endpoint, presence } : null
|
|
151
|
+
}
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
126
155
|
export interface HubSocket {
|
|
127
156
|
conn: HubConn
|
|
128
157
|
send(text: string): void
|
|
@@ -142,6 +171,8 @@ export interface HubDeps {
|
|
|
142
171
|
save(socket: HubSocket): void
|
|
143
172
|
/** structured log line, e.g. relay_action; never bodies or tokens */
|
|
144
173
|
log?: (event: string, fields: Record<string, unknown>) => void
|
|
174
|
+
/** live presence, endpoint and message metadata for a read-only dashboard; never a body */
|
|
175
|
+
dashboard?: (event: DashboardEvent) => void
|
|
145
176
|
/** overrides of {@link HUB_LIMITS}; `Infinity` disables a cap, count or byte limit, durations stay finite */
|
|
146
177
|
limits?: Partial<HubLimits>
|
|
147
178
|
/** the link to other accounts' relays; without it an address of another account is not found */
|
|
@@ -193,8 +224,17 @@ const HUB_MIGRATION = [
|
|
|
193
224
|
`CREATE TABLE IF NOT EXISTS machines (name TEXT PRIMARY KEY, token_id TEXT NOT NULL, kind TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER)`,
|
|
194
225
|
`CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
|
|
195
226
|
`CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)`,
|
|
227
|
+
`CREATE TABLE IF NOT EXISTS local_events (from_ep TEXT NOT NULL, to_ep TEXT NOT NULL, at INTEGER NOT NULL)`,
|
|
228
|
+
`CREATE INDEX IF NOT EXISTS local_events_at ON local_events (at)`,
|
|
196
229
|
]
|
|
197
230
|
|
|
231
|
+
/** kinds a bridge reports for a delivery its broker made on its own machine */
|
|
232
|
+
export const LOCAL_EVENT_KINDS = ["message", "request", "reply", "status", "handback", "spawn"] as const
|
|
233
|
+
/** events per `local_events` frame, recipients per event, and how far back an event's `at` may lie */
|
|
234
|
+
export const LOCAL_EVENTS = { perFrame: 200, recipients: 64, maxAgeMs: 300_000 } as const
|
|
235
|
+
const LOCAL_KEEP_MS = 3_600_000
|
|
236
|
+
const HASH_ID = /^[0-9a-f]{8,64}$/
|
|
237
|
+
|
|
198
238
|
const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"])
|
|
199
239
|
const CLIENT_METHODS = new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS])
|
|
200
240
|
|
|
@@ -517,6 +557,7 @@ export class RelayHub {
|
|
|
517
557
|
this.setState("last_validated", now)
|
|
518
558
|
if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
|
|
519
559
|
this.relay.sweep()
|
|
560
|
+
this.deps.sql.all(`DELETE FROM local_events WHERE at <= ?`, now - LOCAL_KEEP_MS)
|
|
520
561
|
this.expireMachines(now)
|
|
521
562
|
this.forgetTombstones(now)
|
|
522
563
|
const denied = this.deniedGrants()
|
|
@@ -999,6 +1040,8 @@ export class RelayHub {
|
|
|
999
1040
|
this.fleet({ type: "activity", machine, ...row })
|
|
1000
1041
|
return row
|
|
1001
1042
|
}
|
|
1043
|
+
case "local_events":
|
|
1044
|
+
return { recorded: this.localEvents(machine, params.events) }
|
|
1002
1045
|
case "list":
|
|
1003
1046
|
if (params.contacts === true) return { contacts: await this.contactList() }
|
|
1004
1047
|
return { endpoints: core.list(listFilters(params)) }
|
|
@@ -1380,6 +1423,48 @@ export class RelayHub {
|
|
|
1380
1423
|
})
|
|
1381
1424
|
}
|
|
1382
1425
|
|
|
1426
|
+
/**
|
|
1427
|
+
* a bridge's metadata for deliveries its broker made on its own machine: counted for the graph and streamed to watchers and the
|
|
1428
|
+
* dashboard as `local`; unknown endpoints and malformed events are skipped
|
|
1429
|
+
*/
|
|
1430
|
+
private localEvents(machine: string, value: unknown): number {
|
|
1431
|
+
if (!Array.isArray(value) || value.length > LOCAL_EVENTS.perFrame)
|
|
1432
|
+
throw new RelayError("invalid_params", `events is a list of at most ${LOCAL_EVENTS.perFrame}`)
|
|
1433
|
+
const core = this.relay
|
|
1434
|
+
const now = this.now()
|
|
1435
|
+
const resolve = (handle: unknown): string | null => {
|
|
1436
|
+
try {
|
|
1437
|
+
return core.remoteEndpoint(machine, handle)
|
|
1438
|
+
} catch {
|
|
1439
|
+
return null
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
let recorded = 0
|
|
1443
|
+
for (const raw of value) {
|
|
1444
|
+
if (typeof raw !== "object" || raw === null) continue
|
|
1445
|
+
const event = raw as Params
|
|
1446
|
+
const kind = (LOCAL_EVENT_KINDS as readonly unknown[]).includes(event.kind) ? (event.kind as string) : null
|
|
1447
|
+
const at = typeof event.at === "number" && Number.isFinite(event.at) ? Math.min(Math.max(event.at, now - LOCAL_EVENTS.maxAgeMs), now) : null
|
|
1448
|
+
const from = resolve(event.from)
|
|
1449
|
+
if (!kind || at === null || !from || !Array.isArray(event.to) || event.to.length > LOCAL_EVENTS.recipients) continue
|
|
1450
|
+
const to = [...new Set(event.to.map(resolve).filter((id): id is string => id !== null && id !== from))]
|
|
1451
|
+
if (!to.length) continue
|
|
1452
|
+
for (const ep of to) this.deps.sql.all(`INSERT INTO local_events (from_ep, to_ep, at) VALUES (?, ?, ?)`, from, ep, at)
|
|
1453
|
+
this.fleet({
|
|
1454
|
+
type: "message",
|
|
1455
|
+
machine,
|
|
1456
|
+
local: true,
|
|
1457
|
+
...(typeof event.id === "string" && HASH_ID.test(event.id) ? { id: event.id } : {}),
|
|
1458
|
+
...(typeof event.thread === "string" && HASH_ID.test(event.thread) ? { thread: event.thread } : {}),
|
|
1459
|
+
kind,
|
|
1460
|
+
from: core.addressOf(from),
|
|
1461
|
+
to: to.map((ep) => core.addressOf(ep)),
|
|
1462
|
+
})
|
|
1463
|
+
recorded++
|
|
1464
|
+
}
|
|
1465
|
+
return recorded
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1383
1468
|
/** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
|
|
1384
1469
|
private room(sender: string, body: unknown): number {
|
|
1385
1470
|
if (typeof body !== "string") return 0
|
|
@@ -1431,6 +1516,7 @@ export class RelayHub {
|
|
|
1431
1516
|
this.fleet({ type: "presence", machine, endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
|
|
1432
1517
|
if (change.status && !change.added) this.fleet({ type: "status", machine, endpoint: view.address, status: view.status })
|
|
1433
1518
|
}
|
|
1519
|
+
if (full || changes.some((c) => c.added || c.was !== undefined)) this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine })
|
|
1434
1520
|
}
|
|
1435
1521
|
|
|
1436
1522
|
/** a direct client's endpoint changed: the other bridges mirror it */
|
|
@@ -1461,6 +1547,16 @@ export class RelayHub {
|
|
|
1461
1547
|
private fleet(event: Record<string, unknown>): void {
|
|
1462
1548
|
const at = new Date(this.now()).toISOString()
|
|
1463
1549
|
for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
|
|
1550
|
+
const dash = this.deps.dashboard ? dashboardEvent(event, at) : null
|
|
1551
|
+
if (dash) this.dash(dash)
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
private dash(event: DashboardEvent): void {
|
|
1555
|
+
try {
|
|
1556
|
+
this.deps.dashboard?.(event)
|
|
1557
|
+
} catch {
|
|
1558
|
+
this.deps.log?.("relay_dashboard_failed", { account: this.account })
|
|
1559
|
+
}
|
|
1464
1560
|
}
|
|
1465
1561
|
|
|
1466
1562
|
private write(socket: HubSocket, value: unknown): void {
|
|
@@ -1611,9 +1707,13 @@ export class RelayHub {
|
|
|
1611
1707
|
const edges: GraphEdge[] = nodes.flatMap((n) =>
|
|
1612
1708
|
n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn" as const, from: n.parent, to: n.address }] : [],
|
|
1613
1709
|
)
|
|
1710
|
+
const since = this.now() - window * 60_000
|
|
1614
1711
|
const pairs = this.deps.sql.all<{ from_ep: string; to_ep: string; n: number; last: number }>(
|
|
1615
|
-
`SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM
|
|
1616
|
-
|
|
1712
|
+
`SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM (
|
|
1713
|
+
SELECT from_ep, to_ep, at FROM pair_events WHERE at > ? UNION ALL SELECT from_ep, to_ep, at FROM local_events WHERE at > ?
|
|
1714
|
+
) GROUP BY from_ep, to_ep`,
|
|
1715
|
+
since,
|
|
1716
|
+
since,
|
|
1617
1717
|
)
|
|
1618
1718
|
const traffic: GraphEdge[] = pairs.flatMap((row) => {
|
|
1619
1719
|
const from = byId.get(row.from_ep)
|
|
@@ -1621,7 +1721,9 @@ export class RelayHub {
|
|
|
1621
1721
|
return from && to && from !== to ? [{ kind: "message" as const, from, to, count: row.n, last_at: iso(row.last)! }] : []
|
|
1622
1722
|
})
|
|
1623
1723
|
edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)))
|
|
1624
|
-
|
|
1724
|
+
const actors = new Map(this.deps.sql.all<{ name: string; actor_id: string | null }>(`SELECT name, actor_id FROM machines`).map((r) => [r.name, r.actor_id]))
|
|
1725
|
+
const machines = this.machineList().map((m) => ({ ...m, actor: actors.get(m.name) ?? null }))
|
|
1726
|
+
return { minutes: window, machines, nodes, edges }
|
|
1625
1727
|
}
|
|
1626
1728
|
|
|
1627
1729
|
private machineList(): MachineRow[] {
|
|
@@ -1673,6 +1775,7 @@ export class RelayHub {
|
|
|
1673
1775
|
})
|
|
1674
1776
|
this.stored = null
|
|
1675
1777
|
this.toBridges(name, { machine: name, endpoints: [], full: true })
|
|
1778
|
+
this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name })
|
|
1676
1779
|
this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" })
|
|
1677
1780
|
return { removed: name, endpoints: dropped }
|
|
1678
1781
|
}
|
package/src/index.ts
CHANGED
|
@@ -13,12 +13,17 @@ export {
|
|
|
13
13
|
type XEndpoint,
|
|
14
14
|
} from "./federation"
|
|
15
15
|
export {
|
|
16
|
+
type DashboardEvent,
|
|
17
|
+
dashboardEvent,
|
|
16
18
|
GRAPH_MINUTES,
|
|
17
19
|
GRAPH_MINUTES_MAX,
|
|
18
20
|
type GraphEdge,
|
|
21
|
+
type GraphMachine,
|
|
19
22
|
type GraphNode,
|
|
20
23
|
type HubGraph,
|
|
21
24
|
HUB_LIMITS,
|
|
25
|
+
LOCAL_EVENT_KINDS,
|
|
26
|
+
LOCAL_EVENTS,
|
|
22
27
|
RelayHub,
|
|
23
28
|
type HubAuth,
|
|
24
29
|
type HubConn,
|