@ccmsg/cli 0.7.0 → 0.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.18.0"
23
+ "@ccmsg/protocol": "1.20.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
@@ -62,7 +62,7 @@ import {
62
62
  Transport,
63
63
  type UpgradeDecision,
64
64
  } from "../transport/index.ts";
65
- import { Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
65
+ import { Instances, Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
66
66
  import {
67
67
  Gateway,
68
68
  gatewayCapabilities,
@@ -317,6 +317,7 @@ export class Instance {
317
317
  readonly #transport = new Transport();
318
318
  readonly #topics: Topics;
319
319
  readonly #sessions: Sessions;
320
+ readonly #instances: Instances;
320
321
  readonly #status: SessionStatus;
321
322
  readonly #transcripts: Transcripts;
322
323
  readonly #gateway: Gateway;
@@ -401,10 +402,10 @@ export class Instance {
401
402
  const stated = (data as { records?: AuthRecord[] } | undefined)?.records;
402
403
  if (Array.isArray(stated)) this.#auth.merge(stated);
403
404
  },
404
- // What `peers` says about the instances is this instance's own view, so
405
- // it is restated when that view moves (§7.5).
405
+ // The mesh view is this instance's own, so the topic that carries it is
406
+ // restated when that view moves (§7.5).
406
407
  changed: () => {
407
- this.#sessions.refresh();
408
+ this.#instances.refresh();
408
409
  this.#linkMoved();
409
410
  },
410
411
  });
@@ -420,6 +421,9 @@ export class Instance {
420
421
  onActivity: () => {
421
422
  this.#sessions.refresh();
422
423
  },
424
+ onMoved: (sid) => {
425
+ this.#sessions.gatewayMoved(sid);
426
+ },
423
427
  log: (msg, fields) => {
424
428
  this.log.write(msg, fields);
425
429
  },
@@ -532,6 +536,15 @@ export class Instance {
532
536
  publish: (topic, data, instance) => this.#topics.publish(topic, data, instance),
533
537
  });
534
538
 
539
+ this.#instances = new Instances({
540
+ self: this.self,
541
+ ...(this.#mesh === undefined ? {} : { endpoint: this.#mesh.self, mesh: this.#mesh }),
542
+ publish: (topic, data) => {
543
+ this.#topics.publish(topic, data);
544
+ },
545
+ });
546
+
547
+ this.#topics.attach("instances", this.#instances);
535
548
  this.#topics.attach("peers", this.#sessions);
536
549
  this.#topics.attach("agents", this.#sessions);
537
550
  this.#topics.attach("inbox", this.#delivery);
package/src/mesh/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./instances.ts";
1
2
  export * from "./keys.ts";
2
3
  export * from "./mesh.ts";
3
4
  export * from "./probe.ts";
@@ -0,0 +1,76 @@
1
+ import { hostname } from "node:os";
2
+ import type { Endpoint, InstanceId, InstanceInfo } from "@ccmsg/protocol";
3
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
4
+
5
+ /** What the `instances` topic reads: the mesh as this instance sees it, which
6
+ * is the same view `hello` answers with. */
7
+ export interface MeshView {
8
+ instances(): InstanceInfo[];
9
+ }
10
+
11
+ /** The cluster as one instance sees it, itself included.
12
+ *
13
+ * An instance with no mesh is a cluster of one and says so: it is reached at
14
+ * whatever it serves, which is a row without an endpoint when that is the unix
15
+ * socket alone rather than no row at all (contract, `InstanceInfo`).
16
+ *
17
+ * The same answer feeds `hello` and the topic, from here rather than from two
18
+ * places: a greeting and a subscription that disagreed about who is in the
19
+ * cluster would be one instance stating two views of itself. */
20
+ export function clusterView(
21
+ self: InstanceId,
22
+ endpoint: Endpoint | undefined,
23
+ mesh: MeshView | undefined,
24
+ ): InstanceInfo[] {
25
+ return (
26
+ mesh?.instances() ?? [
27
+ {
28
+ id: self,
29
+ ...(endpoint === undefined ? {} : { endpoint }),
30
+ host: hostname(),
31
+ reachable: true,
32
+ },
33
+ ]
34
+ );
35
+ }
36
+
37
+ /** The `instances` topic (§7.5).
38
+ *
39
+ * A link going down reaches a subscriber where it is already listening, rather
40
+ * than only on its next greeting. The value is whole per instance: `reachable`
41
+ * is one instance's reading of every link it has, taken together, and two
42
+ * instances may legitimately disagree about the same link — so a frame states
43
+ * one sender's view entire and leaves every other sender's alone.
44
+ *
45
+ * Nothing is started or stopped by a subscription. The view exists because the
46
+ * mesh exists, and reading it costs a walk over the configured peers (§6.3:
47
+ * what subscription drives is a watch, and there is none here). */
48
+ export class Instances implements UpstreamResource {
49
+ constructor(
50
+ private readonly deps: {
51
+ readonly self: InstanceId;
52
+ readonly endpoint?: Endpoint;
53
+ readonly mesh?: MeshView;
54
+ readonly publish: (topic: string, data: unknown) => void;
55
+ },
56
+ ) {}
57
+
58
+ view(): InstanceInfo[] {
59
+ return clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh);
60
+ }
61
+
62
+ /** State the view, which the mesh asks for whenever a link moves. A frame
63
+ * equal to the last one goes no further than the suppression every topic
64
+ * shares (M5), so restating it costs nothing when nothing moved. */
65
+ refresh(): void {
66
+ this.deps.publish("instances", { instances: this.view() });
67
+ }
68
+
69
+ start(): void {}
70
+
71
+ stop(): void {}
72
+
73
+ snapshot(): readonly TopicValue[] {
74
+ return [{ instance: this.deps.self, data: { instances: this.view() } }];
75
+ }
76
+ }
package/src/mesh/mesh.ts CHANGED
@@ -763,7 +763,7 @@ export class Mesh {
763
763
  this.#host?.element(topic, instance as InstanceId, fields["data"]);
764
764
  return true;
765
765
  }
766
- this.relay.accept(instance as InstanceId, topic, fields["data"]);
766
+ this.relay.accept(instance as InstanceId, topic, fields["data"], fields["snapshot"] === true);
767
767
  return true;
768
768
  }
769
769
 
package/src/mesh/relay.ts CHANGED
@@ -1,40 +1,59 @@
1
+ import type { SessionRow } from "../topics/index.ts";
1
2
  import {
2
- type AgentInfo,
3
3
  type InstanceId,
4
4
  LAST_LIVE_RETENTION_MS,
5
- type LastLiveSession,
6
- type PeerInfo,
7
5
  PLAIN_TOPICS,
8
6
  type Sid,
9
7
  TOPIC_ATTRIBUTES,
10
8
  type Timestamp,
11
9
  } from "@ccmsg/protocol";
12
- import type { TopicValue } from "../topics/index.ts";
10
+ import { Elements, type TopicValue } from "../topics/index.ts";
11
+
12
+ /** The rows of sessions the whole cluster is seen through.
13
+ *
14
+ * They are `element`-granular, and an element topic is relayable only when its
15
+ * elements say whose they are: a row here names the instance that holds the
16
+ * session, so two instances' rows stand side by side under one topic name the
17
+ * way a whole value per instance does. `inbox` and `kv:<ns>` are elements of
18
+ * the same granularity and are not relayed, because their elements carry no
19
+ * such name — an `inbox` frame belongs to a session, not to an instance. */
20
+ const ROW_TOPICS: readonly string[] = ["peers", "agents"];
13
21
 
14
22
  /** The topics a subscriber sees the whole cluster on.
15
23
  *
16
- * The per-instance whole is what makes a cluster view possible at all (§6.2):
17
- * a frame replaces its own instance's entries and leaves every other
18
- * instance's alone, so several instances can state the same topic name without
19
- * colliding. A topic of any other granularity has no such rule and is not
20
- * relayed an `element` topic like `inbox` names one instance's topic while
21
- * its value belongs to a session, and a frame of it carries no way to say
22
- * whose it is. */
23
- export const CLUSTER_TOPICS: readonly string[] = PLAIN_TOPICS.filter(
24
- (topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole",
25
- );
24
+ * A per-instance whole is relayable by construction (§6.2): a frame replaces
25
+ * its own instance's entries and leaves every other instance's alone, so
26
+ * several instances can state the same topic name without colliding. The rows
27
+ * above are relayable for the same reason read one element at a time. */
28
+ export const CLUSTER_TOPICS: readonly string[] = [
29
+ ...PLAIN_TOPICS.filter((topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole"),
30
+ ...ROW_TOPICS,
31
+ ];
26
32
 
27
33
  /** The one topic the mesh carries that the relay does not.
28
34
  *
29
- * It is `element`-granular, so what travels is the entries that changed and the
30
- * receiver merges them by key; and it is the instances' own, so it is asked for
31
- * as the instance rather than on a person's behalf (DR-0001 §2.6). */
35
+ * It is `element`-granular and its elements are the instances' own, so it is
36
+ * asked for as the instance rather than on a person's behalf, and folded into
37
+ * the set this instance holds rather than held here (DR-0001 §2.6). */
32
38
  export const AUTH_TOPIC = "auth_records";
33
39
 
34
40
  export function isClusterTopic(topic: string): boolean {
35
41
  return CLUSTER_TOPICS.includes(topic);
36
42
  }
37
43
 
44
+ /** Whether what a frame of this topic carries is rows to be merged rather than
45
+ * a value to be replaced. */
46
+ function carriesRows(topic: string): boolean {
47
+ return ROW_TOPICS.includes(topic);
48
+ }
49
+
50
+ /** The rows of one frame, under the field each topic names them in. A frame
51
+ * that carries none is one there is nothing to merge from. */
52
+ function rowsOf(topic: string, data: unknown): readonly SessionRow[] {
53
+ const field = (data as Record<string, unknown> | undefined)?.[topic];
54
+ return Array.isArray(field) ? (field as SessionRow[]) : [];
55
+ }
56
+
38
57
  export interface RelayDeps {
39
58
  /** Hand a relayed frame to this instance's own subscribers, under the
40
59
  * instance that produced it (§7.4). */
@@ -70,19 +89,47 @@ export class Relay {
70
89
  }
71
90
 
72
91
  /** A frame a peer pushed on a topic this instance relays.
92
+ *
93
+ * `snapshot` marks the opening frame of a subscription, which carries the
94
+ * whole of what its instance holds rather than what changed.
73
95
  *
74
96
  * `instance` is the one that produced the value, which is not always the
75
97
  * peer it arrived from: a mesh of three relays transitively, and the frame
76
98
  * names its origin the whole way. Held under that origin, and passed on
77
99
  * unchanged — recomputing it would put the same judgement in two places
78
100
  * (§7.4). */
79
- accept(instance: InstanceId, topic: string, data: unknown): void {
101
+ accept(instance: InstanceId, topic: string, data: unknown, snapshot = false): void {
80
102
  if (!isClusterTopic(topic)) return;
81
103
  this.#sweep();
82
104
  const held = this.#held.get(instance) ?? new Map<string, unknown>();
83
105
  this.#held.set(instance, held);
84
- held.set(topic, data);
85
- this.deps.publish(topic, data, instance);
106
+ if (!carriesRows(topic)) {
107
+ held.set(topic, data);
108
+ this.deps.publish(topic, data, instance);
109
+ return;
110
+ }
111
+ // A frame of rows says what changed, so what is held is the rows merged
112
+ // and what travels on is the part of it that said something. The same
113
+ // comparison the mechanism makes of a whole value, made of one element
114
+ // (M5) — and a frame left with no rows is not passed on at all, which is
115
+ // what stops a peer's restatement from becoming a frame for every local
116
+ // subscriber.
117
+ // An opening frame is the whole of what its instance holds, so it is taken
118
+ // as the list restated rather than as changes folded in: a row the peer no
119
+ // longer has is gone from it and from nowhere else, and merging would leave
120
+ // it here forever. What comes of that is the same kind of answer either
121
+ // way — the rows that told this instance something, removals included.
122
+ const rows = this.#rows(held, topic);
123
+ const stated = rowsOf(topic, data);
124
+ const news = snapshot ? rows.diff(stated) : rows.merge(stated);
125
+ if (news.length > 0) this.deps.publish(topic, { [topic]: news }, instance);
126
+ }
127
+
128
+ /** The rows one instance has stated on a topic, made the first time it does. */
129
+ #rows(held: Map<string, unknown>, topic: string): Elements {
130
+ const rows = (held.get(topic) as Elements | undefined) ?? new Elements();
131
+ held.set(topic, rows);
132
+ return rows;
86
133
  }
87
134
 
88
135
  /** The link to this instance is gone. What it said is kept and marked,
@@ -113,7 +160,16 @@ export class Relay {
113
160
  const values: TopicValue[] = [];
114
161
  for (const [instance, held] of this.#held) {
115
162
  const data = held.get(topic);
116
- if (data !== undefined) values.push({ instance, data });
163
+ if (data === undefined) continue;
164
+ // A topic of rows is held merged, and the opening frame of a topic is
165
+ // its whole value — so what a fresh subscriber is handed is every row
166
+ // that instance has stated, in one frame of the same shape as the ones
167
+ // that follow it.
168
+ values.push(
169
+ carriesRows(topic)
170
+ ? { instance, data: { [topic]: (data as Elements).rows() } }
171
+ : { instance, data },
172
+ );
117
173
  }
118
174
  return values;
119
175
  }
@@ -121,29 +177,21 @@ export class Relay {
121
177
  /** Which instance a session belongs to, read from the cluster values the
122
178
  * peers stated (§7.3).
123
179
  *
124
- * `peers` names every session an instance currently holds connected or in
125
- * `last_live` — and is checked first. A session hello has not reached yet
126
- * has no row there but the harness may already know of it, so `agents` is
127
- * checked next; `last_live` is the last resort for one whose instance has
128
- * not stated `agents` at all. Every row names its own instance rather than
129
- * the one that relayed it, so a value that travelled through a third
130
- * instance still points at the session's own. */
180
+ * `peers` names every session an instance holds, connected and lost alike,
181
+ * and is checked first. A session whose greeting has not reached its
182
+ * instance yet has no row there while the harness may already know of it, so
183
+ * `agents` is checked next. Every row names its own instance rather than the
184
+ * one that relayed it, so a row that travelled through a third instance
185
+ * still points at the session's own. */
131
186
  owner(sid: Sid): InstanceId | undefined {
132
187
  this.#sweep();
133
- for (const held of this.#held.values()) {
134
- const value = held.get("peers") as { peers?: PeerInfo[] } | undefined;
135
- const row = value?.peers?.find((peer) => peer.sid === sid);
136
- if (row !== undefined) return row.instance;
137
- }
138
- for (const held of this.#held.values()) {
139
- const value = held.get("agents") as { agents?: AgentInfo[] } | undefined;
140
- const row = value?.agents?.find((agent) => agent.sid === sid);
141
- if (row !== undefined) return row.instance;
142
- }
143
- for (const held of this.#held.values()) {
144
- const value = held.get("peers") as { last_live?: LastLiveSession[] } | undefined;
145
- const row = value?.last_live?.find((session) => session.sid === sid);
146
- if (row !== undefined) return row.instance;
188
+ for (const topic of ROW_TOPICS) {
189
+ for (const held of this.#held.values()) {
190
+ const row = (held.get(topic) as Elements | undefined)
191
+ ?.rows()
192
+ .find((held) => held.sid === sid);
193
+ if (row !== undefined) return row.instance;
194
+ }
147
195
  }
148
196
  return undefined;
149
197
  }
@@ -3,7 +3,6 @@ import type {
3
3
  CandidateSession,
4
4
  InboxMessage,
5
5
  InstanceId,
6
- LastLiveSession,
7
6
  MessageSendArgs,
8
7
  MessageSendResult,
9
8
  Mid,
@@ -15,6 +14,7 @@ import type {
15
14
  UndeliveredReason,
16
15
  } from "@ccmsg/protocol";
17
16
  import { USER_SENDER } from "@ccmsg/protocol";
17
+ import { isLive } from "../sessions/classify.ts";
18
18
  import {
19
19
  type DispatchResult,
20
20
  type HandlerInput,
@@ -35,7 +35,7 @@ const INBOX = "inbox";
35
35
  * the reasons of §4.2 from growing a source per reason. */
36
36
  export interface SessionLookup {
37
37
  classify(sid: Sid): SessionState | undefined;
38
- peers(): { peers: PeerInfo[]; last_live: LastLiveSession[] };
38
+ peerRows(): PeerInfo[];
39
39
  }
40
40
 
41
41
  /** The rest of the cluster, for a message addressed outside this instance.
@@ -290,17 +290,27 @@ export class Delivery implements UpstreamResource {
290
290
  }
291
291
 
292
292
  /** Sessions live now in the repository the addressee belongs to (§4.2).
293
+ *
294
+ * The rows are one list of sessions, connected and lost alike, so which of
295
+ * them can be written to is the classification — asked of the domain, the
296
+ * same way the addressee's own reason was, rather than read off a field of
297
+ * the row (M1).
293
298
  *
294
299
  * The repository is `repo_root` as the session named it. A session that named
295
300
  * none is left out rather than matched on something derived from its `cwd`:
296
301
  * no primary source states that derivation, and the sessions domain does not
297
302
  * make one up either. */
298
303
  #candidates(to: Sid): CandidateSession[] {
299
- const { peers, last_live } = this.deps.sessions.peers();
300
- const root = [...peers, ...last_live].find((row) => row.sid === to)?.repo_root;
304
+ const rows = this.deps.sessions.peerRows();
305
+ const root = rows.find((row) => row.sid === to)?.repo_root;
301
306
  if (root === undefined) return [];
302
- return peers
303
- .filter((peer) => peer.sid !== to && peer.repo_root === root)
307
+ return rows
308
+ .filter(
309
+ (peer) =>
310
+ peer.sid !== to &&
311
+ peer.repo_root === root &&
312
+ isLive({ state: this.deps.sessions.classify(peer.sid) }),
313
+ )
304
314
  .map((peer) => ({
305
315
  sid: peer.sid,
306
316
  ...(peer.ws === "" ? {} : { ws: peer.ws }),
@@ -374,7 +384,7 @@ function callerOf(input: HandlerInput): CallerIdentity | undefined {
374
384
  * notification's subject are the same session seen from two ops, and a session
375
385
  * shown one way there and another way here would read as two. */
376
386
  export function sessionLabel(sessions: SessionLookup, sid: Sid): string {
377
- const peer = sessions.peers().peers.find((row) => row.sid === sid);
387
+ const peer = sessions.peerRows().find((row) => row.sid === sid);
378
388
  if (peer === undefined) return sid;
379
389
  const where = [peer.repo, peer.ws].filter((part) => part !== "").join("/");
380
390
  return where === "" ? sid : where;
@@ -64,3 +64,15 @@ export function classify(
64
64
  if (inputs.last_live === undefined) return undefined;
65
65
  return inputs.last_live.stopped_at === undefined ? "disappeared" : "paused";
66
66
  }
67
+
68
+ /** Whether a classification is one of the connected ones, which is what a row
69
+ * has to be for anything here to reach it.
70
+ *
71
+ * Read off the classification rather than restated: the two sections the
72
+ * vocabulary is divided into are the contract's own, and a second list of
73
+ * which words mean "still there" would be a second place for the rule to drift
74
+ * in (M1). A row that states none is one no section holds, which is not a
75
+ * session anything can be handed to. */
76
+ export function isLive(row: { readonly state?: SessionState }): boolean {
77
+ return row.state !== undefined && row.state !== "paused" && row.state !== "disappeared";
78
+ }
@@ -75,7 +75,10 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
75
75
  },
76
76
  deps.presets,
77
77
  );
78
- const { items, entries } = select(within(classify(located(text)), args), keep);
78
+ const { items, entries } = select(
79
+ within(classify(located(text), deps.files.subjectOf(file)), args),
80
+ keep,
81
+ );
79
82
  const ids = ledger(items);
80
83
  const written_at = Date.now();
81
84
  // The file repeats what it was asked for. A dump outlives the request that
@@ -68,7 +68,7 @@ export function itemsRead(
68
68
  throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
69
69
  }
70
70
  const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
71
- const { items } = select(within(classify(located(text)), args), keep);
71
+ const { items } = select(within(classify(located(text), deps.files.subjectOf(file)), args), keep);
72
72
  const page = paged(items, args.limit, backwards(args));
73
73
  return {
74
74
  items: page.items,
@@ -3,22 +3,39 @@ import { dirname, join } from "node:path";
3
3
  import {
4
4
  LAST_LIVE_RETENTION_MS,
5
5
  type InstanceId,
6
- type LastLiveSession,
6
+ type PeerInfo,
7
7
  type Sid,
8
8
  type Timestamp,
9
9
  } from "@ccmsg/protocol";
10
10
 
11
11
  export const LAST_LIVE_FILE = "last-live.json";
12
12
 
13
- /** What is stored per session: the contract's entry, minus the one field that
14
- * is derived rather than observed.
13
+ /** What is stored per session: the observations a lost session's row is built
14
+ * from, and nothing the row derives or a connection supplies.
15
15
  *
16
16
  * `state` is left out on purpose (M4). It follows from `stopped_at` and from
17
17
  * whether the session is live again, both of which are known when the list is
18
18
  * read, so storing it would be storing a conclusion that can go stale on disk.
19
19
  * `pinned` is left out because no pin is held anywhere yet; when one is, it
20
- * belongs to the session rather than to this list. */
21
- export type StoredEntry = Omit<LastLiveSession, "state" | "pinned">;
20
+ * belongs to the session rather than to this list. The connection fields go
21
+ * with the connection there is none of.
22
+ *
23
+ * `last_seen_at` is required here while the row states it optionally: a row
24
+ * this store holds is by definition one this instance has lost, and when it
25
+ * last saw it is what the retention window is measured from. */
26
+ export type StoredEntry = Omit<
27
+ PeerInfo,
28
+ | "state"
29
+ | "pinned"
30
+ | "last_activity_at"
31
+ | "last_user_input_at"
32
+ | "gateway_active_at"
33
+ | "send_message"
34
+ | "client_version"
35
+ | "protocol_version"
36
+ | "stale_client"
37
+ | "last_seen_at"
38
+ > & { last_seen_at: Timestamp };
22
39
 
23
40
  interface Document {
24
41
  version: number;
@@ -1,5 +1,4 @@
1
1
  import { realpathSync, statSync } from "node:fs";
2
- import { hostname } from "node:os";
3
2
  import { basename, dirname, isAbsolute, join } from "node:path";
4
3
  import {
5
4
  type AgentInfo,
@@ -7,9 +6,10 @@ import {
7
6
  type HelloArgs,
8
7
  type HelloResult,
9
8
  type Endpoint,
9
+ type AgentElement,
10
10
  type InstanceId,
11
11
  type InstanceInfo,
12
- type LastLiveSession,
12
+ type PeerElement,
13
13
  type PeerInfo,
14
14
  PROTOCOL_VERSION,
15
15
  type SessionState,
@@ -20,8 +20,9 @@ import {
20
20
  import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
21
21
  import { within } from "../files/index.ts";
22
22
  import { HARNESS, type Harness } from "../harness/index.ts";
23
+ import { clusterView } from "../mesh/instances.ts";
23
24
  import type { TranscriptFacts } from "../transcript/index.ts";
24
- import type { TopicValue, UpstreamResource } from "../topics/index.ts";
25
+ import { Elements, type TopicValue, type UpstreamResource } from "../topics/index.ts";
25
26
  import { classify, type SessionInputs } from "./classify.ts";
26
27
  import { isWaiting, type OwnSessions, ownSessions } from "./harness.ts";
27
28
  import { LastLiveStore, type StoredEntry } from "./last-live.ts";
@@ -211,6 +212,16 @@ export class Sessions implements UpstreamResource {
211
212
  * then carries on stays connected and keeps its declaration, which is spent
212
213
  * whenever it does leave. */
213
214
  readonly #stopping = new Map<Sid, Timestamp>();
215
+ /** What the last frame of each topic left every subscriber holding, kept
216
+ * whether or not anybody is subscribed.
217
+ *
218
+ * A frame carries a difference, and the difference is taken against what was
219
+ * sent — not against what the last listener happened to see. Which is also
220
+ * what makes it right for a subscriber that arrives after a spell of nobody
221
+ * listening: it is handed every row as a snapshot, and every frame after it
222
+ * says what changed since the last one went out. */
223
+ readonly #sentPeers = new Elements();
224
+ readonly #sentAgents = new Elements();
214
225
 
215
226
  constructor(private readonly deps: SessionsDeps) {
216
227
  this.#harness = ownSessions(
@@ -294,18 +305,9 @@ export class Sessions implements UpstreamResource {
294
305
  protocol_version: PROTOCOL_VERSION,
295
306
  instance: this.deps.self,
296
307
  ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
297
- // Without a mesh the cluster is this instance alone, and it says so:
298
- // an instance serving the unix socket alone has no URL to be dialed at,
299
- // which is a row without an endpoint rather than no row (contract,
300
- // `InstanceInfo`).
301
- instances: this.deps.mesh?.instances() ?? [
302
- {
303
- id: this.deps.self,
304
- ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
305
- host: hostname(),
306
- reachable: true,
307
- },
308
- ],
308
+ // The same view the `instances` topic carries, worked out in one place
309
+ // so a greeting and a subscription cannot state two different clusters.
310
+ instances: clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh),
309
311
  capabilities: [...this.deps.capabilities],
310
312
  version: this.deps.version,
311
313
  started_at: this.deps.startedAt,
@@ -483,9 +485,21 @@ export class Sessions implements UpstreamResource {
483
485
  if (this.#wanted.size === 0) this.#harness.stop();
484
486
  }
485
487
 
488
+ /** What a fresh subscriber is handed: every row, connected and lost alike.
489
+ *
490
+ * A frame of either topic carries the rows that changed, so the opening one
491
+ * has to carry all of them — it is the only frame that states the rows a
492
+ * subscriber was not there for. Stating them is also what the difference
493
+ * after it is taken against: these rows are what the subscriber now holds,
494
+ * and they are the same rows every earlier subscriber was brought up to by
495
+ * the frames it has had. */
486
496
  snapshot(topic: string): readonly TopicValue[] {
497
+ const now = Date.now();
487
498
  const own = this.#own();
488
- const data = topic === "agents" ? this.agents(own) : this.peers(Date.now(), own);
499
+ const data =
500
+ topic === "agents"
501
+ ? { agents: this.#sentAgents.stated(this.agentRows(own)), polled_at: now }
502
+ : { peers: this.#sentPeers.stated(this.peerRows(now, own)) };
489
503
  return [{ instance: this.deps.self, data }];
490
504
  }
491
505
 
@@ -495,9 +509,11 @@ export class Sessions implements UpstreamResource {
495
509
  return this.#harness.running;
496
510
  }
497
511
 
498
- /** The `peers` payload: what is live now, and what was live when this
499
- * instance last saw it. Both travel together because coming back is exactly
500
- * what moves a session from the second list to the first.
512
+ /** Every row `peers` states: what is live now, and what was live when this
513
+ * instance last saw it. One kind of row rather than two lists, because
514
+ * coming back and going quiet are the same row changing its `state` — a
515
+ * client that held two lists would have to move an entry between them to
516
+ * follow one field.
501
517
  *
502
518
  * Live is not the same as connected (§5.2). A session the harness names is
503
519
  * live whether or not it ever greeted us, and it has to be on this list for
@@ -510,41 +526,65 @@ export class Sessions implements UpstreamResource {
510
526
  * — this instance is one that classifies, so it says so on every row rather
511
527
  * than on the rows it happens to have an answer for.
512
528
  *
513
- * `instances` is the same view `hello` answers with, restated here so that a
514
- * link going down reaches a subscriber on the topic it is already on rather
515
- * than only on its next greeting (§7.5). It is this instance's view: what a
516
- * peer relayed here carries the peer's own, and neither is folded into the
517
- * other. An instance with no mesh states none, which is a different thing
518
- * from stating that nothing is reachable. */
519
- peers(
520
- now: Timestamp = Date.now(),
521
- own: Own = this.#own(),
522
- ): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
523
- const instances = this.deps.mesh?.instances();
529
+ * A lost session is a row here rather than a list of its own: its entry in
530
+ * the store holds what was observed, and the two fields a row derives
531
+ * where it stands now, and whether it is pinned are worked out at read
532
+ * time (M4). */
533
+ peerRows(now: Timestamp = Date.now(), own: Own = this.#own()): PeerInfo[] {
534
+ return [
535
+ ...[...this.#connected.values()].map((session) => this.#peer(session, now, own)),
536
+ ...[...own.present]
537
+ .filter((sid) => !this.#connected.has(sid))
538
+ .map((sid) => this.#unconnected(sid, now, own)),
539
+ ...this.#lastLive.entries(now).map((entry) => this.#lost(entry, now, own)),
540
+ ];
541
+ }
542
+
543
+ /** One row of `peers`, for a producer that knows which session moved.
544
+ *
545
+ * The same three sources the list is built from, asked about one sid: a
546
+ * connection here, a session the harness names, an entry among the sessions
547
+ * this instance has lost. A sid none of them holds is one this instance has
548
+ * no row for, and it says so rather than inventing one. */
549
+ #peerRow(sid: Sid, now: Timestamp, own: Own): PeerInfo | undefined {
550
+ const held = this.#connected.get(sid);
551
+ if (held !== undefined) return this.#peer(held, now, own);
552
+ if (own.present.has(sid)) return this.#unconnected(sid, now, own);
553
+ const entry = this.#lastLive.get(sid);
554
+ return entry === undefined ? undefined : this.#lost(entry, now, own);
555
+ }
556
+
557
+ /** A session this instance has lost, as a row: what was observed of it,
558
+ * with the two fields a row derives worked out at read time (M4). */
559
+ #lost(entry: StoredEntry, now: Timestamp, own: Own): PeerInfo {
524
560
  return {
525
- peers: [
526
- ...[...this.#connected.values()].map((session) => this.#peer(session, now, own)),
527
- ...[...own.present]
528
- .filter((sid) => !this.#connected.has(sid))
529
- .map((sid) => this.#unconnected(sid, now, own)),
530
- ],
531
- last_live: this.#lastLive.entries(now).map((entry) => ({
532
- ...entry,
533
- state: this.classify(entry.sid, now, own) ?? "disappeared",
534
- pinned: this.#pinned(entry.sid),
535
- })),
536
- ...(instances === undefined ? {} : { instances }),
561
+ ...entry,
562
+ state: this.classify(entry.sid, now, own) ?? "disappeared",
563
+ pinned: this.#pinned(entry.sid),
537
564
  };
538
565
  }
539
566
 
540
- /** The `agents` payload: the harness's own view, as it stated it.
567
+ /** The gateway saw inference for one session again (§5.1).
568
+ *
569
+ * What moved is one attribute of one row, so that row is what goes out. The
570
+ * sessions domain is not recomputed for it: which sessions there are has not
571
+ * changed, and the whole of that work would be spent to restate a clock.
541
572
  *
542
- * `polled_at` is left out. Stating when the read behind the list ran would
543
- * make every confirmation poll a value the list did not have before, so the
544
- * one suppression every topic shares (M5) would let a five-second heartbeat
545
- * through for a directory that had not changed. */
546
- agents(own: Own = this.#own()): { agents: AgentInfo[] } {
547
- return { agents: [...own.rows.values()] };
573
+ * A sid this instance has no row for publishes nothing. The gateway sits
574
+ * above every config home and its events name only a session id, so one
575
+ * belonging to another config home must not become a row here — the same
576
+ * narrowing the row's own reading of the gateway makes. */
577
+ gatewayMoved(sid: Sid): void {
578
+ const now = Date.now();
579
+ const row = this.#peerRow(sid, now, this.#own());
580
+ if (row === undefined) return;
581
+ const peers = this.#sentPeers.diffRow(row) as PeerElement[];
582
+ if (peers.length > 0) this.deps.publish("peers", { peers });
583
+ }
584
+
585
+ /** Every row `agents` states: the harness's own view, as it stated it. */
586
+ agentRows(own: Own = this.#own()): AgentInfo[] {
587
+ return [...own.rows.values()];
548
588
  }
549
589
 
550
590
  /** Bind a session to this instance, and take what it says about itself. Its
@@ -596,11 +636,16 @@ export class Sessions implements UpstreamResource {
596
636
  this.changed();
597
637
  }
598
638
 
599
- /** Recompute, record what stopped being live, and state both topics.
639
+ /** Recompute, record what stopped being live, and state what moved on both
640
+ * topics.
600
641
  *
601
- * Publishing is unconditional here because suppression belongs to the topic
602
- * mechanism and is written once for every topic (M5) a payload equal to
603
- * the last one goes no further than that. */
642
+ * Both carry the rows that changed, so what is published is the difference
643
+ * against what was last published rather than the whole list: a session
644
+ * whose inference just ran is one row, and restating every row to say so
645
+ * would send a list to report one field. A recompute that found nothing
646
+ * different publishes nothing — the suppression every topic shares (M5) is
647
+ * whole-value and cannot drop a frame of elements, so the diff is where a
648
+ * repeat stops here. */
604
649
  private changed(now: Timestamp = Date.now()): void {
605
650
  const own = this.#own();
606
651
  const live = this.#liveNow(now, own);
@@ -622,8 +667,10 @@ export class Sessions implements UpstreamResource {
622
667
  }
623
668
  this.#reclaim(live);
624
669
  this.#live = live;
625
- this.deps.publish("peers", this.peers(now, own));
626
- this.deps.publish("agents", this.agents(own));
670
+ const peers = this.#sentPeers.diff(this.peerRows(now, own)) as PeerElement[];
671
+ if (peers.length > 0) this.deps.publish("peers", { peers });
672
+ const agents = this.#sentAgents.diff(this.agentRows(own)) as AgentElement[];
673
+ if (agents.length > 0) this.deps.publish("agents", { agents, polled_at: now });
627
674
  this.deps.onChanged?.();
628
675
  }
629
676
 
Binary file
@@ -1,3 +1,4 @@
1
+ export * from "./elements.ts";
1
2
  export * from "./egress.ts";
2
3
  export * from "./handlers.ts";
3
4
  export * from "./topics.ts";
@@ -1,11 +1,17 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
- import type { Sid } from "@ccmsg/protocol";
3
+ import type { Sid, TranscriptSubject } from "@ccmsg/protocol";
4
4
  import { type Harness, HARNESS } from "../harness/index.ts";
5
5
  import { OpError } from "../dispatch/index.ts";
6
6
 
7
7
  const SUFFIX = ".jsonl";
8
8
 
9
+ /** What the harness names an agent's transcript with, and what it calls the
10
+ * kind of task a teammate is. Both are its own words, read where they are
11
+ * written rather than mirrored anywhere. */
12
+ const AGENT_PREFIX = "agent-";
13
+ const TEAMMATE_TASK = "in_process_teammate";
14
+
9
15
  /** Where one harness keeps transcripts under its config home, and how a file
10
16
  * there says which session it belongs to (§3.8).
11
17
  *
@@ -126,6 +132,37 @@ export class TranscriptFiles {
126
132
  return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
127
133
  }
128
134
 
135
+ /** Which standing a transcript was written from, which every item read out
136
+ * of it states (§3.6).
137
+ *
138
+ * The file itself does not say whether an agent was a teammate or an errand:
139
+ * both are marked as sidechains and both are briefed the same way. What says
140
+ * so is the harness's own note beside the file — the same note a teammate is
141
+ * found by name in — and `taskKind` on it is the harness stating which kind
142
+ * of task it started. An envelope in the opening brief looks like the same
143
+ * answer and is not one: it is text somebody wrote, and an errand handed a
144
+ * quoted message carries it too.
145
+ *
146
+ * A note that is missing or unreadable leaves the question unanswered, and
147
+ * the answer then is `sub`: an errand is the standing that claims the least —
148
+ * nothing goes on standing, nobody is addressed by name — so a teammate read
149
+ * as one loses a name it might have been drawn under, where the reverse would
150
+ * have a reader write back to something that is already gone. */
151
+ subjectOf(file: string): TranscriptSubject {
152
+ const name = basename(file);
153
+ if (!name.startsWith(AGENT_PREFIX) || !name.endsWith(SUFFIX)) return "main";
154
+ let note: unknown;
155
+ try {
156
+ note = JSON.parse(
157
+ readFileSync(join(dirname(file), `${name.slice(0, -SUFFIX.length)}.meta.json`), "utf8"),
158
+ );
159
+ } catch {
160
+ return "sub";
161
+ }
162
+ const kind = (note as { taskKind?: unknown } | null)?.taskKind;
163
+ return kind === TEAMMATE_TASK ? "team" : "sub";
164
+ }
165
+
129
166
  /** A teammate's transcript, found by the name it is addressed by.
130
167
  *
131
168
  * The name a teammate carries in conversation is not its filename, so the
@@ -1,3 +1,4 @@
1
+ import type { TranscriptSubject } from "@ccmsg/protocol";
1
2
  import type { Item } from "./item.ts";
2
3
  import {
3
4
  count,
@@ -84,6 +85,7 @@ const OUTSTANDING_CALLS = 4096;
84
85
  type Draft = Record<string, unknown> & {
85
86
  id: string;
86
87
  uuid: string;
88
+ subject: TranscriptSubject;
87
89
  source: { offset: number; bytes: number };
88
90
  type: string;
89
91
  at: number;
@@ -96,8 +98,8 @@ type Draft = Record<string, unknown> & {
96
98
  * apart the harness wrote them. Reading the whole file before any range is
97
99
  * applied is what makes `parent_item` answerable: a result inside the range
98
100
  * whose call fell before it still names the call. */
99
- export function classify(records: Iterable<Located>): Item[] {
100
- const state = new Classification();
101
+ export function classify(records: Iterable<Located>, subject: TranscriptSubject = "main"): Item[] {
102
+ const state = new Classification(subject);
101
103
  return state.readAll(records);
102
104
  }
103
105
 
@@ -117,17 +119,24 @@ export class Classification {
117
119
  #turn = 0;
118
120
  /** The last slash command invoked, which is what its output belongs to. */
119
121
  #slash: string | undefined;
120
- /** Whose file this is, which decides who is at the other end of a plain
121
- * line. A session's own transcript has a person there; a file written for an
122
- * agent has whoever started it, and calling that `user` would have a reader
123
- * take a machine for a person.
122
+ /** Whose file this is, which every item states and which decides who is at
123
+ * the other end of a plain line. A session's own transcript has a person
124
+ * there; a file written for an agent has whoever started it, and calling that
125
+ * `user` would have a reader take a machine for a person.
124
126
  *
125
- * The file says so itself every record of an agent's transcript is marked
126
- * as one so nothing has to be passed in beside it. It is remembered once
127
- * seen rather than read per record: a file is one subject's throughout, and a
128
- * record that omitted the mark would otherwise change who the subject is
129
- * mid-read. */
130
- #subject: "session" | "agent" = "session";
127
+ * It is told rather than read out of the records, because what tells a
128
+ * teammate from an errand is not in the transcript at all: the harness states
129
+ * it beside the file, and whoever opened the file has already read that (§3.6).
130
+ *
131
+ * A record marked as a sidechain inside a file opened as a session's own says
132
+ * the file is an agent's after all, and the reading moves to `sub` — the
133
+ * standing that claims the least. It only ever narrows: a reading told which
134
+ * agent's file it has is not talked out of it by the records. */
135
+ #subject: TranscriptSubject;
136
+
137
+ constructor(subject: TranscriptSubject = "main") {
138
+ this.#subject = subject;
139
+ }
131
140
 
132
141
  /** The records of one chunk as the items they were read as, oldest first.
133
142
  *
@@ -158,7 +167,7 @@ export class Classification {
158
167
  read(record: Row, source: { offset: number; bytes: number }): void {
159
168
  const type = str(record["type"]);
160
169
  if (type === undefined || NOT_ITEMS.has(type)) return;
161
- if (record["isSidechain"] === true) this.#subject = "agent";
170
+ if (record["isSidechain"] === true && this.#subject === "main") this.#subject = "sub";
162
171
  // A record the harness wrote without an id of its own still happened, and
163
172
  // an item is pointed at by the record it came from — so where the record
164
173
  // stands in the file stands in for the id it lacks. The `@` says which of
@@ -174,6 +183,7 @@ export class Classification {
174
183
  const draft: Draft = {
175
184
  id: `${uuid}:${String(index)}`,
176
185
  uuid,
186
+ subject: this.#subject,
177
187
  source,
178
188
  type: kind,
179
189
  at,
@@ -255,7 +265,7 @@ export class Classification {
255
265
  // them is the answer it was started for, and the ones before are what
256
266
  // it hands back mid-flight. No call carries them, which is why
257
267
  // `parent:out` is prose as well as a call.
258
- const kind = this.#subject === "agent" ? "message:parent:out" : "message:user:out";
268
+ const kind = this.#subject === "main" ? "message:user:out" : "message:parent:out";
259
269
  if (said !== undefined && said !== "") make(kind, { text: said });
260
270
  continue;
261
271
  }
@@ -473,9 +483,9 @@ export class Classification {
473
483
  // stands, being told what to do is not the same as being written to.
474
484
  if (record["parentUuid"] === null) {
475
485
  this.#turn += 1;
476
- make(this.#subject === "agent" ? "message:parent:in" : "message:user:in", {
486
+ make(this.#subject === "main" ? "message:user:in" : "message:parent:in", {
477
487
  text: said,
478
- ...(this.#subject === "agent" ? envelope(said) : {}),
488
+ ...(this.#subject === "main" ? {} : envelope(said)),
479
489
  });
480
490
  return;
481
491
  }
@@ -114,6 +114,9 @@ export interface GatewayDeps {
114
114
  /** The gateway saw something happen for a session, which is an input of the
115
115
  * sessions domain (§5.1) rather than of either topic. */
116
116
  readonly onActivity?: () => void;
117
+ /** A session already known to be running was seen again: its clock moved,
118
+ * and the row that carries it is what says so. */
119
+ readonly onMoved?: (sid: Sid) => void;
117
120
  readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
118
121
  /** Replaces the outward read in tests. */
119
122
  readonly fetch?: typeof fetch;
@@ -139,6 +142,7 @@ export class Gateway {
139
142
  self: deps.self,
140
143
  publish: deps.publish,
141
144
  ...(deps.onActivity === undefined ? {} : { onActivity: deps.onActivity }),
145
+ ...(deps.onMoved === undefined ? {} : { onMoved: deps.onMoved }),
142
146
  });
143
147
  this.status =
144
148
  deps.setup.statusUrl === undefined
@@ -14,10 +14,25 @@ export interface LlmRequestsDeps {
14
14
  /** The one way a value reaches subscribers (§6.1). */
15
15
  readonly publish: (topic: string, data: unknown) => void;
16
16
  /** An event moved when a session was last seen running inference, which is
17
- * an input of the sessions domain (§5.1) and not of this topic. */
17
+ * an input of the sessions domain (§5.1) and not of this topic. Told when
18
+ * the window opened, which is the moment the classification can change. */
18
19
  readonly onActivity?: () => void;
20
+ /** The same session seen again inside a window already open: one attribute
21
+ * of one row moved. Told apart from the above because what it asks for is
22
+ * that row restated rather than the whole domain recomputed. */
23
+ readonly onMoved?: (sid: Sid) => void;
19
24
  }
20
25
 
26
+ /** Which of a session's gateway facts moved.
27
+ *
28
+ * `live` is the moment the classification of §5.1 can change, because the
29
+ * window either opened or closed, and the sessions domain recomputes for it.
30
+ * `clock` is the same session seen again inside a window that was already open
31
+ * — the value of an attribute, not a section anything is in — so what it asks
32
+ * for is that one row restated. Both reach a subscriber; they differ in how
33
+ * much work is done to say so. */
34
+ type GatewayMove = "live" | "clock" | "none";
35
+
21
36
  /** Series remembered at once. The prune below already holds this near the
22
37
  * number active in the last cache window; the cap is what bounds a gateway
23
38
  * whose clock runs ahead, whose events would otherwise never expire. */
@@ -69,7 +84,7 @@ export class LlmRequests implements UpstreamResource {
69
84
  * near-ordered in practice, but a redelivery can put an older one after a
70
85
  * newer, and a countdown must not walk backwards. */
71
86
  record(info: LlmRequestObservation): void {
72
- this.active(info.sid, info.received_at);
87
+ this.moved(info.sid, this.active(info.sid, info.received_at));
73
88
  const key = seriesKey(info.sid, info.prefix);
74
89
  const held = this.#series.get(key);
75
90
  if (held !== undefined && held.info.received_at >= info.received_at) return;
@@ -91,7 +106,13 @@ export class LlmRequests implements UpstreamResource {
91
106
  * the window belongs to the request that opened it — and only says the
92
107
  * session was still running inference at that instant. */
93
108
  note(sid: Sid, at: Timestamp): void {
94
- if (this.active(sid, at)) this.deps.onActivity?.();
109
+ this.moved(sid, this.active(sid, at));
110
+ }
111
+
112
+ /** Tell whoever holds the row what this event moved for that session. */
113
+ private moved(sid: Sid, move: GatewayMove): void {
114
+ if (move === "live") this.deps.onActivity?.();
115
+ else if (move === "clock") this.deps.onMoved?.(sid);
95
116
  }
96
117
 
97
118
  /** When the gateway last saw inference for a session (§5.1). Undefined once
@@ -136,21 +157,31 @@ export class LlmRequests implements UpstreamResource {
136
157
 
137
158
  private publish(): void {
138
159
  this.deps.publish("llm_requests", this.entries());
139
- this.deps.onActivity?.();
140
160
  }
141
161
 
142
- /** Note the session was seen, and say whether that moved it forward. */
143
- private active(sid: Sid, at: Timestamp): boolean {
162
+ /** Note the session was seen, and say what that moved.
163
+ *
164
+ * A session already inside its window moves its clock and nothing else, so
165
+ * the row it lands on is restated on its own: inference is observed several
166
+ * times a second, and recomputing the domain for each would spend the whole
167
+ * of that work on one attribute of one row (§5.2). */
168
+ private active(sid: Sid, at: Timestamp): GatewayMove {
144
169
  const held = this.#activeAt.get(sid);
145
- if (held !== undefined && held >= at) return false;
170
+ if (held !== undefined && held >= at) return "none";
171
+ const wasLive = held !== undefined && at - held <= GATEWAY_LIVE_WINDOW_MS;
146
172
  this.#activeAt.set(sid, at);
147
173
  // Sessions the gateway has not seen for longer than the window go: what is
148
174
  // left is what any of this can still say something about.
149
- const floor = at - GATEWAY_LIVE_WINDOW_MS;
175
+ this.prune(at);
176
+ return wasLive ? "clock" : "live";
177
+ }
178
+
179
+ /** Drop the sessions whose window has closed. */
180
+ private prune(now: Timestamp): void {
181
+ const floor = now - GATEWAY_LIVE_WINDOW_MS;
150
182
  for (const [seen, when] of this.#activeAt) {
151
183
  if (when < floor) this.#activeAt.delete(seen);
152
184
  }
153
- return true;
154
185
  }
155
186
 
156
187
  private notePrefix(info: LlmRequestObservation): void {