@ccmsg/cli 0.12.0 → 0.14.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 +2 -2
- package/src/auth/admin.ts +5 -2
- package/src/auth/auth.ts +17 -17
- package/src/auth/records.ts +64 -42
- package/src/cli.ts +38 -5
- package/src/daemon/snapshot.ts +3 -3
- package/src/daemon/supervise.ts +15 -7
- package/src/dispatch/dispatch.ts +4 -4
- 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 +43 -16
- 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/mesh/instances.ts +5 -5
- package/src/mesh/mesh.ts +11 -11
- package/src/mesh/relay.ts +8 -8
- package/src/messaging/delivery.ts +126 -27
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +72 -18
- package/src/messaging/notify.ts +7 -2
- package/src/sessions/dump.ts +73 -11
- 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 +43 -26
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- package/src/topics/egress.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/files.ts +65 -23
- package/src/transcript/read.ts +10 -10
- package/src/transcript/scan.ts +49 -0
package/src/sessions/fork.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import type { ForkOrigin, Sid } from "@ccmsg/protocol";
|
|
4
4
|
import { readRecord, type TranscriptFiles } from "../transcript/index.ts";
|
|
5
|
+
import { breathe, due } from "../transcript/scan.ts";
|
|
5
6
|
|
|
6
7
|
/** How large a transcript may be and still be swept.
|
|
7
8
|
*
|
|
@@ -36,18 +37,21 @@ const SWEEP_MAX_BYTES = 64 * 1024 * 1024;
|
|
|
36
37
|
* Absent covers a session that is no fork, one whose ancestor file is gone,
|
|
37
38
|
* and that undecidable pair. Nothing left on disk tells them apart, and none
|
|
38
39
|
* of them has a seam to place. */
|
|
39
|
-
export function forkOrigin(
|
|
40
|
+
export async function forkOrigin(
|
|
41
|
+
sid: Sid,
|
|
42
|
+
files: TranscriptFiles,
|
|
43
|
+
): Promise<ForkOrigin | undefined> {
|
|
40
44
|
const file = files.session(sid);
|
|
41
|
-
const ours = recordIds(file);
|
|
45
|
+
const ours = await recordIds(file);
|
|
42
46
|
const head = ours?.[0];
|
|
43
47
|
if (ours === undefined || head === undefined) return undefined;
|
|
44
48
|
const mine = new Set(ours);
|
|
45
49
|
|
|
46
50
|
const dir = dirname(file);
|
|
47
51
|
let best: { sid: Sid; copied: number } | undefined;
|
|
48
|
-
for (const candidate of files.all()) {
|
|
52
|
+
for (const candidate of await files.all()) {
|
|
49
53
|
if (candidate.file === file || dirname(candidate.file) !== dir) continue;
|
|
50
|
-
const theirs = recordIds(candidate.file);
|
|
54
|
+
const theirs = await recordIds(candidate.file);
|
|
51
55
|
if (theirs === undefined || theirs[0] !== head) continue;
|
|
52
56
|
const copied = run(ours, new Set(theirs));
|
|
53
57
|
const back = run(theirs, mine);
|
|
@@ -55,7 +59,7 @@ export function forkOrigin(sid: Sid, files: TranscriptFiles): ForkOrigin | undef
|
|
|
55
59
|
// than their copy; an equal one says the records cannot tell, and creation
|
|
56
60
|
// order is what is left.
|
|
57
61
|
if (copied === 0 || back > copied) continue;
|
|
58
|
-
if (back === copied && !older(candidate.file, file)) continue;
|
|
62
|
+
if (back === copied && !(await older(candidate.file, file))) continue;
|
|
59
63
|
// Sibling forks of one ancestor share a prefix too, so several files can
|
|
60
64
|
// match; the longest run is the nearest ancestor and the true seam.
|
|
61
65
|
if (best === undefined || copied > best.copied) best = { sid: candidate.sid, copied };
|
|
@@ -81,28 +85,31 @@ function run(ids: readonly string[], other: ReadonlySet<string>): number {
|
|
|
81
85
|
* A creation time of zero is a filesystem that does not record one, which is
|
|
82
86
|
* not an ancient file: two of those are simply not ordered, and the pair they
|
|
83
87
|
* belong to gets no answer. */
|
|
84
|
-
function older(candidate: string, file: string): boolean {
|
|
85
|
-
const theirs = bornAt(candidate);
|
|
86
|
-
const ours = bornAt(file);
|
|
88
|
+
async function older(candidate: string, file: string): Promise<boolean> {
|
|
89
|
+
const theirs = await bornAt(candidate);
|
|
90
|
+
const ours = await bornAt(file);
|
|
87
91
|
if (theirs === undefined || ours === undefined) return false;
|
|
88
92
|
return theirs < ours;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
/** Every record id in a file, in order. Undefined for a file too large to
|
|
92
96
|
* sweep or one that could not be read. */
|
|
93
|
-
function recordIds(file: string): string[] | undefined {
|
|
97
|
+
async function recordIds(file: string): Promise<string[] | undefined> {
|
|
94
98
|
let text: string;
|
|
95
99
|
try {
|
|
96
|
-
if (
|
|
97
|
-
text =
|
|
100
|
+
if ((await stat(file)).size > SWEEP_MAX_BYTES) return undefined;
|
|
101
|
+
text = await readFile(file, "utf8");
|
|
98
102
|
} catch {
|
|
99
103
|
return undefined;
|
|
100
104
|
}
|
|
101
105
|
const ids: string[] = [];
|
|
106
|
+
let read = 0;
|
|
102
107
|
for (const line of text.split("\n")) {
|
|
103
108
|
// Most of a transcript's bytes sit in a handful of very large records, and
|
|
104
109
|
// parsing one to learn it carries no id is the cost this avoids.
|
|
105
110
|
if (line === "" || !line.includes('"uuid"')) continue;
|
|
111
|
+
read += 1;
|
|
112
|
+
if (due(read)) await breathe();
|
|
106
113
|
const uuid = readRecord(line)?.uuid;
|
|
107
114
|
if (uuid !== undefined) ids.push(uuid);
|
|
108
115
|
}
|
|
@@ -117,9 +124,9 @@ function recordIds(file: string): string[] | undefined {
|
|
|
117
124
|
* millisecond the contract states instants in either — two transcripts written
|
|
118
125
|
* moments apart share one, and the whole use of this value is telling which
|
|
119
126
|
* came first. */
|
|
120
|
-
function bornAt(file: string): number | undefined {
|
|
127
|
+
async function bornAt(file: string): Promise<number | undefined> {
|
|
121
128
|
try {
|
|
122
|
-
const born =
|
|
129
|
+
const born = (await stat(file)).birthtimeMs;
|
|
123
130
|
return born > 0 ? born : undefined;
|
|
124
131
|
} catch {
|
|
125
132
|
return undefined;
|
package/src/sessions/handlers.ts
CHANGED
|
@@ -122,9 +122,11 @@ export function sessionHandlers(deps: SessionOpsDeps) {
|
|
|
122
122
|
* cycle, happen where the config is read. */
|
|
123
123
|
"dump.presets.read": (): DumpPresetsReadResult => ({ presets: [...deps.presets] }),
|
|
124
124
|
|
|
125
|
-
"session.fork.origin.read": (
|
|
125
|
+
"session.fork.origin.read": async (
|
|
126
|
+
input: HandlerInput,
|
|
127
|
+
): Promise<SessionForkOriginReadResult> => {
|
|
126
128
|
const args = input.args as unknown as SessionForkOriginReadArgs;
|
|
127
|
-
const origin = forkOrigin(args.sid, deps.files);
|
|
129
|
+
const origin = await forkOrigin(args.sid, deps.files);
|
|
128
130
|
return origin === undefined ? {} : { origin };
|
|
129
131
|
},
|
|
130
132
|
|
|
@@ -135,7 +137,7 @@ export function sessionHandlers(deps: SessionOpsDeps) {
|
|
|
135
137
|
return { removed: deps.forget(args.sid) };
|
|
136
138
|
},
|
|
137
139
|
|
|
138
|
-
"transcript.read": (input: HandlerInput): TranscriptReadResult => {
|
|
140
|
+
"transcript.read": async (input: HandlerInput): Promise<TranscriptReadResult> => {
|
|
139
141
|
const args = input.args as unknown as TranscriptReadArgs;
|
|
140
142
|
if (!sees(args.sid, viewer(input))) {
|
|
141
143
|
// The role sets the visible range, not the permission (DESIGN §2.2): outside
|
|
@@ -144,8 +146,8 @@ export function sessionHandlers(deps: SessionOpsDeps) {
|
|
|
144
146
|
// the caller was not entitled to ask.
|
|
145
147
|
throw new OpError("not_found", `no transcript is known for ${args.sid}`);
|
|
146
148
|
}
|
|
147
|
-
const file = deps.files.locate(args.sid, args);
|
|
148
|
-
return readSlice(args.sid, file, args.before, args.max_bytes);
|
|
149
|
+
const file = await deps.files.locate(args.sid, args);
|
|
150
|
+
return await readSlice(args.sid, file, args.before, args.max_bytes);
|
|
149
151
|
},
|
|
150
152
|
|
|
151
153
|
/** The same transcript, as the items it was read into.
|
|
@@ -153,12 +155,12 @@ export function sessionHandlers(deps: SessionOpsDeps) {
|
|
|
153
155
|
* The role narrows it the way it narrows the raw read: what a role may see
|
|
154
156
|
* is one rule whatever is being read, and a caller that cannot see a
|
|
155
157
|
* session cannot see it in either vocabulary. */
|
|
156
|
-
"transcript.items.read": (input: HandlerInput): TranscriptItemsReadResult => {
|
|
158
|
+
"transcript.items.read": async (input: HandlerInput): Promise<TranscriptItemsReadResult> => {
|
|
157
159
|
const args = input.args as unknown as TranscriptItemsReadArgs;
|
|
158
160
|
if (!sees(args.sid, viewer(input))) {
|
|
159
161
|
throw new OpError("not_found", `no transcript is known for ${args.sid}`);
|
|
160
162
|
}
|
|
161
|
-
return itemsRead(args, { files: deps.files, presets: deps.presets });
|
|
163
|
+
return await itemsRead(args, { files: deps.files, presets: deps.presets });
|
|
162
164
|
},
|
|
163
165
|
};
|
|
164
166
|
}
|
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,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
23
24
|
import { within } from "../files/index.ts";
|
|
24
25
|
import { HARNESS, type Harness } from "../harness/index.ts";
|
|
25
|
-
import {
|
|
26
|
+
import { meshView } from "../mesh/instances.ts";
|
|
26
27
|
import type { TranscriptFacts } from "../transcript/index.ts";
|
|
27
28
|
import { Elements, type TopicValue, type UpstreamResource } from "../topics/index.ts";
|
|
28
29
|
import { classify, type SessionInputs } from "./classify.ts";
|
|
@@ -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
|
};
|
|
@@ -315,8 +323,8 @@ export class Sessions implements UpstreamResource {
|
|
|
315
323
|
instance: this.deps.self,
|
|
316
324
|
...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
|
|
317
325
|
// The same view the `instances` topic carries, worked out in one place
|
|
318
|
-
// so a greeting and a subscription cannot state two different
|
|
319
|
-
instances:
|
|
326
|
+
// so a greeting and a subscription cannot state two different meshs.
|
|
327
|
+
instances: meshView(this.deps.self, this.deps.endpoint, this.deps.mesh),
|
|
320
328
|
capabilities: [...this.deps.capabilities],
|
|
321
329
|
version: this.deps.version,
|
|
322
330
|
started_at: this.deps.startedAt,
|
|
@@ -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/topics/egress.ts
CHANGED
|
@@ -6,9 +6,9 @@ import type { Requester } from "../dispatch/index.ts";
|
|
|
6
6
|
* list cannot see a change arrive sooner than the display draws it, so frames
|
|
7
7
|
* closer together than a few display frames are spent on nothing, while a wait
|
|
8
8
|
* long enough to be read as lag starts around a quarter of a second. Towards
|
|
9
|
-
* the
|
|
9
|
+
* the mesh: a relayed frame waits once per hop, so the delay a subscriber
|
|
10
10
|
* sees is this value times the hops between it and the instance that produced
|
|
11
|
-
* the value — at 100 ms a two-hop
|
|
11
|
+
* the value — at 100 ms a two-hop mesh still answers inside the window a
|
|
12
12
|
* person reads as immediate, which a longer period would leave.
|
|
13
13
|
*
|
|
14
14
|
* It is not a poll. Nothing is looked at when the period elapses: the timer is
|
package/src/topics/topics.ts
CHANGED
|
Binary file
|