@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 +1 -1
- package/src/files/containment.ts +6 -23
- package/src/instance/instance.ts +9 -4
- package/src/instance/paths.ts +6 -0
- package/src/sessions/fork.ts +1 -1
- package/src/sessions/harness.ts +5 -5
- package/src/sessions/status.ts +56 -20
- package/src/sessions/workspace.ts +15 -15
- package/src/topics/handlers.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/cache.ts +208 -0
- package/src/transcript/files.ts +17 -48
- package/src/transcript/fold.ts +62 -2
- package/src/transcript/index.ts +3 -1
- package/src/transcript/items/classify.ts +77 -0
- package/src/transcript/items/index.ts +1 -1
- package/src/transcript/tail.ts +68 -87
- package/src/transcript/transcripts.ts +134 -51
package/package.json
CHANGED
package/src/files/containment.ts
CHANGED
|
@@ -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. */
|
package/src/instance/instance.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/src/instance/paths.ts
CHANGED
|
@@ -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)),
|
package/src/sessions/fork.ts
CHANGED
|
@@ -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;
|
package/src/sessions/harness.ts
CHANGED
|
@@ -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
|
|
175
|
-
*
|
|
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
|
|
252
|
-
*
|
|
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);
|
package/src/sessions/status.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
SessionStatusSnapshot,
|
|
6
6
|
Sid,
|
|
7
7
|
} from "@ccmsg/protocol";
|
|
8
|
-
import {
|
|
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
|
-
):
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
): Promise<
|
|
43
|
+
SessionStatusSnapshot & {
|
|
44
|
+
sid: Sid;
|
|
45
|
+
}
|
|
46
|
+
> {
|
|
45
47
|
const stopped = stoppedOn(facts);
|
|
46
|
-
const root = where.root === undefined ? undefined :
|
|
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
|
-
:
|
|
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
|
-
|
|
147
|
-
|
|
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")
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 {
|
|
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 =
|
|
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(
|
|
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 =
|
|
119
|
-
return
|
|
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
|
|
package/src/topics/handlers.ts
CHANGED
|
@@ -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 => {
|
package/src/topics/topics.ts
CHANGED
|
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
|
+
}
|