@ccmsg/cli 0.9.0 → 0.10.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.
@@ -9,7 +9,16 @@ import type { InstanceId } from "@ccmsg/protocol";
9
9
  * spelling is sized for. */
10
10
  const ID_BYTES = 16;
11
11
 
12
- const ID = /^[0-9a-f]{32}$/;
12
+ export const ID = /^[0-9a-f]{32}$/;
13
+
14
+ /** A fresh id of that width, for whatever is being named.
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. */
19
+ export function newId(): string {
20
+ return randomBytes(ID_BYTES).toString("hex");
21
+ }
13
22
 
14
23
  /** This instance's identity, read from the state directory and generated there
15
24
  * the first time it is asked for.
@@ -26,7 +35,7 @@ const ID = /^[0-9a-f]{32}$/;
26
35
  export function instanceIdentity(file: string): InstanceId {
27
36
  const held = read(file);
28
37
  if (held !== undefined) return held;
29
- const made = randomBytes(ID_BYTES).toString("hex");
38
+ const made = newId();
30
39
  mkdirSync(dirname(file), { recursive: true });
31
40
  writeFileSync(file, `${made}\n`);
32
41
  return made;
@@ -176,7 +176,7 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
176
176
  try {
177
177
  // 3. the config. A broken one ends the start rather than turning the
178
178
  // setting it carried silently off (DV-Q9).
179
- const config = loadConfig(paths.configFile, paths.configHome);
179
+ const config = await loadConfig(paths.configDir, paths.configHome);
180
180
  // What the config says of the gateway, resolved before anything is built
181
181
  // from it: a webhook source whose secret cannot be read ends the start
182
182
  // here, for the same reason a broken config does (DV-Q9).
@@ -675,7 +675,14 @@ export class Instance {
675
675
  // address is what says the caller is local (DR-0001 §2.2).
676
676
  handle: (frame, conn) => {
677
677
  const admin = adminRequestOf(frame);
678
- if (admin !== undefined) return Promise.resolve(handleAdmin(this.#auth, admin));
678
+ if (admin !== undefined) {
679
+ return Promise.resolve(
680
+ handleAdmin(
681
+ { auth: this.#auth, ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }) },
682
+ admin,
683
+ ),
684
+ );
685
+ }
679
686
  return this.handle(frame, conn);
680
687
  },
681
688
  }),
@@ -2,6 +2,7 @@ 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
6
 
6
7
  /** Every path one instance uses, decided in one place (daemon-v2 §8.1).
7
8
  *
@@ -14,10 +15,17 @@ export interface InstancePaths {
14
15
  readonly configHome: string;
15
16
  /** What distinguishes this instance's files from another instance's. */
16
17
  readonly key: string;
17
- /** The one file a person edits, shared by every instance on this host: it
18
- * carries the defaults and the list of config homes, so it is not derived
19
- * from the config home the way the rest of these are. */
18
+ /** Where a person writes settings, shared by every instance on this host: it
19
+ * holds what every instance starts from and one file per instance, so it is
20
+ * not derived from the config home the way the rest of these are. */
21
+ readonly configDir: string;
22
+ /** The file every instance's settings start from. */
20
23
  readonly configFile: string;
24
+ /** Where the file naming this config home lives, one per instance. */
25
+ 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;
21
29
  readonly stateDir: string;
22
30
  /** The address clients connect to. A symlink to whichever `socketReal` is
23
31
  * currently serving, so a client's path outlives the process behind it. */
@@ -116,7 +124,11 @@ export function resolvePathsFor(configHome: string, env: Env = process.env): Ins
116
124
  return {
117
125
  configHome,
118
126
  key,
119
- configFile: join(configDir, "config.json"),
127
+ configDir,
128
+ configFile: join(configDir, CONFIG_FILE),
129
+ instancesDir: join(configDir, INSTANCES_DIR),
130
+ clustersFile: join(configDir, CLUSTERS_FILE),
131
+ clustersDir: join(configDir, CLUSTERS_DIR),
120
132
  stateDir,
121
133
  socketDir,
122
134
  socket: join(socketDir, SOCKET_NAME),
@@ -169,9 +181,10 @@ export function resolveSupervisorSocket(env: Env = process.env): string {
169
181
 
170
182
  export const SUPERVISOR_SOCKET = "supervise.sock";
171
183
 
172
- /** The shared config file, for a caller that has no instance to resolve. */
184
+ /** The file every instance's settings start from, for a caller that has no
185
+ * instance to resolve. */
173
186
  export function resolveConfigFile(env: Env = process.env): string {
174
- return join(resolveConfigDir(env), "config.json");
187
+ return join(resolveConfigDir(env), CONFIG_FILE);
175
188
  }
176
189
 
177
190
  /** A name for one config home that is readable and cannot collide.
package/src/mesh/mesh.ts CHANGED
@@ -334,7 +334,35 @@ export class Mesh {
334
334
  * is what found which entry that is. */
335
335
  get peers(): Endpoint[] {
336
336
  const self = this.self;
337
- return this.deps.peers.filter((peer) => peer !== self);
337
+ return this.deps.peers.filter((peer) => peer !== self && !this.#forgotten.has(peer));
338
+ }
339
+
340
+ /** The peers taken off this host's list while this instance was running.
341
+ *
342
+ * Config is read once (DV-Q8) and this does not change that: what a person
343
+ * writes goes on taking effect at the next start. What this holds is the one
344
+ * edit that cannot wait for one — an endpoint this host is no longer to be
345
+ * talking to, which would otherwise stay connected until somebody restarted
346
+ * the instance. */
347
+ readonly #forgotten = new Set<Endpoint>();
348
+
349
+ /** Stop being a peer of this endpoint: drop the link if there is one, stop
350
+ * dialling it, and refuse its greeting if it dials us.
351
+ *
352
+ * Answers whether anything was actually cut, so `ccmsg mesh remove` can say
353
+ * which instances were talking to it rather than that it asked them all. */
354
+ forget(peer: Endpoint): boolean {
355
+ this.#forgotten.add(peer);
356
+ const retry = this.#retries.get(peer);
357
+ if (retry !== undefined) {
358
+ clearTimeout(retry);
359
+ this.#retries.delete(peer);
360
+ }
361
+ const link = this.#links.get(peer);
362
+ if (link === undefined) return false;
363
+ link.conn.close();
364
+ this.#drop(peer, link.conn);
365
+ return true;
338
366
  }
339
367
 
340
368
  /** Where peers reach this instance, as the probe settled it (§7.1).
@@ -620,7 +648,7 @@ export class Mesh {
620
648
  if (claim.ver !== MESH_VER) {
621
649
  throw new OpError("invalid_args", `this instance speaks mesh handshake ${MESH_VER}`);
622
650
  }
623
- if (!this.deps.peers.includes(claim.iss)) {
651
+ if (!this.deps.peers.includes(claim.iss) || this.#forgotten.has(claim.iss)) {
624
652
  throw new OpError("forbidden", `${claim.iss} is not a peer of this instance`);
625
653
  }
626
654
  if (claim.aud !== self) {
@@ -9,11 +9,50 @@ import type { LlmRequestInfo, Sid, Timestamp } from "@ccmsg/protocol";
9
9
  * nothing about which instance received it. */
10
10
  export type LlmRequestObservation = Omit<LlmRequestInfo, "main" | "instance">;
11
11
 
12
- /** An answer the gateway saw close. Only the two fields the sessions domain
13
- * reads: it says inference for that session has stopped running, and when. */
12
+ /** How the prompt cache actually worked for one request, as the gateway read it
13
+ * off the answer's usage. The gateway's own closed vocabulary: a word outside
14
+ * it is dropped rather than carried, so nothing downstream has to decide what
15
+ * an unknown verdict means for a countdown. */
16
+ export type CacheResult = "hit" | "written" | "partial" | "none" | "unknown";
17
+
18
+ const CACHE_RESULTS: readonly CacheResult[] = ["hit", "written", "partial", "none", "unknown"];
19
+
20
+ function cacheResultOf(value: unknown): CacheResult | undefined {
21
+ return CACHE_RESULTS.find((result) => result === value);
22
+ }
23
+
24
+ /** An answer the gateway saw close. It says inference for that session has
25
+ * stopped running and when, and it carries the one thing only an answer knows:
26
+ * whether the cache the request counted on was actually there. That verdict
27
+ * belongs to a series, so the series is named too. */
14
28
  export interface LlmResponseObservation {
15
29
  readonly sid: Sid;
16
30
  readonly at: Timestamp;
31
+ readonly prefix?: string;
32
+ readonly cache?: CacheResult;
33
+ /** The instant of the request this is the answer to. A series has several
34
+ * requests in flight, so it is what says which of them this verdict is
35
+ * about. */
36
+ readonly request_at?: Timestamp;
37
+ }
38
+
39
+ /** A keepalive the gateway raised into a conversation. Nothing here replays it;
40
+ * what is read is the name of the promise it carries, so a later withdrawal can
41
+ * be matched against it. On this notice the name is the signal's own `nonce`. */
42
+ export interface CacheKeepaliveObservation {
43
+ readonly sid: Sid;
44
+ readonly prefix?: string;
45
+ readonly notice: string;
46
+ }
47
+
48
+ /** The gateway withdrawing a promised lifetime by name. `of` names one promise
49
+ * and only that one: a series whose latest promise is a different name has been
50
+ * extended by someone else since, and this notice says nothing about it. */
51
+ export interface CacheExpiredObservation {
52
+ readonly sid: Sid;
53
+ readonly prefix?: string;
54
+ readonly of: string;
55
+ readonly at: Timestamp;
17
56
  }
18
57
 
19
58
  /** One item of a posted batch, as this instance reads it.
@@ -23,15 +62,25 @@ export interface LlmResponseObservation {
23
62
  * difference is the whole value of the log line: a batch of ignorable items is
24
63
  * the gateway working, a batch of unreadable ones is a schema that moved. */
25
64
  export type GatewayItem =
26
- | { readonly kind: "request"; readonly info: LlmRequestObservation }
65
+ | {
66
+ readonly kind: "request";
67
+ readonly info: LlmRequestObservation;
68
+ /** The name of the lifetime this request promised, when it promised one.
69
+ * Kept beside the observation rather than inside it: it is how two
70
+ * notices of the gateway's are matched to each other, and nothing a
71
+ * client reads (§3.5). */
72
+ readonly notice?: string;
73
+ }
27
74
  | { readonly kind: "response"; readonly info: LlmResponseObservation }
75
+ | { readonly kind: "keepalive"; readonly info: CacheKeepaliveObservation }
76
+ | { readonly kind: "cache_expired"; readonly info: CacheExpiredObservation }
28
77
  | { readonly kind: "ignored" };
29
78
 
30
- /** Kinds the gateway posts beside the two above: a keepalive it wants replayed
31
- * into a session, and its keepalive strategy being held off for one. Neither
32
- * is read here. They are named rather than reached as "not a request", so a
33
- * kind the gateway grows still arrives as unreadable and shows up in the log. */
34
- const IGNORED = new Set(["cache_keepalive", "keepalive_paused"]);
79
+ /** A kind the gateway posts that nothing here reads: its keepalive strategy
80
+ * being held off for a session. It is named rather than reached as "not a
81
+ * request", so a kind the gateway grows still arrives as unreadable and shows
82
+ * up in the log. */
83
+ const IGNORED = new Set(["keepalive_paused"]);
35
84
 
36
85
  /** The fields whose name is the same on both sides, and whose value is already
37
86
  * this contract's unit — a count of seconds, or an instant in Unix ms. */
@@ -76,13 +125,54 @@ export function parseGatewayItem(value: unknown): GatewayItem | undefined {
76
125
  const info = responseOf(raw);
77
126
  return info === undefined ? undefined : { kind: "response", info };
78
127
  }
128
+ if (kind === "cache_keepalive") {
129
+ const info = keepaliveOf(raw);
130
+ // A signal that named no promise is still the gateway working: it is the
131
+ // notice this instance has nothing to match later, not one it misread.
132
+ return info === undefined ? { kind: "ignored" } : { kind: "keepalive", info };
133
+ }
134
+ if (kind === "cache_expired") {
135
+ const info = expiredOf(raw);
136
+ return info === undefined ? undefined : { kind: "cache_expired", info };
137
+ }
79
138
  // The forwarding notice is the one kind that carries no mark, because it
80
139
  // existed before the others did. So it is a request by position, and only
81
140
  // when it names no kind at all: an item that names one and is not handled
82
141
  // above must not be read as a request whose fields happen to line up.
83
142
  if (kind !== undefined) return undefined;
84
143
  const info = requestOf(raw);
85
- return info === undefined ? undefined : { kind: "request", info };
144
+ if (info === undefined) return undefined;
145
+ const notice = raw["cache_notice"];
146
+ return {
147
+ kind: "request",
148
+ info,
149
+ ...(typeof notice === "string" && notice !== "" ? { notice } : {}),
150
+ };
151
+ }
152
+
153
+ function keepaliveOf(raw: Record<string, unknown>): CacheKeepaliveObservation | undefined {
154
+ const sid = raw["session_id"];
155
+ // On this notice the promise's name and the signal's own password are the
156
+ // same value, stated under either field, so both are read as the one name.
157
+ const notice = raw["cache_notice"] ?? raw["nonce"];
158
+ if (typeof sid !== "string" || sid === "") return undefined;
159
+ if (typeof notice !== "string" || notice === "") return undefined;
160
+ return { sid, notice, ...seriesOf(raw) };
161
+ }
162
+
163
+ function expiredOf(raw: Record<string, unknown>): CacheExpiredObservation | undefined {
164
+ const at = raw["ts"];
165
+ const sid = raw["session_id"];
166
+ const of = raw["of"];
167
+ if (!isInstant(at) || typeof sid !== "string" || sid === "") return undefined;
168
+ if (typeof of !== "string" || of === "") return undefined;
169
+ return { sid, of, at, ...seriesOf(raw) };
170
+ }
171
+
172
+ /** The series half of a key, when the notice names one. */
173
+ function seriesOf(raw: Record<string, unknown>): { prefix?: string } {
174
+ const prefix = raw["prefix"];
175
+ return typeof prefix === "string" && prefix !== "" ? { prefix } : {};
86
176
  }
87
177
 
88
178
  function requestOf(raw: Record<string, unknown>): LlmRequestObservation | undefined {
@@ -115,7 +205,15 @@ function responseOf(raw: Record<string, unknown>): LlmResponseObservation | unde
115
205
  const at = raw["ts"];
116
206
  const sid = raw["session_id"];
117
207
  if (!isInstant(at) || typeof sid !== "string" || sid === "") return undefined;
118
- return { sid, at };
208
+ const cache = cacheResultOf(raw["cache"]);
209
+ const requestAt = raw["request_ts"];
210
+ return {
211
+ sid,
212
+ at,
213
+ ...seriesOf(raw),
214
+ ...(cache === undefined ? {} : { cache }),
215
+ ...(isInstant(requestAt) ? { request_at: requestAt } : {}),
216
+ };
119
217
  }
120
218
 
121
219
  /** A number that can be an instant on this wire. Rejecting a non-number is
@@ -143,6 +143,7 @@ export class Gateway {
143
143
  publish: deps.publish,
144
144
  ...(deps.onActivity === undefined ? {} : { onActivity: deps.onActivity }),
145
145
  ...(deps.onMoved === undefined ? {} : { onMoved: deps.onMoved }),
146
+ ...(deps.log === undefined ? {} : { log: deps.log }),
146
147
  });
147
148
  this.status =
148
149
  deps.setup.statusUrl === undefined
@@ -200,10 +201,14 @@ export class Gateway {
200
201
  continue;
201
202
  }
202
203
  if (item.kind === "request") {
203
- this.requests.record(item.info);
204
+ this.requests.record(item.info, item.notice);
204
205
  this.status?.noteRequestStatus(item.info.status);
205
206
  } else if (item.kind === "response") {
206
- this.requests.note(item.info.sid, item.info.at);
207
+ this.requests.note(item.info);
208
+ } else if (item.kind === "keepalive") {
209
+ this.requests.noteKeepalive(item.info);
210
+ } else if (item.kind === "cache_expired") {
211
+ this.requests.expire(item.info);
207
212
  }
208
213
  }
209
214
  if (unreadable > 0) {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type InstanceId,
3
+ LLM_PROMPT_CACHE_TTL_MS,
3
4
  llmCacheWindowEndAt,
4
5
  type LlmRequestInfo,
5
6
  type Sid,
@@ -7,7 +8,12 @@ import {
7
8
  } from "@ccmsg/protocol";
8
9
  import { GATEWAY_LIVE_WINDOW_MS } from "../sessions/index.ts";
9
10
  import type { TopicValue, UpstreamResource } from "../topics/index.ts";
10
- import type { LlmRequestObservation } from "./events.ts";
11
+ import type {
12
+ CacheExpiredObservation,
13
+ CacheKeepaliveObservation,
14
+ LlmRequestObservation,
15
+ LlmResponseObservation,
16
+ } from "./events.ts";
11
17
 
12
18
  export interface LlmRequestsDeps {
13
19
  readonly self: InstanceId;
@@ -21,6 +27,7 @@ export interface LlmRequestsDeps {
21
27
  * of one row moved. Told apart from the above because what it asks for is
22
28
  * that row restated rather than the whole domain recomputed. */
23
29
  readonly onMoved?: (sid: Sid) => void;
30
+ readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
24
31
  }
25
32
 
26
33
  /** Which of a session's gateway facts moved.
@@ -49,6 +56,10 @@ interface Series {
49
56
  /** Orders a session's series by when it started using them, which is the
50
57
  * tiebreak when it has several the sharing rule does not disqualify. */
51
58
  firstSeen: number;
59
+ /** The name of the lifetime this series was last promised, when the gateway
60
+ * named one. It is what a withdrawal is matched against, and it is held here
61
+ * rather than published because it means nothing outside that match. */
62
+ notice?: string;
52
63
  }
53
64
 
54
65
  /** What the gateway saw go upstream, per conversation series, and when each
@@ -83,7 +94,7 @@ export class LlmRequests implements UpstreamResource {
83
94
  * The newer of the two wins when a series already has one: events are
84
95
  * near-ordered in practice, but a redelivery can put an older one after a
85
96
  * newer, and a countdown must not walk backwards. */
86
- record(info: LlmRequestObservation): void {
97
+ record(info: LlmRequestObservation, notice?: string): void {
87
98
  this.moved(info.sid, this.active(info.sid, info.received_at));
88
99
  const key = seriesKey(info.sid, info.prefix);
89
100
  const held = this.#series.get(key);
@@ -93,7 +104,14 @@ export class LlmRequests implements UpstreamResource {
93
104
  // end of the map's order, which is what makes the eviction below drop the
94
105
  // one seen least recently. `firstSeen` survives that move.
95
106
  this.#series.delete(key);
96
- this.#series.set(key, { info, firstSeen: held?.firstSeen ?? ++this.#sequence });
107
+ // The name is replaced rather than merged: a request that promises nothing
108
+ // leaves the series with no promise to withdraw, which is what a request
109
+ // that cached nothing means.
110
+ this.#series.set(key, {
111
+ info,
112
+ firstSeen: held?.firstSeen ?? ++this.#sequence,
113
+ ...(notice === undefined ? {} : { notice }),
114
+ });
97
115
  while (this.#series.size > MAX_SERIES) {
98
116
  const oldest = this.#series.keys().next();
99
117
  if (oldest.done === true) break;
@@ -102,11 +120,87 @@ export class LlmRequests implements UpstreamResource {
102
120
  this.publish();
103
121
  }
104
122
 
105
- /** Take one answer the gateway saw close. It moves nothing on this topic —
106
- * the window belongs to the request that opened it and only says the
107
- * session was still running inference at that instant. */
108
- note(sid: Sid, at: Timestamp): void {
109
- this.moved(sid, this.active(sid, at));
123
+ /** Take one answer the gateway saw close. It says the session was still
124
+ * running inference at that instant, and it carries the one verdict only an
125
+ * answer holds: whether the cache the request counted on was there. `hit` and
126
+ * `partial` confirm the window the request stated, so nothing moves; `written`
127
+ * says that window was a promise about a cache that no longer existed. */
128
+ note(info: LlmResponseObservation): void {
129
+ this.moved(info.sid, this.active(info.sid, info.at));
130
+ if (info.cache === "written") this.rebuild(info);
131
+ }
132
+
133
+ /** A keepalive the gateway raised names the lifetime it promises. Held
134
+ * against the series so a withdrawal naming it can be told from one naming a
135
+ * promise since replaced. A series nothing is held for has no window to
136
+ * withdraw, so the name has nothing to attach to. */
137
+ noteKeepalive(info: CacheKeepaliveObservation): void {
138
+ const series = this.#series.get(seriesKey(info.sid, info.prefix));
139
+ if (series === undefined) return;
140
+ series.notice = info.notice;
141
+ }
142
+
143
+ /** The gateway withdrawing a promised lifetime by name.
144
+ *
145
+ * Only the series whose latest promise is the one named loses its window: a
146
+ * different name means that promise was replaced — by this gateway's next
147
+ * request or by another gateway watching the same series — and the window
148
+ * standing now is not the one being withdrawn. The window going to zero is
149
+ * the row leaving, since this topic carries the open ones. */
150
+ expire(info: CacheExpiredObservation): void {
151
+ const key = seriesKey(info.sid, info.prefix);
152
+ const series = this.#series.get(key);
153
+ if (series === undefined || series.notice !== info.of) return;
154
+ this.#series.delete(key);
155
+ this.publish();
156
+ }
157
+
158
+ /** The cache was gone and the whole prompt was written again, so the window
159
+ * starts at the moment of that writing rather than where the request said.
160
+ *
161
+ * The chain the request projected (`cache_until_at` and the breakeven beside
162
+ * it) described a chain that was not continued, so it is dropped rather than
163
+ * carried onto a window that begins elsewhere; the gateway states the new
164
+ * projection on its next event. The promise is dropped with it: it named the
165
+ * lifetime that just turned out not to exist. */
166
+ private rebuild(info: LlmResponseObservation): void {
167
+ const key = seriesKey(info.sid, info.prefix);
168
+ const series = this.#series.get(key);
169
+ if (series === undefined) return;
170
+ // The answer names the request it belongs to. A verdict about a request
171
+ // the series has already replaced is about a window that is no longer the
172
+ // one drawn, so it moves nothing.
173
+ const held = series.info;
174
+ if (
175
+ info.request_at === undefined
176
+ ? held.received_at > info.at
177
+ : info.request_at !== held.received_at
178
+ ) {
179
+ return;
180
+ }
181
+ // The gateway judged the signal applied because it came back in time; the
182
+ // answer says what it was applied to was written from nothing. Said out
183
+ // loud because it is the one case where those two readings disagree.
184
+ if (held.keepalive === "applied") {
185
+ this.deps.log?.("a keepalive was applied to a cache that had to be rebuilt", {
186
+ sid: info.sid,
187
+ ...(info.prefix === undefined ? {} : { prefix: info.prefix }),
188
+ });
189
+ }
190
+ const {
191
+ cache_until_at: _until,
192
+ cache_until_count: _untilCount,
193
+ cache_breakeven_until_at: _breakeven,
194
+ cache_breakeven_count: _breakevenCount,
195
+ ...rest
196
+ } = held;
197
+ const ttl =
198
+ rest.cache_ttl_secs === undefined ? LLM_PROMPT_CACHE_TTL_MS : rest.cache_ttl_secs * 1000;
199
+ this.#series.set(key, {
200
+ firstSeen: series.firstSeen,
201
+ info: { ...rest, cache_since_at: info.at, cache_expires_at: info.at + ttl },
202
+ });
203
+ this.publish();
110
204
  }
111
205
 
112
206
  /** Tell whoever holds the row what this event moved for that session. */