agent-comms 9.0.8 → 9.0.10

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.
Files changed (71) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridges/user/web/contract.d.ts +1 -1
  5. package/dist/bridges/user/web/contract.d.ts.map +1 -1
  6. package/dist/bridges/user/web/contract.js +3 -2
  7. package/dist/bridges/user/web/contract.js.map +1 -1
  8. package/dist/bridges/user/web/dist/bundle.js +1 -1
  9. package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
  10. package/dist/bridges/user/web/dist/sw.js +1 -1
  11. package/dist/bridges/user/web/event-id.d.ts +10 -0
  12. package/dist/bridges/user/web/event-id.d.ts.map +1 -0
  13. package/dist/bridges/user/web/event-id.js +14 -0
  14. package/dist/bridges/user/web/event-id.js.map +1 -0
  15. package/dist/bridges/user/web/event-publisher.d.ts +13 -3
  16. package/dist/bridges/user/web/event-publisher.d.ts.map +1 -1
  17. package/dist/bridges/user/web/event-publisher.js +59 -16
  18. package/dist/bridges/user/web/event-publisher.js.map +1 -1
  19. package/dist/bridges/user/web/frontend/event-pump.d.ts +4 -2
  20. package/dist/bridges/user/web/frontend/event-pump.d.ts.map +1 -1
  21. package/dist/bridges/user/web/frontend/event-pump.js +25 -7
  22. package/dist/bridges/user/web/frontend/event-pump.js.map +1 -1
  23. package/dist/bridges/user/web/frontend/mesh-worker.d.ts.map +1 -1
  24. package/dist/bridges/user/web/frontend/mesh-worker.js +4 -3
  25. package/dist/bridges/user/web/frontend/mesh-worker.js.map +1 -1
  26. package/dist/bridges/user/web/frontend/project-tree.d.ts.map +1 -1
  27. package/dist/bridges/user/web/frontend/project-tree.js +2 -1
  28. package/dist/bridges/user/web/frontend/project-tree.js.map +1 -1
  29. package/dist/bridges/user/web/router.d.ts.map +1 -1
  30. package/dist/bridges/user/web/router.js +5 -6
  31. package/dist/bridges/user/web/router.js.map +1 -1
  32. package/dist/bridges/user/web/test/event-publisher.unit.test.d.ts +2 -0
  33. package/dist/bridges/user/web/test/event-publisher.unit.test.d.ts.map +1 -0
  34. package/dist/bridges/user/web/test/event-publisher.unit.test.js +245 -0
  35. package/dist/bridges/user/web/test/event-publisher.unit.test.js.map +1 -0
  36. package/dist/bridges/user/web/test/orpc-router.integration.test.js +10 -0
  37. package/dist/bridges/user/web/test/orpc-router.integration.test.js.map +1 -1
  38. package/dist/core/machine-list-groups.d.ts.map +1 -1
  39. package/dist/core/machine-list-groups.js +2 -1
  40. package/dist/core/machine-list-groups.js.map +1 -1
  41. package/dist/core/ordering.d.ts +14 -0
  42. package/dist/core/ordering.d.ts.map +1 -0
  43. package/dist/core/ordering.js +27 -0
  44. package/dist/core/ordering.js.map +1 -0
  45. package/dist/core/relay-selection.d.ts.map +1 -1
  46. package/dist/core/relay-selection.js +2 -1
  47. package/dist/core/relay-selection.js.map +1 -1
  48. package/dist/core/store.d.ts.map +1 -1
  49. package/dist/core/store.js +5 -2
  50. package/dist/core/store.js.map +1 -1
  51. package/dist/test/bridge-mesh-data-directory.integration.test.js +2 -1
  52. package/dist/test/bridge-mesh-data-directory.integration.test.js.map +1 -1
  53. package/dist/test/conformance-functions.helper.d.ts.map +1 -1
  54. package/dist/test/conformance-functions.helper.js +3 -0
  55. package/dist/test/conformance-functions.helper.js.map +1 -1
  56. package/dist/test/conformance-kept-tests.unit.test.js +1 -0
  57. package/dist/test/conformance-kept-tests.unit.test.js.map +1 -1
  58. package/dist/test/filestore.unit.test.js +40 -0
  59. package/dist/test/filestore.unit.test.js.map +1 -1
  60. package/package.json +2 -2
  61. package/src/bridges/user/web/contract.ts +5 -2
  62. package/src/bridges/user/web/dist/bundle.js +1 -1
  63. package/src/bridges/user/web/dist/mesh-worker.js +4 -4
  64. package/src/bridges/user/web/dist/sw.js +1 -1
  65. package/src/bridges/user/web/event-id.ts +18 -0
  66. package/src/bridges/user/web/event-publisher.ts +68 -18
  67. package/src/bridges/user/web/router.ts +5 -7
  68. package/src/core/machine-list-groups.ts +2 -1
  69. package/src/core/ordering.ts +27 -0
  70. package/src/core/relay-selection.ts +2 -1
  71. package/src/core/store.ts +5 -2
@@ -1 +1 @@
1
- (function(){var e=`agent-comms-v1`,t=[{"revision":"41c6737126547228482ecc2b359801e6","url":"mesh-worker.js"},{"revision":"f1ee3afb8c9e8f0d7315fd93d6457316","url":"index.html"},{"revision":"3c68c960e2ae7cf0918c97b921c287da","url":"bundle.js"}].map(e=>e.url);self.addEventListener(`install`,n=>{n.waitUntil(self.caches.open(e).then(async e=>e.addAll(t)).then(async()=>self.skipWaiting()))}),self.addEventListener(`activate`,t=>{t.waitUntil(self.caches.keys().then(async t=>Promise.all(t.filter(t=>t!==e).map(async e=>self.caches.delete(e)))))});var n=200,r=300;function i(e,t){return e.method===`GET`&&t.status>=n&&t.status<r}self.addEventListener(`fetch`,t=>{if(new URL(t.request.url).pathname.startsWith(`/api/`)){t.respondWith(fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n}).catch(async()=>self.caches.open(e).then(async e=>e.match(t.request)).then(e=>e??new Response(`Offline`,{status:503}))));return}t.respondWith(self.caches.open(e).then(async e=>e.match(t.request)).then(async n=>n||fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n})))})})();
1
+ (function(){var e=`agent-comms-v1`,t=[{"revision":"66fb8e407d25a3d34fd7df939ebd2e96","url":"mesh-worker.js"},{"revision":"f1ee3afb8c9e8f0d7315fd93d6457316","url":"index.html"},{"revision":"aef2de9b9f0ed47024e02762fc047436","url":"bundle.js"}].map(e=>e.url);self.addEventListener(`install`,n=>{n.waitUntil(self.caches.open(e).then(async e=>e.addAll(t)).then(async()=>self.skipWaiting()))}),self.addEventListener(`activate`,t=>{t.waitUntil(self.caches.keys().then(async t=>Promise.all(t.filter(t=>t!==e).map(async e=>self.caches.delete(e)))))});var n=200,r=300;function i(e,t){return e.method===`GET`&&t.status>=n&&t.status<r}self.addEventListener(`fetch`,t=>{if(new URL(t.request.url).pathname.startsWith(`/api/`)){t.respondWith(fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n}).catch(async()=>self.caches.open(e).then(async e=>e.match(t.request)).then(e=>e??new Response(`Offline`,{status:503}))));return}t.respondWith(self.caches.open(e).then(async e=>e.match(t.request)).then(async n=>n||fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n})))})})();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The shape of an event id, shared by the publisher that issues ids and the contract that bounds what a client may send back, so neither can drift from the other.
3
+ */
4
+
5
+ /** Bytes of randomness in a publisher's instance; the instance is their hex form. */
6
+ export const INSTANCE_BYTES = 8;
7
+
8
+ /** The separator between an id's instance and its sequence number. Hex never contains it. */
9
+ export const ID_SEPARATOR = ".";
10
+
11
+ /** Characters of an instance: two hex digits per byte. */
12
+ const INSTANCE_LENGTH = INSTANCE_BYTES * 2;
13
+
14
+ /** The longest id a publisher can issue: its instance, the separator and the digits of the largest sequence number a JavaScript number holds exactly. A longer string cannot be an id of any publisher. */
15
+ export const MAX_EVENT_ID_LENGTH =
16
+ INSTANCE_LENGTH +
17
+ ID_SEPARATOR.length +
18
+ String(Number.MAX_SAFE_INTEGER).length;
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * In-memory, resumable event publisher for the web UI's subscribeEvents stream.
3
3
  *
4
- * oRPC v2 beta (`\@orpc/server`, `\@orpc/client`, `\@orpc/contract` at 2.0.0-beta.35 -- confirmed against the actual published tarballs, not just docs) ships no MemoryPublisher/EventPublisher class. This is a hand-written equivalent built on the primitives that do exist: a plain async generator (satisfies AsyncIteratorObject on its own) and withEventMeta (from `\@standard-server/core`, re-exported by `\@orpc/server`) to tag each published event with a monotonic id oRPC's own RetryLinkPlugin already knows how to read back as lastEventId on reconnect.
4
+ * oRPC v2 beta (`\@orpc/server`, `\@orpc/client`, `\@orpc/contract` at 2.0.0-beta.35 -- confirmed against the actual published tarballs, not just docs) ships no MemoryPublisher/EventPublisher class. This is a hand-written equivalent built on the primitives that do exist: a plain async generator (satisfies AsyncIteratorObject on its own) and withEventMeta (from `\@standard-server/core`, re-exported by `\@orpc/server`) to tag each published event with an id oRPC's own RetryLinkPlugin already knows how to read back as lastEventId on reconnect. An id is `<instance>.<seq>`: the instance is drawn at random when the publisher starts, so an id from before a restart, or from another publisher, can never be mistaken for one of this publisher's events.
5
5
  *
6
6
  * One instance per WebServerHandle -- not a module-level singleton -- so multiple concurrent web server instances (multiple bridges each starting their own web UI) never share replay buffers.
7
7
  */
8
8
 
9
9
  import { withEventMeta } from "@orpc/server";
10
10
  import type { MeshEvent } from "./contract.js";
11
+ import {
12
+ ID_SEPARATOR,
13
+ INSTANCE_BYTES,
14
+ MAX_EVENT_ID_LENGTH,
15
+ } from "./event-id.js";
11
16
 
12
17
  /** A plain function call is opaque to TS's control-flow narrowing, unlike a direct `signal?.aborted` comparison -- narrowing that across an `await` (the abort can genuinely flip mid-wait) produces a false "these types have no overlap" error at the re-check below the await. */
13
18
  function isAborted(signal: AbortSignal | undefined): boolean {
@@ -17,12 +22,20 @@ function isAborted(signal: AbortSignal | undefined): boolean {
17
22
  export interface MeshEventPublisherOptions {
18
23
  /** How long a published event stays available for replay by a resuming subscriber. */
19
24
  retentionMs?: number;
25
+ /** The most events kept for replay. Defaults to {@link DEFAULT_MAX_BUFFERED_EVENTS}. */
26
+ maxBufferedEvents?: number;
20
27
  }
21
28
 
22
29
  const DEFAULT_RETENTION_MS = 60_000;
23
30
 
31
+ /** The most events a publisher keeps for replay, whatever their age. An event can carry a whole mesh snapshot, so a burst of resynchronisations must not hold sixty seconds of them: past this a subscriber whose id fell off the end starts from a snapshot, as one whose id aged out does. */
32
+ const DEFAULT_MAX_BUFFERED_EVENTS = 1_000;
33
+
34
+ /** Radix of the hex form of the instance. */
35
+ const HEX_RADIX = 16;
36
+
24
37
  interface BufferedEvent {
25
- id: string;
38
+ seq: number;
26
39
  event: MeshEvent;
27
40
  publishedAt: number;
28
41
  }
@@ -30,23 +43,40 @@ interface BufferedEvent {
30
43
  export interface MeshEventSubscribeOptions {
31
44
  signal?: AbortSignal;
32
45
  lastEventId?: string;
46
+ /** Builds the snapshot a subscriber starts from when it has no usable id. The snapshot is built after the subscriber is listening, so no event published meanwhile is lost. */
47
+ snapshot: () => MeshEvent;
48
+ }
49
+
50
+ function drawInstance(): string {
51
+ return Array.from(
52
+ globalThis.crypto.getRandomValues(new Uint8Array(INSTANCE_BYTES)),
53
+ (byte) => byte.toString(HEX_RADIX).padStart(2, "0"),
54
+ ).join("");
33
55
  }
34
56
 
35
57
  export class MeshEventPublisher {
36
58
  private seq = 0;
59
+ private readonly instance = drawInstance();
37
60
  private readonly buffer: BufferedEvent[] = [];
38
61
  private readonly listeners = new Set<(event: MeshEvent) => void>();
39
62
  private readonly retentionMs: number;
63
+ private readonly maxBufferedEvents: number;
40
64
 
41
65
  constructor(options: Readonly<MeshEventPublisherOptions> = {}) {
42
66
  this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
67
+ this.maxBufferedEvents =
68
+ options.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS;
43
69
  }
44
70
 
45
71
  publish(event: MeshEvent): void {
46
72
  this.prune();
47
- const id = String(++this.seq);
48
- const tagged = withEventMeta(event, { id });
49
- this.buffer.push({ id, event: tagged, publishedAt: Date.now() });
73
+ const seq = ++this.seq;
74
+ const tagged = withEventMeta(event, {
75
+ id: `${this.instance}${ID_SEPARATOR}${String(seq)}`,
76
+ });
77
+ this.buffer.push({ seq, event: tagged, publishedAt: Date.now() });
78
+ // Sequence numbers stay contiguous, so dropping from the head leaves "older than the oldest retained" exact.
79
+ while (this.buffer.length > this.maxBufferedEvents) this.buffer.shift();
50
80
  for (const listener of this.listeners) listener(tagged);
51
81
  }
52
82
 
@@ -61,24 +91,34 @@ export class MeshEventPublisher {
61
91
  }
62
92
 
63
93
  /**
64
- * Replays buffered events after `lastEventId` (or every buffered event, if a resuming subscriber's `lastEventId` has already aged out of the retention window), then yields new events as they're published until `signal` aborts.
94
+ * The events a subscriber holding `lastEventId` is missing, or undefined when they cannot be known: the id is not this publisher's, is malformed, or is older than the oldest retained event, so events between it and the retained ones are gone.
95
+ */
96
+ private missedSince(lastEventId: string): BufferedEvent[] | undefined {
97
+ // Refused before any split: no id of any publisher is this long.
98
+ if (lastEventId.length > MAX_EVENT_ID_LENGTH) return undefined;
99
+ const [instance, rawSeq, ...rest] = lastEventId.split(ID_SEPARATOR);
100
+ if (instance !== this.instance || rawSeq === undefined || rest.length > 0) {
101
+ return undefined;
102
+ }
103
+ if (!/^[0-9]+$/u.test(rawSeq)) return undefined;
104
+ const seq = Number(rawSeq);
105
+ if (seq > this.seq) return undefined;
106
+ const oldest = this.buffer[0];
107
+ // With nothing retained the only id that is not missing anything is the latest one.
108
+ if (oldest === undefined) return seq === this.seq ? [] : undefined;
109
+ if (seq < oldest.seq - 1) return undefined;
110
+ return this.buffer.filter((buffered) => buffered.seq > seq);
111
+ }
112
+
113
+ /**
114
+ * Starts a subscriber and yields to it until `signal` aborts. A subscriber with a usable `lastEventId` first gets exactly the events it missed. Any other subscriber, with no id or with one that cannot be resumed from (see {@link MeshEventPublisher.missedSince}), first gets `snapshot()`. Live events follow in both cases, and none published while the replay or snapshot is being delivered is lost.
65
115
  */
66
116
  async *subscribe(
67
- options: Readonly<MeshEventSubscribeOptions> = {},
117
+ options: Readonly<MeshEventSubscribeOptions>,
68
118
  ): AsyncGenerator<MeshEvent, void, void> {
69
- const { signal, lastEventId } = options;
119
+ const { signal, lastEventId, snapshot } = options;
70
120
  this.prune();
71
121
 
72
- if (lastEventId !== undefined) {
73
- const startIndex = this.buffer.findIndex((b) => b.id === lastEventId);
74
- const replay =
75
- startIndex === -1 ? this.buffer : this.buffer.slice(startIndex + 1);
76
- for (const buffered of replay) {
77
- if (isAborted(signal)) return;
78
- yield buffered.event;
79
- }
80
- }
81
-
82
122
  const queue: MeshEvent[] = [];
83
123
  let wake: (() => void) | undefined;
84
124
  const listener = (event: MeshEvent): void => {
@@ -88,6 +128,16 @@ export class MeshEventPublisher {
88
128
  this.listeners.add(listener);
89
129
 
90
130
  try {
131
+ const missed =
132
+ lastEventId === undefined ? undefined : this.missedSince(lastEventId);
133
+ if (missed === undefined) {
134
+ yield snapshot();
135
+ } else {
136
+ for (const buffered of missed) {
137
+ if (isAborted(signal)) return;
138
+ yield buffered.event;
139
+ }
140
+ }
91
141
  for (;;) {
92
142
  if (isAborted(signal)) return;
93
143
  if (queue.length === 0) {
@@ -131,16 +131,14 @@ export const meshRouter = {
131
131
  // lastEventId is set two ways: oRPC's own RetryLinkPlugin threads it automatically on a mid-stream reconnect (the protocol-level `lastEventId` above); a tab that reconnects from cold (a fresh subscribeEvents call, e.g. after being closed and reopened) has to pass it explicitly as input instead, since there's no in-flight call for the protocol layer to resume.
132
132
  const resumeFrom = lastEventId ?? input.lastEventId;
133
133
 
134
- if (resumeFrom === undefined) {
135
- yield {
136
- kind: "state_sync" as const,
137
- state: toBrowserState(context.controller.meshStore.serialise()),
138
- };
139
- }
140
-
134
+ // The publisher decides between replaying what the id's holder missed and starting from this snapshot: an id from before a restart, or older than the retained events, cannot be resumed from.
141
135
  yield* context.publisher.subscribe({
142
136
  ...(signal ? { signal } : {}),
143
137
  ...(resumeFrom !== undefined ? { lastEventId: resumeFrom } : {}),
138
+ snapshot: () => ({
139
+ kind: "state_sync" as const,
140
+ state: toBrowserState(context.controller.meshStore.serialise()),
141
+ }),
144
142
  });
145
143
  }),
146
144
  };
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { AgentIdentity } from "./types.js";
6
6
  import type { Namer } from "./naming.js";
7
+ import { compareCodeUnits } from "./ordering.js";
7
8
 
8
9
  /** One run of listed agents and the heading above it; no heading when nothing is grouped at all. */
9
10
  export interface AgentGroup {
@@ -40,7 +41,7 @@ export function groupAgentsByMachine(
40
41
  .sort(([a], [b]) => {
41
42
  if (a === ownMachine) return -1;
42
43
  if (b === ownMachine) return 1;
43
- return a.localeCompare(b);
44
+ return compareCodeUnits(a, b);
44
45
  })
45
46
  .map(([machine, members]) => ({
46
47
  heading: `Machine ${namer(machine)}${machine === ownMachine ? " (this machine)" : ""}:`,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Orderings that depend on neither a locale nor a runtime's collation data, so every implementation sorts the same names the same way. Strings compare by UTF-16 code unit, the order of JavaScript's `<`.
3
+ */
4
+
5
+ /**
6
+ * Compares two strings by UTF-16 code unit.
7
+ * @returns A negative number when `a` sorts first, a positive one when `b` does, 0 when they are equal.
8
+ */
9
+ export function compareCodeUnits(a: string, b: string): number {
10
+ if (a < b) return -1;
11
+ if (a > b) return 1;
12
+ return 0;
13
+ }
14
+
15
+ /** Folds the ASCII letters A to Z to lower case and leaves every other character as it is, so no Unicode case table is involved. */
16
+ function foldAsciiCase(name: string): string {
17
+ return name.replace(/[A-Z]/gu, (letter) => letter.toLowerCase());
18
+ }
19
+
20
+ /**
21
+ * Compares two names a person reads: ASCII letters are folded to lower case before comparing by UTF-16 code unit, and names that fold equal are ordered by the names as written, so the order is total and stable.
22
+ * @returns A negative number when `a` sorts first, a positive one when `b` does, 0 when they are equal.
23
+ */
24
+ export function compareDisplayNames(a: string, b: string): number {
25
+ const folded = compareCodeUnits(foldAsciiCase(a), foldAsciiCase(b));
26
+ return folded === 0 ? compareCodeUnits(a, b) : folded;
27
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { BlockList, isIPv4, isIPv6 } from "node:net";
10
+ import { compareCodeUnits } from "./ordering.js";
10
11
 
11
12
  /** A relay offer this store has heard, with what it knows about the device making it. */
12
13
  export interface RelayOffer {
@@ -104,7 +105,7 @@ export function selectRelay(
104
105
  ): SelectedRelay {
105
106
  const eligible = input.offers
106
107
  .filter((offer) => offer.deviceHex !== input.ownDeviceHex)
107
- .toSorted((a, b) => a.deviceHex.localeCompare(b.deviceHex));
108
+ .toSorted((a, b) => compareCodeUnits(a.deviceHex, b.deviceHex));
108
109
  for (const tier of OFFER_TIERS) {
109
110
  for (const offer of eligible) {
110
111
  const admitted =
package/src/core/store.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  RoomMessageSchema,
24
24
  RoomSchema,
25
25
  } from "./types.js";
26
+ import { compareCodeUnits } from "./ordering.js";
26
27
  import type {
27
28
  AgentIdentity,
28
29
  DeliveryEvent,
@@ -584,7 +585,8 @@ export class FileStore implements CommsStore {
584
585
  messages.push(msg);
585
586
  }
586
587
  }
587
- messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
588
+ // Every timestamp this store writes is `new Date().toISOString()`: fixed width, UTC, millisecond precision, so UTF-16 order is chronological order (held by a test at the producer).
589
+ messages.sort((a, b) => compareCodeUnits(a.timestamp, b.timestamp));
588
590
  return messages;
589
591
  } catch {
590
592
  return [];
@@ -653,6 +655,7 @@ export class FileStore implements CommsStore {
653
655
  events.push(event);
654
656
  await fs.unlink(path.join(dir, file));
655
657
  }
658
+ // Same fixed timestamp form as readRoomMessages sorts on.
656
659
  events.sort((a, b) => {
657
660
  const ta =
658
661
  a.type === "room_message" || a.type === "dm"
@@ -662,7 +665,7 @@ export class FileStore implements CommsStore {
662
665
  b.type === "room_message" || b.type === "dm"
663
666
  ? b.message.timestamp
664
667
  : "";
665
- return ta.localeCompare(tb);
668
+ return compareCodeUnits(ta, tb);
666
669
  });
667
670
  return events;
668
671
  } catch {