@scp-dev/hub 0.4.0 → 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 CHANGED
@@ -583,10 +583,14 @@ 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: MachineRow[];
593
+ machines: GraphMachine[];
590
594
  nodes: GraphNode[];
591
595
  edges: GraphEdge[];
592
596
  }
@@ -606,6 +610,7 @@ type DashboardEvent = {
606
610
  kind: string;
607
611
  from: string;
608
612
  to: string[];
613
+ local?: true;
609
614
  } | {
610
615
  type: "endpoints";
611
616
  at: string;
@@ -668,6 +673,14 @@ declare const HUB_LIMITS: {
668
673
  type HubLimits = {
669
674
  [K in keyof typeof HUB_LIMITS]: number;
670
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
+ };
671
684
  /** the hosted relay of one account: machines, bridges, direct clients, push and replay over a FederatedCore; pure, sockets are injected */
672
685
  declare class RelayHub {
673
686
  private readonly deps;
@@ -799,6 +812,11 @@ declare class RelayHub {
799
812
  private deliverOn;
800
813
  private readonly deliverJudged;
801
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;
802
820
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
803
821
  private room;
804
822
  /** bytes of message bodies held, and of the messages from other accounts */
@@ -865,4 +883,4 @@ declare class RelayHub {
865
883
  xnotice(endpoint: string, text: string): void;
866
884
  }
867
885
 
868
- export { type DashboardEvent, 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, dashboardEvent };
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 === 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 === 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,
@@ -2017,7 +2019,8 @@ function dashboardEvent(event, at) {
2017
2019
  if (event.type === "message") {
2018
2020
  const from = str(event.from);
2019
2021
  const to = Array.isArray(event.to) ? event.to.filter((a) => typeof a === "string") : [];
2020
- return from && to.length ? { type: "message", at, kind: str(event.kind) ?? "message", from, to } : null;
2022
+ if (!from || !to.length) return null;
2023
+ return { type: "message", at, kind: str(event.kind) ?? "message", from, to, ...event.local === true ? { local: true } : {} };
2021
2024
  }
2022
2025
  if (event.type === "presence") {
2023
2026
  const endpoint = str(event.endpoint);
@@ -2055,8 +2058,14 @@ var TOUCH_MS = 1e4;
2055
2058
  var HUB_MIGRATION = [
2056
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)`,
2057
2060
  `CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
2058
- `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)`
2059
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}$/;
2060
2069
  var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"]);
2061
2070
  var CLIENT_METHODS = /* @__PURE__ */ new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS]);
2062
2071
  var iso2 = (ms) => ms === null ? null : new Date(ms).toISOString();
@@ -2335,6 +2344,7 @@ var RelayHub = class _RelayHub {
2335
2344
  this.setState("last_validated", now);
2336
2345
  if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
2337
2346
  this.relay.sweep();
2347
+ this.deps.sql.all(`DELETE FROM local_events WHERE at <= ?`, now - LOCAL_KEEP_MS);
2338
2348
  this.expireMachines(now);
2339
2349
  this.forgetTombstones(now);
2340
2350
  const denied = this.deniedGrants();
@@ -2790,6 +2800,8 @@ var RelayHub = class _RelayHub {
2790
2800
  this.fleet({ type: "activity", machine, ...row });
2791
2801
  return row;
2792
2802
  }
2803
+ case "local_events":
2804
+ return { recorded: this.localEvents(machine, params.events) };
2793
2805
  case "list":
2794
2806
  if (params.contacts === true) return { contacts: await this.contactList() };
2795
2807
  return { endpoints: core.list(listFilters(params)) };
@@ -3137,6 +3149,47 @@ var RelayHub = class _RelayHub {
3137
3149
  hop: result.hop
3138
3150
  });
3139
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
+ }
3140
3193
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
3141
3194
  room(sender, body) {
3142
3195
  if (typeof body !== "string") return 0;
@@ -3354,9 +3407,13 @@ var RelayHub = class _RelayHub {
3354
3407
  const edges = nodes.flatMap(
3355
3408
  (n) => n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn", from: n.parent, to: n.address }] : []
3356
3409
  );
3410
+ const since = this.now() - window * 6e4;
3357
3411
  const pairs = this.deps.sql.all(
3358
- `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
3359
- this.now() - window * 6e4
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
3360
3417
  );
3361
3418
  const traffic = pairs.flatMap((row) => {
3362
3419
  const from = byId.get(row.from_ep);
@@ -3364,7 +3421,9 @@ var RelayHub = class _RelayHub {
3364
3421
  return from && to && from !== to ? [{ kind: "message", from, to, count: row.n, last_at: iso2(row.last) }] : [];
3365
3422
  });
3366
3423
  edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)));
3367
- return { minutes: window, machines: this.machineList(), nodes, edges };
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 };
3368
3427
  }
3369
3428
  machineList() {
3370
3429
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
@@ -3522,6 +3581,8 @@ export {
3522
3581
  HUB_LIMITS,
3523
3582
  JUDGE_LIMITS,
3524
3583
  JudgeGate,
3584
+ LOCAL_EVENTS,
3585
+ LOCAL_EVENT_KINDS,
3525
3586
  MACHINE_NAME,
3526
3587
  RelayHub,
3527
3588
  dashboardEvent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.4.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.4"
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 === 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 === 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: MachineRow[]
122
+ machines: GraphMachine[]
118
123
  nodes: GraphNode[]
119
124
  edges: GraphEdge[]
120
125
  }
@@ -126,7 +131,7 @@ export const GRAPH_MINUTES_MAX = 60
126
131
  /** what a read-only dashboard stream sees as it happens: addresses, kinds and presence, never a body, note, summary or label */
127
132
  export type DashboardEvent =
128
133
  | { type: "presence"; at: string; machine: string | null; endpoint: string; presence: Presence }
129
- | { type: "message"; at: string; kind: string; from: string; to: string[] }
134
+ | { type: "message"; at: string; kind: string; from: string; to: string[]; local?: true }
130
135
  | { type: "endpoints"; at: string; machine: string }
131
136
 
132
137
  const str = (v: unknown): string | null => (typeof v === "string" ? v : null)
@@ -136,7 +141,8 @@ export function dashboardEvent(event: Record<string, unknown>, at: string): Dash
136
141
  if (event.type === "message") {
137
142
  const from = str(event.from)
138
143
  const to = Array.isArray(event.to) ? event.to.filter((a): a is string => typeof a === "string") : []
139
- return from && to.length ? { type: "message", at, kind: str(event.kind) ?? "message", from, to } : null
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 } : {}) }
140
146
  }
141
147
  if (event.type === "presence") {
142
148
  const endpoint = str(event.endpoint)
@@ -218,8 +224,17 @@ const HUB_MIGRATION = [
218
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)`,
219
225
  `CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
220
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)`,
221
229
  ]
222
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
+
223
238
  const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"])
224
239
  const CLIENT_METHODS = new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS])
225
240
 
@@ -542,6 +557,7 @@ export class RelayHub {
542
557
  this.setState("last_validated", now)
543
558
  if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
544
559
  this.relay.sweep()
560
+ this.deps.sql.all(`DELETE FROM local_events WHERE at <= ?`, now - LOCAL_KEEP_MS)
545
561
  this.expireMachines(now)
546
562
  this.forgetTombstones(now)
547
563
  const denied = this.deniedGrants()
@@ -1024,6 +1040,8 @@ export class RelayHub {
1024
1040
  this.fleet({ type: "activity", machine, ...row })
1025
1041
  return row
1026
1042
  }
1043
+ case "local_events":
1044
+ return { recorded: this.localEvents(machine, params.events) }
1027
1045
  case "list":
1028
1046
  if (params.contacts === true) return { contacts: await this.contactList() }
1029
1047
  return { endpoints: core.list(listFilters(params)) }
@@ -1405,6 +1423,48 @@ export class RelayHub {
1405
1423
  })
1406
1424
  }
1407
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
+
1408
1468
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
1409
1469
  private room(sender: string, body: unknown): number {
1410
1470
  if (typeof body !== "string") return 0
@@ -1647,9 +1707,13 @@ export class RelayHub {
1647
1707
  const edges: GraphEdge[] = nodes.flatMap((n) =>
1648
1708
  n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn" as const, from: n.parent, to: n.address }] : [],
1649
1709
  )
1710
+ const since = this.now() - window * 60_000
1650
1711
  const pairs = this.deps.sql.all<{ from_ep: string; to_ep: string; n: number; last: number }>(
1651
- `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
1652
- this.now() - window * 60_000,
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,
1653
1717
  )
1654
1718
  const traffic: GraphEdge[] = pairs.flatMap((row) => {
1655
1719
  const from = byId.get(row.from_ep)
@@ -1657,7 +1721,9 @@ export class RelayHub {
1657
1721
  return from && to && from !== to ? [{ kind: "message" as const, from, to, count: row.n, last_at: iso(row.last)! }] : []
1658
1722
  })
1659
1723
  edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)))
1660
- return { minutes: window, machines: this.machineList(), nodes, edges }
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 }
1661
1727
  }
1662
1728
 
1663
1729
  private machineList(): MachineRow[] {
package/src/index.ts CHANGED
@@ -18,9 +18,12 @@ export {
18
18
  GRAPH_MINUTES,
19
19
  GRAPH_MINUTES_MAX,
20
20
  type GraphEdge,
21
+ type GraphMachine,
21
22
  type GraphNode,
22
23
  type HubGraph,
23
24
  HUB_LIMITS,
25
+ LOCAL_EVENT_KINDS,
26
+ LOCAL_EVENTS,
24
27
  RelayHub,
25
28
  type HubAuth,
26
29
  type HubConn,