@ccmsg/cli 0.10.1 → 0.11.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.
@@ -13,9 +13,9 @@ export const ID = /^[0-9a-f]{32}$/;
13
13
 
14
14
  /** A fresh id of that width, for whatever is being named.
15
15
  *
16
- * Shared with the cluster ids rather than written again there: what an id has
17
- * to be is unguessable-by-accident and the same width wherever it is read, and
18
- * a second generator is a second answer to how wide that is. */
16
+ * One generator rather than one per kind of id: what an id has to be is
17
+ * unguessable-by-accident and the same width wherever it is read, and a second
18
+ * generator is a second answer to how wide that is. */
19
19
  export function newId(): string {
20
20
  return randomBytes(ID_BYTES).toString("hex");
21
21
  }
@@ -89,7 +89,14 @@ import {
89
89
  recordsDir,
90
90
  } from "../auth/index.ts";
91
91
  import { type Cidr, clientAddress, parseCidr } from "./client.ts";
92
- import { type EntryConfig, type InstanceConfig, loadConfig } from "./config.ts";
92
+ import {
93
+ applied,
94
+ configOf,
95
+ DEFAULT_CONFIG,
96
+ type EntryConfig,
97
+ type InstanceConfig,
98
+ settle,
99
+ } from "./config.ts";
93
100
  import { completeHandlers } from "./handlers.ts";
94
101
  import { acquireLock, type Held, isHeldByUs, type Lock } from "./lock.ts";
95
102
  import { Log } from "./log.ts";
@@ -112,6 +119,15 @@ export interface StartOptions {
112
119
  readonly configHome?: string;
113
120
  /** Mirror the log to stderr. A foreground run wants it; a test does not. */
114
121
  readonly echoLog?: boolean;
122
+ /** Whether this start is the one that reads the edited files and writes down
123
+ * what checked out (§8.2).
124
+ *
125
+ * A supervisor does that for the instances it starts, so its children read
126
+ * what it applied and write nothing: one writer means no two processes
127
+ * racing over the same file, and it means `config diff --satisfied` compares
128
+ * against a value exactly one thing produced. A foreground start with no
129
+ * supervisor above it is the writer, because there is nobody else to be. */
130
+ readonly settle?: boolean;
115
131
  /** Overrides the confirmation poll of the sessions watch, for tests. */
116
132
  readonly pollMs?: number;
117
133
  /** Overrides the mesh's own intervals, for a test that cannot wait out a
@@ -176,7 +192,7 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
176
192
  try {
177
193
  // 3. the config. A broken one ends the start rather than turning the
178
194
  // setting it carried silently off (DV-Q9).
179
- const config = await loadConfig(paths.configDir, paths.configHome);
195
+ const config = await configFor(paths, log, options.settle ?? true);
180
196
  // What the config says of the gateway, resolved before anything is built
181
197
  // from it: a webhook source whose secret cannot be read ends the start
182
198
  // here, for the same reason a broken config does (DV-Q9).
@@ -192,12 +208,12 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
192
208
  // `daemon run` on one the shared file does not list — gets its id now
193
209
  // rather than from an `add` that never happened (DR-0001 §2.1).
194
210
  const id = instanceIdentity(paths.instanceIdFile);
195
- // 5. the endpoint list, for an instance that has a mesh.
211
+ // 5. the mesh, for an instance the data names an address for.
196
212
  //
197
- // Which entry of it is this instance is settled by the probe, and the probe
198
- // has to arrive at a listener so the WebSocket is bound here and handed
199
- // to the instance. A list that names this instance no times, or twice, ends
200
- // the start.
213
+ // Which entry of the list is this instance is its own row, so nothing has
214
+ // to be asked of the network to settle it (§7.1). The WebSocket is still
215
+ // bound here and handed over, because the instance does not exist yet and
216
+ // a peer may dial the moment the address is up.
201
217
  const mesh = meshFor(id, config, log, options.meshTiming);
202
218
  const wiring = mesh === undefined ? undefined : await bindForMesh(config, mesh);
203
219
  // 6-8 are the instance's own construction and listen.
@@ -223,6 +239,35 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
223
239
  }
224
240
  }
225
241
 
242
+ /** What this config home runs with: the settings that were read and checked.
243
+ *
244
+ * Either read from what was applied, or — on the start that has nobody above
245
+ * it — read from the edited files and written down if it holds. Both end at
246
+ * the same place: what this instance runs with is a value that checked out.
247
+ * A config that does not hold leaves the applied one standing and is written
248
+ * to the log, because an instance that was serving a session is not something
249
+ * a typo should take down (§8.3).
250
+ *
251
+ * A config home nothing states settings for runs the built-in ones, which is
252
+ * the unix socket and no mesh: `daemon run` on a directory nobody registered
253
+ * is a thing a person may do. */
254
+ async function configFor(paths: InstancePaths, log: Log, check: boolean): Promise<InstanceConfig> {
255
+ if (!check) {
256
+ // Read, and nothing else: whoever started this instance has already read
257
+ // the files and written down what held.
258
+ const standing = applied(paths.stateRoot);
259
+ return (
260
+ (standing === undefined ? undefined : configOf(standing, paths.configHome)?.config) ??
261
+ DEFAULT_CONFIG
262
+ );
263
+ }
264
+ const settled = await settle(paths.configDir, paths.stateRoot);
265
+ for (const problem of settled.problems) {
266
+ log.write("config refused", { file: problem.file, problem: problem.msg });
267
+ }
268
+ return configOf(settled.satisfied, paths.configHome)?.config ?? DEFAULT_CONFIG;
269
+ }
270
+
226
271
  /** The mesh, on an instance configured for one.
227
272
  *
228
273
  * Two things have to be true: peers to dial, and an address they can dial back.
@@ -234,10 +279,14 @@ function meshFor(
234
279
  log: Log,
235
280
  timing?: MeshTiming,
236
281
  ): Mesh | undefined {
237
- if (config.peers.length === 0 || config.entry === undefined) return undefined;
282
+ const self = config.endpoint;
283
+ if (self === undefined || config.endpoints.length === 0 || config.entry === undefined) {
284
+ return undefined;
285
+ }
238
286
  return new Mesh({
239
287
  id,
240
- peers: config.peers,
288
+ self,
289
+ peers: config.endpoints.map((row) => row.endpoint),
241
290
  conns: new ConnRegistry(),
242
291
  log: (msg, fields) => {
243
292
  log.write(msg, fields);
@@ -259,12 +308,11 @@ export interface MeshWiring {
259
308
  attach(instance: Instance): void;
260
309
  }
261
310
 
262
- /** Bind the WebSocket, settle which endpoint this instance is, and hand both on.
311
+ /** Bind the WebSocket before the instance exists, and hand it on.
263
312
  *
264
- * The listener answers the two pre-authentication routes from the moment it is
265
- * up the probe of self-identification and the key of mesh-peer-auth §6 — and
266
- * refuses everything else until the instance exists, which is a window of one
267
- * round of probes. */
313
+ * A peer may dial the moment the address is up, so the listener answers the one
314
+ * pre-authentication route from that moment the key of mesh-peer-auth §6 —
315
+ * and refuses everything else until there is an instance to answer. */
268
316
  async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiring> {
269
317
  const entry = config.entry as EntryConfig;
270
318
  let instance: Instance | undefined;
@@ -284,16 +332,6 @@ async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiri
284
332
  instance?.accepted(conn, info);
285
333
  },
286
334
  });
287
- try {
288
- await mesh.identify();
289
- } catch (cause) {
290
- // The listener is bound before the endpoint list is checked, so it is this
291
- // function's to release when the check refuses — nothing else holds it yet,
292
- // and a port left bound by a refused start is one the next start cannot
293
- // have.
294
- await ws.close();
295
- throw cause;
296
- }
297
335
  return {
298
336
  conns: mesh.conns,
299
337
  ws,
@@ -718,7 +756,7 @@ export class Instance {
718
756
  pid: process.pid,
719
757
  socket: this.paths.socket,
720
758
  http: this.http,
721
- peers: this.config.peers.length,
759
+ peers: this.config.endpoints.length,
722
760
  });
723
761
  }
724
762
 
@@ -2,7 +2,15 @@ import { createHash } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, isAbsolute, join } from "node:path";
4
4
  import { currentSession, HARNESS } from "../harness/index.ts";
5
- import { CLUSTERS_DIR, CLUSTERS_FILE, CONFIG_FILE, INSTANCES_DIR } from "./config.ts";
5
+ import {
6
+ CONFIG_FILE,
7
+ ENDPOINTS_FILE,
8
+ INSTANCES_DIR,
9
+ REJECTED_DIR,
10
+ SATISFIED_FILE,
11
+ STATE_CONFIG_DIR,
12
+ SUPERVISOR_FILE,
13
+ } from "./config.ts";
6
14
 
7
15
  /** Every path one instance uses, decided in one place (daemon-v2 §8.1).
8
16
  *
@@ -23,9 +31,15 @@ export interface InstancePaths {
23
31
  readonly configFile: string;
24
32
  /** Where the file naming this config home lives, one per instance. */
25
33
  readonly instancesDir: string;
26
- /** Which clusters this host knows of, and where each one's file is. */
27
- readonly clustersFile: string;
28
- readonly clustersDir: string;
34
+ /** The mesh as data, and which of it this host starts. */
35
+ readonly endpointsFile: string;
36
+ readonly supervisorFile: string;
37
+ /** Where what has been read and checked is kept, which is the only thing the
38
+ * supervisor and the instances read (§8.2). */
39
+ readonly stateRoot: string;
40
+ readonly satisfiedFile: string;
41
+ /** Where a file is put before it is overwritten by the checked copy. */
42
+ readonly rejectedDir: string;
29
43
  readonly stateDir: string;
30
44
  /** The address clients connect to. A symlink to whichever `socketReal` is
31
45
  * currently serving, so a client's path outlives the process behind it. */
@@ -119,6 +133,7 @@ export function resolvePaths(env: Env = process.env): InstancePaths {
119
133
  export function resolvePathsFor(configHome: string, env: Env = process.env): InstancePaths {
120
134
  const key = instanceKey(configHome);
121
135
  const configDir = resolveConfigDir(env);
136
+ const stateRoot = resolveStateRoot(env);
122
137
  const stateDir = appDir(env, "CCMSG_STATE_DIR", "XDG_STATE_HOME", [".local", "state"], key);
123
138
  const socketDir = socketDirFor(stateDir, key);
124
139
  return {
@@ -127,8 +142,11 @@ export function resolvePathsFor(configHome: string, env: Env = process.env): Ins
127
142
  configDir,
128
143
  configFile: join(configDir, CONFIG_FILE),
129
144
  instancesDir: join(configDir, INSTANCES_DIR),
130
- clustersFile: join(configDir, CLUSTERS_FILE),
131
- clustersDir: join(configDir, CLUSTERS_DIR),
145
+ endpointsFile: join(configDir, ENDPOINTS_FILE),
146
+ supervisorFile: join(configDir, SUPERVISOR_FILE),
147
+ stateRoot,
148
+ satisfiedFile: join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE),
149
+ rejectedDir: join(stateRoot, REJECTED_DIR),
132
150
  stateDir,
133
151
  socketDir,
134
152
  socket: join(socketDir, SOCKET_NAME),
package/src/mesh/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export * from "./instances.ts";
2
2
  export * from "./keys.ts";
3
3
  export * from "./mesh.ts";
4
- export * from "./probe.ts";
5
4
  export * from "./relay.ts";
6
5
  export * from "./wire.ts";
package/src/mesh/mesh.ts CHANGED
@@ -30,16 +30,13 @@ import {
30
30
  randomId,
31
31
  verifyProof,
32
32
  } from "./keys.ts";
33
- import { PeerProbe, type PeerReport } from "./probe.ts";
34
33
  import {
35
- isProbePath,
36
34
  jwkEndpoint,
37
35
  type JwkRequest,
38
36
  type JwkResponse,
39
37
  kidOfPath,
40
38
  MESH_PROTOCOL,
41
39
  meshFrameOf,
42
- type ProbeBody,
43
40
  wsEndpoint,
44
41
  } from "./wire.ts";
45
42
 
@@ -135,9 +132,10 @@ export interface MeshHost {
135
132
  export interface MeshDeps {
136
133
  /** This instance's id, which is what it is called on the wire. */
137
134
  readonly id: InstanceId;
138
- /** Every mesh endpoint, this instance's own among them. Which one that is is
139
- * settled by `identify`, not configured (DR-0001 §2.7). */
135
+ /** Every mesh endpoint, this instance's own among them. */
140
136
  readonly peers: readonly Endpoint[];
137
+ /** Which of them is this instance, as the data says (§7.1). */
138
+ readonly self: Endpoint;
141
139
  readonly conns: ConnRegistry;
142
140
  readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
143
141
  /** Something changed about which peers are reachable. */
@@ -259,10 +257,9 @@ export class Mesh {
259
257
  readonly #minted = new Map<string, Minted>();
260
258
  readonly #retries = new Map<Endpoint, ReturnType<typeof setTimeout>>();
261
259
  readonly #backoff = new Map<Endpoint, number>();
262
- readonly #probe = new PeerProbe();
263
- /** Which of the configured endpoints is this instance, settled by `identify`
260
+ /** Which of the configured endpoints is this instance, as the data said
264
261
  * before anything is dialled and fixed from then on (§5.5). */
265
- #self: Endpoint | undefined;
262
+ readonly #self: Endpoint;
266
263
  /** The authenticated endpoint-to-id mapping (DR-0001 §2.1), in both
267
264
  * directions: a handshake writes it, `to_instance` reads it to find the link
268
265
  * to dial down, and a disconnection leaves it standing so a peer that is out
@@ -293,6 +290,12 @@ export class Mesh {
293
290
  readonly relay: Relay;
294
291
 
295
292
  constructor(private readonly deps: MeshDeps) {
293
+ this.#self = deps.self;
294
+ // The one binding this instance did not have to learn: its own. That is
295
+ // what makes "an id already answering elsewhere" cover the case of a peer
296
+ // claiming to be us — which is what the instance at a moved instance's old
297
+ // URL looks like from the new one.
298
+ this.#bind(deps.self, deps.id);
296
299
  this.relay = new Relay({
297
300
  publish: (topic, data, instance) => {
298
301
  this.#host?.publish(topic, data, instance);
@@ -365,14 +368,10 @@ export class Mesh {
365
368
  return true;
366
369
  }
367
370
 
368
- /** Where peers reach this instance, as the probe settled it (§7.1).
369
- *
370
- * Asked only after `identify`: everything that reads it the handshake's
371
- * `aud`, the mesh's own routes, what `hello` reports — happens on an instance
372
- * that has already started, and a start where the probe did not settle ends
373
- * instead. */
371
+ /** Where peers reach this instance: the row of the mesh carrying its own id
372
+ * (§7.1). Everything that reads it — the handshake's `aud`, the mesh's own
373
+ * routes, what `hello` reports is the one address the data states. */
374
374
  get self(): Endpoint {
375
- if (this.#self === undefined) throw new Error("this mesh has not identified itself yet");
376
375
  return this.#self;
377
376
  }
378
377
 
@@ -608,24 +607,6 @@ export class Mesh {
608
607
  else conn.send(frame);
609
608
  }
610
609
 
611
- /** Settle which configured endpoint is this instance, before anything is
612
- * dialled (§7.1).
613
- *
614
- * Run once the listener is up, because the probe this instance sends itself
615
- * has to arrive somewhere. A list that reaches this instance no times or
616
- * more than once ends the start; a peer that is merely asleep is recorded
617
- * and dialled later. */
618
- async identify(): Promise<PeerReport> {
619
- const report = await this.#probe.identify(this.deps.peers);
620
- this.#self = report.self;
621
- // The table opens with the one binding this instance did not have to learn:
622
- // its own. That is what makes "an id already answering elsewhere" cover the
623
- // case of a peer claiming to be us — which is what the instance at a moved
624
- // instance's old URL looks like from the new one.
625
- this.#bind(report.self, this.deps.id);
626
- return report;
627
- }
628
-
629
610
  /** Start dialling. Each peer is attempted independently, and a peer that is
630
611
  * not there is retried rather than waited for. */
631
612
  connect(): void {
@@ -801,45 +782,21 @@ export class Mesh {
801
782
  return this.#pending.has(conn);
802
783
  }
803
784
 
804
- // --- the HTTP surface: the key of §6 and the probe of self-identification ---
785
+ // --- the HTTP surface: the key of §6 ---
805
786
 
806
- /** Answer the two requests that are served before anything is proven, or
807
- * nothing when the request is not one of them. */
787
+ /** Answer the one request that is served before anything is proven, or
788
+ * nothing when the request is not it. */
808
789
  async route(request: Request): Promise<Response | undefined> {
809
790
  const pathname = new URL(request.url).pathname;
810
- // The probe is matched by the end of the path, because it is what settles
811
- // which endpoint this instance is: while one is arriving there is no
812
- // endpoint to hang it under.
813
- if (isProbePath(pathname)) return await this.#answerProbe(request);
814
791
  // The key is below this instance's own endpoint and nowhere else, which is
815
792
  // what keeps two instances on one origin from answering for each other's
816
793
  // keys (mesh-peer-auth §6.3); the person's entry is matched by the end of
817
- // the path instead (DR-0001 §2.7). The probe has settled that endpoint by
818
- // the time any key is asked for: a request arriving before then belongs to
819
- // no handshake, since nothing has been dialled yet.
820
- if (this.#self === undefined) return undefined;
794
+ // the path instead (DR-0001 §2.7).
821
795
  const kid = kidOfPath(pathname, this.#self);
822
796
  if (kid !== undefined) return await this.#serveKey(kid, request);
823
797
  return undefined;
824
798
  }
825
799
 
826
- async #answerProbe(request: Request): Promise<Response> {
827
- let body: unknown;
828
- try {
829
- body = await request.json();
830
- } catch {
831
- return new Response("a probe is a JSON object", { status: 400 });
832
- }
833
- const probe = body as Partial<ProbeBody>;
834
- // An unknown generation is ignored rather than refused: the comparison is
835
- // the sender's, so a receiver that cannot read the probe costs the sender
836
- // nothing it could not already have (§5.1).
837
- if (probe.ver === MESH_VER && typeof probe.token === "string") {
838
- this.#probe.accept(probe.token);
839
- }
840
- return Response.json({});
841
- }
842
-
843
800
  async #serveKey(kid: string, request: Request): Promise<Response> {
844
801
  if (!this.#allowKeyRequest()) {
845
802
  return new Response("too many key requests", { status: 429 });
package/src/mesh/wire.ts CHANGED
@@ -9,15 +9,9 @@ import type { MeshJwk } from "./keys.ts";
9
9
  * sharing one origin apart: the key of `https://h/a/` is only ever fetched from
10
10
  * below `/a/`, so `https://h/b/` cannot answer for it and a proof made with b's
11
11
  * key cannot pass as a's (mesh-peer-auth §6.3). The separation is the shape of
12
- * the URLs rather than a rule written somewhere.
13
- *
14
- * The probe is the exception, and has to be: it is what tells an instance which
15
- * endpoint it is, so while one is arriving there is nothing yet to hang it
16
- * under. */
12
+ * the URLs rather than a rule written somewhere. */
17
13
  const WS_ROUTE = "ws";
18
14
  const JWK_ROUTE = "mesh/jwk/";
19
- const PROBE_ROUTE = "mesh/probe";
20
- const PROBE_PATH = `/${PROBE_ROUTE}`;
21
15
 
22
16
  /** Where a peer's mesh link is dialled.
23
17
  *
@@ -36,10 +30,6 @@ export function jwkEndpoint(endpoint: Endpoint, kid: string): string {
36
30
  return `${endpoint}${JWK_ROUTE}${encodeURIComponent(kid)}`;
37
31
  }
38
32
 
39
- export function probeEndpoint(endpoint: Endpoint): string {
40
- return `${endpoint}${PROBE_ROUTE}`;
41
- }
42
-
43
33
  /** The `kid` a request names, or nothing when the path is not a key request. */
44
34
  export function kidOfPath(pathname: string, self: Endpoint): string | undefined {
45
35
  const prefix = `${new URL(self).pathname}${JWK_ROUTE}`;
@@ -48,18 +38,6 @@ export function kidOfPath(pathname: string, self: Endpoint): string | undefined
48
38
  return kid === "" ? undefined : kid;
49
39
  }
50
40
 
51
- /** Whether this request is a probe.
52
- *
53
- * Matched by the end of the path and not below an endpoint, because a probe is
54
- * what settles which endpoint this instance is: at the moment one arrives there
55
- * is no endpoint to hang it under, and the prefix it came in on is whatever the
56
- * sender's list or a proxy in front of it says. Nothing is decided here anyway
57
- * — the receiver only echoes acceptance, and the comparison belongs to whoever
58
- * minted the token (§5.1). */
59
- export function isProbePath(pathname: string): boolean {
60
- return pathname.endsWith(PROBE_PATH);
61
- }
62
-
63
41
  /** The subprotocol a dialling instance offers.
64
42
  *
65
43
  * A peer is let through the handshake on this marker alone and proves who it is
@@ -97,9 +75,3 @@ export interface JwkRequest {
97
75
  export interface JwkResponse {
98
76
  readonly jwk: MeshJwk;
99
77
  }
100
-
101
- /** What a self-identification probe carries (mesh-self-identification §5.1). */
102
- export interface ProbeBody {
103
- readonly ver: number;
104
- readonly token: string;
105
- }
@@ -83,31 +83,14 @@ function heading(file: SessionDumpFile, view: DumpView): string[] {
83
83
  function draw(item: Item, child: Item | undefined, view: DumpView, parent?: string): string[] {
84
84
  const own = fragment(item);
85
85
  const answer = child === undefined ? undefined : fragment(child);
86
- const nested = child !== undefined && spoken(child.type);
87
86
  const link = isResult(item)
88
87
  ? arrow("←", parent)
89
88
  : (arrow("→", fields(item)["result_item"]) ?? waiting(item));
90
89
  const head = isResult(item)
91
90
  ? words(prefix(item), link, own.head, clock(item))
92
- : words(
93
- prefix(item),
94
- own.head,
95
- link,
96
- nested || answer === undefined ? undefined : answer.head,
97
- clock(item),
98
- );
99
- const under = [
100
- ...body(own.body, view),
101
- ...(answer === undefined || nested ? [] : body(answer.body, view)),
102
- ];
103
- const lines = [head, ...under.map((line) => `${INDENT}${line}`)];
104
- if (!nested || child === undefined || answer === undefined) return lines;
105
- // The agent's answer, under the brief that asked for it. It keeps a heading
106
- // of its own — it has its own instant, and often a status the brief could
107
- // not have known — and is indented to say whose answer it is.
108
- lines.push(`${INDENT}${words(prefix(child), answer.head, clock(child))}`);
109
- for (const line of body(answer.body, view)) lines.push(`${INDENT}${INDENT}${line}`);
110
- return lines;
91
+ : words(prefix(item), own.head, link, answer?.head, clock(item));
92
+ const under = [...body(own.body, view), ...(answer === undefined ? [] : body(answer.body, view))];
93
+ return [head, ...under.map((line) => `${INDENT}${line}`)];
111
94
  }
112
95
 
113
96
  /** `[id] type`, which is how an item is pointed at: the id is what the links
@@ -238,9 +221,11 @@ function pair(items: readonly Item[]): {
238
221
  const call = where.get(to);
239
222
  if (call === undefined) continue;
240
223
  // A pair the reader would have to scroll between is left where each half
241
- // happened, unless it is an agent's: what an agent was asked and what it
242
- // answered are one exchange whatever fell between them.
243
- if (!spoken(item.type) && call !== at - 1) continue;
224
+ // happened. What is being read is a run of moments in the order they
225
+ // happened, and an answer that arrived minutes later — which is the usual
226
+ // case for an agent would put a later moment in the middle of an earlier
227
+ // one. The two halves point at each other by id instead.
228
+ if (call !== at - 1) continue;
244
229
  child.set(call, at);
245
230
  folded.add(at);
246
231
  }
@@ -255,12 +240,3 @@ function pair(items: readonly Item[]): {
255
240
  function joined(item: Item, key: string): string {
256
241
  return `${item.type.startsWith("message.") ? "message" : "tool"}\n${key}`;
257
242
  }
258
-
259
- /** Whether an item is the conversation half of starting an agent, as opposed
260
- * to the call's own half. What was asked and what came back is one exchange
261
- * however many turns fell between them, so these are drawn together wherever
262
- * they ended up — a conversation split across the page is one nobody can
263
- * follow. */
264
- function spoken(type: string): boolean {
265
- return type.startsWith("message.sub") || type.startsWith("message.team");
266
- }
package/src/mesh/probe.ts DELETED
@@ -1,105 +0,0 @@
1
- import type { Endpoint } from "@ccmsg/protocol";
2
- import { MESH_VER, randomId } from "./keys.ts";
3
- import { probeEndpoint, type ProbeBody } from "./wire.ts";
4
-
5
- /** How long a probe may take to come back.
6
- *
7
- * An endpoint that does not answer is either asleep or misconfigured, and
8
- * waiting longer tells the two apart no better. It bounds startup rather than
9
- * deciding correctness: only the probe that lands back here decides anything. */
10
- export const PROBE_TIMEOUT_MS = 3_000;
11
-
12
- /** The configured endpoints do not say which instance this is.
13
- *
14
- * Its own class so startup can refuse the same way a broken config does (§8.3,
15
- * DV-Q9): a list that names this instance zero times, or twice, is a list that
16
- * cannot be acted on. */
17
- export class SelfEndpointError extends Error {
18
- constructor(msg: string) {
19
- super(msg);
20
- this.name = "SelfEndpointError";
21
- }
22
- }
23
-
24
- /** What one round of probes settled. */
25
- export interface PeerReport {
26
- /** The one configured endpoint that turned out to be this instance. */
27
- readonly self: Endpoint;
28
- /** The endpoints that did not answer. Recorded and not refused: a peer that
29
- * is asleep is the normal state of this mesh (§7.1, DV-Q11). */
30
- readonly unreachable: readonly Endpoint[];
31
- }
32
-
33
- /** The probes in flight, and the endpoint each was sent to.
34
- *
35
- * This is where an instance learns which of the configured endpoints it is
36
- * (mesh-self-identification §5). A `token` is minted per endpoint and sent
37
- * there; the one that arrives back at this process was sent to this process,
38
- * and the endpoint it was addressed to is therefore this instance's own.
39
- *
40
- * Every endpoint is probed, this instance's own included: the probe to
41
- * ourselves is the one that always lands, which is what makes a peer echoing
42
- * a stolen token show up as two matches rather than as a wrong answer (§4.2).
43
- *
44
- * The table is destroyed when the run finishes: what the exercise leaves behind
45
- * is the settled endpoint and nothing else (§7.3). */
46
- export class PeerProbe {
47
- #sent = new Map<string, Endpoint>();
48
- readonly #matched = new Set<Endpoint>();
49
-
50
- /** A probe arrived here. Answering is unconditional and holds no state: the
51
- * comparison is the sender's, and this instance is the sender for exactly one
52
- * of the probes it is currently answering (§5.1). */
53
- accept(token: string): void {
54
- const sentTo = this.#sent.get(token);
55
- if (sentTo !== undefined) this.#matched.add(sentTo);
56
- }
57
-
58
- /** Probe every configured endpoint and settle which one is this instance.
59
- *
60
- * An endpoint that did not answer is left out of the count rather than
61
- * refused, so a mesh whose other host is asleep still starts (DV-Q11): the
62
- * count only ever decides on endpoints that answered, and the probe to
63
- * ourselves always does. */
64
- async identify(peers: readonly Endpoint[]): Promise<PeerReport> {
65
- const targets = [...new Set(peers)];
66
- this.#sent = new Map(targets.map((target) => [randomId(), target]));
67
- const unreachable: Endpoint[] = [];
68
- await Promise.all(
69
- [...this.#sent].map(async ([token, target]) => {
70
- if (!(await this.#probe(target, token))) unreachable.push(target);
71
- }),
72
- );
73
- const matched = [...this.#matched];
74
- this.#sent = new Map();
75
- this.#matched.clear();
76
- if (matched.length !== 1) {
77
- throw new SelfEndpointError(
78
- matched.length === 0
79
- ? `none of the configured endpoints reached this instance: ${targets.join(", ")}`
80
- : `several configured endpoints reach this instance: ${matched.join(", ")}`,
81
- );
82
- }
83
- return { self: matched[0] as Endpoint, unreachable };
84
- }
85
-
86
- /** Whether the endpoint answered. What it answered does not matter: the
87
- * comparison happens where the probe lands, not in its reply. */
88
- async #probe(target: Endpoint, token: string): Promise<boolean> {
89
- const body: ProbeBody = { ver: MESH_VER, token };
90
- try {
91
- const response = await fetch(probeEndpoint(target), {
92
- method: "POST",
93
- // Closed after the one round trip it is: a probe is sent once at
94
- // startup, and a pooled connection kept open for it would outlive the
95
- // exercise and hold the listener at the far end.
96
- headers: { "content-type": "application/json", connection: "close" },
97
- body: JSON.stringify(body),
98
- signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
99
- });
100
- return response.ok;
101
- } catch {
102
- return false;
103
- }
104
- }
105
- }