@ccmsg/cli 0.14.1 → 0.15.0

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.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -1,4 +1,3 @@
1
- import { realpathSync } from "node:fs";
2
1
  import { realpath } from "node:fs/promises";
3
2
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
4
3
  import type { FileKind, Role, Sid } from "@ccmsg/protocol";
@@ -28,7 +27,7 @@ export interface SessionRoots {
28
27
 
29
28
  /** Who states a session's allowlists. */
30
29
  export interface RootsSource {
31
- roots(sid: Sid): SessionRoots | undefined;
30
+ roots(sid: Sid): Promise<SessionRoots | undefined>;
32
31
  }
33
32
 
34
33
  /** One path, decided.
@@ -71,7 +70,7 @@ export class Containment {
71
70
 
72
71
  /** A path named by kind, as an op's arguments give it. */
73
72
  async locate(args: PathArgs, viewer: Viewer = {}): Promise<Located> {
74
- const roots = this.rootsFor(args.sid, viewer);
73
+ const roots = await this.rootsFor(args.sid, viewer);
75
74
  const named = await this.absolute(args, roots);
76
75
  const real = await canonical(named);
77
76
  return { ...(await this.admit(args.kind, real, roots)), named };
@@ -85,7 +84,7 @@ export class Containment {
85
84
  async identify(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located | undefined> {
86
85
  let roots: SessionRoots;
87
86
  try {
88
- roots = this.rootsFor(sid, viewer);
87
+ roots = await this.rootsFor(sid, viewer);
89
88
  } catch {
90
89
  return undefined;
91
90
  }
@@ -108,7 +107,7 @@ export class Containment {
108
107
  * rather than as forbidden — the path is reachable, and only writing there
109
108
  * is not. */
110
109
  async inbox(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located> {
111
- const roots = this.rootsFor(sid, viewer);
110
+ const roots = await this.rootsFor(sid, viewer);
112
111
  const cwd = roots.cwd;
113
112
  if (cwd === undefined || !isAbsolute(cwd)) {
114
113
  throw new OpError("path_forbidden", `${sid} states no working directory to write into`);
@@ -128,14 +127,14 @@ export class Containment {
128
127
  return this.locate({ sid: args.sid, kind: args.kind, path: args.path ?? "" }, viewer);
129
128
  }
130
129
 
131
- private rootsFor(sid: Sid, viewer: Viewer): SessionRoots {
130
+ private async rootsFor(sid: Sid, viewer: Viewer): Promise<SessionRoots> {
132
131
  if (!sees(sid, viewer)) {
133
132
  throw new OpError(
134
133
  "path_forbidden",
135
134
  `the files of ${sid} are outside this connection's range`,
136
135
  );
137
136
  }
138
- const roots = this.source.roots(sid);
137
+ const roots = await this.source.roots(sid);
139
138
  if (roots === undefined) {
140
139
  throw new OpError("path_forbidden", `nothing is known about the files of ${sid}`);
141
140
  }
@@ -256,22 +255,6 @@ export async function canonical(path: string): Promise<string> {
256
255
  }
257
256
  }
258
257
 
259
- /** The synchronous form required while `session.status` states its value synchronously (DESIGN §6); an asynchronous topic value uses `canonical` as the single path. */
260
- export function canonicalSync(path: string): string {
261
- const absolute = resolve(path);
262
- try {
263
- return realpathSync(absolute);
264
- } catch {
265
- const parent = dirname(absolute);
266
- if (parent === absolute) return absolute;
267
- try {
268
- return join(realpathSync(parent), basename(absolute));
269
- } catch {
270
- return absolute;
271
- }
272
- }
273
- }
274
-
275
258
  /** Whether a resolved path is the root or below it. Shared with the launcher,
276
259
  * whose roots come from config rather than from a session: what "inside" means
277
260
  * is the same question, and two spellings of it could come apart. */
@@ -51,7 +51,7 @@ import {
51
51
  SessionStatus,
52
52
  } from "../sessions/index.ts";
53
53
  import { topicHandlers, Topics } from "../topics/index.ts";
54
- import { TranscriptFiles, Transcripts } from "../transcript/index.ts";
54
+ import { FoldCache, TranscriptFiles, Transcripts } from "../transcript/index.ts";
55
55
  import {
56
56
  type AuthorizedUpgrade,
57
57
  ConnRegistry,
@@ -502,6 +502,9 @@ export class Instance {
502
502
  this.#transcripts = new Transcripts({
503
503
  self: this.self,
504
504
  pathOf: (sid) => transcriptFiles.path(sid),
505
+ // What a past reading of each transcript reached, so a transcript is
506
+ // read from its beginning once rather than once per start.
507
+ cache: new FoldCache(join(paths.cacheDir, "transcripts")),
505
508
  publish: (topic, data) => {
506
509
  this.#topics.publish(topic, data);
507
510
  },
@@ -536,7 +539,7 @@ export class Instance {
536
539
  },
537
540
  ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
538
541
  onChanged: () => {
539
- this.#status.refresh();
542
+ void this.#status.refresh();
540
543
  // A session that is live again is one route (a) can be tried against,
541
544
  // which is what the inbox is waiting for (DESIGN §6.7).
542
545
  void this.#delivery.retry();
@@ -559,6 +562,7 @@ export class Instance {
559
562
  release: (sid) => {
560
563
  this.#transcripts.release(sid);
561
564
  },
565
+ ready: (sid) => this.#transcripts.ready(sid),
562
566
  publish: (topic, data) => {
563
567
  this.#topics.publish(topic, data);
564
568
  },
@@ -664,10 +668,11 @@ export class Instance {
664
668
  // greeting says where the session works, and the fold says which folders
665
669
  // its editor names and which files outside them its transcript named.
666
670
  const files = new Containment({
667
- roots: (sid): SessionRoots | undefined => {
671
+ roots: async (sid): Promise<SessionRoots | undefined> => {
668
672
  const where = this.#sessions.where(sid);
669
673
  if (where.root === undefined && where.cwd === undefined) return undefined;
670
- const status = sessionStatusOf(sid, this.#transcripts.facts(sid), where);
674
+ await this.#transcripts.ready(sid);
675
+ const status = await sessionStatusOf(sid, this.#transcripts.facts(sid), where);
671
676
  return {
672
677
  ...where,
673
678
  workspace_folders: status.workspace_folders.map((folder) => folder.path),
@@ -41,6 +41,11 @@ export interface InstancePaths {
41
41
  /** Where a file is put before it is overwritten by the checked copy. */
42
42
  readonly rejectedDir: string;
43
43
  readonly stateDir: string;
44
+ /** Where what was derived from a file is kept so it need not be derived
45
+ * again. Everything here can be rebuilt from the file it came from, so the
46
+ * directory may be emptied at any moment and the only cost is the work of
47
+ * deriving it once more. */
48
+ readonly cacheDir: string;
44
49
  /** The address clients connect to. A symlink to whichever `socketReal` is
45
50
  * currently serving, so a client's path outlives the process behind it. */
46
51
  readonly socket: string;
@@ -148,6 +153,7 @@ export function resolvePathsFor(configHome: string, env: Env = process.env): Ins
148
153
  satisfiedFile: join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE),
149
154
  rejectedDir: join(stateRoot, REJECTED_DIR),
150
155
  stateDir,
156
+ cacheDir: appDir(env, "CCMSG_CACHE_DIR", "XDG_CACHE_HOME", [".cache"], key),
151
157
  socketDir,
152
158
  socket: join(socketDir, SOCKET_NAME),
153
159
  socketReal: join(socketDir, realSocketName(process.pid)),
@@ -41,7 +41,7 @@ export async function forkOrigin(
41
41
  sid: Sid,
42
42
  files: TranscriptFiles,
43
43
  ): Promise<ForkOrigin | undefined> {
44
- const file = files.session(sid);
44
+ const file = await files.session(sid);
45
45
  const ours = await recordIds(file);
46
46
  const head = ours?.[0];
47
47
  if (ours === undefined || head === undefined) return undefined;
@@ -171,8 +171,9 @@ export class HarnessSessions implements OwnSessions {
171
171
  * here too: a pid from a poll that has not run is a number belonging to
172
172
  * nobody.
173
173
  *
174
- * Read in place because the directory is a handful of small files of this
175
- * uid's own config home (M6) — a syscall or two per session, not a wait. */
174
+ * Read in place because the answer may not depend on anybody waiting: what
175
+ * this states is that a session exists, and a reading that could be waited
176
+ * for would make it something the callers above cannot ask (DESIGN §4.2). */
176
177
  scan(): ReadonlyMap<Sid, AgentInfo> {
177
178
  const rows = new Map<Sid, AgentInfo>();
178
179
  const names = this.#watch.names().filter((name) => STATE_FILE.test(name));
@@ -248,9 +249,8 @@ class DirectoryWatch {
248
249
  this.#timer = undefined;
249
250
  }
250
251
 
251
- /** What is in the directory now. Read in place because it is a handful of
252
- * small entries of this uid's own config home (M6) a syscall or two, not a
253
- * wait. */
252
+ /** What is in the directory now, read in place for the reason `scan()` is:
253
+ * it is the same answer, and the callers above it cannot wait for one. */
254
254
  names(): string[] {
255
255
  try {
256
256
  return readdirSync(this.dir);
@@ -5,7 +5,7 @@ import type {
5
5
  SessionStatusSnapshot,
6
6
  Sid,
7
7
  } from "@ccmsg/protocol";
8
- import { canonicalSync, within } from "../files/containment.ts";
8
+ import { canonical, within } from "../files/containment.ts";
9
9
  import type { TopicValue, UpstreamResource } from "../topics/index.ts";
10
10
  import { topicParam } from "../topics/index.ts";
11
11
  import type { TranscriptFacts } from "../transcript/index.ts";
@@ -35,15 +35,26 @@ export function stoppedOn(facts: TranscriptFacts): SessionApiError | undefined {
35
35
  * root is known. A session that stated no root contributes none of them rather
36
36
  * than all of them: the list is the allowlist an `external` read is checked
37
37
  * against, so not knowing where the session works has to admit nothing. */
38
- export function sessionStatusOf(
38
+ export async function sessionStatusOf(
39
39
  sid: Sid,
40
40
  facts: TranscriptFacts,
41
41
  where: SessionWhere = {},
42
- ): SessionStatusSnapshot & {
43
- sid: Sid;
44
- } {
42
+ ): Promise<
43
+ SessionStatusSnapshot & {
44
+ sid: Sid;
45
+ }
46
+ > {
45
47
  const stopped = stoppedOn(facts);
46
- const root = where.root === undefined ? undefined : canonicalSync(where.root);
48
+ const root = where.root === undefined ? undefined : await canonical(where.root);
49
+ const named =
50
+ root === undefined
51
+ ? []
52
+ : await Promise.all(
53
+ facts.named_files.map(async (file) => ({
54
+ file,
55
+ real: await canonical(file.path),
56
+ })),
57
+ );
47
58
  return {
48
59
  sid,
49
60
  todos: [...facts.todos],
@@ -54,8 +65,8 @@ export function sessionStatusOf(
54
65
  external_files:
55
66
  root === undefined
56
67
  ? []
57
- : facts.named_files.filter((file) => !within(canonicalSync(file.path), root)),
58
- workspace_folders: workspaceFolders(where.cwd),
68
+ : named.filter((each) => !within(each.real, root)).map((each) => each.file),
69
+ workspace_folders: await workspaceFolders(where.cwd),
59
70
  ...(stopped === undefined ? {} : { api_error: stopped }),
60
71
  };
61
72
  }
@@ -81,6 +92,10 @@ export interface SessionStatusDeps {
81
92
  /** The tail behind a session's fold, asked for and let go by name. */
82
93
  readonly hold: (sid: Sid) => void;
83
94
  readonly release: (sid: Sid) => void;
95
+ /** When the fold of a held session has read the whole transcript. What this
96
+ * owner states rests on the fold, so it waits on this before stating it
97
+ * (CT-Q8). */
98
+ readonly ready: (sid: Sid) => Promise<void>;
84
99
  /** The one way a value reaches subscribers (DESIGN §6.1). */
85
100
  readonly publish: (topic: string, data: unknown) => void;
86
101
  }
@@ -105,6 +120,15 @@ export class SessionStatus implements UpstreamResource {
105
120
  * convergence happens once rather than in the middle of itself. */
106
121
  #converging = false;
107
122
  #pending = false;
123
+ /** Which pass is the one entitled to state a value.
124
+ *
125
+ * Stating one waits on a reading, and two readings started in either order
126
+ * can finish in either order — so the later pass read the later facts, and
127
+ * the earlier one must not publish behind it. Every pass takes a number and
128
+ * gives up the moment a later one has taken a higher one: what a subscriber
129
+ * is told is what the most recent reading said, and never what an older one
130
+ * caught up to saying (DR-0015 §2.5). */
131
+ #stating = 0;
108
132
 
109
133
  constructor(private readonly deps: SessionStatusDeps) {}
110
134
 
@@ -112,16 +136,16 @@ export class SessionStatus implements UpstreamResource {
112
136
 
113
137
  start(topic: string): void {
114
138
  this.#wanted.add(topic);
115
- this.refresh();
139
+ void this.refresh();
116
140
  }
117
141
 
118
142
  stop(topic: string): void {
119
143
  this.#wanted.delete(topic);
120
- this.refresh();
144
+ void this.refresh();
121
145
  }
122
146
 
123
- snapshot(topic: string): readonly TopicValue[] {
124
- const data = this.value(topic);
147
+ async snapshot(topic: string): Promise<readonly TopicValue[]> {
148
+ const data = await this.value(topic);
125
149
  return data === undefined ? [] : [{ instance: this.deps.self, data }];
126
150
  }
127
151
 
@@ -129,7 +153,7 @@ export class SessionStatus implements UpstreamResource {
129
153
  * says, and which sessions exist, are the two things that move either.
130
154
  *
131
155
  * Called by whoever changes one of them, rather than on a timer (M3). */
132
- refresh(): void {
156
+ async refresh(): Promise<void> {
133
157
  if (this.#converging) {
134
158
  this.#pending = true;
135
159
  return;
@@ -143,8 +167,15 @@ export class SessionStatus implements UpstreamResource {
143
167
  } finally {
144
168
  this.#converging = false;
145
169
  }
146
- for (const topic of this.#wanted) {
147
- const data = this.value(topic);
170
+ // The topics as they stand now: stating one waits on a reading, and a
171
+ // subscription arriving during that wait must not be iterated into.
172
+ const stating = [...this.#wanted];
173
+ this.#stating += 1;
174
+ const mine = this.#stating;
175
+ for (const topic of stating) {
176
+ const data = await this.value(topic);
177
+ // A later pass has read later facts; this one has nothing to add.
178
+ if (mine !== this.#stating) return;
148
179
  if (data !== undefined) this.deps.publish(topic, data);
149
180
  }
150
181
  }
@@ -169,12 +200,17 @@ export class SessionStatus implements UpstreamResource {
169
200
 
170
201
  /** What a topic of this owner currently says, for a snapshot and for a
171
202
  * change alike — built here and nowhere else, so the two cannot drift. */
172
- private value(topic: string): unknown {
173
- if (topic === "session.errors") return this.errors();
203
+ private async value(topic: string): Promise<unknown> {
204
+ if (topic === "session.errors") {
205
+ // Every session's fold, so the list is of the transcripts as they read
206
+ // rather than of the ones that happen to have been read by now.
207
+ await Promise.all(this.deps.sessions().map((sid) => this.deps.ready(sid)));
208
+ return this.errors();
209
+ }
174
210
  const sid = topicParam(topic);
175
- return sid === undefined
176
- ? undefined
177
- : sessionStatusOf(sid, this.deps.facts(sid), this.deps.where(sid));
211
+ if (sid === undefined) return undefined;
212
+ await this.deps.ready(sid);
213
+ return await sessionStatusOf(sid, this.deps.facts(sid), this.deps.where(sid));
178
214
  }
179
215
 
180
216
  /** Bring the held tails in line with what the subscriptions need. */
@@ -1,4 +1,4 @@
1
- import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
4
4
  import type { WorkspaceFolder } from "@ccmsg/protocol";
@@ -14,15 +14,15 @@ import type { WorkspaceFolder } from "@ccmsg/protocol";
14
14
  *
15
15
  * A session that has no workspace file names no folders, which is what an empty
16
16
  * list means to the contract and admits no `workspace` path at all. */
17
- export function workspaceFolders(cwd: string | undefined): WorkspaceFolder[] {
17
+ export async function workspaceFolders(cwd: string | undefined): Promise<WorkspaceFolder[]> {
18
18
  if (cwd === undefined || !isAbsolute(cwd)) return [];
19
19
  const folders: WorkspaceFolder[] = [];
20
20
  const seen = new Set<string>();
21
- for (const file of workspaceFiles(cwd)) {
22
- for (const spec of specs(file)) {
21
+ for (const file of await workspaceFiles(cwd)) {
22
+ for (const spec of await specs(file)) {
23
23
  // Relative to the workspace file, which is how an editor reads them.
24
- const real = directory(resolve(dirname(file), spec.path));
25
- if (real === undefined || overbroad(real) || seen.has(real)) continue;
24
+ const real = await directory(resolve(dirname(file), spec.path));
25
+ if (real === undefined || (await overbroad(real)) || seen.has(real)) continue;
26
26
  seen.add(real);
27
27
  folders.push({ name: spec.name ?? basename(real), path: real });
28
28
  }
@@ -32,10 +32,10 @@ export function workspaceFolders(cwd: string | undefined): WorkspaceFolder[] {
32
32
 
33
33
  /** The workspace files directly beside the session's working directory, in a
34
34
  * fixed order so the same directory always states its folders the same way. */
35
- function workspaceFiles(cwd: string): string[] {
35
+ async function workspaceFiles(cwd: string): Promise<string[]> {
36
36
  let entries: string[];
37
37
  try {
38
- entries = readdirSync(cwd);
38
+ entries = await readdir(cwd);
39
39
  } catch {
40
40
  return [];
41
41
  }
@@ -47,10 +47,10 @@ function workspaceFiles(cwd: string): string[] {
47
47
 
48
48
  /** What one workspace file declares: the `folders` array, and of each entry the
49
49
  * path it names and the name it may give that path. */
50
- function specs(file: string): { path: string; name?: string }[] {
50
+ async function specs(file: string): Promise<{ path: string; name?: string }[]> {
51
51
  let parsed: unknown;
52
52
  try {
53
- parsed = JSON.parse(uncommented(readFileSync(file, "utf8")));
53
+ parsed = JSON.parse(uncommented(await readFile(file, "utf8")));
54
54
  } catch {
55
55
  // Written by hand and half-saved, or not a workspace file after all.
56
56
  return [];
@@ -113,10 +113,10 @@ function uncommented(text: string): string {
113
113
 
114
114
  /** What the filesystem calls a folder that is one. A path naming a file, or
115
115
  * nothing at all, names no folder and is dropped. */
116
- function directory(path: string): string | undefined {
116
+ async function directory(path: string): Promise<string | undefined> {
117
117
  try {
118
- const real = realpathSync(path);
119
- return statSync(real).isDirectory() ? real : undefined;
118
+ const real = await realpath(path);
119
+ return (await stat(real)).isDirectory() ? real : undefined;
120
120
  } catch {
121
121
  return undefined;
122
122
  }
@@ -127,9 +127,9 @@ function directory(path: string): string | undefined {
127
127
  * The root and the home directory and anything above them are refused: a
128
128
  * workspace file naming one of those turns the `workspace` surface into the
129
129
  * whole filesystem, and the folders are an allowlist rather than a hint. */
130
- function overbroad(real: string): boolean {
130
+ async function overbroad(real: string): Promise<boolean> {
131
131
  if (real === sep || dirname(real) === real) return true;
132
- const home = directory(homedir());
132
+ const home = await directory(homedir());
133
133
  return home !== undefined && (real === home || home.startsWith(withSep(real)));
134
134
  }
135
135
 
@@ -7,9 +7,9 @@ import type { SubscribeOutcome, Topics } from "./topics.ts";
7
7
  * it to `Topics`, and turns the outcome into the contract's own answer. */
8
8
  export function topicHandlers(topics: Topics) {
9
9
  return {
10
- "topic.subscribe": (input: HandlerInput): TopicSubscribeResult => {
10
+ "topic.subscribe": async (input: HandlerInput): Promise<TopicSubscribeResult> => {
11
11
  const topic = topicOf(input);
12
- answer(topics.subscribe(input.conn, topic), topic);
12
+ answer(await topics.subscribe(input.conn, topic), topic);
13
13
  return { topic };
14
14
  },
15
15
  "topic.unsubscribe": (input: HandlerInput): TopicSubscribeResult => {
Binary file
@@ -0,0 +1,208 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, rename, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { ClassificationState, Item } from "./items/index.ts";
5
+ import type { FoldState } from "./fold.ts";
6
+
7
+ /** Which shape of folded state this build can read back.
8
+ *
9
+ * A cache entry is the answer a past run derived, taken on trust: nothing
10
+ * re-reads the bytes it was derived from. So it may only be read back by a
11
+ * build that would have derived the same answer from them, and the version is
12
+ * how that is asserted — raise it whenever what the fold keeps or how it reads
13
+ * a record changes, and every entry written before says nothing to this build.
14
+ *
15
+ * `test/transcript.test.ts` holds the digest of the sources this number stands
16
+ * for and fails when they move without it, so the assertion is checked rather
17
+ * than remembered. */
18
+ export const FOLD_CACHE_VERSION = 2;
19
+
20
+ /** What one session's fold had reached, as it is written down.
21
+ *
22
+ * The offset is what makes the rest of it usable: it says which bytes the
23
+ * state accounts for, so the next run reads from there instead of from the
24
+ * beginning. The file's identity is beside it because the offset counts bytes
25
+ * of one file — a transcript replaced by another of the same name has bytes
26
+ * this state describes none of. */
27
+ export interface FoldCacheEntry {
28
+ readonly version: number;
29
+ readonly path: string;
30
+ readonly dev: number;
31
+ readonly ino: number;
32
+ /** Just past the last record the state accounts for. */
33
+ readonly offset: number;
34
+ readonly fold: FoldState;
35
+ /** What the reading that produced the items would carry into the next record:
36
+ * the turn it had reached, whose file it decided this is, and the calls still
37
+ * waiting for an answer. Without it a resumed run would answer a record
38
+ * differently from the run that read everything before it — counting turns
39
+ * from zero again, and calling a result whose call is known here the reserved
40
+ * name for a call nobody saw. */
41
+ readonly reading: ClassificationState;
42
+ /** The end of the reading, which is what a subscription opens on. Kept with
43
+ * the fold because both are derived from the same pass, and a resumed run
44
+ * that held only the fold would open the items topic on an empty list while
45
+ * claiming to know the session. */
46
+ readonly items: readonly Item[];
47
+ }
48
+
49
+ /** Where folded transcripts are kept between runs.
50
+ *
51
+ * Everything here can be derived again from the transcript it came from, so a
52
+ * miss, a discarded entry and an emptied directory are all the same thing: the
53
+ * file is read from its beginning. Nothing asks whether a write succeeded for
54
+ * that reason — a cache that could not be written costs the next run one read.
55
+ *
56
+ * Writes are chained rather than overlapped, so two saves of the same session
57
+ * cannot race over the temporary file they rename from. */
58
+ export class FoldCache {
59
+ #writing: Promise<void> = Promise.resolve();
60
+
61
+ constructor(private readonly dir: string) {}
62
+
63
+ /** What was folded out of this file, or nothing when what is on disk does not
64
+ * describe the file as it stands. */
65
+ async read(path: string): Promise<FoldCacheEntry | undefined> {
66
+ let entry: FoldCacheEntry;
67
+ try {
68
+ entry = JSON.parse(await Bun.file(this.#fileFor(path)).text()) as FoldCacheEntry;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ // The shape is checked and not assumed. An entry whose version matches but
73
+ // whose fields are not what this build reads back would otherwise be taken
74
+ // apart by whoever reads it, and a reading that throws leaves the session
75
+ // unopenable until the instance restarts — where a file that says nothing
76
+ // this build can use costs one reading (DR-0015 §2.5: what an await brings
77
+ // back is an input, not a promise kept).
78
+ if (!describes(entry, path)) return undefined;
79
+ let known: Awaited<ReturnType<typeof stat>>;
80
+ try {
81
+ known = await stat(path);
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ // A different file under the same name, or the same file grown shorter than
86
+ // the bytes the state accounts for: either way the state describes bytes
87
+ // that are not there, and the file is read from its beginning.
88
+ if (known.dev !== entry.dev || known.ino !== entry.ino) return undefined;
89
+ if (known.size < entry.offset) return undefined;
90
+ return entry;
91
+ }
92
+
93
+ /** Keep what has been folded so far. The file's identity is read here rather
94
+ * than taken from the caller, so what is written describes the file the
95
+ * offset was actually counted in. */
96
+ save(
97
+ path: string,
98
+ offset: number,
99
+ fold: FoldState,
100
+ reading: ClassificationState,
101
+ items: readonly Item[],
102
+ ): Promise<void> {
103
+ // The items are taken now rather than when the write runs: the offset and
104
+ // the fold describe this moment, and a list still being appended to would
105
+ // put records past the offset into an entry that claims to end at it.
106
+ const held = [...items];
107
+ this.#writing = this.#writing.then(() => this.#write(path, offset, fold, reading, held));
108
+ return this.#writing;
109
+ }
110
+
111
+ /** Forget what was folded out of this file, for a transcript that turned out
112
+ * to be another one. */
113
+ drop(path: string): Promise<void> {
114
+ this.#writing = this.#writing.then(async () => {
115
+ await unlink(this.#fileFor(path)).catch(() => undefined);
116
+ });
117
+ return this.#writing;
118
+ }
119
+
120
+ async #write(
121
+ path: string,
122
+ offset: number,
123
+ fold: FoldState,
124
+ reading: ClassificationState,
125
+ items: readonly Item[],
126
+ ): Promise<void> {
127
+ let known: Awaited<ReturnType<typeof stat>>;
128
+ try {
129
+ known = await stat(path);
130
+ } catch {
131
+ return;
132
+ }
133
+ const entry: FoldCacheEntry = {
134
+ version: FOLD_CACHE_VERSION,
135
+ path,
136
+ dev: known.dev,
137
+ ino: known.ino,
138
+ offset,
139
+ fold,
140
+ reading,
141
+ items: [...items],
142
+ };
143
+ const file = this.#fileFor(path);
144
+ const temporary = `${file}.${process.pid}.tmp`;
145
+ try {
146
+ await mkdir(this.dir, { recursive: true });
147
+ await writeFile(temporary, JSON.stringify(entry));
148
+ await rename(temporary, file);
149
+ } catch {
150
+ await unlink(temporary).catch(() => undefined);
151
+ }
152
+ }
153
+
154
+ /** One file per transcript, named by a digest of its path: the path is what
155
+ * identifies the transcript, and a digest of it is a name every filesystem
156
+ * takes however the path was spelled. */
157
+ #fileFor(path: string): string {
158
+ return join(this.dir, `${createHash("sha256").update(path).digest("hex").slice(0, 32)}.json`);
159
+ }
160
+ }
161
+
162
+ /** Whether what was read back is an entry this build can take up.
163
+ *
164
+ * Everything the fold and the reading are restored from is checked, because
165
+ * restoring walks it: a field of the wrong shape is a file that says nothing
166
+ * this build can use, which is the same as no file at all. What is not checked
167
+ * is what nothing walks — the contents of an item, of a call's arguments, of a
168
+ * todo — since those are carried whole and stated as they were written. */
169
+ function describes(entry: unknown, path: string): entry is FoldCacheEntry {
170
+ if (!isObject(entry)) return false;
171
+ if (entry["version"] !== FOLD_CACHE_VERSION || entry["path"] !== path) return false;
172
+ if (!counted(entry["offset"]) || !counted(entry["dev"]) || !counted(entry["ino"])) return false;
173
+ return states(entry["fold"]) && reads(entry["reading"]) && Array.isArray(entry["items"]);
174
+ }
175
+
176
+ function states(fold: unknown): boolean {
177
+ if (!isObject(fold)) return false;
178
+ for (const key of ["files", "todos", "teammates", "background", "workflows", "agents", "calls"]) {
179
+ const held = fold[key];
180
+ if (!Array.isArray(held)) return false;
181
+ for (const pair of held) {
182
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") return false;
183
+ }
184
+ }
185
+ return true;
186
+ }
187
+
188
+ function reads(reading: unknown): boolean {
189
+ if (!isObject(reading)) return false;
190
+ if (!counted(reading["turn"]) || typeof reading["subject"] !== "string") return false;
191
+ if (!Array.isArray(reading["calls"])) return false;
192
+ for (const pair of reading["calls"]) {
193
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") return false;
194
+ const call: unknown = pair[1];
195
+ if (!isObject(call) || typeof call["tool"] !== "string" || typeof call["name"] !== "string") {
196
+ return false;
197
+ }
198
+ }
199
+ return true;
200
+ }
201
+
202
+ function isObject(value: unknown): value is Record<string, unknown> {
203
+ return typeof value === "object" && value !== null && !Array.isArray(value);
204
+ }
205
+
206
+ function counted(value: unknown): value is number {
207
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
208
+ }