@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/src/transcript/tail.ts
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
2
2
|
import { open, stat } from "node:fs/promises";
|
|
3
3
|
import { CONFIRM_POLL_MS } from "../sessions/harness.ts";
|
|
4
|
+
import { breathe } from "./scan.ts";
|
|
4
5
|
|
|
5
|
-
/** How much of
|
|
6
|
+
/** How much of a transcript one read takes at a time.
|
|
6
7
|
*
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* records at the sizes the harness writes, which
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* `last_user_input_at` already means. */
|
|
17
|
-
export const FOLD_TAIL_BYTES = 1024 * 1024;
|
|
18
|
-
|
|
19
|
-
/** What the tail found appended, with the offsets that place it.
|
|
8
|
+
* The reading of a whole file is cut into reads of this size and the loop is
|
|
9
|
+
* handed back between them, so following a transcript of any size costs the
|
|
10
|
+
* instance a read of fixed size rather than a pause proportional to the file.
|
|
11
|
+
* A megabyte is a few hundred records at the sizes the harness writes, which
|
|
12
|
+
* is large enough that the turns themselves cost nothing measurable against
|
|
13
|
+
* the parsing they carry. */
|
|
14
|
+
export const READ_CHUNK_BYTES = 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** What the tail read, with the offsets that place it.
|
|
20
17
|
*
|
|
21
18
|
* The offsets are the ones a transcript read pages by, so what arrives live
|
|
22
19
|
* and what was read stitch together without reading anything twice. `end` is
|
|
@@ -32,10 +29,10 @@ export interface Appended {
|
|
|
32
29
|
export interface TailDeps {
|
|
33
30
|
/** Complete lines only; a record still being written waits for its end. */
|
|
34
31
|
readonly onAppended: (appended: Appended) => void;
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
32
|
+
/** What was already in the file when the tail opened, oldest first, in the
|
|
33
|
+
* reads it arrived in. The input of what is folded and of what is
|
|
37
34
|
* classified, not something a subscriber is sent. */
|
|
38
|
-
readonly
|
|
35
|
+
readonly onExisting: (read: Appended) => void;
|
|
39
36
|
/** The file is not the one the tail was reading: it shrank, so what was
|
|
40
37
|
* folded out of the old contents no longer describes it. */
|
|
41
38
|
readonly onTruncated: () => void;
|
|
@@ -63,17 +60,7 @@ export class TranscriptTail {
|
|
|
63
60
|
constructor(
|
|
64
61
|
private readonly path: string,
|
|
65
62
|
private readonly deps: TailDeps,
|
|
66
|
-
) {
|
|
67
|
-
// Where the file ends, read before anything can ask. A subscription's
|
|
68
|
-
// snapshot states this and is answered in the same turn the tail is
|
|
69
|
-
// created, so a size that only the awaited seed had filled in would be
|
|
70
|
-
// reported as zero and every byte already written would look appended.
|
|
71
|
-
// Reading it here also fixes what the seed reads: the seed takes this size
|
|
72
|
-
// rather than stating a newer one, so nothing lands between the size the
|
|
73
|
-
// subscriber was given and the first frame it is sent.
|
|
74
|
-
this.#size = sizeNow(path);
|
|
75
|
-
this.#offset = this.#size;
|
|
76
|
-
}
|
|
63
|
+
) {}
|
|
77
64
|
|
|
78
65
|
/** The transcript's size as last observed, which is what a subscription's
|
|
79
66
|
* snapshot states and where the frames after it begin. */
|
|
@@ -81,21 +68,29 @@ export class TranscriptTail {
|
|
|
81
68
|
return this.#size;
|
|
82
69
|
}
|
|
83
70
|
|
|
71
|
+
/** Just past the last record read, which is what a reading resumed later
|
|
72
|
+
* starts from. */
|
|
73
|
+
get offset(): number {
|
|
74
|
+
return this.#offset;
|
|
75
|
+
}
|
|
76
|
+
|
|
84
77
|
get running(): boolean {
|
|
85
78
|
return this.#watcher !== undefined || this.#timer !== undefined;
|
|
86
79
|
}
|
|
87
80
|
|
|
88
|
-
/**
|
|
81
|
+
/** Read what is already there, then begin following.
|
|
82
|
+
*
|
|
83
|
+
* `from` is where a reading of this same file left off — nothing for a file
|
|
84
|
+
* being read for the first time, which is read from its beginning. Everything
|
|
85
|
+
* before the tail's own first append is therefore accounted for, whether it
|
|
86
|
+
* was read now or read once before and remembered.
|
|
89
87
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
* stated as empty and the whole existing end of the file would arrive later
|
|
95
|
-
* as though it had just been appended. */
|
|
96
|
-
async start(): Promise<void> {
|
|
88
|
+
* This is awaited by whoever states a value derived from the file, so a
|
|
89
|
+
* subscriber is told what the whole transcript says rather than what its end
|
|
90
|
+
* says (CT-Q8). */
|
|
91
|
+
async start(from = 0): Promise<void> {
|
|
97
92
|
if (this.running) return;
|
|
98
|
-
this.#
|
|
93
|
+
await this.#catchUp(from);
|
|
99
94
|
try {
|
|
100
95
|
this.#watcher = watch(this.path, () => void this.refresh());
|
|
101
96
|
} catch {
|
|
@@ -121,20 +116,17 @@ export class TranscriptTail {
|
|
|
121
116
|
return this.#reading;
|
|
122
117
|
}
|
|
123
118
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.#
|
|
130
|
-
|
|
131
|
-
// is dropped: what is read are whole records or nothing.
|
|
132
|
-
const at = from === 0 ? 0 : complete.indexOf(NEWLINE) + 1;
|
|
133
|
-
this.deps.onSeed({ lines: split(complete, at), start: from + at, end: this.#offset, size });
|
|
119
|
+
/** The file as it already stands, read to the end it had when this began.
|
|
120
|
+
* What is written while it runs is left to the first append, so the size a
|
|
121
|
+
* subscription opens on and the first frame after it meet exactly. */
|
|
122
|
+
async #catchUp(from: number): Promise<void> {
|
|
123
|
+
this.#offset = from;
|
|
124
|
+
this.#size = await this.#stat(from);
|
|
125
|
+
await this.#consume(this.#size, this.deps.onExisting);
|
|
134
126
|
}
|
|
135
127
|
|
|
136
128
|
async #read(): Promise<void> {
|
|
137
|
-
const size = await this.#stat();
|
|
129
|
+
const size = await this.#stat(this.#offset);
|
|
138
130
|
if (size < this.#offset) {
|
|
139
131
|
// Shorter than what was already consumed: the file was replaced, so what
|
|
140
132
|
// was folded out of it describes nothing, and reading resumes from its
|
|
@@ -143,21 +135,36 @@ export class TranscriptTail {
|
|
|
143
135
|
this.deps.onTruncated();
|
|
144
136
|
}
|
|
145
137
|
this.#size = size;
|
|
146
|
-
|
|
147
|
-
const complete = whole(await this.#slice(this.#offset, size));
|
|
148
|
-
if (complete.byteLength === 0) return;
|
|
149
|
-
const start = this.#offset;
|
|
150
|
-
const end = start + complete.byteLength;
|
|
151
|
-
this.#offset = end;
|
|
152
|
-
this.deps.onAppended({ lines: split(complete, 0), start, end, size });
|
|
138
|
+
await this.#consume(size, this.deps.onAppended);
|
|
153
139
|
}
|
|
154
140
|
|
|
155
|
-
|
|
141
|
+
/** Everything up to `size`, in reads of a fixed size with the loop handed
|
|
142
|
+
* back between them. A record still being written ends the pass: it is left
|
|
143
|
+
* for the read that finds its end. */
|
|
144
|
+
async #consume(size: number, state: (read: Appended) => void): Promise<void> {
|
|
145
|
+
while (this.#offset < size) {
|
|
146
|
+
// A record longer than one read is taken in one piece rather than in
|
|
147
|
+
// halves, so the window grows until it holds a whole one.
|
|
148
|
+
let to = Math.min(this.#offset + READ_CHUNK_BYTES, size);
|
|
149
|
+
let complete = whole(await this.#slice(this.#offset, to));
|
|
150
|
+
while (complete.byteLength === 0 && to < size) {
|
|
151
|
+
to = Math.min(to + READ_CHUNK_BYTES, size);
|
|
152
|
+
complete = whole(await this.#slice(this.#offset, to));
|
|
153
|
+
}
|
|
154
|
+
if (complete.byteLength === 0) return;
|
|
155
|
+
const start = this.#offset;
|
|
156
|
+
this.#offset = start + complete.byteLength;
|
|
157
|
+
state({ lines: split(complete), start, end: this.#offset, size });
|
|
158
|
+
if (this.#offset < size) await breathe();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async #stat(fallback: number): Promise<number> {
|
|
156
163
|
try {
|
|
157
164
|
return (await stat(this.path)).size;
|
|
158
165
|
} catch {
|
|
159
166
|
// Not there. Nothing was appended, and the poll keeps looking.
|
|
160
|
-
return
|
|
167
|
+
return fallback;
|
|
161
168
|
}
|
|
162
169
|
}
|
|
163
170
|
|
|
@@ -169,26 +176,6 @@ export class TranscriptTail {
|
|
|
169
176
|
* arithmetic puts it, inside a character as readily as before one, and
|
|
170
177
|
* decoding first would turn those bytes into a replacement character of a
|
|
171
178
|
* different length and move every offset derived from it. */
|
|
172
|
-
/** The same range, read without yielding, which is what the seed is read
|
|
173
|
-
* through: the turn that starts a tail is the turn that answers a
|
|
174
|
-
* subscription, and it has to hold the end of the file by then. Bounded by
|
|
175
|
-
* `FOLD_TAIL_BYTES` however large the transcript is. */
|
|
176
|
-
#sliceSync(from: number, to: number): Buffer {
|
|
177
|
-
if (to <= from) return Buffer.alloc(0);
|
|
178
|
-
let handle: number;
|
|
179
|
-
try {
|
|
180
|
-
handle = openSync(this.path, "r");
|
|
181
|
-
} catch {
|
|
182
|
-
return Buffer.alloc(0);
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
const buffer = Buffer.alloc(to - from);
|
|
186
|
-
return buffer.subarray(0, readSync(handle, buffer, 0, buffer.length, from));
|
|
187
|
-
} finally {
|
|
188
|
-
closeSync(handle);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
179
|
async #slice(from: number, to: number): Promise<Buffer> {
|
|
193
180
|
if (to <= from) return Buffer.alloc(0);
|
|
194
181
|
const handle = await open(this.path, "r").catch(() => undefined);
|
|
@@ -213,19 +200,13 @@ function whole(bytes: Buffer): Buffer {
|
|
|
213
200
|
return last < 0 ? Buffer.alloc(0) : bytes.subarray(0, last + 1);
|
|
214
201
|
}
|
|
215
202
|
|
|
216
|
-
/** The records in what was read,
|
|
217
|
-
function split(complete: Buffer
|
|
203
|
+
/** The records in what was read, which begins at a record. */
|
|
204
|
+
function split(complete: Buffer): string[] {
|
|
218
205
|
const lines: string[] = [];
|
|
219
|
-
for (let from =
|
|
206
|
+
for (let from = 0; from < complete.byteLength;) {
|
|
220
207
|
const newline = complete.indexOf(NEWLINE, from);
|
|
221
208
|
lines.push(complete.toString("utf8", from, newline));
|
|
222
209
|
from = newline + 1;
|
|
223
210
|
}
|
|
224
211
|
return lines;
|
|
225
212
|
}
|
|
226
|
-
|
|
227
|
-
/** How large the file is right now, or zero for one that is not there yet.
|
|
228
|
-
* Synchronous because the value is wanted before the first await. */
|
|
229
|
-
function sizeNow(path: string): number {
|
|
230
|
-
return statSync(path, { throwIfNoEntry: false })?.size ?? 0;
|
|
231
|
-
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { InstanceId, Sid } from "@ccmsg/protocol";
|
|
2
2
|
import { topicParam, type TopicValue, type UpstreamResource } from "../topics/index.ts";
|
|
3
|
+
import type { FoldCache } from "./cache.ts";
|
|
3
4
|
import { NO_FACTS, type TranscriptFacts, TranscriptFold } from "./fold.ts";
|
|
4
5
|
import { Classification, type Item, positioned } from "./items/index.ts";
|
|
5
6
|
import { type Appended, TranscriptTail } from "./tail.ts";
|
|
6
7
|
|
|
7
8
|
/** How many items a subscription to `transcript.items:<sid>` opens with.
|
|
8
9
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* asks for it by range rather than waiting for a snapshot to grow. */
|
|
10
|
+
* The end of a reading that covers the whole file, bounded by a count because
|
|
11
|
+
* the whole file is what was read: an opening frame is what a live view draws,
|
|
12
|
+
* and two hundred items is several turns at the sizes the harness writes —
|
|
13
|
+
* more than such a view shows at once. A client that wants further back asks
|
|
14
|
+
* for it by range rather than waiting for a snapshot to grow. */
|
|
15
15
|
export const ITEMS_SNAPSHOT = 200;
|
|
16
16
|
|
|
17
17
|
/** Which of the two topics a name is. Both are fed by one tail, so the
|
|
@@ -26,7 +26,11 @@ export interface TranscriptsDeps {
|
|
|
26
26
|
* (DESIGN §4.2), or the `<sid>.jsonl` under this instance's `projects/` that
|
|
27
27
|
* carries its name. A sid neither names nor is named by a file there has
|
|
28
28
|
* none, and nothing is guessed for it. */
|
|
29
|
-
readonly pathOf: (sid: Sid) => string | undefined
|
|
29
|
+
readonly pathOf: (sid: Sid) => Promise<string | undefined>;
|
|
30
|
+
/** Where a reading of a transcript is kept so the next one resumes from it.
|
|
31
|
+
* Absent leaves every reading starting from the file's beginning, which is
|
|
32
|
+
* the same answer at the price of reading it again. */
|
|
33
|
+
readonly cache?: FoldCache;
|
|
30
34
|
/** The one way a value reaches subscribers (DESIGN §6.1). */
|
|
31
35
|
readonly publish: (topic: string, data: unknown) => void;
|
|
32
36
|
/** The fold now says something different about this session. What the fold
|
|
@@ -70,16 +74,20 @@ export class Transcripts implements UpstreamResource {
|
|
|
70
74
|
* there, which is the whole of the snapshot for a topic whose frames are an
|
|
71
75
|
* append rather than a value (DESIGN §6.2). A session whose transcript this
|
|
72
76
|
* instance cannot find has nothing to state, and the subscriber begins at
|
|
73
|
-
* the first thing appended after one appears.
|
|
74
|
-
|
|
77
|
+
* the first thing appended after one appears.
|
|
78
|
+
*
|
|
79
|
+
* Answered once the transcript has been read, which is what makes the size
|
|
80
|
+
* and the items it states describe the same whole file the fold does. */
|
|
81
|
+
async snapshot(topic: string): Promise<readonly TopicValue[]> {
|
|
75
82
|
const sid = topicParam(topic);
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
if (sid === undefined) return [];
|
|
84
|
+
await this.ready(sid);
|
|
85
|
+
const followed = this.#followed.get(sid);
|
|
86
|
+
const tail = followed?.tail;
|
|
87
|
+
if (followed === undefined || tail === undefined) return [];
|
|
78
88
|
// The items topic holds a list that is only appended to, so its snapshot
|
|
79
89
|
// is the end of that list rather than a place to start from.
|
|
80
|
-
const data = isItems(topic)
|
|
81
|
-
? { sid, items: [...followed.recent] }
|
|
82
|
-
: { sid, size: followed.tail.size };
|
|
90
|
+
const data = isItems(topic) ? { sid, items: [...followed.recent] } : { sid, size: tail.size };
|
|
83
91
|
return [{ instance: this.deps.self, data }];
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -89,10 +97,17 @@ export class Transcripts implements UpstreamResource {
|
|
|
89
97
|
return this.#followed.get(sid)?.fold.facts ?? NO_FACTS;
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
/** When the transcript held for a session has been read. Whoever states a
|
|
101
|
+
* value the fold settles waits on this, so what is stated describes the
|
|
102
|
+
* whole file rather than the part of it read so far (CT-Q8). */
|
|
103
|
+
ready(sid: Sid): Promise<void> {
|
|
104
|
+
return this.#followed.get(sid)?.ready ?? Promise.resolve();
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
/** Whether a session's transcript is being followed, which is how "the
|
|
93
108
|
* subscription drives the resource" is observable from outside. */
|
|
94
109
|
following(sid: Sid): boolean {
|
|
95
|
-
return this.#followed.get(sid)?.tail
|
|
110
|
+
return this.#followed.get(sid)?.tail?.running === true;
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
/** Ask for a session's transcript to be followed. Each hold is released
|
|
@@ -103,11 +118,15 @@ export class Transcripts implements UpstreamResource {
|
|
|
103
118
|
held.holds += 1;
|
|
104
119
|
return;
|
|
105
120
|
}
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
const followed: Followed = {
|
|
122
|
+
holds: 1,
|
|
123
|
+
fold: new TranscriptFold(),
|
|
124
|
+
reading: new Classification(),
|
|
125
|
+
recent: [],
|
|
126
|
+
ready: Promise.resolve(),
|
|
127
|
+
};
|
|
109
128
|
this.#followed.set(sid, followed);
|
|
110
|
-
|
|
129
|
+
followed.ready = this.#open(sid, followed);
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
release(sid: Sid): void {
|
|
@@ -116,7 +135,8 @@ export class Transcripts implements UpstreamResource {
|
|
|
116
135
|
held.holds -= 1;
|
|
117
136
|
if (held.holds > 0) return;
|
|
118
137
|
this.#followed.delete(sid);
|
|
119
|
-
held.tail
|
|
138
|
+
held.tail?.stop();
|
|
139
|
+
void this.#remember(held);
|
|
120
140
|
// What the fold held goes with it: the values it derived describe a file
|
|
121
141
|
// this instance is no longer reading, and stating them from memory would
|
|
122
142
|
// outlive the reading that justified them.
|
|
@@ -129,36 +149,100 @@ export class Transcripts implements UpstreamResource {
|
|
|
129
149
|
const followed = new Map(this.#followed);
|
|
130
150
|
this.#followed.clear();
|
|
131
151
|
for (const [sid, entry] of followed) {
|
|
132
|
-
entry.tail
|
|
152
|
+
entry.tail?.stop();
|
|
153
|
+
void this.#remember(entry);
|
|
133
154
|
this.deps.onFacts(sid);
|
|
134
155
|
}
|
|
135
156
|
}
|
|
136
157
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
/** Find the file, take up whatever reading of it was left behind, and read
|
|
159
|
+
* the rest of it.
|
|
160
|
+
*
|
|
161
|
+
* Every await here is a window in which the last hold may be released, so
|
|
162
|
+
* what was true before each one is asked again after it (DR-0015 §2.5): a
|
|
163
|
+
* reading nobody wants any more stops where it is, rather than going on to
|
|
164
|
+
* put a watch on a file and states about it into a session that has since
|
|
165
|
+
* been opened afresh. */
|
|
166
|
+
async #open(sid: Sid, followed: Followed): Promise<void> {
|
|
167
|
+
try {
|
|
168
|
+
const path = await this.deps.pathOf(sid);
|
|
169
|
+
if (!this.#holds(sid, followed)) return;
|
|
170
|
+
if (path === undefined) {
|
|
171
|
+
// Nothing to follow. The entry goes rather than standing as a session
|
|
172
|
+
// with an empty fold, so a later hold looks for the file again.
|
|
173
|
+
this.#followed.delete(sid);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
followed.path = path;
|
|
177
|
+
const kept = await this.deps.cache?.read(path);
|
|
178
|
+
if (!this.#holds(sid, followed)) return;
|
|
179
|
+
let settled = false;
|
|
180
|
+
if (kept !== undefined) {
|
|
181
|
+
followed.fold.restore(kept.fold);
|
|
182
|
+
followed.recent.push(...kept.items);
|
|
183
|
+
// The reading resumes as the same reading: the turn it had reached and
|
|
184
|
+
// the calls it was still waiting on are taken up with the items they
|
|
185
|
+
// belong to, so a result answered now names the call a reader holds.
|
|
186
|
+
followed.reading.restore(kept.reading, followed.recent);
|
|
187
|
+
settled = true;
|
|
188
|
+
}
|
|
189
|
+
const tail = new TranscriptTail(path, {
|
|
190
|
+
onExisting: (read) => {
|
|
191
|
+
// What was already in the file: it settles what the fold says and
|
|
192
|
+
// opens the reading that classifies what comes next, and it is not an
|
|
193
|
+
// append, so nothing is published for it.
|
|
194
|
+
this.#keep(followed, read);
|
|
195
|
+
if (foldAll(followed.fold, read.lines)) settled = true;
|
|
151
196
|
},
|
|
152
|
-
onAppended: (appended) => this.#appended(sid,
|
|
197
|
+
onAppended: (appended) => this.#appended(sid, followed, appended),
|
|
153
198
|
onTruncated: () => {
|
|
154
|
-
fold.reset();
|
|
155
|
-
this.#reset(
|
|
199
|
+
followed.fold.reset();
|
|
200
|
+
this.#reset(followed);
|
|
201
|
+
void this.deps.cache?.drop(path);
|
|
156
202
|
this.deps.onFacts(sid);
|
|
157
203
|
},
|
|
158
204
|
...(this.deps.pollMs === undefined ? {} : { pollMs: this.deps.pollMs }),
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
|
|
205
|
+
});
|
|
206
|
+
followed.tail = tail;
|
|
207
|
+
await tail.start(kept?.offset ?? 0);
|
|
208
|
+
if (!this.#holds(sid, followed)) {
|
|
209
|
+
// Released while the file was being read: the watch this just put on it
|
|
210
|
+
// is the only thing left of the reading, and it goes with it.
|
|
211
|
+
tail.stop();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
await this.#remember(followed);
|
|
215
|
+
// A file that said nothing says nothing: the reading is finished either
|
|
216
|
+
// way, and only a reading that settled something is news to the domain.
|
|
217
|
+
if (settled) this.deps.onFacts(sid);
|
|
218
|
+
} catch {
|
|
219
|
+
// The reading could not be made. The entry goes rather than standing as
|
|
220
|
+
// one whose `ready` will never settle, since every later hold would join
|
|
221
|
+
// the same failure instead of trying the file again.
|
|
222
|
+
if (this.#followed.get(sid) === followed) this.#followed.delete(sid);
|
|
223
|
+
followed.tail?.stop();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Whether this is still the reading that session is being followed by. A
|
|
228
|
+
* release during an await takes the entry out, and a hold after it puts a
|
|
229
|
+
* different one in; neither is this one. */
|
|
230
|
+
#holds(sid: Sid, followed: Followed): boolean {
|
|
231
|
+
return this.#followed.get(sid) === followed;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Write down where the reading has reached, so the next one starts there. */
|
|
235
|
+
async #remember(followed: Followed): Promise<void> {
|
|
236
|
+
const path = followed.path;
|
|
237
|
+
const tail = followed.tail;
|
|
238
|
+
if (path === undefined || tail === undefined) return;
|
|
239
|
+
await this.deps.cache?.save(
|
|
240
|
+
path,
|
|
241
|
+
tail.offset,
|
|
242
|
+
followed.fold.held,
|
|
243
|
+
followed.reading.held,
|
|
244
|
+
followed.recent,
|
|
245
|
+
);
|
|
162
246
|
}
|
|
163
247
|
|
|
164
248
|
/** The one pass over what was appended: the lines go to the fold, to the
|
|
@@ -170,8 +254,8 @@ export class Transcripts implements UpstreamResource {
|
|
|
170
254
|
* now was made in bytes that went past long ago, and a reading started when
|
|
171
255
|
* somebody subscribed would not know it. What the memory holds is bounded —
|
|
172
256
|
* the calls still outstanding, and the items of the opening frame. */
|
|
173
|
-
#appended(sid: Sid,
|
|
174
|
-
const changed = foldAll(fold, appended.lines);
|
|
257
|
+
#appended(sid: Sid, followed: Followed, appended: Appended): void {
|
|
258
|
+
const changed = foldAll(followed.fold, appended.lines);
|
|
175
259
|
this.deps.publish(`transcript:${sid}`, {
|
|
176
260
|
sid,
|
|
177
261
|
lines: [...appended.lines],
|
|
@@ -179,7 +263,7 @@ export class Transcripts implements UpstreamResource {
|
|
|
179
263
|
end: appended.end,
|
|
180
264
|
size: appended.size,
|
|
181
265
|
});
|
|
182
|
-
const items = this.#keep(
|
|
266
|
+
const items = this.#keep(followed, appended);
|
|
183
267
|
// A record still being written was not read, so there is nothing to say
|
|
184
268
|
// about it yet; a chunk whose records were all the interface's own
|
|
185
269
|
// bookkeeping says nothing either.
|
|
@@ -191,9 +275,7 @@ export class Transcripts implements UpstreamResource {
|
|
|
191
275
|
* subscription to open on. A result that fills in a call already handed over
|
|
192
276
|
* is not sent again: the call named nothing to wait for and the result names
|
|
193
277
|
* the call, so a reader ties the two together from what it already has. */
|
|
194
|
-
#keep(
|
|
195
|
-
const followed = this.#followed.get(sid);
|
|
196
|
-
if (followed === undefined) return [];
|
|
278
|
+
#keep(followed: Followed, chunk: Appended): readonly Item[] {
|
|
197
279
|
const items = followed.reading.readAll(positioned(chunk.lines, chunk.start));
|
|
198
280
|
followed.recent.push(...items);
|
|
199
281
|
if (followed.recent.length > ITEMS_SNAPSHOT) {
|
|
@@ -204,9 +286,7 @@ export class Transcripts implements UpstreamResource {
|
|
|
204
286
|
|
|
205
287
|
/** The file is not the one that was being read, so neither the reading nor
|
|
206
288
|
* what it produced describes it. */
|
|
207
|
-
#reset(
|
|
208
|
-
const followed = this.#followed.get(sid);
|
|
209
|
-
if (followed === undefined) return;
|
|
289
|
+
#reset(followed: Followed): void {
|
|
210
290
|
followed.reading = new Classification();
|
|
211
291
|
followed.recent.length = 0;
|
|
212
292
|
}
|
|
@@ -219,7 +299,10 @@ interface Followed {
|
|
|
219
299
|
reading: Classification;
|
|
220
300
|
/** The end of what has been read, which is what a subscription opens on. */
|
|
221
301
|
readonly recent: Item[];
|
|
222
|
-
|
|
302
|
+
/** Settled once the file has been found and read. */
|
|
303
|
+
ready: Promise<void>;
|
|
304
|
+
path?: string;
|
|
305
|
+
tail?: TranscriptTail;
|
|
223
306
|
}
|
|
224
307
|
|
|
225
308
|
function foldAll(fold: TranscriptFold, lines: readonly string[]): boolean {
|