@ccmsg/cli 0.2.11 → 0.2.12

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.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -413,13 +413,23 @@ export class Instance {
413
413
  },
414
414
  });
415
415
 
416
+ // Which transcript a sid means. Only this instance's config home is ever
417
+ // looked in (M6): a session that greeted said where its transcript is, and
418
+ // one that never greeted is looked for under that home and nowhere else.
419
+ // Both the ops that read a transcript and the tails that follow one ask
420
+ // here, so a sid resolves to the same file whichever way it is reached.
421
+ const transcriptFiles = new TranscriptFiles({
422
+ configHome: paths.configHome,
423
+ announced: (sid) => this.#sessions.transcriptPath(sid),
424
+ });
425
+
416
426
  // The transcript tails and their folds. Built before the sessions domain
417
427
  // and reading from it lazily: the fold is one of the sessions domain's
418
428
  // inputs (§5.1) while the path to follow is one of its outputs, and the
419
429
  // two meet at the moment a tail starts rather than at construction.
420
430
  this.#transcripts = new Transcripts({
421
431
  self: this.self,
422
- pathOf: (sid) => this.#sessions.transcriptPath(sid),
432
+ pathOf: (sid) => transcriptFiles.path(sid),
423
433
  publish: (topic, data) => {
424
434
  this.#topics.publish(topic, data);
425
435
  },
@@ -445,6 +455,9 @@ export class Instance {
445
455
  transcript: this.#transcripts,
446
456
  gateway: this.#gateway,
447
457
  terminals: hostTerminalReader(),
458
+ log: (msg, fields) => {
459
+ this.log.write(msg, fields);
460
+ },
448
461
  ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
449
462
  onChanged: () => {
450
463
  this.#status.refresh();
@@ -569,15 +582,6 @@ export class Instance {
569
582
  });
570
583
  const origin = config.upstream.sandbox_origin;
571
584
 
572
- // Which transcript an op means, for the ops that read one rather than
573
- // follow one. Only this instance's config home is ever looked in (M6): a
574
- // session that greeted said where its transcript is, and one that never
575
- // greeted is looked for under that home and nowhere else.
576
- const transcriptFiles = new TranscriptFiles({
577
- configHome: paths.configHome,
578
- announced: (sid) => this.#sessions.transcriptPath(sid),
579
- });
580
-
581
585
  this.#handlers = completeHandlers({
582
586
  hello: this.#sessions.hello,
583
587
  session_stopping: this.#sessions.stopping,
@@ -67,6 +67,13 @@ export interface SessionsDeps {
67
67
  readonly onChanged?: () => void;
68
68
  /** How often the confirmation poll runs, for a test that cannot wait. */
69
69
  readonly pollMs?: number;
70
+ /** Where this domain says what it declined to act on. A greeting whose
71
+ * `transcript_path` this instance will not read is answered `ok` all the
72
+ * same — the field is simply absent from what `peers` says of the session —
73
+ * and the reason it is absent is operational rather than contractual, so it
74
+ * is written here for `ccmsg daemon log` to answer with. Absent where
75
+ * nothing collects it. */
76
+ readonly log?: (message: string, fields?: Record<string, unknown>) => void;
70
77
  /** How the terminal a session runs in is read from its process. Absent on a
71
78
  * host where no process's environment can be read, where every row's
72
79
  * terminal stays unknown — which is a state the classification has. */
@@ -245,7 +252,7 @@ export class Sessions implements UpstreamResource {
245
252
  const expiresAt = this.deps.authExpiresAt?.(input.conn);
246
253
  const sid = requiredSid(args);
247
254
  if (sid !== undefined) {
248
- this.register(sid, args, this.deps.configHome);
255
+ this.register(sid, args);
249
256
  input.conn.onClose(() => this.release(sid));
250
257
  }
251
258
  return {
@@ -503,10 +510,15 @@ export class Sessions implements UpstreamResource {
503
510
  * silence for a retraction would let each of them erase what the last one
504
511
  * knew, and the session would be described by whichever process spoke most
505
512
  * recently rather than by everything it has said. */
506
- private register(sid: Sid, args: HelloArgs, configHome: string): void {
513
+ private register(sid: Sid, args: HelloArgs): void {
507
514
  const now = Date.now();
508
515
  const held = this.#connected.get(sid);
509
- const meta = { ...this.#stated.get(sid), ...metaOf(args, configHome) };
516
+ const meta = {
517
+ ...this.#stated.get(sid),
518
+ ...metaOf(args, this.deps.configHome, (refused) => {
519
+ this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
520
+ }),
521
+ };
510
522
  this.#connected.set(sid, {
511
523
  sid,
512
524
  connected_at: held?.connected_at ?? now,
@@ -689,15 +701,22 @@ export class Sessions implements UpstreamResource {
689
701
  * else — a session naming a file elsewhere is a session that named nothing,
690
702
  * which is what a session that stayed silent already is (M6). It is not an
691
703
  * error: how a session describes itself is its own business, and the instance
692
- * simply does not act on a description it cannot stand behind. */
693
- function metaOf(args: HelloArgs, configHome: string): SessionMeta {
704
+ * simply does not act on a description it cannot stand behind. Why a path was
705
+ * not taken is told to `refused`, which is the operator's answer to a field
706
+ * that is simply absent from what `peers` says. */
707
+ function metaOf(
708
+ args: HelloArgs,
709
+ configHome: string,
710
+ refused: (reason: string) => void,
711
+ ): SessionMeta {
694
712
  const meta: Record<string, string> = {};
695
713
  for (const field of META_FIELDS) {
696
714
  const value = args[field];
697
715
  if (value === undefined) continue;
698
716
  if (field === "transcript_path") {
699
- const path = ownTranscript(value, configHome);
700
- if (path !== undefined) meta[field] = path;
717
+ const taken = ownTranscript(value, configHome);
718
+ if (typeof taken === "string") meta[field] = taken;
719
+ else refused(taken.refused);
701
720
  continue;
702
721
  }
703
722
  meta[field] = value;
@@ -721,19 +740,33 @@ function metaOf(args: HelloArgs, configHome: string): SessionMeta {
721
740
  * symlink and one spelled directly are the same path, and a link anywhere
722
741
  * along it that leads out of the tree lands outside and is refused. What is
723
742
  * already there must be a file: a directory by that name is not a transcript.
724
- */
725
- function ownTranscript(named: string, configHome: string): string | undefined {
726
- if (!isAbsolute(named)) return undefined;
727
- let projects: string;
743
+ *
744
+ * `projects/` itself is settled the same way, so a config home whose first
745
+ * session has yet to write anything is a boundary all the same: the directory
746
+ * that is there is followed, the part that is not is taken as spelled, and the
747
+ * comparison is between two paths resolved by one rule. The config home is not
748
+ * treated that way — an instance answers for a home it is running out of, and
749
+ * one that is not there names no tree to be inside of. */
750
+ function ownTranscript(named: string, configHome: string): string | Refused {
751
+ if (!isAbsolute(named)) return { refused: "not an absolute path" };
752
+ let projects: string | undefined;
728
753
  try {
729
- projects = realpathSync(join(configHome, "projects"));
754
+ projects = resolveAsFarAsItGoes(join(realpathSync(configHome), "projects"));
730
755
  } catch {
731
- return undefined;
756
+ return { refused: "the config home is not there" };
732
757
  }
733
758
  const settled = resolveAsFarAsItGoes(named);
734
- if (settled === undefined || !within(settled, projects)) return undefined;
759
+ if (projects === undefined || settled === undefined || !within(settled, projects)) {
760
+ return { refused: "outside this config home's projects tree" };
761
+ }
735
762
  const stat = statSync(settled, { throwIfNoEntry: false });
736
- return stat === undefined || stat.isFile() ? settled : undefined;
763
+ if (stat !== undefined && !stat.isFile()) return { refused: "not a file" };
764
+ return settled;
765
+ }
766
+
767
+ /** Why a stated path was not taken, in the words the log states it in. */
768
+ interface Refused {
769
+ readonly refused: string;
737
770
  }
738
771
 
739
772
  /** The path with every segment of it that exists resolved.
@@ -38,11 +38,24 @@ export interface TranscriptFilesDeps {
38
38
  export class TranscriptFiles {
39
39
  constructor(private readonly deps: TranscriptFilesDeps) {}
40
40
 
41
- /** The session's own transcript. */
42
- session(sid: Sid): string {
41
+ /** The session's own transcript, or nothing where this instance holds none.
42
+ *
43
+ * Two ways to the one file, in the order of what each is good for: what the
44
+ * session announced is exact and costs no search, and the walk finds the
45
+ * file by the identity it carries in its name (`<sid>.jsonl`) for a session
46
+ * that never greeted or is no longer running. Both stay inside this
47
+ * instance's `projects/` — the announced path because it was taken only if
48
+ * it was inside it, the walk because that tree is what it walks (M6). */
49
+ path(sid: Sid): string | undefined {
43
50
  const announced = this.deps.announced(sid);
44
51
  if (announced !== undefined && isFile(announced)) return announced;
45
- const found = this.find(sid);
52
+ return this.find(sid);
53
+ }
54
+
55
+ /** The session's own transcript, for an op that has nothing to answer
56
+ * without one. */
57
+ session(sid: Sid): string {
58
+ const found = this.path(sid);
46
59
  if (found === undefined) throw new OpError("not_found", `no transcript is held for ${sid}`);
47
60
  return found;
48
61
  }
@@ -5,8 +5,10 @@ import { type Appended, TranscriptTail } from "./tail.ts";
5
5
 
6
6
  export interface TranscriptsDeps {
7
7
  readonly self: InstanceId;
8
- /** Where a session's transcript is, as the session announced it (§5.1). A
9
- * sid with no path is one that never said, and nothing is guessed for it. */
8
+ /** Where a session's transcript is: what it announced when it greeted
9
+ * (§5.1), or the `<sid>.jsonl` under this instance's `projects/` that
10
+ * carries its name. A sid neither names nor is named by a file there has
11
+ * none, and nothing is guessed for it. */
10
12
  readonly pathOf: (sid: Sid) => string | undefined;
11
13
  /** The one way a value reaches subscribers (§6.1). */
12
14
  readonly publish: (topic: string, data: unknown) => void;