@ccmsg/cli 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth/admin.ts +5 -2
- package/src/auth/auth.ts +16 -16
- package/src/auth/records.ts +63 -41
- package/src/daemon/supervise.ts +15 -7
- package/src/files/containment.ts +42 -19
- package/src/files/files.ts +123 -104
- package/src/files/sandbox.ts +0 -0
- package/src/instance/instance.ts +40 -11
- package/src/instance/log.ts +29 -13
- package/src/kv/store.ts +104 -56
- package/src/launcher/launcher.ts +8 -5
- package/src/launcher/roots.ts +10 -7
- package/src/launcher/tree.ts +15 -12
- package/src/messaging/delivery.ts +28 -11
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +83 -21
- package/src/sessions/dump.ts +11 -9
- package/src/sessions/fork.ts +21 -14
- package/src/sessions/handlers.ts +9 -7
- package/src/sessions/items.ts +10 -8
- package/src/sessions/last-live.ts +28 -5
- package/src/sessions/registry.ts +40 -23
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- package/src/transcript/files.ts +65 -23
- package/src/transcript/read.ts +10 -10
- package/src/transcript/scan.ts +49 -0
package/src/sessions/items.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import type {
|
|
3
3
|
DumpPreset,
|
|
4
4
|
TranscriptItemsReadArgs,
|
|
@@ -8,15 +8,14 @@ import { OpError } from "../dispatch/index.ts";
|
|
|
8
8
|
import type { TranscriptFiles } from "../transcript/index.ts";
|
|
9
9
|
import {
|
|
10
10
|
bounded,
|
|
11
|
-
classify,
|
|
12
11
|
type Item,
|
|
13
12
|
ledger,
|
|
14
|
-
located,
|
|
15
13
|
select,
|
|
16
14
|
selection,
|
|
17
15
|
within,
|
|
18
16
|
} from "../transcript/items/index.ts";
|
|
19
17
|
import { READ_LIMIT } from "../transcript/read.ts";
|
|
18
|
+
import { classified } from "../transcript/scan.ts";
|
|
20
19
|
|
|
21
20
|
/** How many items one read may carry.
|
|
22
21
|
*
|
|
@@ -52,23 +51,26 @@ export interface ItemsReadDeps {
|
|
|
52
51
|
* is. That is what makes a link answerable: a result inside the range whose
|
|
53
52
|
* call fell before it still names the call, and the caller can ask for the
|
|
54
53
|
* call by the id it was given. */
|
|
55
|
-
export function itemsRead(
|
|
54
|
+
export async function itemsRead(
|
|
56
55
|
args: TranscriptItemsReadArgs,
|
|
57
56
|
deps: ItemsReadDeps,
|
|
58
|
-
): TranscriptItemsReadResult {
|
|
57
|
+
): Promise<TranscriptItemsReadResult> {
|
|
59
58
|
bounded(args);
|
|
60
|
-
const file = deps.files.locate(
|
|
59
|
+
const file = await deps.files.locate(
|
|
61
60
|
args.sid,
|
|
62
61
|
args.agent_id === undefined ? {} : { agent_id: args.agent_id },
|
|
63
62
|
);
|
|
64
63
|
let text: string;
|
|
65
64
|
try {
|
|
66
|
-
text =
|
|
65
|
+
text = await readFile(file, "utf8");
|
|
67
66
|
} catch {
|
|
68
67
|
throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
|
|
69
68
|
}
|
|
70
69
|
const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
|
|
71
|
-
const { items } = select(
|
|
70
|
+
const { items } = select(
|
|
71
|
+
within(await classified(text, await deps.files.subjectOf(file)), args),
|
|
72
|
+
keep,
|
|
73
|
+
);
|
|
72
74
|
const page = paged(items, args.limit, backwards(args));
|
|
73
75
|
return {
|
|
74
76
|
items: page.items,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import {
|
|
4
5
|
LAST_LIVE_RETENTION_MS,
|
|
@@ -53,6 +54,9 @@ const VERSION = 1;
|
|
|
53
54
|
export class LastLiveStore {
|
|
54
55
|
#entries = new Map<Sid, StoredEntry>();
|
|
55
56
|
|
|
57
|
+
/** The writes already asked for, as one chain. */
|
|
58
|
+
#written: Promise<void> = Promise.resolve();
|
|
59
|
+
|
|
56
60
|
/** `id` is this instance's own: every entry this store holds is by
|
|
57
61
|
* definition an observation *this* instance made, so `instance` is forced
|
|
58
62
|
* to it on both ends (load and record) rather than trusted from whatever
|
|
@@ -114,6 +118,12 @@ export class LastLiveStore {
|
|
|
114
118
|
return true;
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
/** Settle once every write asked for so far has landed. What a stop waits on,
|
|
122
|
+
* and what a reader of the file has to wait for to see the last change. */
|
|
123
|
+
async flush(): Promise<void> {
|
|
124
|
+
await this.#written;
|
|
125
|
+
}
|
|
126
|
+
|
|
117
127
|
#prune(now: Timestamp): boolean {
|
|
118
128
|
let dropped = false;
|
|
119
129
|
for (const [sid, entry] of this.#entries) {
|
|
@@ -125,13 +135,26 @@ export class LastLiveStore {
|
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
/** Written whole through a temporary file, so a daemon killed mid-write
|
|
128
|
-
* leaves the previous list rather than half of this one.
|
|
138
|
+
* leaves the previous list rather than half of this one.
|
|
139
|
+
*
|
|
140
|
+
* A session appearing or going is an ordinary event of a running instance, so
|
|
141
|
+
* the write does not hold it still (DR-0015). The body is taken here, before
|
|
142
|
+
* anything is awaited, and each write is chained onto the one before it: what
|
|
143
|
+
* lands last is what the list said last, and two of them cannot be sharing
|
|
144
|
+
* one temporary file. */
|
|
129
145
|
#save(): void {
|
|
130
146
|
const document: Document = { version: VERSION, sessions: [...this.#entries.values()] };
|
|
131
147
|
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
this.#written = this.#written.then(async () => {
|
|
149
|
+
try {
|
|
150
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
151
|
+
await writeFile(temporary, `${JSON.stringify(document)}\n`);
|
|
152
|
+
await rename(temporary, this.file);
|
|
153
|
+
} catch {
|
|
154
|
+
// A list that could not be written costs the Paused and Disappeared
|
|
155
|
+
// rows of the next run, and nothing of this one.
|
|
156
|
+
}
|
|
157
|
+
});
|
|
135
158
|
}
|
|
136
159
|
}
|
|
137
160
|
|
package/src/sessions/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
3
4
|
import {
|
|
4
5
|
type AgentInfo,
|
|
@@ -243,6 +244,13 @@ export class Sessions implements UpstreamResource {
|
|
|
243
244
|
this.#reclaim(this.#live);
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
/** Settle the list of lost sessions on disk. The writes happen as sessions
|
|
248
|
+
* come and go (DR-0015); this is for whoever has to see the file as it stands
|
|
249
|
+
* rather than as it was a moment ago — a stop, or a reader of the file. */
|
|
250
|
+
async flush(): Promise<void> {
|
|
251
|
+
await this.#lastLive.flush();
|
|
252
|
+
}
|
|
253
|
+
|
|
246
254
|
/** Drop the `last_live` entry of every session that is live, which is what
|
|
247
255
|
* keeps one session off both lists.
|
|
248
256
|
*
|
|
@@ -258,10 +266,10 @@ export class Sessions implements UpstreamResource {
|
|
|
258
266
|
* can speak about, and where everything this instance knows about where that
|
|
259
267
|
* session lives comes from. The greeting names its sid because the op it
|
|
260
268
|
* arrived under is the one whose schema asks for one. */
|
|
261
|
-
helloSession = (input: HandlerInput): HelloResult => {
|
|
269
|
+
helloSession = async (input: HandlerInput): Promise<HelloResult> => {
|
|
262
270
|
const args = input.args as unknown as HelloSessionArgs;
|
|
263
271
|
this.#greetable(input, args.protocol_version);
|
|
264
|
-
this.register(args.sid, args);
|
|
272
|
+
await this.register(args.sid, args);
|
|
265
273
|
input.conn.onClose(() => this.release(args.sid));
|
|
266
274
|
return this.#greeted(input);
|
|
267
275
|
};
|
|
@@ -609,15 +617,15 @@ export class Sessions implements UpstreamResource {
|
|
|
609
617
|
* silence for a retraction would let each of them erase what the last one
|
|
610
618
|
* knew, and the session would be described by whichever process spoke most
|
|
611
619
|
* recently rather than by everything it has said. */
|
|
612
|
-
private register(sid: Sid, args: HelloSessionArgs): void {
|
|
613
|
-
const
|
|
620
|
+
private async register(sid: Sid, args: HelloSessionArgs): Promise<void> {
|
|
621
|
+
const stated = await metaOf(this.deps, args, (refused) => {
|
|
622
|
+
this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
|
|
623
|
+
});
|
|
624
|
+
// Read after the path has been settled, so that what this writes is built
|
|
625
|
+
// on the session as it stands now rather than as it stood before.
|
|
614
626
|
const held = this.#connected.get(sid);
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
...metaOf(this.deps, args, (refused) => {
|
|
618
|
-
this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
|
|
619
|
-
}),
|
|
620
|
-
};
|
|
627
|
+
const now = Date.now();
|
|
628
|
+
const meta = { ...this.#stated.get(sid), ...stated };
|
|
621
629
|
this.#connected.set(sid, {
|
|
622
630
|
sid,
|
|
623
631
|
connected_at: held?.connected_at ?? now,
|
|
@@ -841,17 +849,17 @@ export class Sessions implements UpstreamResource {
|
|
|
841
849
|
* simply does not act on a description it cannot stand behind. Why a path was
|
|
842
850
|
* not taken is told to `refused`, which is the operator's answer to a field
|
|
843
851
|
* that is simply absent from what `peers` says. */
|
|
844
|
-
function metaOf(
|
|
852
|
+
async function metaOf(
|
|
845
853
|
deps: Pick<SessionsDeps, "configHome" | "harness">,
|
|
846
854
|
args: HelloSessionArgs,
|
|
847
855
|
refused: (reason: string) => void,
|
|
848
|
-
): SessionMeta {
|
|
856
|
+
): Promise<SessionMeta> {
|
|
849
857
|
const meta: Record<string, string> = {};
|
|
850
858
|
for (const field of META_FIELDS) {
|
|
851
859
|
const value = args[field];
|
|
852
860
|
if (value === undefined) continue;
|
|
853
861
|
if (field === "transcript_path") {
|
|
854
|
-
const taken = ownTranscript(value, deps);
|
|
862
|
+
const taken = await ownTranscript(value, deps);
|
|
855
863
|
if (typeof taken === "string") meta[field] = taken;
|
|
856
864
|
else refused(taken.refused);
|
|
857
865
|
continue;
|
|
@@ -884,27 +892,36 @@ function metaOf(
|
|
|
884
892
|
* comparison is between two paths resolved by one rule. The config home is not
|
|
885
893
|
* treated that way — an instance answers for a home it is running out of, and
|
|
886
894
|
* one that is not there names no tree to be inside of. */
|
|
887
|
-
function ownTranscript(
|
|
895
|
+
async function ownTranscript(
|
|
888
896
|
named: string,
|
|
889
897
|
deps: Pick<SessionsDeps, "configHome" | "harness">,
|
|
890
|
-
): string | Refused {
|
|
898
|
+
): Promise<string | Refused> {
|
|
891
899
|
if (!isAbsolute(named)) return { refused: "not an absolute path" };
|
|
892
900
|
let tree: string | undefined;
|
|
893
901
|
try {
|
|
894
|
-
const home =
|
|
895
|
-
tree = resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
|
|
902
|
+
const home = await realpath(deps.configHome);
|
|
903
|
+
tree = await resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
|
|
896
904
|
} catch {
|
|
897
905
|
return { refused: "the config home is not there" };
|
|
898
906
|
}
|
|
899
|
-
const settled = resolveAsFarAsItGoes(named);
|
|
907
|
+
const settled = await resolveAsFarAsItGoes(named);
|
|
900
908
|
if (tree === undefined || settled === undefined || !within(settled, tree)) {
|
|
901
909
|
return { refused: "outside this config home's transcript tree" };
|
|
902
910
|
}
|
|
903
|
-
const
|
|
904
|
-
if (
|
|
911
|
+
const known = await stated(settled);
|
|
912
|
+
if (known !== undefined && !known.isFile()) return { refused: "not a file" };
|
|
905
913
|
return settled;
|
|
906
914
|
}
|
|
907
915
|
|
|
916
|
+
/** What is at a path, or nothing where there is nothing at it. */
|
|
917
|
+
async function stated(path: string): Promise<Stats | undefined> {
|
|
918
|
+
try {
|
|
919
|
+
return await stat(path);
|
|
920
|
+
} catch {
|
|
921
|
+
return undefined;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
908
925
|
/** Why a stated path was not taken, in the words the log states it in. */
|
|
909
926
|
interface Refused {
|
|
910
927
|
readonly refused: string;
|
|
@@ -917,12 +934,12 @@ interface Refused {
|
|
|
917
934
|
* kept as it was spelled. The result is compared against the tree as a whole,
|
|
918
935
|
* which is what makes a `..` among the unwritten segments land wherever it
|
|
919
936
|
* actually points rather than pass for being spelled inside. */
|
|
920
|
-
function resolveAsFarAsItGoes(path: string): string | undefined {
|
|
937
|
+
async function resolveAsFarAsItGoes(path: string): Promise<string | undefined> {
|
|
921
938
|
const unwritten: string[] = [];
|
|
922
939
|
let at = path;
|
|
923
940
|
for (;;) {
|
|
924
941
|
try {
|
|
925
|
-
return join(
|
|
942
|
+
return join(await realpath(at), ...unwritten);
|
|
926
943
|
} catch {
|
|
927
944
|
const parent = dirname(at);
|
|
928
945
|
// The root itself always resolves, so this is a path that named
|
package/src/sessions/search.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { parse, sep } from "node:path";
|
|
3
3
|
import type {
|
|
4
4
|
InstanceId,
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
} from "@ccmsg/protocol";
|
|
10
10
|
import { OpError } from "../dispatch/index.ts";
|
|
11
11
|
import { readRecord, type TranscriptFile, type TranscriptFiles } from "../transcript/index.ts";
|
|
12
|
+
import { breathe, due } from "../transcript/scan.ts";
|
|
12
13
|
|
|
13
14
|
/** What one search may read, and what it may answer with.
|
|
14
15
|
*
|
|
@@ -57,7 +58,10 @@ export interface SearchDeps {
|
|
|
57
58
|
* directory listing and a `stat` already say — the session id, the working
|
|
58
59
|
* directory as the project directory spells it, when the file was last touched
|
|
59
60
|
* — and only what survives that is read. */
|
|
60
|
-
export function search(
|
|
61
|
+
export async function search(
|
|
62
|
+
args: SessionSearchArgs,
|
|
63
|
+
deps: SearchDeps,
|
|
64
|
+
): Promise<SessionSearchResult> {
|
|
61
65
|
if ((args.config_dirs ?? [deps.configHome]).every((dir) => dir !== deps.configHome)) {
|
|
62
66
|
// Every config home the caller named is one this instance does not know,
|
|
63
67
|
// which the contract says to ignore — leaving nothing to search.
|
|
@@ -72,7 +76,7 @@ export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearch
|
|
|
72
76
|
const hits: SessionSearchHit[] = [];
|
|
73
77
|
let budget = SCAN_BUDGET_BYTES;
|
|
74
78
|
let truncated = false;
|
|
75
|
-
for (const candidate of deps.files.all()) {
|
|
79
|
+
for (const candidate of await deps.files.all()) {
|
|
76
80
|
if (sid !== undefined && !candidate.sid.toLowerCase().includes(sid)) continue;
|
|
77
81
|
if (candidate.updated_at < since) continue;
|
|
78
82
|
if (!looksLike(candidate.project, cwdWords)) continue;
|
|
@@ -84,7 +88,7 @@ export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearch
|
|
|
84
88
|
break;
|
|
85
89
|
}
|
|
86
90
|
budget -= candidate.size;
|
|
87
|
-
const hit = read(candidate, clauses, wanted, deps);
|
|
91
|
+
const hit = await read(candidate, clauses, wanted, deps);
|
|
88
92
|
// The working directory the project directory only approximates: a hit is
|
|
89
93
|
// kept when the transcript's own `cwd` holds every word asked for.
|
|
90
94
|
if (hit !== undefined && holds(hit.cwd, cwdWords)) hits.push(hit);
|
|
@@ -169,15 +173,15 @@ function compile(args: SessionSearchArgs): { clauses: Clause[]; budgets: Budget[
|
|
|
169
173
|
* The pass is one: the records that carry the query also carry the working
|
|
170
174
|
* directory, the title and what the session last ran as, so a hit is built
|
|
171
175
|
* from the reading that decided it rather than from a second one. */
|
|
172
|
-
function read(
|
|
176
|
+
async function read(
|
|
173
177
|
candidate: TranscriptFile,
|
|
174
178
|
clauses: readonly Clause[],
|
|
175
179
|
wanted: { user: boolean; agent: boolean },
|
|
176
180
|
deps: SearchDeps,
|
|
177
|
-
): SessionSearchHit | undefined {
|
|
181
|
+
): Promise<SessionSearchHit | undefined> {
|
|
178
182
|
let text: string;
|
|
179
183
|
try {
|
|
180
|
-
text =
|
|
184
|
+
text = await readFile(candidate.file, "utf8");
|
|
181
185
|
} catch {
|
|
182
186
|
// Gone since it was listed, which is a session that ended mid-search.
|
|
183
187
|
return undefined;
|
|
@@ -188,8 +192,13 @@ function read(
|
|
|
188
192
|
let model: string | undefined;
|
|
189
193
|
let effort: string | undefined;
|
|
190
194
|
let createdAt: number | undefined;
|
|
195
|
+
let read = 0;
|
|
191
196
|
for (const line of text.split("\n")) {
|
|
192
197
|
if (line === "") continue;
|
|
198
|
+
read += 1;
|
|
199
|
+
// The file arrived in one `await` and reading it is CPU from here on, so
|
|
200
|
+
// the pass hands the loop back as it goes rather than at the file's end.
|
|
201
|
+
if (due(read)) await breathe();
|
|
193
202
|
const record = readRecord(line);
|
|
194
203
|
if (record === undefined) continue;
|
|
195
204
|
cwd ??= record.cwd;
|
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 { canonicalSync, 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";
|
|
@@ -43,7 +43,7 @@ export function sessionStatusOf(
|
|
|
43
43
|
sid: Sid;
|
|
44
44
|
} {
|
|
45
45
|
const stopped = stoppedOn(facts);
|
|
46
|
-
const root = where.root === undefined ? undefined :
|
|
46
|
+
const root = where.root === undefined ? undefined : canonicalSync(where.root);
|
|
47
47
|
return {
|
|
48
48
|
sid,
|
|
49
49
|
todos: [...facts.todos],
|
|
@@ -54,7 +54,7 @@ export function sessionStatusOf(
|
|
|
54
54
|
external_files:
|
|
55
55
|
root === undefined
|
|
56
56
|
? []
|
|
57
|
-
: facts.named_files.filter((file) => !within(
|
|
57
|
+
: facts.named_files.filter((file) => !within(canonicalSync(file.path), root)),
|
|
58
58
|
workspace_folders: workspaceFolders(where.cwd),
|
|
59
59
|
...(stopped === undefined ? {} : { api_error: stopped }),
|
|
60
60
|
};
|
package/src/transcript/files.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readdirSync,
|
|
1
|
+
import { readdirSync, type Stats, statSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, join } from "node:path";
|
|
3
4
|
import type { Sid, TranscriptSubject } from "@ccmsg/protocol";
|
|
4
5
|
import { type Harness, HARNESS } from "../harness/index.ts";
|
|
@@ -114,7 +115,7 @@ export class TranscriptFiles {
|
|
|
114
115
|
* `agent_id` and `teammate` are two ways of naming the same kind of file and
|
|
115
116
|
* cannot be combined — a request carrying both names two files and is a
|
|
116
117
|
* caller's mistake rather than a choice this makes for them. */
|
|
117
|
-
locate(sid: Sid, names: AgentNames = {}): string {
|
|
118
|
+
async locate(sid: Sid, names: AgentNames = {}): Promise<string> {
|
|
118
119
|
const file = this.session(sid);
|
|
119
120
|
if (names.agent_id !== undefined && names.teammate !== undefined) {
|
|
120
121
|
throw new OpError("invalid_args", "agent_id and teammate name two different transcripts");
|
|
@@ -127,9 +128,11 @@ export class TranscriptFiles {
|
|
|
127
128
|
}
|
|
128
129
|
const under = agentsDir(file, names.run_id);
|
|
129
130
|
if (names.agent_id !== undefined) {
|
|
130
|
-
return existing(
|
|
131
|
+
return await existing(
|
|
132
|
+
join(under, `agent-${name(names.agent_id, AGENT_ID, "agent_id")}${SUFFIX}`),
|
|
133
|
+
);
|
|
131
134
|
}
|
|
132
|
-
return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
|
|
135
|
+
return await this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
/** Which standing a transcript was written from, which every item read out
|
|
@@ -148,13 +151,13 @@ export class TranscriptFiles {
|
|
|
148
151
|
* nothing goes on standing, nobody is addressed by name — so a teammate read
|
|
149
152
|
* as one loses a name it might have been drawn under, where the reverse would
|
|
150
153
|
* have a reader write back to something that is already gone. */
|
|
151
|
-
subjectOf(file: string): TranscriptSubject {
|
|
154
|
+
async subjectOf(file: string): Promise<TranscriptSubject> {
|
|
152
155
|
const name = basename(file);
|
|
153
156
|
if (!name.startsWith(AGENT_PREFIX) || !name.endsWith(SUFFIX)) return "main";
|
|
154
157
|
let note: unknown;
|
|
155
158
|
try {
|
|
156
159
|
note = JSON.parse(
|
|
157
|
-
|
|
160
|
+
await readFile(join(dirname(file), `${name.slice(0, -SUFFIX.length)}.meta.json`), "utf8"),
|
|
158
161
|
);
|
|
159
162
|
} catch {
|
|
160
163
|
return "sub";
|
|
@@ -168,10 +171,10 @@ export class TranscriptFiles {
|
|
|
168
171
|
* The name a teammate carries in conversation is not its filename, so the
|
|
169
172
|
* directory's own records are read for it rather than the name being
|
|
170
173
|
* substituted into a path. */
|
|
171
|
-
private teammate(under: string, wanted: string): string {
|
|
174
|
+
private async teammate(under: string, wanted: string): Promise<string> {
|
|
172
175
|
let names: string[];
|
|
173
176
|
try {
|
|
174
|
-
names =
|
|
177
|
+
names = await readdir(under);
|
|
175
178
|
} catch {
|
|
176
179
|
throw new OpError("not_found", `no agent has run under this session`);
|
|
177
180
|
}
|
|
@@ -179,13 +182,13 @@ export class TranscriptFiles {
|
|
|
179
182
|
if (!each.endsWith(".meta.json")) continue;
|
|
180
183
|
let document: unknown;
|
|
181
184
|
try {
|
|
182
|
-
document = JSON.parse(
|
|
185
|
+
document = JSON.parse(await readFile(join(under, each), "utf8"));
|
|
183
186
|
} catch {
|
|
184
187
|
continue;
|
|
185
188
|
}
|
|
186
189
|
const named = (document as { name?: unknown } | null)?.name;
|
|
187
190
|
if (named !== wanted) continue;
|
|
188
|
-
return existing(join(under, `${each.slice(0, -".meta.json".length)}${SUFFIX}`));
|
|
191
|
+
return await existing(join(under, `${each.slice(0, -".meta.json".length)}${SUFFIX}`));
|
|
189
192
|
}
|
|
190
193
|
throw new OpError("not_found", `no teammate of this session is addressed as ${wanted}`);
|
|
191
194
|
}
|
|
@@ -195,16 +198,16 @@ export class TranscriptFiles {
|
|
|
195
198
|
* The one enumeration a search and a fork sweep both start from. It states
|
|
196
199
|
* the file and what a `stat` already said about it, so neither has to stat
|
|
197
200
|
* again to decide whether to open it. */
|
|
198
|
-
all(): TranscriptFile[] {
|
|
201
|
+
async all(): Promise<TranscriptFile[]> {
|
|
199
202
|
const layout = LAYOUTS[this.deps.harness];
|
|
200
203
|
const found: TranscriptFile[] = [];
|
|
201
|
-
for (const dir of
|
|
202
|
-
for (const entry of
|
|
204
|
+
for (const dir of await walked(this.#root(), layout.depth)) {
|
|
205
|
+
for (const entry of await listed(dir)) {
|
|
203
206
|
const sid = layout.sidOf(entry);
|
|
204
207
|
if (sid === undefined) continue;
|
|
205
208
|
const file = join(dir, entry);
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
209
|
+
const known = await stated(file);
|
|
210
|
+
if (known === undefined) continue;
|
|
208
211
|
found.push({
|
|
209
212
|
sid,
|
|
210
213
|
file,
|
|
@@ -213,9 +216,9 @@ export class TranscriptFiles {
|
|
|
213
216
|
// a session ran narrows nothing, and the transcript's own `cwd`
|
|
214
217
|
// decides as it already does.
|
|
215
218
|
...(this.deps.harness === "claude" ? { project: basename(dir) } : {}),
|
|
216
|
-
size:
|
|
217
|
-
created_at: Math.round(
|
|
218
|
-
updated_at: Math.round(
|
|
219
|
+
size: known.size,
|
|
220
|
+
created_at: Math.round(known.birthtimeMs || known.ctimeMs),
|
|
221
|
+
updated_at: Math.round(known.mtimeMs),
|
|
219
222
|
});
|
|
220
223
|
}
|
|
221
224
|
}
|
|
@@ -260,7 +263,27 @@ export class TranscriptFiles {
|
|
|
260
263
|
*
|
|
261
264
|
* Names are read rather than dates computed: what is there is what the harness
|
|
262
265
|
* wrote, and a tree with a directory nobody expected is one whose files are
|
|
263
|
-
* still found.
|
|
266
|
+
* still found.
|
|
267
|
+
*
|
|
268
|
+
* There are two of this walk, and of the two readings below it, because the
|
|
269
|
+
* two callers are not alike: `all()` runs from an op and reads the tree
|
|
270
|
+
* without holding the instance, while `path()` also runs from a subscription
|
|
271
|
+
* being opened, which states its value in the turn it is opened in and so
|
|
272
|
+
* cannot wait (DESIGN §6.2). The one that cannot wait is the one CT-Q8 and the
|
|
273
|
+
* issue `fold-from-head-with-versioned-cache` settle, and the two become one
|
|
274
|
+
* reading again when they do. */
|
|
275
|
+
async function walked(root: string, depth: number): Promise<string[]> {
|
|
276
|
+
let level = [root];
|
|
277
|
+
for (let step = 0; step < depth; step += 1) {
|
|
278
|
+
const below: string[] = [];
|
|
279
|
+
for (const dir of level) {
|
|
280
|
+
for (const entry of await listed(dir)) below.push(join(dir, entry));
|
|
281
|
+
}
|
|
282
|
+
level = below;
|
|
283
|
+
}
|
|
284
|
+
return level;
|
|
285
|
+
}
|
|
286
|
+
|
|
264
287
|
function directories(root: string, depth: number): string[] {
|
|
265
288
|
let level = [root];
|
|
266
289
|
for (let step = 0; step < depth; step += 1) {
|
|
@@ -305,11 +328,21 @@ function name(value: string, shape: RegExp, field: string): string {
|
|
|
305
328
|
return value;
|
|
306
329
|
}
|
|
307
330
|
|
|
308
|
-
function existing(file: string): string {
|
|
309
|
-
if (
|
|
331
|
+
async function existing(file: string): Promise<string> {
|
|
332
|
+
if ((await stated(file)) === undefined) {
|
|
333
|
+
throw new OpError("not_found", "no transcript is held for that agent");
|
|
334
|
+
}
|
|
310
335
|
return file;
|
|
311
336
|
}
|
|
312
337
|
|
|
338
|
+
async function listed(dir: string): Promise<string[]> {
|
|
339
|
+
try {
|
|
340
|
+
return await readdir(dir);
|
|
341
|
+
} catch {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
313
346
|
function names(dir: string): string[] {
|
|
314
347
|
try {
|
|
315
348
|
return readdirSync(dir);
|
|
@@ -318,10 +351,19 @@ function names(dir: string): string[] {
|
|
|
318
351
|
}
|
|
319
352
|
}
|
|
320
353
|
|
|
354
|
+
async function stated(file: string): Promise<Stats | undefined> {
|
|
355
|
+
try {
|
|
356
|
+
const known = await stat(file);
|
|
357
|
+
return known.isFile() ? known : undefined;
|
|
358
|
+
} catch {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
321
363
|
function statOf(file: string) {
|
|
322
364
|
try {
|
|
323
|
-
const
|
|
324
|
-
return
|
|
365
|
+
const known = statSync(file);
|
|
366
|
+
return known.isFile() ? known : undefined;
|
|
325
367
|
} catch {
|
|
326
368
|
return undefined;
|
|
327
369
|
}
|
package/src/transcript/read.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { open, stat } from "node:fs/promises";
|
|
2
2
|
import type { TranscriptReadResult, Sid } from "@ccmsg/protocol";
|
|
3
3
|
import { OpError } from "../dispatch/index.ts";
|
|
4
4
|
|
|
@@ -20,15 +20,15 @@ export const READ_LIMIT = 512 * 1024;
|
|
|
20
20
|
*
|
|
21
21
|
* The offsets are the ones the `transcript` topic's frames carry, so what a
|
|
22
22
|
* client reads and what arrives live stitch together without overlap. */
|
|
23
|
-
export function readSlice(
|
|
23
|
+
export async function readSlice(
|
|
24
24
|
sid: Sid,
|
|
25
25
|
file: string,
|
|
26
26
|
before?: number,
|
|
27
27
|
maxBytes?: number,
|
|
28
|
-
): TranscriptReadResult {
|
|
28
|
+
): Promise<TranscriptReadResult> {
|
|
29
29
|
let size: number;
|
|
30
30
|
try {
|
|
31
|
-
size =
|
|
31
|
+
size = (await stat(file)).size;
|
|
32
32
|
} catch {
|
|
33
33
|
throw new OpError("not_found", `the transcript of ${sid} could not be read`);
|
|
34
34
|
}
|
|
@@ -45,7 +45,7 @@ export function readSlice(
|
|
|
45
45
|
// arithmetic puts them — inside a character as readily as before one — and
|
|
46
46
|
// decoding first would turn those bytes into replacement characters of a
|
|
47
47
|
// different length, moving every offset derived from them.
|
|
48
|
-
const bytes = slice(file, probe, until);
|
|
48
|
+
const bytes = await slice(file, probe, until);
|
|
49
49
|
// What of the read is whole records: everything up to the last newline. A
|
|
50
50
|
// record the writer has not finished ends the file without one.
|
|
51
51
|
const lastNewline = bytes.lastIndexOf(NEWLINE);
|
|
@@ -69,14 +69,14 @@ const NEWLINE = 0x0a;
|
|
|
69
69
|
|
|
70
70
|
/** The bytes in a range. A range that reads short — the file was truncated
|
|
71
71
|
* between the stat and the read — yields what was actually there. */
|
|
72
|
-
function slice(file: string, from: number, to: number): Buffer {
|
|
72
|
+
async function slice(file: string, from: number, to: number): Promise<Buffer> {
|
|
73
73
|
if (to <= from) return Buffer.alloc(0);
|
|
74
|
-
const handle =
|
|
74
|
+
const handle = await open(file, "r");
|
|
75
75
|
try {
|
|
76
76
|
const buffer = Buffer.alloc(to - from);
|
|
77
|
-
const
|
|
78
|
-
return buffer.subarray(0,
|
|
77
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, from);
|
|
78
|
+
return buffer.subarray(0, bytesRead);
|
|
79
79
|
} finally {
|
|
80
|
-
|
|
80
|
+
await handle.close();
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { TranscriptSubject } from "@ccmsg/protocol";
|
|
2
|
+
import { Classification, type Item } from "./items/index.ts";
|
|
3
|
+
import { positioned, span } from "./items/record.ts";
|
|
4
|
+
|
|
5
|
+
/** How many records one pass reads before it hands the loop back.
|
|
6
|
+
*
|
|
7
|
+
* A transcript is read whole by the ops that classify one, and reading it is
|
|
8
|
+
* CPU rather than IO: the file arrives in one `await` and every record in it is
|
|
9
|
+
* then parsed. Nothing else on the instance runs while that happens, so the
|
|
10
|
+
* pass is cut into turns — small enough that the wait an op elsewhere sees is
|
|
11
|
+
* one turn of parsing, large enough that the turns themselves cost nothing
|
|
12
|
+
* measurable against the parsing they carry. */
|
|
13
|
+
const RECORDS_PER_TURN = 2000;
|
|
14
|
+
|
|
15
|
+
/** Hand the event loop back, so that whatever else is waiting on it runs.
|
|
16
|
+
*
|
|
17
|
+
* The pause of a macrotask rather than of a microtask: a promise resolved with
|
|
18
|
+
* nothing to wait for is drained before the loop is reached at all, which would
|
|
19
|
+
* make the yield a shape in the code and nothing in the behaviour. */
|
|
20
|
+
export function breathe(): Promise<void> {
|
|
21
|
+
return new Promise((resume) => {
|
|
22
|
+
setImmediate(resume);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether this many records have gone by since the last pause. */
|
|
27
|
+
export function due(read: number): boolean {
|
|
28
|
+
return read > 0 && read % RECORDS_PER_TURN === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A whole transcript as the items it was read into, yielding as it goes.
|
|
32
|
+
*
|
|
33
|
+
* The same reading `classify` does over the whole text at once, cut into turns:
|
|
34
|
+
* one `Classification` is fed chunk after chunk, so a call in one chunk is
|
|
35
|
+
* still known when its result arrives in a later one, and the items come back
|
|
36
|
+
* in the order the records are in. */
|
|
37
|
+
export async function classified(text: string, subject: TranscriptSubject): Promise<Item[]> {
|
|
38
|
+
const reading = new Classification(subject);
|
|
39
|
+
const lines = text.split("\n");
|
|
40
|
+
const items: Item[] = [];
|
|
41
|
+
let offset = 0;
|
|
42
|
+
for (let from = 0; from < lines.length; from += RECORDS_PER_TURN) {
|
|
43
|
+
const chunk = lines.slice(from, from + RECORDS_PER_TURN);
|
|
44
|
+
for (const item of reading.readAll(positioned(chunk, offset))) items.push(item);
|
|
45
|
+
for (const line of chunk) offset += span(line);
|
|
46
|
+
if (from + RECORDS_PER_TURN < lines.length) await breathe();
|
|
47
|
+
}
|
|
48
|
+
return items;
|
|
49
|
+
}
|