@volter/supercode-teams 0.3.85 → 0.3.87

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/mail/store.mjs ADDED
@@ -0,0 +1,419 @@
1
+ // This machine's mail, as the native binary keeps it (crates/harness/src/mailbox.rs): a Maildir per address under the
2
+ // mail folder (`<harness>-<hash>/` with its `address` file and `tmp`, `new`, `claimed`, `cur` folders), one JSON envelope
3
+ // per message named `<created_at_ms, 13 digits>.<id>.json`, moved only by rename (a claim prefixes its reader's pid).
4
+ //
5
+ // The daemon holds every envelope's header in memory (the header index), read once when its file appears or moves, from
6
+ // its watch on the mail folder; a call answers from it, and reads a body from its one file only when that message is
7
+ // asked for. Until `message watch` moves into the daemon, the native binary writes into the same folders, so the writes
8
+ // here are mailbox.rs's own: the same names and the same renames (docs/architecture/overview.md, "The mail operations").
9
+ import { EventEmitter } from 'node:events';
10
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, watch, writeFileSync, writeSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { blake3Hex } from './envelope.mjs';
13
+
14
+ /** The folders an envelope can be in, and what each means. */
15
+ const STATES = { new: 'unread', claimed: 'unread', cur: 'read' };
16
+ /** Fewest hex digits after the kind (`m-`, `u-`, `a-`) a prefix may give. */
17
+ export const MIN_PREFIX_DIGITS = 4;
18
+
19
+ const alive = (pid) => {
20
+ if (!Number.isInteger(pid) || pid <= 0) return false;
21
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
22
+ };
23
+ const readEnvelope = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
24
+ const order = (a, b) => (a.envelope.created_at_ms - b.envelope.created_at_ms) || (a.envelope.id < b.envelope.id ? -1 : a.envelope.id > b.envelope.id ? 1 : 0);
25
+
26
+ /** The header an index keeps of an envelope: everything but its body. */
27
+ const headerOf = (envelope) => { const { body, ...header } = envelope; return header; };
28
+
29
+ export class MailStore extends EventEmitter {
30
+ constructor({ mailRoot, machine = null, log = () => {} }) {
31
+ super();
32
+ this.root = mailRoot; this.machine = machine; this.log = log;
33
+ this.boxes = new Map(); // folder name → { address, dir, files: Map(`${state folder}/${file}` → { header, state, path }), skipped: Set }
34
+ this.byAddress = new Map(); // address → folder name
35
+ this.unread = null; // why the mail folder is not current, when it is not
36
+ }
37
+
38
+ // ---- the index ------------------------------------------------------------------------------------------------
39
+
40
+ /** Read every mailbox once (the owner's startup load), then follow the folder's events. */
41
+ start() {
42
+ mkdirSync(this.root, { recursive: true });
43
+ this.#rescan();
44
+ this.#watch();
45
+ return this;
46
+ }
47
+
48
+ stop() { try { this.watcher?.close(); } catch { /* closed */ } clearTimeout(this.retry); }
49
+
50
+ #watch(delay = 0) {
51
+ try {
52
+ this.watcher = watch(this.root, { recursive: true }, (_event, name) => this.#event(name ? String(name) : null));
53
+ this.watcher.on('error', (error) => { try { this.watcher.close(); } catch { /* closed */ } this.#lost(error, delay); });
54
+ if (this.unread) { this.unread = null; this.#rescan(); }
55
+ } catch (error) { this.#lost(error, delay); }
56
+ }
57
+
58
+ #lost(error, delay) {
59
+ const next = Math.min(300_000, Math.max(2000, (delay || 1000) * 2));
60
+ this.unread = `the mail folder ${this.root} is not watched (${error.message}); watched again in ${Math.round(next / 1000)} s`;
61
+ this.log(`mail: ${this.unread}`);
62
+ this.retry = setTimeout(() => this.#watch(next), next); this.retry.unref?.();
63
+ }
64
+
65
+ /** One event: the file it names read again (or its mailbox, or the whole folder when it names neither). */
66
+ #event(name) {
67
+ if (!name) return this.#rescan();
68
+ const [folder, part, file] = name.split(/[\\/]/);
69
+ if (!folder) return;
70
+ if (!part) return this.#readBox(folder);
71
+ if (!STATES[part] || !file) { if (part === 'address') this.#readBox(folder); return; }
72
+ if (!this.boxes.has(folder)) return this.#readBox(folder);
73
+ this.#readFile(folder, part, file);
74
+ }
75
+
76
+ #rescan() {
77
+ let names = [];
78
+ try { names = readdirSync(this.root); } catch (error) { this.unread = `the mail folder ${this.root} could not be read: ${error.message}`; return; }
79
+ const present = new Set(names);
80
+ for (const folder of [...this.boxes.keys()]) if (!present.has(folder)) this.#dropBox(folder);
81
+ for (const folder of names) this.#readBox(folder);
82
+ }
83
+
84
+ #dropBox(folder) {
85
+ const box = this.boxes.get(folder);
86
+ if (!box) return;
87
+ this.boxes.delete(folder);
88
+ if (this.byAddress.get(box.address) === folder) this.byAddress.delete(box.address);
89
+ this.emit('changed', box.address);
90
+ }
91
+
92
+ /** A mailbox read whole: its address, and every envelope in each of its folders. */
93
+ #readBox(folder) {
94
+ let address = null;
95
+ try { address = readFileSync(join(this.root, folder, 'address'), 'utf8').trim(); } catch { /* not a mailbox */ }
96
+ if (!address) { this.#dropBox(folder); return; }
97
+ const box = { address, dir: join(this.root, folder), files: new Map(), skipped: new Set() };
98
+ this.boxes.set(folder, box);
99
+ this.byAddress.set(address, folder);
100
+ for (const part of Object.keys(STATES)) {
101
+ let files = [];
102
+ try { files = readdirSync(join(box.dir, part)); } catch { continue; }
103
+ for (const file of files) this.#take(box, part, file);
104
+ }
105
+ this.emit('changed', address);
106
+ }
107
+
108
+ /** One envelope file read again on its event: held while it is there, let go once it is gone. */
109
+ #readFile(folder, part, file) {
110
+ const box = this.boxes.get(folder);
111
+ if (!box) return;
112
+ box.files.delete(`${part}/${file}`); box.skipped.delete(`${part}/${file}`);
113
+ this.#take(box, part, file);
114
+ this.emit('changed', box.address);
115
+ }
116
+
117
+ #take(box, part, file) {
118
+ if (!file.endsWith('.json')) return;
119
+ const path = join(box.dir, part, file);
120
+ let text;
121
+ try { text = readFileSync(path, 'utf8'); } catch { return; } // gone (a rename took it): its new place has its own event
122
+ let envelope = null; try { envelope = JSON.parse(text); } catch { /* unreadable */ }
123
+ // an unreadable file is skipped, never fatal, and said: a mailbox that skipped one is partial
124
+ if (!envelope || typeof envelope.id !== 'string') { box.skipped.add(`${part}/${file}`); return; }
125
+ // its body's first line is kept beside the header (never in it): a listing of threads names each root by it with no
126
+ // body read
127
+ const firstLine = typeof envelope.body === 'string' ? envelope.body.slice(0, 400).split('\n')[0] : '';
128
+ box.files.set(`${part}/${file}`, { header: headerOf(envelope), state: STATES[part], path, part, firstLine });
129
+ }
130
+
131
+ // ---- what it holds --------------------------------------------------------------------------------------------
132
+
133
+ // What is held is the watch's: a listing and a lookup answer from the index, never from a walk of the mail folder. A
134
+ // mailbox the index has not seen (its folder's event lost) is read once, by its own folder's name, when it is asked
135
+ // about by its address; a delivery's decisions (a claim, a send's dedupe) read the one mailbox they name.
136
+
137
+ /** Every mailbox's address, as the index holds them. */
138
+ addresses() { return [...this.byAddress.keys()]; }
139
+
140
+ /** One mailbox's envelopes, oldest first, as `{ envelope (header only), state, path }`, and the files it skipped. */
141
+ inventory(address) {
142
+ const box = this.boxOf(address) ?? this.#named(address);
143
+ if (!box) return { stored: [], skipped: [] };
144
+ const stored = [...box.files.values()].map((entry) => ({ envelope: entry.header, state: entry.state, path: entry.path, part: entry.part }));
145
+ stored.sort(order);
146
+ return { stored, skipped: [...box.skipped] };
147
+ }
148
+
149
+ /** An envelope whole (its body read from its file), or null when it is gone. */
150
+ body(stored) { return readEnvelope(stored.path); }
151
+
152
+ /** The messages filed here whose id is `id` or starts with it (a prefix shorter than the kind and
153
+ * MIN_PREFIX_DIGITS digits matches nothing): each with the mailbox holding it. */
154
+ find(id) {
155
+ const digits = id.includes('-') ? id.slice(id.indexOf('-') + 1).length : 0;
156
+ if (digits < MIN_PREFIX_DIGITS) return [];
157
+ const found = [];
158
+ for (const box of this.boxes.values()) {
159
+ for (const entry of box.files.values()) {
160
+ if (!entry.header.id.startsWith(id) || found.some((item) => item.stored.envelope.id === entry.header.id)) continue;
161
+ found.push({ address: box.address, stored: { envelope: entry.header, state: entry.state, path: entry.path, part: entry.part } });
162
+ }
163
+ }
164
+ return found;
165
+ }
166
+
167
+ /** The first line of each message whose id is in `ids`, from the index in one pass (no file read): id → line. */
168
+ firstLines(ids) {
169
+ const wanted = new Set(ids);
170
+ const lines = new Map();
171
+ if (!wanted.size) return lines;
172
+ for (const box of this.boxes.values()) {
173
+ for (const entry of box.files.values()) {
174
+ if (wanted.has(entry.header.id) && !lines.has(entry.header.id)) lines.set(entry.header.id, entry.firstLine ?? '');
175
+ }
176
+ }
177
+ return lines;
178
+ }
179
+
180
+ // ---- writes, as mailbox.rs makes them --------------------------------------------------------------------------
181
+
182
+ /** The unread mail of `address` (not the user's own turns) moved into `claimed/` under its reader's pid (`pid`, the
183
+ * reading process: a claim whose reader ends goes back to `new/`), each claim recorded; another reader that took one
184
+ * first keeps it (mailbox.rs `claim_unread`). Oldest first. */
185
+ claimUnread(address, via, caller, pid = process.pid) {
186
+ // the mailbox's own folders, read here (one named read): what decides a delivery never comes from the watch-fed
187
+ // index alone, whose events a platform can drop (an FSEvents drop, an inotify overflow)
188
+ const box = this.boxOf(address) ?? this.#named(address);
189
+ if (!box) return [];
190
+ this.#recoverAbandoned(box);
191
+ let names = [];
192
+ try { names = readdirSync(join(box.dir, 'new')); } catch { return []; }
193
+ const claimed = [];
194
+ for (const file of names) {
195
+ if (!file.endsWith('.json')) continue;
196
+ const path = join(box.dir, 'new', file);
197
+ const envelope = readEnvelope(path);
198
+ if (!envelope || typeof envelope.id !== 'string') { box.skipped.add(`new/${file}`); continue; }
199
+ if (envelope.kind === 'user') continue;
200
+ const target = join(box.dir, 'claimed', `${pid}.${file}`);
201
+ try { renameSync(path, target); }
202
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
203
+ try { this.recordClaim(box, envelope.id, { pid, via, caller: caller ?? null, recipient: true, at_ms: Date.now() }); } catch { /* the claim's record is best kept, never the claim */ }
204
+ claimed.push({ envelope: headerOf(envelope), state: 'unread', path: target, part: 'claimed' });
205
+ }
206
+ claimed.sort(order);
207
+ return claimed;
208
+ }
209
+
210
+ /** A claimed envelope reached its reader: moved to `cur/` under its own name (mailbox.rs `acknowledge`). */
211
+ acknowledge(address, claimed) {
212
+ const box = this.boxes.get(this.byAddress.get(address));
213
+ if (!box) return;
214
+ const name = claimed.path.split(/[\\/]/).pop();
215
+ const original = name.includes('.') ? name.slice(name.indexOf('.') + 1) : name;
216
+ renameSync(claimed.path, join(box.dir, 'cur', original));
217
+ }
218
+
219
+ /** Who took a message to its reader, kept beside the mailbox (`claims/<id>.json`, mailbox.rs `record_claim`). */
220
+ recordClaim(box, id, claim) {
221
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('invalid message id');
222
+ mkdirSync(join(box.dir, 'claims'), { recursive: true });
223
+ const temporary = join(box.dir, 'tmp', `claim.${id}`);
224
+ writeFileSync(temporary, JSON.stringify(claim));
225
+ renameSync(temporary, join(box.dir, 'claims', `${id}.json`));
226
+ }
227
+
228
+ /** A claim whose reader has ended goes back to `new/` (mailbox.rs `recover_abandoned_claims`). */
229
+ #recoverAbandoned(box) {
230
+ let names = [];
231
+ try { names = readdirSync(join(box.dir, 'claimed')); } catch { return; }
232
+ for (const name of names) {
233
+ const at = name.indexOf('.');
234
+ if (at < 0) continue;
235
+ const pid = Number(name.slice(0, at));
236
+ if (!alive(pid)) { try { renameSync(join(box.dir, 'claimed', name), join(box.dir, 'new', name.slice(at + 1))); } catch { /* another reader recovered it */ } }
237
+ }
238
+ }
239
+
240
+ // ---- a send's writes (3b-1), as mailbox.rs makes them -------------------------------------------------------------
241
+
242
+ /** The mailbox of `address`, made when it is not there yet: its four folders and its `address` file (mailbox.rs
243
+ * `open`). Taken into the index at once, so the write that follows is answered from it. */
244
+ open(address) {
245
+ const known = this.boxOf(address);
246
+ if (known) return known;
247
+ const [, , harness] = String(address).split(':');
248
+ const folder = `${harness.replace(/[^A-Za-z0-9-]/g, '_')}-${blake3Hex(String(address)).slice(0, 24)}`;
249
+ const dir = join(this.root, folder);
250
+ for (const part of ['tmp', 'new', 'claimed', 'cur']) mkdirSync(join(dir, part), { recursive: true });
251
+ if (!existsSync(join(dir, 'address'))) writeFileSync(join(dir, 'address'), `${address}\n`);
252
+ this.#readBox(folder);
253
+ return this.boxes.get(folder);
254
+ }
255
+
256
+ /** The envelope filed in `address`'s mailbox under exactly `id`, read from its folders (mailbox.rs `find`): `{ path,
257
+ * part, state }`, or null. Never the index alone: a send's dedupe and its door's "already delivered" decide on it. */
258
+ findIn(address, id) {
259
+ const box = this.boxOf(address) ?? this.#named(address);
260
+ if (!box) return null;
261
+ const suffix = `.${id}.json`;
262
+ for (const part of Object.keys(STATES)) {
263
+ let names = [];
264
+ try { names = readdirSync(join(box.dir, part)); } catch { continue; }
265
+ const file = names.find((name) => name.endsWith(suffix));
266
+ if (file) return { path: join(box.dir, part, file), part, state: STATES[part] };
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /** The messages in `address`'s mailbox whose id starts with `prefix`, read from its folders (mailbox.rs `find_prefix`):
272
+ * whole envelopes. */
273
+ findPrefixIn(address, prefix) {
274
+ const box = this.boxOf(address) ?? this.#named(address);
275
+ if (!box) return [];
276
+ const found = [];
277
+ for (const part of Object.keys(STATES)) {
278
+ let names = [];
279
+ try { names = readdirSync(join(box.dir, part)); } catch { continue; }
280
+ for (const file of names) {
281
+ if (!file.endsWith('.json')) continue;
282
+ const fields = file.slice(0, -'.json'.length).split('.');
283
+ if (!fields[fields.length - 1].startsWith(prefix)) continue;
284
+ const envelope = readEnvelope(join(box.dir, part, file));
285
+ if (envelope && !found.some((item) => item.envelope.id === envelope.id)) found.push({ envelope, path: join(box.dir, part, file), part, state: STATES[part] });
286
+ }
287
+ }
288
+ return found;
289
+ }
290
+
291
+ /** Each message id in `address`'s mailbox with the message it answers, read from its folders: a reply chain's links. */
292
+ links(address) {
293
+ const box = this.boxOf(address) ?? this.#named(address);
294
+ const links = new Map();
295
+ if (!box) return links;
296
+ for (const part of Object.keys(STATES)) {
297
+ let names = [];
298
+ try { names = readdirSync(join(box.dir, part)); } catch { continue; }
299
+ for (const file of names) {
300
+ if (!file.endsWith('.json')) continue;
301
+ const key = `${part}/${file}`;
302
+ const header = box.files.get(key)?.header ?? (() => { this.#take(box, part, file); return box.files.get(key)?.header; })();
303
+ if (header) links.set(header.id, header.in_reply_to ?? null);
304
+ }
305
+ }
306
+ return links;
307
+ }
308
+
309
+ /** Mail that came to this machine through a link is for its own sessions only: never filed for another machine's
310
+ * address (mailbox.rs `refuse_onward`). */
311
+ #refuseOnward(address, envelope) {
312
+ const machine = String(address).split(':')[1];
313
+ if (this.machine && machine !== this.machine && String(envelope?.sender_identity?.observation ?? '').startsWith('link:')) {
314
+ throw new Error(`not carried on to ${address}: a message that came to this machine through a link is for its own sessions only`);
315
+ }
316
+ }
317
+
318
+ /** File `envelope` unread (mailbox.rs `deliver`): `tmp/` written whole with create-new and synced, then renamed into
319
+ * `new/`; an id already filed is not written again. Answers its path. */
320
+ deliver(address, envelope) {
321
+ this.#refuseOnward(address, envelope);
322
+ const box = this.open(address);
323
+ const existing = this.findIn(address, envelope.id);
324
+ if (existing) return existing.path;
325
+ const name = `${String(envelope.created_at_ms).padStart(13, '0')}.${envelope.id}.json`;
326
+ const temporary = join(box.dir, 'tmp', name);
327
+ const destination = join(box.dir, 'new', name);
328
+ try {
329
+ const fd = openSync(temporary, 'wx');
330
+ try { writeSync(fd, JSON.stringify(envelope)); fsyncSync(fd); } finally { closeSync(fd); }
331
+ renameSync(temporary, destination);
332
+ } catch (error) { try { unlinkSync(temporary); } catch { /* not written */ } throw error; }
333
+ this.#take(box, 'new', name);
334
+ this.emit('changed', address);
335
+ return destination;
336
+ }
337
+
338
+ /** File an envelope that reached its reader by another door, already read (mailbox.rs `deliver_read`): into `cur/`;
339
+ * an id already filed is not written again. Answers its path. */
340
+ deliverRead(address, envelope) {
341
+ this.#refuseOnward(address, envelope);
342
+ const box = this.open(address);
343
+ const existing = this.findIn(address, envelope.id);
344
+ if (existing) return existing.path;
345
+ const name = `${String(envelope.created_at_ms).padStart(13, '0')}.${envelope.id}.json`;
346
+ const temporary = join(box.dir, 'tmp', name);
347
+ writeFileSync(temporary, JSON.stringify(envelope));
348
+ renameSync(temporary, join(box.dir, 'cur', name));
349
+ this.#take(box, 'cur', name);
350
+ this.emit('changed', address);
351
+ return join(box.dir, 'cur', name);
352
+ }
353
+
354
+ /** A filed message moved to `cur/` under its own name (mailbox.rs `mark_read`). */
355
+ markRead(address, path) {
356
+ const box = this.open(address);
357
+ renameSync(path, join(box.dir, 'cur', path.split(/[\\/]/).pop()));
358
+ }
359
+
360
+ /** A wake kept beside the envelope (mailbox.rs `request_wake`): its watcher hands it to the session's door. */
361
+ requestWake(address, id) {
362
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('invalid wake message id');
363
+ const box = this.open(address);
364
+ mkdirSync(join(box.dir, 'wake'), { recursive: true });
365
+ writeFileSync(join(box.dir, 'wake', id), '');
366
+ }
367
+
368
+ /** A wake's delivery so far (mailbox.rs `wake_state`): an empty or absent file is a fresh wake. */
369
+ wakeState(address, id) {
370
+ const box = this.open(address);
371
+ try { return { attempts: 0, next_at_ms: 0, last_error: null, ...JSON.parse(readFileSync(join(box.dir, 'wake', id), 'utf8')) }; }
372
+ catch { return { attempts: 0, next_at_ms: 0, last_error: null }; }
373
+ }
374
+
375
+ /** A wake's delivery recorded (mailbox.rs `set_wake_state`): only while its wake is there. */
376
+ setWakeState(address, id, state) {
377
+ const box = this.open(address);
378
+ const path = join(box.dir, 'wake', id);
379
+ if (!existsSync(path)) return;
380
+ const temporary = join(box.dir, 'tmp', `wake.${id}`);
381
+ writeFileSync(temporary, JSON.stringify({ attempts: state.attempts ?? 0, next_at_ms: state.next_at_ms ?? 0, last_error: state.last_error ?? null, handover_at_ms: state.handover_at_ms ?? null, failing_since_ms: state.failing_since_ms ?? null }));
382
+ renameSync(temporary, path);
383
+ }
384
+
385
+ /** One sender's wish to hear when the receiver next ends a turn (mailbox.rs `subscribe_idle`); the native watch settles it
386
+ * until it moves into the daemon (step 4). */
387
+ subscribeIdle(address, subscription) {
388
+ const box = this.open(address);
389
+ const dir = join(box.dir, 'subscriptions');
390
+ mkdirSync(dir, { recursive: true });
391
+ const temporary = join(dir, `.${subscription.message_id}.tmp`);
392
+ writeFileSync(temporary, JSON.stringify({ message_id: subscription.message_id, subscriber: subscription.subscriber, created_at_ms: subscription.created_at_ms ?? Date.now(), seen_working: false, notice: subscription.notice !== false, final_reply: subscription.final_reply === true }));
393
+ renameSync(temporary, join(dir, `${subscription.message_id}.json`));
394
+ }
395
+
396
+ /** Who took a message to its reader, by its mailbox's address (record_claim). */
397
+ claimFor(address, id, claim) { this.recordClaim(this.open(address), id, claim); }
398
+
399
+ /** The latest claim of a message, when it has one (mailbox.rs `claim_of`). */
400
+ claimOf(address, id) {
401
+ const box = this.boxOf(address) ?? this.#named(address);
402
+ if (!box) return null;
403
+ try { return JSON.parse(readFileSync(join(box.dir, 'claims', `${id}.json`), 'utf8')); } catch { return null; }
404
+ }
405
+
406
+ /** A mailbox's folder for writes, by its address, when it is one this machine has. */
407
+ boxOf(address) { return this.boxes.get(this.byAddress.get(address)) ?? null; }
408
+
409
+ /** The mailbox of `address` read by its own folder's name (mailbox.rs `directory_name`), when the index has not seen
410
+ * it (its folder's event was dropped): taken into the index from that one read, or null when there is none. */
411
+ #named(address) {
412
+ const [, , harness] = String(address).split(':');
413
+ if (!harness) return null;
414
+ const folder = `${harness.replace(/[^A-Za-z0-9-]/g, '_')}-${blake3Hex(String(address)).slice(0, 24)}`;
415
+ this.#readBox(folder);
416
+ const box = this.boxes.get(folder) ?? null;
417
+ return box?.address === address ? box : null;
418
+ }
419
+ }