@ccmsg/cli 0.14.0 → 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 +12 -5
- package/src/instance/paths.ts +6 -0
- package/src/messaging/inbox.ts +35 -3
- 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,12 +562,15 @@ 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
|
},
|
|
565
569
|
});
|
|
566
570
|
|
|
567
|
-
const inbox = new Inbox(inboxPath(paths.stateDir))
|
|
571
|
+
const inbox = new Inbox(inboxPath(paths.stateDir), (msg, fields) => {
|
|
572
|
+
this.log.write(msg, fields);
|
|
573
|
+
});
|
|
568
574
|
inbox.load();
|
|
569
575
|
this.#persisted.push(inbox);
|
|
570
576
|
// Route (a) is the harness's own way in (DESIGN §6.5): Claude Code's messaging
|
|
@@ -662,10 +668,11 @@ export class Instance {
|
|
|
662
668
|
// greeting says where the session works, and the fold says which folders
|
|
663
669
|
// its editor names and which files outside them its transcript named.
|
|
664
670
|
const files = new Containment({
|
|
665
|
-
roots: (sid): SessionRoots | undefined => {
|
|
671
|
+
roots: async (sid): Promise<SessionRoots | undefined> => {
|
|
666
672
|
const where = this.#sessions.where(sid);
|
|
667
673
|
if (where.root === undefined && where.cwd === undefined) return undefined;
|
|
668
|
-
|
|
674
|
+
await this.#transcripts.ready(sid);
|
|
675
|
+
const status = await sessionStatusOf(sid, this.#transcripts.facts(sid), where);
|
|
669
676
|
return {
|
|
670
677
|
...where,
|
|
671
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/messaging/inbox.ts
CHANGED
|
@@ -4,10 +4,12 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import {
|
|
5
5
|
INBOX_MAX_PER_SID,
|
|
6
6
|
INBOX_RETENTION_MS,
|
|
7
|
-
|
|
7
|
+
InboxMessage,
|
|
8
8
|
type InboxRemovedReason,
|
|
9
|
-
|
|
9
|
+
isValid,
|
|
10
|
+
Sid,
|
|
10
11
|
type Timestamp,
|
|
12
|
+
validationErrors,
|
|
11
13
|
} from "@ccmsg/protocol";
|
|
12
14
|
|
|
13
15
|
export const INBOX_FILE = "inbox.jsonl";
|
|
@@ -50,7 +52,12 @@ export class Inbox {
|
|
|
50
52
|
* subscribed for them to be news to. */
|
|
51
53
|
#onRemoved?: (mid: string, reason: InboxRemovedReason) => void;
|
|
52
54
|
|
|
53
|
-
constructor(
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly file: string,
|
|
57
|
+
/** Where a line the replay could not keep is named. Absent in the tests
|
|
58
|
+
* that are about the holding rather than about what is said of it. */
|
|
59
|
+
private readonly log: (message: string, fields: Record<string, unknown>) => void = () => {},
|
|
60
|
+
) {}
|
|
54
61
|
|
|
55
62
|
/** Hear about messages leaving. */
|
|
56
63
|
onRemoved(told: (mid: string, reason: InboxRemovedReason) => void): void {
|
|
@@ -79,6 +86,7 @@ export class Inbox {
|
|
|
79
86
|
// The last line of a file the daemon was killed while writing.
|
|
80
87
|
continue;
|
|
81
88
|
}
|
|
89
|
+
if (record.v === "add" && !this.#stateable(record.sid, record.message)) continue;
|
|
82
90
|
this.#replay(record);
|
|
83
91
|
}
|
|
84
92
|
this.#expire(now);
|
|
@@ -168,6 +176,30 @@ export class Inbox {
|
|
|
168
176
|
return highest;
|
|
169
177
|
}
|
|
170
178
|
|
|
179
|
+
/** Whether a line read back is a message the contract can state.
|
|
180
|
+
*
|
|
181
|
+
* The file outlives the contract that wrote it, and what is held is answered
|
|
182
|
+
* to a person as rows of the `inbox` topic — so a single line the contract
|
|
183
|
+
* has since outgrown, replayed as if it were current, is a frame the reader
|
|
184
|
+
* refuses and a whole view lost for it. An instance states only what the
|
|
185
|
+
* contract can say, about its own file as much as about anything else.
|
|
186
|
+
*
|
|
187
|
+
* Dropped rather than mended: the message is the sender's words and the
|
|
188
|
+
* contract is what says how they are spelled, so there is nothing here that
|
|
189
|
+
* could write a spelling the contract would accept without inventing it. It
|
|
190
|
+
* leaves through the compaction that follows the replay, which writes back
|
|
191
|
+
* only what is held; nothing is appended and no removal is stated, since a
|
|
192
|
+
* replay has nobody subscribed to hear one and no `mid` the contract would
|
|
193
|
+
* take to name it by. */
|
|
194
|
+
#stateable(sid: unknown, message: unknown): boolean {
|
|
195
|
+
const why = isValid(Sid, sid)
|
|
196
|
+
? validationErrors(InboxMessage, message)
|
|
197
|
+
: [`sid: ${JSON.stringify(sid)} is no session id`];
|
|
198
|
+
if (why.length === 0) return true;
|
|
199
|
+
this.log("dropped an inbox record the contract cannot state", { sid, why });
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
#replay(record: Record_): void {
|
|
172
204
|
if (record.v === "add") {
|
|
173
205
|
const held = this.#held.get(record.sid) ?? [];
|
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
|