@ccmsg/cli 0.13.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 +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 +37 -10
- 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 +48 -18
- 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/files/files.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
writeFileSync,
|
|
13
|
-
} from "node:fs";
|
|
2
|
+
lstat,
|
|
3
|
+
mkdir,
|
|
4
|
+
open,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
stat,
|
|
9
|
+
unlink,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
14
12
|
import type { Dirent } from "node:fs";
|
|
15
13
|
import { basename, dirname, join, relative } from "node:path";
|
|
16
14
|
import type {
|
|
@@ -62,27 +60,34 @@ const FIND_VISITS = 20_000;
|
|
|
62
60
|
* caller — the role dispatch states for a `scope: "role"` op, and the session
|
|
63
61
|
* the connection speaks for — is handed over unread. */
|
|
64
62
|
export function fileHandlers(paths: Containment) {
|
|
65
|
-
const viewer = (input: HandlerInput): Viewer => ({
|
|
63
|
+
const viewer = (input: HandlerInput): Viewer => ({
|
|
64
|
+
role: input.role,
|
|
65
|
+
sid: input.identity?.sid,
|
|
66
|
+
});
|
|
66
67
|
|
|
67
68
|
return {
|
|
68
|
-
"dir.list": (input: HandlerInput): DirListResult => {
|
|
69
|
+
"dir.list": async (input: HandlerInput): Promise<DirListResult> => {
|
|
69
70
|
const args = input.args as unknown as DirListArgs;
|
|
70
|
-
const at = paths.root(args, viewer(input));
|
|
71
|
-
const stat = existing(at);
|
|
71
|
+
const at = await paths.root(args, viewer(input));
|
|
72
|
+
const stat = await existing(at);
|
|
72
73
|
if (!stat.isDirectory()) throw new OpError("not_found", `${args.path ?? ""} is not a folder`);
|
|
73
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
sid: args.sid,
|
|
76
|
+
path: at.path,
|
|
77
|
+
entries: await entriesOf(at.real),
|
|
78
|
+
};
|
|
74
79
|
},
|
|
75
80
|
|
|
76
|
-
"file.read": (input: HandlerInput): FileReadResult => {
|
|
81
|
+
"file.read": async (input: HandlerInput): Promise<FileReadResult> => {
|
|
77
82
|
const args = input.args as unknown as FileReadArgs;
|
|
78
|
-
const at = paths.locate(args, viewer(input));
|
|
79
|
-
const stat = existing(at);
|
|
83
|
+
const at = await paths.locate(args, viewer(input));
|
|
84
|
+
const stat = await existing(at);
|
|
80
85
|
if (!stat.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
|
|
81
86
|
// As much as the answer may carry and no more: a file larger than the
|
|
82
87
|
// limit is answered from its head, so reading it whole would cost the
|
|
83
88
|
// instance the whole of a file whose size is what the limit exists to
|
|
84
89
|
// refuse.
|
|
85
|
-
const head = bytesOf(at.real, READ_LIMIT);
|
|
90
|
+
const head = await bytesOf(at.real, READ_LIMIT);
|
|
86
91
|
const binary = isBinary(head);
|
|
87
92
|
return {
|
|
88
93
|
sid: args.sid,
|
|
@@ -95,115 +100,126 @@ export function fileHandlers(paths: Containment) {
|
|
|
95
100
|
};
|
|
96
101
|
},
|
|
97
102
|
|
|
98
|
-
"file.write": (input: HandlerInput): FileWriteResult => {
|
|
103
|
+
"file.write": async (input: HandlerInput): Promise<FileWriteResult> => {
|
|
99
104
|
const args = input.args as unknown as FileWriteArgs;
|
|
100
|
-
const at = paths.inbox(args.sid, args.path, viewer(input));
|
|
105
|
+
const at = await paths.inbox(args.sid, args.path, viewer(input));
|
|
101
106
|
// The inbox takes new notes, so an existing name is refused rather than
|
|
102
107
|
// replaced; the folder itself is made, since a repository that has never
|
|
103
108
|
// had one is exactly where the first note goes (DR-0019 §2.1).
|
|
104
|
-
|
|
105
|
-
create(at.real, args.content);
|
|
109
|
+
await mkdir(dirname(at.real), { recursive: true });
|
|
110
|
+
await create(at.real, args.content);
|
|
106
111
|
return { sid: args.sid, path: at.path };
|
|
107
112
|
},
|
|
108
113
|
|
|
109
|
-
"file.create": (input: HandlerInput): FileCreateResult => {
|
|
114
|
+
"file.create": async (input: HandlerInput): Promise<FileCreateResult> => {
|
|
110
115
|
const args = input.args as unknown as FileCreateArgs;
|
|
111
|
-
const at = paths.locate(args, viewer(input));
|
|
116
|
+
const at = await paths.locate(args, viewer(input));
|
|
112
117
|
const parent = dirname(at.real);
|
|
113
|
-
if (!isDirectory(parent)) {
|
|
118
|
+
if (!(await isDirectory(parent))) {
|
|
114
119
|
throw new OpError("not_found", `${args.path} has no folder to be created in`);
|
|
115
120
|
}
|
|
116
|
-
create(at.real, args.content);
|
|
121
|
+
await create(at.real, args.content);
|
|
117
122
|
return { sid: args.sid, path: at.path };
|
|
118
123
|
},
|
|
119
124
|
|
|
120
|
-
"file.edit": (input: HandlerInput): FileEditResult => {
|
|
125
|
+
"file.edit": async (input: HandlerInput): Promise<FileEditResult> => {
|
|
121
126
|
const args = input.args as unknown as FileEditArgs;
|
|
122
|
-
const at = paths.locate(args, viewer(input));
|
|
123
|
-
const
|
|
124
|
-
if (!
|
|
125
|
-
if (isBinary(bytesOf(at.real, SNIFF))) {
|
|
127
|
+
const at = await paths.locate(args, viewer(input));
|
|
128
|
+
const before = await existing(at);
|
|
129
|
+
if (!before.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
|
|
130
|
+
if (isBinary(await bytesOf(at.real, SNIFF))) {
|
|
126
131
|
throw new OpError("not_a_text_file", `${args.path} holds binary content`);
|
|
127
132
|
}
|
|
128
|
-
if (mtimeOf(
|
|
133
|
+
if (mtimeOf(before) !== args.expected_mtime_at || before.size !== args.expected_size) {
|
|
129
134
|
throw new OpError("file_conflict", `${args.path} changed since it was read`);
|
|
130
135
|
}
|
|
131
|
-
replace(at.real, args.content);
|
|
132
|
-
const after =
|
|
133
|
-
return {
|
|
136
|
+
await replace(at.real, args.content);
|
|
137
|
+
const after = await stat(at.real);
|
|
138
|
+
return {
|
|
139
|
+
sid: args.sid,
|
|
140
|
+
path: at.path,
|
|
141
|
+
size: after.size,
|
|
142
|
+
mtime_at: mtimeOf(after),
|
|
143
|
+
};
|
|
134
144
|
},
|
|
135
145
|
|
|
136
|
-
"file.delete": (input: HandlerInput): FileDeleteResult => {
|
|
146
|
+
"file.delete": async (input: HandlerInput): Promise<FileDeleteResult> => {
|
|
137
147
|
const args = input.args as unknown as FileDeleteArgs;
|
|
138
|
-
const at = paths.locate(args, viewer(input));
|
|
148
|
+
const at = await paths.locate(args, viewer(input));
|
|
139
149
|
// What is unlinked is what is named, so this reads the name itself rather
|
|
140
150
|
// than what it resolves to: a symlink is refused as the wrong kind of
|
|
141
151
|
// thing instead of taking its target's answer. The resolved path is the
|
|
142
152
|
// one containment admitted and would answer for the target, which is the
|
|
143
153
|
// file a link inside the root could otherwise be pointed at.
|
|
144
|
-
const stat = lstatOf(at.named);
|
|
154
|
+
const stat = await lstatOf(at.named);
|
|
145
155
|
if (stat === undefined) throw new OpError("not_found", `${args.path} is not there`);
|
|
146
156
|
if (!stat.isFile()) {
|
|
147
157
|
throw new OpError("path_forbidden", `${args.path} is not a plain file`);
|
|
148
158
|
}
|
|
149
|
-
|
|
159
|
+
await unlink(at.named);
|
|
150
160
|
return { sid: args.sid, path: at.path };
|
|
151
161
|
},
|
|
152
162
|
|
|
153
|
-
"file.find": (input: HandlerInput): FileFindResult => {
|
|
163
|
+
"file.find": async (input: HandlerInput): Promise<FileFindResult> => {
|
|
154
164
|
const args = input.args as unknown as FileFindArgs;
|
|
155
|
-
const at = paths.root(
|
|
156
|
-
{
|
|
165
|
+
const at = await paths.root(
|
|
166
|
+
{
|
|
167
|
+
sid: args.sid,
|
|
168
|
+
kind: args.kind,
|
|
169
|
+
...(args.root === undefined ? {} : { path: args.root }),
|
|
170
|
+
},
|
|
157
171
|
viewer(input),
|
|
158
172
|
);
|
|
159
173
|
const terms = parseQuery(args.query);
|
|
160
174
|
// A query with nothing to include matches nothing rather than the whole
|
|
161
175
|
// tree, so a cleared search box costs no walk at all.
|
|
162
176
|
if (terms.include.length === 0) return { sid: args.sid, hits: [], truncated: false };
|
|
163
|
-
const walk = find(at, terms, args.respect_gitignore ?? true);
|
|
177
|
+
const walk = await find(at, terms, args.respect_gitignore ?? true);
|
|
164
178
|
return { sid: args.sid, hits: walk.hits, truncated: walk.truncated };
|
|
165
179
|
},
|
|
166
180
|
|
|
167
|
-
"file.stat": (input: HandlerInput): FileStatResult => {
|
|
181
|
+
"file.stat": async (input: HandlerInput): Promise<FileStatResult> => {
|
|
168
182
|
const args = input.args as unknown as FileStatArgs;
|
|
169
|
-
const results =
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
183
|
+
const results = await Promise.all(
|
|
184
|
+
args.paths.map(async (path): Promise<FileStatEntry | null> => {
|
|
185
|
+
const at = await paths.identify(args.sid, path, viewer(input));
|
|
186
|
+
if (at === undefined || !(await isFile(at.real))) return null;
|
|
187
|
+
return { kind: at.kind, path: at.path };
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
174
190
|
return { results };
|
|
175
191
|
},
|
|
176
192
|
};
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
/** The file a located path names, or the contract's word for "not there". */
|
|
180
|
-
function existing(at: Located) {
|
|
196
|
+
async function existing(at: Located) {
|
|
181
197
|
try {
|
|
182
|
-
return
|
|
198
|
+
return await stat(at.real);
|
|
183
199
|
} catch {
|
|
184
200
|
throw new OpError("not_found", `${at.path} is not there`);
|
|
185
201
|
}
|
|
186
202
|
}
|
|
187
203
|
|
|
188
|
-
function lstatOf(path: string) {
|
|
204
|
+
async function lstatOf(path: string) {
|
|
189
205
|
try {
|
|
190
|
-
return
|
|
206
|
+
return await lstat(path);
|
|
191
207
|
} catch {
|
|
192
208
|
return undefined;
|
|
193
209
|
}
|
|
194
210
|
}
|
|
195
211
|
|
|
196
|
-
function isFile(path: string): boolean {
|
|
212
|
+
async function isFile(path: string): Promise<boolean> {
|
|
197
213
|
try {
|
|
198
|
-
return
|
|
214
|
+
return (await stat(path)).isFile();
|
|
199
215
|
} catch {
|
|
200
216
|
return false;
|
|
201
217
|
}
|
|
202
218
|
}
|
|
203
219
|
|
|
204
|
-
function isDirectory(path: string): boolean {
|
|
220
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
205
221
|
try {
|
|
206
|
-
return
|
|
222
|
+
return (await stat(path)).isDirectory();
|
|
207
223
|
} catch {
|
|
208
224
|
return false;
|
|
209
225
|
}
|
|
@@ -216,14 +232,14 @@ function mtimeOf(stat: { mtimeMs: number }): Timestamp {
|
|
|
216
232
|
}
|
|
217
233
|
|
|
218
234
|
/** A file's leading bytes, at most `limit` of them. */
|
|
219
|
-
function bytesOf(path: string, limit: number): Buffer {
|
|
220
|
-
const
|
|
235
|
+
async function bytesOf(path: string, limit: number): Promise<Buffer> {
|
|
236
|
+
const handle = await open(path, "r");
|
|
221
237
|
try {
|
|
222
238
|
const buffer = Buffer.alloc(limit);
|
|
223
|
-
const
|
|
224
|
-
return buffer.subarray(0,
|
|
239
|
+
const { bytesRead } = await handle.read(buffer, 0, limit, 0);
|
|
240
|
+
return buffer.subarray(0, bytesRead);
|
|
225
241
|
} finally {
|
|
226
|
-
|
|
242
|
+
await handle.close();
|
|
227
243
|
}
|
|
228
244
|
}
|
|
229
245
|
|
|
@@ -233,9 +249,9 @@ function isBinary(bytes: Buffer): boolean {
|
|
|
233
249
|
|
|
234
250
|
/** Write a file that must not be there yet. The exclusive open is what decides
|
|
235
251
|
* it: a check followed by a write would answer about the moment before. */
|
|
236
|
-
function create(path: string, content: string): void {
|
|
252
|
+
async function create(path: string, content: string): Promise<void> {
|
|
237
253
|
try {
|
|
238
|
-
|
|
254
|
+
await writeFile(path, content, { flag: "wx" });
|
|
239
255
|
} catch (cause) {
|
|
240
256
|
if ((cause as NodeJS.ErrnoException).code === "EEXIST") {
|
|
241
257
|
throw new OpError("file_exists", `${basename(path)} is already there`);
|
|
@@ -247,39 +263,42 @@ function create(path: string, content: string): void {
|
|
|
247
263
|
/** Replace a file's content whole. The write lands beside it and is renamed
|
|
248
264
|
* over it, so a reader sees either the old file or the new one and never a
|
|
249
265
|
* half-written one. */
|
|
250
|
-
function replace(path: string, content: string): void {
|
|
266
|
+
async function replace(path: string, content: string): Promise<void> {
|
|
251
267
|
const temporary = `${path}.ccmsg-${process.pid}-${Date.now()}`;
|
|
252
|
-
|
|
268
|
+
await writeFile(temporary, content);
|
|
253
269
|
try {
|
|
254
|
-
|
|
270
|
+
await rename(temporary, path);
|
|
255
271
|
} catch (cause) {
|
|
256
|
-
|
|
272
|
+
await unlink(temporary);
|
|
257
273
|
throw cause;
|
|
258
274
|
}
|
|
259
275
|
}
|
|
260
276
|
|
|
261
|
-
function entriesOf(dir: string): DirEntry[] {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
? "
|
|
268
|
-
: entry.
|
|
269
|
-
? "
|
|
270
|
-
:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
277
|
+
async function entriesOf(dir: string): Promise<DirEntry[]> {
|
|
278
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
279
|
+
return (
|
|
280
|
+
await Promise.all(
|
|
281
|
+
entries.map(async (entry): Promise<DirEntry> => {
|
|
282
|
+
const type = entry.isSymbolicLink()
|
|
283
|
+
? "symlink"
|
|
284
|
+
: entry.isDirectory()
|
|
285
|
+
? "dir"
|
|
286
|
+
: entry.isFile()
|
|
287
|
+
? "file"
|
|
288
|
+
: "other";
|
|
289
|
+
// A symlink is reported as itself, so what is stated about it is the link
|
|
290
|
+
// and never what it points at — including one pointing out of the root,
|
|
291
|
+
// which is listed here and refuses to resolve everywhere else.
|
|
292
|
+
const info = type === "symlink" ? undefined : await lstatOf(join(dir, entry.name));
|
|
293
|
+
return {
|
|
294
|
+
name: entry.name,
|
|
295
|
+
type,
|
|
296
|
+
...(info?.isFile() === true ? { size: info.size } : {}),
|
|
297
|
+
...(info === undefined ? {} : { mtime_at: mtimeOf(info) }),
|
|
298
|
+
};
|
|
299
|
+
}),
|
|
300
|
+
)
|
|
301
|
+
).sort((a, b) => a.name.localeCompare(b.name));
|
|
283
302
|
}
|
|
284
303
|
|
|
285
304
|
interface Terms {
|
|
@@ -312,17 +331,17 @@ function matches(path: string, terms: Terms): boolean {
|
|
|
312
331
|
* Both caps are reported the same way: the hits are the ones found, and
|
|
313
332
|
* `truncated` says they are not the whole match set. Saying so is better than
|
|
314
333
|
* implying these are all. */
|
|
315
|
-
function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
334
|
+
async function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
316
335
|
const hits: FileFindHit[] = [];
|
|
317
336
|
let visits = 0;
|
|
318
337
|
let truncated = false;
|
|
319
338
|
|
|
320
|
-
const walk = (dir: string, ignored: Ignores): void => {
|
|
339
|
+
const walk = async (dir: string, ignored: Ignores): Promise<void> => {
|
|
321
340
|
if (truncated) return;
|
|
322
|
-
const here = respectGitignore ? ignored.descend(dir) : ignored;
|
|
341
|
+
const here = respectGitignore ? await ignored.descend(dir) : ignored;
|
|
323
342
|
let entries: Dirent[];
|
|
324
343
|
try {
|
|
325
|
-
entries =
|
|
344
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
326
345
|
} catch {
|
|
327
346
|
// A folder that cannot be read contributes nothing, and a walk that
|
|
328
347
|
// stopped at one would answer less than it can.
|
|
@@ -349,13 +368,13 @@ function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
|
349
368
|
// Only real directories are descended: a symlink is answered as itself,
|
|
350
369
|
// and following one would walk out of the root the walk is bounded by.
|
|
351
370
|
if (isDir && !entry.isSymbolicLink()) {
|
|
352
|
-
walk(full, here);
|
|
371
|
+
await walk(full, here);
|
|
353
372
|
if (truncated) return;
|
|
354
373
|
}
|
|
355
374
|
}
|
|
356
375
|
};
|
|
357
376
|
|
|
358
|
-
walk(at.real, EMPTY_IGNORES);
|
|
377
|
+
await walk(at.real, EMPTY_IGNORES);
|
|
359
378
|
return { hits, truncated };
|
|
360
379
|
}
|
|
361
380
|
|
|
@@ -372,7 +391,7 @@ function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
|
372
391
|
* hiding one. */
|
|
373
392
|
interface Ignores {
|
|
374
393
|
hides(name: string, isDir: boolean): boolean;
|
|
375
|
-
descend(dir: string): Ignores
|
|
394
|
+
descend(dir: string): Promise<Ignores>;
|
|
376
395
|
}
|
|
377
396
|
|
|
378
397
|
const ALWAYS_HIDDEN = new Set([".git"]);
|
|
@@ -385,17 +404,17 @@ function makeIgnores(patterns: readonly RegExp[]): Ignores {
|
|
|
385
404
|
if (ALWAYS_HIDDEN.has(name)) return true;
|
|
386
405
|
return patterns.some((pattern) => pattern.test(name));
|
|
387
406
|
},
|
|
388
|
-
descend(dir) {
|
|
389
|
-
const own = readIgnoreFile(join(dir, ".gitignore"));
|
|
407
|
+
async descend(dir) {
|
|
408
|
+
const own = await readIgnoreFile(join(dir, ".gitignore"));
|
|
390
409
|
return own.length === 0 ? makeIgnores(patterns) : makeIgnores([...patterns, ...own]);
|
|
391
410
|
},
|
|
392
411
|
};
|
|
393
412
|
}
|
|
394
413
|
|
|
395
|
-
function readIgnoreFile(file: string): RegExp[] {
|
|
414
|
+
async function readIgnoreFile(file: string): Promise<RegExp[]> {
|
|
396
415
|
let text: string;
|
|
397
416
|
try {
|
|
398
|
-
text =
|
|
417
|
+
text = await readFile(file, "utf8");
|
|
399
418
|
} catch {
|
|
400
419
|
return [];
|
|
401
420
|
}
|
package/src/files/sandbox.ts
CHANGED
|
Binary file
|
package/src/instance/instance.ts
CHANGED
|
@@ -363,6 +363,11 @@ export class Instance {
|
|
|
363
363
|
readonly #direct: DirectRoute;
|
|
364
364
|
readonly #notify: Notify;
|
|
365
365
|
readonly #translate: Translate | undefined;
|
|
366
|
+
/** Everything that writes to the state directory as it changes rather than
|
|
367
|
+
* at exit (DESIGN §2.5). A stop waits for what they have asked for before it
|
|
368
|
+
* lets go of the config home, so a successor reads what this instance last
|
|
369
|
+
* said (DESIGN §8.5 step 4). */
|
|
370
|
+
readonly #persisted: { flush(): Promise<void> }[] = [];
|
|
366
371
|
/** The person's authentication: who may open a connection, and the records
|
|
367
372
|
* that say so (DR-0001). */
|
|
368
373
|
readonly #auth: Auth;
|
|
@@ -438,7 +443,19 @@ export class Instance {
|
|
|
438
443
|
// what this instance writes travels as its own (DR-0001 §2.6).
|
|
439
444
|
element: (_topic, _instance, data) => {
|
|
440
445
|
const stated = (data as { records?: AuthRecord[] } | undefined)?.records;
|
|
441
|
-
|
|
446
|
+
// Folded in as it arrives: the element callback is not what the peer
|
|
447
|
+
// waits on, and the set is written down before the removals in it are
|
|
448
|
+
// acted on (DR-0015). A set that could not be written is said here and
|
|
449
|
+
// nowhere else — there is no caller to answer, and the records come
|
|
450
|
+
// back from the peers that also hold them.
|
|
451
|
+
if (Array.isArray(stated)) {
|
|
452
|
+
void this.#auth.merge(stated).catch((cause: unknown) => {
|
|
453
|
+
this.log.write("auth records merge failed", {
|
|
454
|
+
instance: this.self,
|
|
455
|
+
cause: String(cause),
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
}
|
|
442
459
|
},
|
|
443
460
|
// The mesh view is this instance's own, so the topic that carries it is
|
|
444
461
|
// restated when that view moves (DESIGN §7.5).
|
|
@@ -549,6 +566,7 @@ export class Instance {
|
|
|
549
566
|
|
|
550
567
|
const inbox = new Inbox(inboxPath(paths.stateDir));
|
|
551
568
|
inbox.load();
|
|
569
|
+
this.#persisted.push(inbox);
|
|
552
570
|
// Route (a) is the harness's own way in (DESIGN §6.5): Claude Code's messaging
|
|
553
571
|
// socket, Codex's thread queue. Which one an instance speaks follows the
|
|
554
572
|
// config home it answers for (DESIGN §4.1), and the flag turns the route off for
|
|
@@ -602,6 +620,7 @@ export class Instance {
|
|
|
602
620
|
const kv = new KvStore(join(paths.stateDir, KV_DIR), this.self, (topic, data) => {
|
|
603
621
|
this.#topics.publish(topic, data);
|
|
604
622
|
});
|
|
623
|
+
this.#persisted.push(kv);
|
|
605
624
|
this.#topics.attach("kv", kv);
|
|
606
625
|
|
|
607
626
|
// The credentials, tokens and removals the mesh shares (DR-0001 §2.6).
|
|
@@ -615,6 +634,7 @@ export class Instance {
|
|
|
615
634
|
this.#topics.publish("auth.records", { records: written });
|
|
616
635
|
},
|
|
617
636
|
});
|
|
637
|
+
this.#persisted.push(records);
|
|
618
638
|
this.#auth = new Auth({
|
|
619
639
|
self: this.self,
|
|
620
640
|
records,
|
|
@@ -714,11 +734,9 @@ export class Instance {
|
|
|
714
734
|
handle: (frame, conn) => {
|
|
715
735
|
const admin = adminRequestOf(frame);
|
|
716
736
|
if (admin !== undefined) {
|
|
717
|
-
return
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
admin,
|
|
721
|
-
),
|
|
737
|
+
return handleAdmin(
|
|
738
|
+
{ auth: this.#auth, ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }) },
|
|
739
|
+
admin,
|
|
722
740
|
);
|
|
723
741
|
}
|
|
724
742
|
return this.handle(frame, conn);
|
|
@@ -997,10 +1015,16 @@ export class Instance {
|
|
|
997
1015
|
// 3. tell the connections, while they can still be told
|
|
998
1016
|
const restarting: RestartingEvent = { ev: "restarting", instance: this.self };
|
|
999
1017
|
for (const conn of this.#conns) conn.send(restarting);
|
|
1000
|
-
// 4. settle what is persisted. `last_live
|
|
1001
|
-
// they change rather than at exit, so
|
|
1002
|
-
//
|
|
1018
|
+
// 4. settle what is persisted. `last_live`, the inbox, the store and the
|
|
1019
|
+
// records are written as they change rather than at exit (DESIGN §2.5), so
|
|
1020
|
+
// what is held back is only what has been asked for and has not landed —
|
|
1021
|
+
// and it has to land before the lock goes, since a successor reads these
|
|
1022
|
+
// files as it starts.
|
|
1003
1023
|
this.log.write("stopping", { instance: this.self });
|
|
1024
|
+
await Promise.allSettled([
|
|
1025
|
+
this.#sessions.flush(),
|
|
1026
|
+
...this.#persisted.map((held) => held.flush()),
|
|
1027
|
+
]);
|
|
1004
1028
|
// 5. let the resources go, the unix socket last. Closing takes the path
|
|
1005
1029
|
// this process bound, and only that one: the stable address is a symlink
|
|
1006
1030
|
// nothing here touches, because a successor may have already pointed it at
|
|
@@ -1016,8 +1040,11 @@ export class Instance {
|
|
|
1016
1040
|
} finally {
|
|
1017
1041
|
// The pid and lock are the observable proof that this process is still
|
|
1018
1042
|
// leaving. Released only after every listener has finished closing, so a
|
|
1019
|
-
// client cannot mistake an unreachable socket for a completed stop
|
|
1043
|
+
// client cannot mistake an unreachable socket for a completed stop — and
|
|
1044
|
+
// after the log's own last lines, this one included, are on disk: what a
|
|
1045
|
+
// reader wants from the log of a stopped instance is how it ended.
|
|
1020
1046
|
remove(this.paths.pidFile);
|
|
1047
|
+
await this.log.flush();
|
|
1021
1048
|
this.lock.release();
|
|
1022
1049
|
}
|
|
1023
1050
|
}
|
package/src/instance/log.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
|
|
4
|
-
/** The instance's log: one writer, and
|
|
5
|
-
*
|
|
5
|
+
/** The instance's log: one writer, and the file in the order the calls were
|
|
6
|
+
* made (DESIGN §2.5).
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* line
|
|
8
|
+
* A line is written where anything at all is happening — a lifecycle step, a
|
|
9
|
+
* mesh callback, a session appearing — so the write cannot be the one thing
|
|
10
|
+
* that stops the instance while it lands (DR-0015). Each append is chained onto
|
|
11
|
+
* the one before it, which is what keeps the order the calls stated; what a
|
|
12
|
+
* kill can cost is the last line or two that had not reached the file yet, and
|
|
13
|
+
* for a log that is a cheaper loss than holding the process still for every
|
|
14
|
+
* line.
|
|
15
|
+
*
|
|
16
|
+
* `flush` is for the one moment that loss is avoidable: a stop that is being
|
|
17
|
+
* waited on can wait for its own last line. */
|
|
12
18
|
export class Log {
|
|
19
|
+
/** The appends already asked for, as one chain. */
|
|
20
|
+
#written: Promise<void> = Promise.resolve();
|
|
21
|
+
|
|
13
22
|
constructor(
|
|
14
23
|
private readonly file: string,
|
|
15
24
|
/** Mirrored to stderr so a foreground run shows what it is doing. */
|
|
@@ -21,10 +30,17 @@ export class Log {
|
|
|
21
30
|
write(message: string, fields: Record<string, unknown> = {}): void {
|
|
22
31
|
const line = JSON.stringify({ at: new Date().toISOString(), message, ...fields });
|
|
23
32
|
if (this.echo) process.stderr.write(`${line}\n`);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
this.#written = this.#written.then(async () => {
|
|
34
|
+
try {
|
|
35
|
+
await appendFile(this.file, `${line}\n`);
|
|
36
|
+
} catch {
|
|
37
|
+
// A log that cannot be written is not a reason to stop serving.
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Settle once every line asked for so far is on disk. */
|
|
43
|
+
async flush(): Promise<void> {
|
|
44
|
+
await this.#written;
|
|
29
45
|
}
|
|
30
46
|
}
|