@volter/supercode-teams 0.3.151 → 0.3.152
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/README.md +14 -1
- package/bin/teams.mjs +10 -2
- package/machine.mjs +128 -43
- package/mail/agents.mjs +40 -119
- package/mail/carrier.mjs +72 -111
- package/mail/envelope.mjs +8 -10
- package/mail/hooks.mjs +43 -27
- package/mail/idle.mjs +14 -5
- package/mail/import-maildir.mjs +350 -0
- package/mail/questions.mjs +38 -46
- package/mail/reads.mjs +15 -26
- package/mail/relay-endpoint.mjs +14 -22
- package/mail/relay.mjs +100 -65
- package/mail/retire-maildir.mjs +122 -0
- package/mail/send.mjs +65 -81
- package/mail/sqlite-store.mjs +476 -0
- package/mail/sqlite.mjs +353 -0
- package/mail/transcript.mjs +3 -3
- package/mail/upkeep.mjs +46 -26
- package/mail/verbs.mjs +14 -36
- package/native-codex-pane.mjs +6 -6
- package/native-initial-input.mjs +6 -6
- package/native-launch-binding.mjs +35 -0
- package/package.json +2 -2
- package/session-people.mjs +7 -8
- package/terminal-mail.mjs +8 -19
- package/mail/store.mjs +0 -531
package/mail/agents.mjs
CHANGED
|
@@ -2,116 +2,58 @@
|
|
|
2
2
|
// docs/adr/0008-agent-mailbox.md). An agent is addressed `sc:<machine>:agent:<name>`; its record names its main session,
|
|
3
3
|
// where a root addressed to it goes. A thread is a root and its replies, with its participants: a `to` participant
|
|
4
4
|
// receives each line through its door, woken; a `cc` participant has it filed unread. `plan` decides who receives one
|
|
5
|
-
// message. The
|
|
6
|
-
//
|
|
7
|
-
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
+
// message. The daemon owns these records in its mail database. A thread update
|
|
6
|
+
// is one synchronous transaction; no other process opens a thread file or lock.
|
|
8
7
|
import { spawn } from 'node:child_process';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
8
|
import { blake3Hex } from './envelope.mjs';
|
|
11
9
|
import { cameThroughLink, parseAddress, threadOf } from './compose.mjs';
|
|
12
10
|
|
|
13
11
|
/** The pseudo-harness of an agent address. */
|
|
14
12
|
export const AGENT_HARNESS = 'agent';
|
|
15
|
-
/** How recent a folder time is not trusted to say its folder is unchanged (a coarse clock's tick, a 1 s filesystem). */
|
|
16
|
-
const RACY_MS = 2000;
|
|
17
13
|
/** How long a send waits for a resumed session to be reachable again. */
|
|
18
14
|
export const RESUME_WAIT_MS = 45_000;
|
|
19
15
|
|
|
20
16
|
const validName = (name) => typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/** A record written whole and renamed into place (mail_agent.rs `write_atomically`). */
|
|
24
|
-
export function writeAtomically(path, text) {
|
|
25
|
-
mkdirSync(join(path, '..'), { recursive: true });
|
|
26
|
-
const temporary = `${path.replace(/\.[^./]*$/, '')}.tmp.${process.pid}`;
|
|
27
|
-
writeFileSync(temporary, text);
|
|
28
|
-
renameSync(temporary, path);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
32
|
-
|
|
33
|
-
/** serde_json's `to_vec_pretty` of a thread, its fields in mail_agent.rs's order and its empty optionals left out. */
|
|
34
|
-
function threadText(thread) {
|
|
17
|
+
/** Preserve the former record's fields, leaving its empty optionals out. */
|
|
18
|
+
function threadRecord(thread) {
|
|
35
19
|
const out = { id: thread.id, agent: thread.agent, holder: thread.holder, participants: thread.participants.map(({ address, role }) => ({ address, role })) };
|
|
36
20
|
if (thread.markers && Object.keys(thread.markers).length) out.markers = Object.fromEntries(Object.entries(thread.markers).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
37
21
|
if (thread.surface != null) out.surface = thread.surface;
|
|
38
22
|
out.created_at_ms = thread.created_at_ms;
|
|
39
23
|
if (thread.holder_launched) out.holder_launched = true;
|
|
40
24
|
if (thread.parent != null) out.parent = thread.parent;
|
|
41
|
-
return
|
|
25
|
+
return out;
|
|
42
26
|
}
|
|
43
27
|
|
|
44
28
|
export class Agents {
|
|
45
|
-
constructor({
|
|
46
|
-
Object.assign(this, {
|
|
47
|
-
this.agentsDir = join(mailRoot, 'agents');
|
|
48
|
-
this.threadsDir = join(mailRoot, 'threads');
|
|
49
|
-
this.locks = new Map(); // thread id → the write in progress here (one writer per thread in this process)
|
|
29
|
+
constructor({ store, machine, supercodeBin, env = process.env, recordedConfig = async () => null, discover = async () => ({ sessions: [] }) }) {
|
|
30
|
+
Object.assign(this, { store, machine, supercodeBin, env, recordedConfig, discover });
|
|
50
31
|
}
|
|
51
32
|
|
|
52
33
|
// ---- records ---------------------------------------------------------------------------------------------------
|
|
53
34
|
|
|
54
35
|
/** The agent named `name`, when one is declared. */
|
|
55
|
-
load(name) { return validName(name) ?
|
|
36
|
+
load(name) { return validName(name) ? this.store.record('agents', name) : null; }
|
|
56
37
|
|
|
57
38
|
/** Every declared agent, by name. */
|
|
58
39
|
all() {
|
|
59
|
-
|
|
60
|
-
try { names = readdirSync(this.agentsDir); } catch { return []; }
|
|
61
|
-
return names.filter((name) => name.endsWith('.json')).map((name) => readJson(join(this.agentsDir, name))).filter((agent) => agent?.name && agent?.main_session).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
40
|
+
return this.store.records('agents').map(({ value }) => value).filter((agent) => agent?.name && agent?.main_session).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
62
41
|
}
|
|
63
42
|
|
|
64
43
|
/** The thread `id`, when the mailbox keeps one. */
|
|
65
|
-
thread(id) { return validName(id) ?
|
|
44
|
+
thread(id) { return validName(id) ? this.store.record('threads', id) : null; }
|
|
66
45
|
|
|
67
46
|
/** Every thread, oldest first. */
|
|
68
|
-
threads() {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* The thread records, read once and kept while their folder is unchanged, with an index of them by holder and by
|
|
72
|
-
* channel marker. Every writer (this daemon, and mail_agent.rs's in the native watch until step 4) renames a whole
|
|
73
|
-
* record into the folder, which moves its time: one stat a call, never a read of every record, though a send's plan
|
|
74
|
-
* asks on every send and an agent's threads grow with its history. A folder time within RACY_MS of now is not trusted
|
|
75
|
-
* (a write in the same tick of a coarse clock leaves it unmoved): the folder is listed again, each record checked by
|
|
76
|
-
* its inode, until its time is older.
|
|
77
|
-
*/
|
|
78
|
-
#records() {
|
|
79
|
-
let changed;
|
|
80
|
-
try { changed = statSync(this.threadsDir).mtimeMs; } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
|
|
81
|
-
const cache = this.threadsCache;
|
|
82
|
-
if (cache?.changed === changed && Date.now() - changed > RACY_MS) return cache;
|
|
83
|
-
// The folder changed, or changed too recently for its time to be trusted: its names are listed and each record's
|
|
84
|
-
// file is stat'ed, and only a record whose inode moved (every writer renames a whole new file into place) is read
|
|
85
|
-
// again. A burst of writes costs a listing and a stat a record, not a read of every record.
|
|
86
|
-
let names = [];
|
|
87
|
-
try { names = readdirSync(this.threadsDir); } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
|
|
88
|
-
const files = new Map();
|
|
89
|
-
for (const name of names) {
|
|
90
|
-
if (!name.endsWith('.json')) continue;
|
|
91
|
-
// its inode, time and size together (an inode can be used again once freed): a rename moves at least one
|
|
92
|
-
let stamp;
|
|
93
|
-
try { const stat = statSync(join(this.threadsDir, name)); stamp = `${stat.ino}:${stat.mtimeMs}:${stat.size}`; } catch { continue; }
|
|
94
|
-
const known = cache?.files?.get(name);
|
|
95
|
-
const thread = known && known.stamp === stamp ? known.thread : readJson(join(this.threadsDir, name));
|
|
96
|
-
if (thread?.id) files.set(name, { stamp, thread });
|
|
97
|
-
}
|
|
98
|
-
const list = [...files.values()].map((file) => file.thread)
|
|
99
|
-
.sort((a, b) => (a.created_at_ms - b.created_at_ms) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
100
|
-
const byHolder = new Map(), byMarker = new Map();
|
|
101
|
-
for (const thread of list) {
|
|
102
|
-
byHolder.set(thread.holder, [...(byHolder.get(thread.holder) ?? []), thread]);
|
|
103
|
-
for (const [marker, id] of Object.entries(thread.markers ?? {})) if (!byMarker.has(marker)) byMarker.set(marker, { thread, id });
|
|
104
|
-
}
|
|
105
|
-
const records = { changed, list, byHolder, byMarker, files };
|
|
106
|
-
// kept either way (its records are each current by inode); trusted without a listing only once its time is old
|
|
107
|
-
this.threadsCache = records;
|
|
108
|
-
return records;
|
|
47
|
+
threads() {
|
|
48
|
+
return this.store.statement("SELECT record FROM mail_records WHERE collection = 'threads' ORDER BY json_extract(record, '$.created_at_ms'), json_extract(record, '$.id')")
|
|
49
|
+
.all().map(({ record }) => JSON.parse(record)).filter((thread) => thread?.id);
|
|
109
50
|
}
|
|
110
51
|
|
|
111
52
|
|
|
112
53
|
/** The thread `address` holds for an agent other than as its main session (mail_agent.rs `thread_held_by`). */
|
|
113
54
|
heldBy(address) {
|
|
114
|
-
const held =
|
|
55
|
+
const held = this.store.statement("SELECT record FROM mail_records WHERE collection = 'threads' AND json_extract(record, '$.holder') = ? ORDER BY json_extract(record, '$.created_at_ms') DESC, json_extract(record, '$.id') DESC")
|
|
56
|
+
.all(address).map(({ record }) => JSON.parse(record)).find((thread) => {
|
|
115
57
|
if (thread.parent != null) return false;
|
|
116
58
|
const agent = this.load(thread.agent);
|
|
117
59
|
return agent != null && agent.main_session !== address;
|
|
@@ -121,8 +63,10 @@ export class Agents {
|
|
|
121
63
|
|
|
122
64
|
/** The thread whose channel message has the platform id `marker`, with that message's id. */
|
|
123
65
|
ofMarker(marker) {
|
|
124
|
-
const found = this
|
|
125
|
-
|
|
66
|
+
const found = this.store.statement(`SELECT r.record, m.message FROM thread_markers m
|
|
67
|
+
JOIN mail_records r ON r.collection = m.collection AND r.key = m.thread
|
|
68
|
+
WHERE m.marker = ? ORDER BY json_extract(r.record, '$.created_at_ms'), json_extract(r.record, '$.id') LIMIT 1`).get(marker);
|
|
69
|
+
return found ? { thread: JSON.parse(found.record), id: JSON.parse(found.message) } : null;
|
|
126
70
|
}
|
|
127
71
|
|
|
128
72
|
/** The agent `address` is a session of: its main, or a thread's holder. */
|
|
@@ -133,46 +77,17 @@ export class Agents {
|
|
|
133
77
|
return held ? this.load(held.agent) : null;
|
|
134
78
|
}
|
|
135
79
|
|
|
136
|
-
/**
|
|
137
|
-
*
|
|
138
|
-
* a live one is waited on for 30 s, then the send fails), and answer it as saved; `create()` makes it when it is not
|
|
139
|
-
* kept yet (mail_agent.rs `update_thread`).
|
|
140
|
-
*/
|
|
80
|
+
/** Change a thread and its marker index together; `create()` supplies a missing
|
|
81
|
+
* record. No native-door await or external writer belongs in this transaction. */
|
|
141
82
|
async update(id, create, change) {
|
|
142
83
|
if (!validName(id)) return null;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
mkdirSync(this.threadsDir, { recursive: true });
|
|
151
|
-
const path = join(this.threadsDir, `${id}.json`);
|
|
152
|
-
const lock = join(this.threadsDir, `${id}.lock`);
|
|
153
|
-
const started = Date.now();
|
|
154
|
-
for (;;) {
|
|
155
|
-
try { closeSync(openSync(lock, 'wx')); break; }
|
|
156
|
-
catch (error) {
|
|
157
|
-
if (error.code !== 'EEXIST') throw error;
|
|
158
|
-
let stale = false;
|
|
159
|
-
try { stale = Date.now() - statSync(lock).mtimeMs > 10_000; } catch { /* gone meanwhile */ }
|
|
160
|
-
if (stale) { try { unlinkSync(lock); } catch { /* another took it */ } }
|
|
161
|
-
else if (Date.now() - started > 30_000) throw new Error(`thread ${id} is locked by another writer`);
|
|
162
|
-
await sleep(20);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
const thread = readJson(path) ?? create();
|
|
167
|
-
if (!thread) return null;
|
|
168
|
-
change(thread);
|
|
169
|
-
writeAtomically(path, threadText(thread));
|
|
170
|
-
return thread;
|
|
171
|
-
} finally { try { unlinkSync(lock); } catch { /* released */ } }
|
|
172
|
-
} finally {
|
|
173
|
-
release();
|
|
174
|
-
if (this.locks.get(id) === chained) this.locks.delete(id);
|
|
175
|
-
}
|
|
84
|
+
return this.store.transaction(() => {
|
|
85
|
+
const thread = this.thread(id) ?? create();
|
|
86
|
+
if (!thread) return null;
|
|
87
|
+
if (change(thread)?.then instanceof Function) throw new Error('mail thread updates must be synchronous');
|
|
88
|
+
this.store.putRecord('threads', id, threadRecord(thread));
|
|
89
|
+
return thread;
|
|
90
|
+
});
|
|
176
91
|
}
|
|
177
92
|
|
|
178
93
|
/** Record `agent`, replacing an earlier record of the same name (mail_agent.rs `declare`): its fields in its order, an
|
|
@@ -183,19 +98,23 @@ export class Agents {
|
|
|
183
98
|
if (agent.folder != null) out.folder = agent.folder;
|
|
184
99
|
if (agent.harness != null) out.harness = agent.harness;
|
|
185
100
|
if (agent.idle_minutes != null) out.idle_minutes = agent.idle_minutes;
|
|
186
|
-
|
|
101
|
+
this.store.putRecord('agents', agent.name, out);
|
|
187
102
|
}
|
|
188
103
|
|
|
189
104
|
/** Name `name` the owner's account manager: every line the owner writes to any agent is CC'd to its main session. */
|
|
190
|
-
setOwnersAccountManager(name) {
|
|
105
|
+
setOwnersAccountManager(name) { this.store.putRecord('agent_settings', 'owners-account-manager', name); }
|
|
191
106
|
|
|
192
107
|
/** The owner's account manager, when one is named and declared. */
|
|
193
108
|
ownersAccountManager() {
|
|
194
|
-
|
|
109
|
+
const name = this.store.record('agent_settings', 'owners-account-manager');
|
|
110
|
+
return typeof name === 'string' ? this.load(name.trim()) : null;
|
|
195
111
|
}
|
|
196
112
|
|
|
197
113
|
/** The threads of the agent `name`, oldest first. */
|
|
198
|
-
threadsOf(name) {
|
|
114
|
+
threadsOf(name) {
|
|
115
|
+
return this.store.statement("SELECT record FROM mail_records WHERE collection = 'threads' AND json_extract(record, '$.agent') = ? ORDER BY json_extract(record, '$.created_at_ms'), json_extract(record, '$.id')")
|
|
116
|
+
.all(name).map(({ record }) => JSON.parse(record)).filter((thread) => thread?.id);
|
|
117
|
+
}
|
|
199
118
|
|
|
200
119
|
/**
|
|
201
120
|
* A line that reached an agent's main session through its own input (its terminal, its DM) is a root of the agent:
|
|
@@ -346,6 +265,7 @@ export class Agents {
|
|
|
346
265
|
* <its recorded mode>`: never open's unattended defaults (mail_agent.rs `resume`), and no window on the screen. */
|
|
347
266
|
async resume(address) {
|
|
348
267
|
const recorded = await this.#resumeArguments(address);
|
|
268
|
+
if (this.stopped) throw new Error('the daemon is stopping; no agent session was resumed');
|
|
349
269
|
if (!recorded) throw new Error(`${address} has no recorded permissions to resume with; its mail waits`);
|
|
350
270
|
const parsed = parseAddress(address);
|
|
351
271
|
// a Claude holder comes back under a name of its own (its agent's, and its id's start), never its folder's, which
|
|
@@ -354,7 +274,7 @@ export class Agents {
|
|
|
354
274
|
const thread = this.threads().find((item) => item.holder === address);
|
|
355
275
|
if (thread) recorded.push('--name', `${thread.agent}-${[...parsed.session].slice(0, 6).join('')}`);
|
|
356
276
|
}
|
|
357
|
-
|
|
277
|
+
this.store.putRecord('agent_resumed', blake3Hex(address).slice(0, 24), String(Date.now()));
|
|
358
278
|
const { code, stderr } = await new Promise((resolve) => {
|
|
359
279
|
// a holder resumed by mail runs in the background: no window opens on the machine's screen for it
|
|
360
280
|
const child = spawn(this.supercodeBin, ['open', parsed.session, '--detach', '--window=false', '--', ...recorded], { stdio: ['ignore', 'ignore', 'pipe'], env: this.env });
|
|
@@ -382,6 +302,8 @@ export class Agents {
|
|
|
382
302
|
if (config?.session_id !== parsed.session) return null;
|
|
383
303
|
return resumeArguments(config);
|
|
384
304
|
}
|
|
305
|
+
|
|
306
|
+
stop() { this.stopped = true; }
|
|
385
307
|
}
|
|
386
308
|
|
|
387
309
|
/** A recorded configuration as resume arguments, or null when it is of a kind not carried (mail_agent.rs). */
|
|
@@ -416,4 +338,3 @@ export function resumeArguments(config) {
|
|
|
416
338
|
if (model) args.push('--model', model);
|
|
417
339
|
return args;
|
|
418
340
|
}
|
|
419
|
-
|
package/mail/carrier.mjs
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
// The mailbox's own delivery (D140; docs/architecture/overview.md, step 4b): every message filed with a wake (by a filer
|
|
2
2
|
// that does not wait, a board's dispatcher, a send to a hooked session) is handed to its session by the session's own
|
|
3
3
|
// door, by this daemon. Mail for another machine is carried there through its mail door and filed with the same wake,
|
|
4
|
-
// so that machine's daemon delivers it.
|
|
5
|
-
// (
|
|
6
|
-
//
|
|
7
|
-
// - a failed attempt waits FIRST_RETRY_MS doubling to LAST_RETRY_MS, kept in the wake itself
|
|
4
|
+
// so that machine's daemon delivers it. A wake is a row of the mail store (mail step 2), followed from the write that
|
|
5
|
+
// makes it (the store's `wake` event; every wake read at start), and each is attempted when it is due. Its rules are the
|
|
6
|
+
// native carrier's (mail_watch.rs `MailCarrier`, deleted):
|
|
7
|
+
// - a failed attempt waits FIRST_RETRY_MS doubling to LAST_RETRY_MS, kept in the wake itself;
|
|
8
8
|
// - a wake expires once it has failed WAKE_ATTEMPTS counted times and its first counted failure is older than its bound
|
|
9
9
|
// (LOCAL_WAKE_LIFETIME_MS here, REMOTE_WAKE_LIFETIME_MS for another machine): the message stays filed and unread, and
|
|
10
|
-
// the expiry is recorded
|
|
10
|
+
// the expiry is recorded (read by `supercode message expired`);
|
|
11
11
|
// - mail for a machine that is offline is not failing: the machine is probed once per LAST_RETRY_MS (its wait kept once
|
|
12
|
-
// per machine,
|
|
12
|
+
// per machine, read by `supercode message waiting`), all its mail is carried when it
|
|
13
13
|
// answers, and the mail expires only past OFFLINE_CEILING_MS;
|
|
14
14
|
// - a hand-over is waited for at most HANDOVER_BOUND_MS, and one mailbox's attempt never waits on another's;
|
|
15
|
-
// - each attempt is a line of
|
|
16
|
-
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
-
import { join } from 'node:path';
|
|
15
|
+
// - each attempt is a line of the carrier's record.
|
|
18
16
|
import { render } from './envelope.mjs';
|
|
19
17
|
|
|
20
18
|
const FIRST_RETRY_MS = 2_000;
|
|
@@ -28,9 +26,7 @@ const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
|
|
|
28
26
|
// mailbox's wakes, and only for this long
|
|
29
27
|
const HANDOVER_BOUND_MS = 150_000;
|
|
30
28
|
|
|
31
|
-
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
32
29
|
const offline = (text) => String(text).includes('machine_offline');
|
|
33
|
-
const safeMachine = (machine) => String(machine).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
34
30
|
|
|
35
31
|
export class MailCarrier {
|
|
36
32
|
/**
|
|
@@ -39,64 +35,59 @@ export class MailCarrier {
|
|
|
39
35
|
* `typeTurn(pane, harness, text, ids)` a wake typed into a hooked session's pane; `askMachine(machine, request)` one
|
|
40
36
|
* request to another machine's mail door.
|
|
41
37
|
*/
|
|
42
|
-
constructor({ machine,
|
|
43
|
-
Object.assign(this, { machine,
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
38
|
+
constructor({ machine, store, live, handOver, typeTurn, askMachine, log = () => {} }) {
|
|
39
|
+
Object.assign(this, { machine, store, live, handOver, typeTurn, askMachine, log });
|
|
40
|
+
this.stopped = false;
|
|
41
|
+
this.timers = new Map(); // `${address}\0${id}` → timer of a wake here, due
|
|
42
|
+
this.running = new Set(); // mailboxes (addresses) with an attempt in flight
|
|
46
43
|
this.machines = new Map(); // machine → timer of its next carry
|
|
47
44
|
this.carrying = new Set(); // machines with a carry in flight
|
|
48
|
-
this.remote = new Map(); // machine → Map(`${
|
|
45
|
+
this.remote = new Map(); // machine → Map(`${address}\0${id}` → { address, id }): wakes for another machine
|
|
49
46
|
}
|
|
50
47
|
|
|
51
|
-
/** Follow every wake: those filed before this daemon (
|
|
52
|
-
*
|
|
53
|
-
*
|
|
48
|
+
/** Follow every wake: those filed before this daemon (every wake the store holds), each as the store names it (the
|
|
49
|
+
* write that made it), and every wake again once per LAST_RETRY_MS, so a wake no event named is carried within that
|
|
50
|
+
* bound. */
|
|
54
51
|
start() {
|
|
55
|
-
this.
|
|
52
|
+
this.onWake = ({ address, id }) => this.#wake(address, id);
|
|
53
|
+
this.store.on('wake', this.onWake);
|
|
56
54
|
this.#sweep();
|
|
57
55
|
this.sweeper = setInterval(() => this.#sweep(), LAST_RETRY_MS);
|
|
58
56
|
this.sweeper.unref?.();
|
|
59
57
|
return this;
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
/** Every
|
|
63
|
-
#sweep() {
|
|
64
|
-
let folders = [];
|
|
65
|
-
try { folders = readdirSync(this.mailRoot); } catch { return; }
|
|
66
|
-
for (const folder of folders) this.#rewake(folder);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
stop() { clearInterval(this.sweeper); for (const timer of [...this.timers.values(), ...this.machines.values()]) clearTimeout(timer); this.timers.clear(); this.machines.clear(); }
|
|
60
|
+
/** Every wake followed (one already due or scheduled is not scheduled twice). */
|
|
61
|
+
#sweep() { for (const { address, id } of this.store.allWakes()) this.#wake(address, id); }
|
|
70
62
|
|
|
71
|
-
|
|
63
|
+
stop() { this.stopped = true; this.store.off('wake', this.onWake); clearInterval(this.sweeper); for (const timer of [...this.timers.values(), ...this.machines.values()]) clearTimeout(timer); this.timers.clear(); this.machines.clear(); }
|
|
72
64
|
|
|
73
|
-
/** A wake seen (
|
|
74
|
-
#wake(
|
|
75
|
-
if (!/^[A-Za-z0-9_-]+$/.test(id)) return;
|
|
76
|
-
const address = this.#address(folder);
|
|
77
|
-
if (!address) return;
|
|
65
|
+
/** A wake seen (written or changed): attempted when it is due. */
|
|
66
|
+
#wake(address, id) {
|
|
67
|
+
if (this.stopped || !/^[A-Za-z0-9_-]+$/.test(id) || !address) return;
|
|
78
68
|
const machine = address.split(':')[1];
|
|
79
69
|
if (machine !== this.machine) {
|
|
80
70
|
if (!this.remote.has(machine)) this.remote.set(machine, new Map());
|
|
81
|
-
this.remote.get(machine).set(`${
|
|
71
|
+
this.remote.get(machine).set(`${address}\0${id}`, { address, id });
|
|
82
72
|
this.#scheduleMachine(machine);
|
|
83
73
|
return;
|
|
84
74
|
}
|
|
85
|
-
const key = `${
|
|
75
|
+
const key = `${address}\0${id}`;
|
|
86
76
|
if (this.timers.has(key)) return;
|
|
87
77
|
const state = this.store.wakeState(address, id);
|
|
88
|
-
const timer = setTimeout(() => { this.timers.delete(key); void this.#local(
|
|
78
|
+
const timer = setTimeout(() => { this.timers.delete(key); void this.#local(address); }, Math.max(0, (state.next_at_ms ?? 0) - Date.now()));
|
|
89
79
|
timer.unref?.();
|
|
90
80
|
this.timers.set(key, timer);
|
|
91
81
|
}
|
|
92
82
|
|
|
93
83
|
/** The wakes of one mailbox here that are due, attempted (mail_watch.rs `tick`, its local half). */
|
|
94
|
-
async #local(
|
|
95
|
-
if (this.
|
|
96
|
-
this.running.
|
|
84
|
+
async #local(address) {
|
|
85
|
+
if (this.stopped) return;
|
|
86
|
+
if (this.running.has(address)) { setTimeout(() => this.#rewake(address), 1000).unref?.(); return; }
|
|
87
|
+
this.running.add(address);
|
|
97
88
|
try {
|
|
98
89
|
const now = Date.now();
|
|
99
|
-
const due = this.#pending(
|
|
90
|
+
const due = this.#pending(address).filter((id) => (this.store.wakeState(address, id).next_at_ms ?? 0) <= now);
|
|
100
91
|
if (!due.length) return;
|
|
101
92
|
// a session in a daemon pane whose mail is typed there: a hooked one (its hook shows it the rest), and one with no
|
|
102
93
|
// hook and no relay (Grok), whose mail that found its composer busy waits here until it is free
|
|
@@ -104,7 +95,7 @@ export class MailCarrier {
|
|
|
104
95
|
if ((session?.door === 'hook' || session?.door === 'pane') && session.pane) {
|
|
105
96
|
for (const id of due) {
|
|
106
97
|
const stored = this.store.findIn(address, id);
|
|
107
|
-
const envelope = stored ?
|
|
98
|
+
const envelope = stored ? this.store.body(stored) : null;
|
|
108
99
|
let carried;
|
|
109
100
|
if (!envelope) carried = { kind: 'filed' };
|
|
110
101
|
else {
|
|
@@ -114,8 +105,9 @@ export class MailCarrier {
|
|
|
114
105
|
carried = typed?.delivered ? { kind: 'delivered' } : typed?.queued ? { kind: 'filed' } : { kind: 'later', reason: 'its pane was not at its prompt' };
|
|
115
106
|
} catch (error) { carried = { kind: 'later', reason: error.message }; }
|
|
116
107
|
}
|
|
108
|
+
if (this.stopped) return;
|
|
117
109
|
this.#record({ to: address, id, path: `${session.door}-pane`, carried });
|
|
118
|
-
this.#settle(
|
|
110
|
+
this.#settle(address, id, carried);
|
|
119
111
|
}
|
|
120
112
|
return;
|
|
121
113
|
}
|
|
@@ -135,41 +127,34 @@ export class MailCarrier {
|
|
|
135
127
|
carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
|
|
136
128
|
} catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
|
|
137
129
|
}
|
|
130
|
+
if (this.stopped) return;
|
|
138
131
|
this.#record({ to: address, id, path: 'door', carried });
|
|
139
|
-
this.#settle(
|
|
132
|
+
this.#settle(address, id, carried);
|
|
140
133
|
}
|
|
141
134
|
} finally {
|
|
142
|
-
this.running.delete(
|
|
143
|
-
this.#rewake(
|
|
135
|
+
this.running.delete(address);
|
|
136
|
+
this.#rewake(address);
|
|
144
137
|
}
|
|
145
138
|
}
|
|
146
139
|
|
|
147
140
|
/** A mailbox's wakes followed again (each one scheduled at its own due time). */
|
|
148
|
-
#rewake(
|
|
149
|
-
let ids = [];
|
|
150
|
-
try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { return; }
|
|
151
|
-
for (const id of ids) this.#wake(folder, id);
|
|
152
|
-
}
|
|
141
|
+
#rewake(address) { if (this.stopped) return; for (const id of this.store.wakes(address)) this.#wake(address, id); }
|
|
153
142
|
|
|
154
143
|
/** A mailbox's wakes whose message is still filed; a wake whose message is gone is let go (mailbox.rs `pending_wakes`). */
|
|
155
|
-
#pending(
|
|
156
|
-
|
|
157
|
-
try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { return []; }
|
|
158
|
-
return ids.filter((id) => {
|
|
144
|
+
#pending(address) {
|
|
145
|
+
return this.store.wakes(address).filter((id) => {
|
|
159
146
|
if (this.store.findIn(address, id)) return true;
|
|
160
|
-
|
|
147
|
+
this.store.removeWake(address, id);
|
|
161
148
|
return false;
|
|
162
149
|
});
|
|
163
150
|
}
|
|
164
151
|
|
|
165
152
|
// ---- another machine's mail ------------------------------------------------------------------------------------
|
|
166
153
|
|
|
167
|
-
#machineWaitPath(machine) { return join(this.mailRoot, 'machines', `${safeMachine(machine)}.json`); }
|
|
168
|
-
|
|
169
154
|
/** A machine's mail carried at its next due time: its wait's next probe when it is offline, else its earliest wake. */
|
|
170
155
|
#scheduleMachine(machine) {
|
|
171
|
-
if (this.machines.has(machine) || this.carrying.has(machine)) return;
|
|
172
|
-
const wait =
|
|
156
|
+
if (this.stopped || this.machines.has(machine) || this.carrying.has(machine)) return;
|
|
157
|
+
const wait = this.store.machineWait(machine);
|
|
173
158
|
let at = wait?.next_probe_ms ?? null;
|
|
174
159
|
if (at == null) {
|
|
175
160
|
at = Infinity;
|
|
@@ -182,15 +167,14 @@ export class MailCarrier {
|
|
|
182
167
|
}
|
|
183
168
|
|
|
184
169
|
/** Every wake filed here for `machine` (as their events named them), with when its message was filed, oldest first; a
|
|
185
|
-
* wake whose
|
|
170
|
+
* wake whose message is gone, or that is gone itself, is let go. */
|
|
186
171
|
#remoteWakes(machine) {
|
|
187
172
|
const items = [];
|
|
188
173
|
for (const [key, item] of this.remote.get(machine) ?? []) {
|
|
189
|
-
|
|
190
|
-
try { readFileSync(join(this.mailRoot, item.folder, 'wake', item.id)); } catch { present = false; }
|
|
174
|
+
const present = this.store.hasWake(item.address, item.id);
|
|
191
175
|
const found = present ? this.store.findIn(item.address, item.id) : null;
|
|
192
|
-
if (!found) { this.remote.get(machine).delete(key); if (present)
|
|
193
|
-
const envelope =
|
|
176
|
+
if (!found) { this.remote.get(machine).delete(key); if (present) this.store.removeWake(item.address, item.id); continue; }
|
|
177
|
+
const envelope = this.store.body(found);
|
|
194
178
|
items.push({ ...item, filed: Number(envelope?.created_at_ms) || 0 });
|
|
195
179
|
}
|
|
196
180
|
return items.sort((a, b) => a.filed - b.filed);
|
|
@@ -199,17 +183,18 @@ export class MailCarrier {
|
|
|
199
183
|
/** One machine's mail carried (mail_watch.rs `tick`, its remote half): its oldest due message probes the machine, and
|
|
200
184
|
* when it answers, the rest follow in this pass. */
|
|
201
185
|
async #carry(machine) {
|
|
186
|
+
if (this.stopped) return;
|
|
202
187
|
this.carrying.add(machine);
|
|
203
188
|
try {
|
|
204
189
|
const now = Date.now();
|
|
205
190
|
const items = this.#remoteWakes(machine);
|
|
206
191
|
if (!items.length) return;
|
|
207
|
-
const wait =
|
|
192
|
+
const wait = this.store.machineWait(machine);
|
|
208
193
|
if (wait) {
|
|
209
194
|
// a machine away longer than the ceiling is not coming back for this mail: it expires, saying so
|
|
210
195
|
if (now - (wait.offline_since_ms ?? now) >= OFFLINE_CEILING_MS) {
|
|
211
196
|
const reason = `${machine} has been offline for ${OFFLINE_CEILING_MS / 86_400_000} days (since epoch ms ${wait.offline_since_ms}): ${wait.last_error ?? 'not linked'}`;
|
|
212
|
-
for (const item of items) this.#expire(item.
|
|
197
|
+
for (const item of items) this.#expire(item.address, item.id, this.store.wakeState(item.address, item.id).attempts ?? 0, reason);
|
|
213
198
|
this.#setMachineWait(machine, null);
|
|
214
199
|
this.#line({ t: now, machine, outcome: 'expired', messages: items.length, reason });
|
|
215
200
|
return;
|
|
@@ -225,11 +210,12 @@ export class MailCarrier {
|
|
|
225
210
|
if (!found || (found.state === 'read' && this.store.claimOf(item.address, item.id)?.recipient === true)) carried = { kind: 'filed' };
|
|
226
211
|
else {
|
|
227
212
|
called = true;
|
|
228
|
-
carried = await this.#carryOne(machine, item.address,
|
|
213
|
+
carried = await this.#carryOne(machine, item.address, this.store.body(found));
|
|
229
214
|
}
|
|
215
|
+
if (this.stopped) return;
|
|
230
216
|
if (carried.kind === 'offline') { offlineAnswer = carried.reason; break; }
|
|
231
217
|
carriedHere++;
|
|
232
|
-
this.#settle(item.
|
|
218
|
+
this.#settle(item.address, item.id, carried);
|
|
233
219
|
}
|
|
234
220
|
if (called) this.#line({ t: now, machine, outcome: offlineAnswer ? 'offline' : 'carried', waiting: items.length, carried: carriedHere, reason: offlineAnswer });
|
|
235
221
|
if (offlineAnswer) this.#setMachineWait(machine, { machine, offline_since_ms: wait?.offline_since_ms ?? now, next_probe_ms: now + LAST_RETRY_MS, probes: (wait?.probes ?? 0) + 1, last_error: offlineAnswer });
|
|
@@ -256,10 +242,7 @@ export class MailCarrier {
|
|
|
256
242
|
}
|
|
257
243
|
|
|
258
244
|
#setMachineWait(machine, wait) {
|
|
259
|
-
|
|
260
|
-
if (!wait) { try { rmSync(path, { force: true }); } catch { /* gone */ } return; }
|
|
261
|
-
try { mkdirSync(join(this.mailRoot, 'machines'), { recursive: true }); writeFileSync(`${path}.tmp`, JSON.stringify(wait)); renameSync(`${path}.tmp`, path); }
|
|
262
|
-
catch (error) { this.log(`carrier: ${machine}'s wait was not kept: ${error.message}`); }
|
|
245
|
+
try { this.store.setMachineWait(machine, wait); } catch (error) { this.log(`carrier: ${machine}'s wait was not kept: ${error.message}`); }
|
|
263
246
|
}
|
|
264
247
|
|
|
265
248
|
// ---- what waits, and what expired (`message waiting`, `message expired`) -----------------------------------------
|
|
@@ -268,21 +251,16 @@ export class MailCarrier {
|
|
|
268
251
|
* filed first (mailbox.rs `waiting_mail`, field for field). */
|
|
269
252
|
waiting() {
|
|
270
253
|
const all = [];
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
for (const folder of folders) {
|
|
274
|
-
let ids = [];
|
|
275
|
-
try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { continue; }
|
|
276
|
-
if (!ids.length) continue;
|
|
277
|
-
const address = this.#address(folder);
|
|
278
|
-
if (!address) continue;
|
|
254
|
+
const waits = new Map();
|
|
255
|
+
for (const { address, id } of this.store.allWakes()) {
|
|
279
256
|
const machine = address.split(':')[1];
|
|
280
|
-
|
|
281
|
-
|
|
257
|
+
if (machine !== this.machine && !waits.has(machine)) waits.set(machine, this.store.machineWait(machine));
|
|
258
|
+
const wait = machine !== this.machine ? waits.get(machine) : null;
|
|
259
|
+
{
|
|
282
260
|
const state = this.store.wakeState(address, id);
|
|
283
261
|
if (!wait && !(state.attempts > 0) && state.last_error == null) continue;
|
|
284
262
|
const found = this.store.findIn(address, id);
|
|
285
|
-
const envelope = found ?
|
|
263
|
+
const envelope = found ? this.store.body(found) : null;
|
|
286
264
|
if (!envelope) continue;
|
|
287
265
|
all.push({ id, to: address, from: envelope.from ?? '', subject: envelope.subject ?? null, filed_at_ms: Number(envelope.created_at_ms) || 0,
|
|
288
266
|
waiting_for: wait ? wait.machine : null, since_ms: wait?.offline_since_ms ?? state.failing_since_ms ?? (Number(envelope.created_at_ms) || 0),
|
|
@@ -293,23 +271,12 @@ export class MailCarrier {
|
|
|
293
271
|
}
|
|
294
272
|
|
|
295
273
|
/** Every wake that expired here, oldest first (mailbox.rs `expired_wakes`). */
|
|
296
|
-
expired() {
|
|
297
|
-
const all = [];
|
|
298
|
-
let folders = [];
|
|
299
|
-
try { folders = readdirSync(this.mailRoot); } catch { return all; }
|
|
300
|
-
for (const folder of folders) {
|
|
301
|
-
let names = [];
|
|
302
|
-
try { names = readdirSync(join(this.mailRoot, folder, 'expired')); } catch { continue; }
|
|
303
|
-
for (const name of names) { const expiry = readJson(join(this.mailRoot, folder, 'expired', name)); if (expiry?.id) all.push(expiry); }
|
|
304
|
-
}
|
|
305
|
-
return all.sort((a, b) => (a.expired_at_ms ?? 0) - (b.expired_at_ms ?? 0));
|
|
306
|
-
}
|
|
274
|
+
expired() { return this.store.expired(); }
|
|
307
275
|
|
|
308
276
|
// ---- how an attempt went ---------------------------------------------------------------------------------------
|
|
309
277
|
|
|
310
278
|
/** Record how a wake went: done, waiting for its next attempt, or expired at its bound (mail_watch.rs `settle`). */
|
|
311
|
-
#settle(
|
|
312
|
-
const wakePath = join(this.mailRoot, folder, 'wake', id);
|
|
279
|
+
#settle(address, id, carried) {
|
|
313
280
|
if (carried.kind === 'delivered' || carried.kind === 'filed') {
|
|
314
281
|
if (carried.kind === 'delivered') {
|
|
315
282
|
// the carrier's own hand-off record: delivered here by its door, or carried to its machine
|
|
@@ -317,7 +284,7 @@ export class MailCarrier {
|
|
|
317
284
|
const found = this.store.findIn(address, id);
|
|
318
285
|
if (found && found.state !== 'read') { try { this.store.markRead(address, found.path); } catch { /* read already */ } }
|
|
319
286
|
}
|
|
320
|
-
|
|
287
|
+
this.store.removeWake(address, id);
|
|
321
288
|
return;
|
|
322
289
|
}
|
|
323
290
|
if (carried.kind === 'offline') return;
|
|
@@ -326,29 +293,23 @@ export class MailCarrier {
|
|
|
326
293
|
const attempts = (state.attempts ?? 0) + 1;
|
|
327
294
|
const since = state.failing_since_ms ?? now;
|
|
328
295
|
const lifetime = address.split(':')[1] === this.machine ? LOCAL_WAKE_LIFETIME_MS : REMOTE_WAKE_LIFETIME_MS;
|
|
329
|
-
if (attempts >= WAKE_ATTEMPTS && now - since >= lifetime) { this.#expire(
|
|
296
|
+
if (attempts >= WAKE_ATTEMPTS && now - since >= lifetime) { this.#expire(address, id, attempts, carried.reason); return; }
|
|
330
297
|
const wait = Math.min(LAST_RETRY_MS, FIRST_RETRY_MS * 2 ** Math.min(attempts - 1, 16));
|
|
331
298
|
this.store.setWakeState(address, id, { ...state, attempts, failing_since_ms: since, next_at_ms: now + wait, last_error: carried.reason });
|
|
332
299
|
}
|
|
333
300
|
|
|
334
|
-
/** End a wake undelivered: the message stays filed and unread, and the expiry is recorded
|
|
335
|
-
#expire(
|
|
301
|
+
/** End a wake undelivered: the message stays filed and unread, and the expiry is recorded. */
|
|
302
|
+
#expire(address, id, attempts, reason) {
|
|
336
303
|
const found = this.store.findIn(address, id);
|
|
337
|
-
const envelope = found ?
|
|
304
|
+
const envelope = found ? this.store.body(found) : null;
|
|
338
305
|
const expiry = { id, to: address, from: envelope?.from ?? '', subject: envelope?.subject ?? null, filed_at_ms: Number(envelope?.created_at_ms) || 0, expired_at_ms: Date.now(), attempts, reason };
|
|
339
|
-
try {
|
|
340
|
-
mkdirSync(join(this.mailRoot, folder, 'expired'), { recursive: true });
|
|
341
|
-
const temporary = join(this.mailRoot, folder, 'tmp', `expired.${id}`);
|
|
342
|
-
writeFileSync(temporary, JSON.stringify(expiry));
|
|
343
|
-
renameSync(temporary, join(this.mailRoot, folder, 'expired', `${id}.json`));
|
|
344
|
-
rmSync(join(this.mailRoot, folder, 'wake', id), { force: true });
|
|
345
|
-
} catch (error) { this.log(`carrier: the expiry of ${id} was not recorded: ${error.message}`); }
|
|
306
|
+
try { this.store.expire(address, id, expiry); } catch (error) { this.log(`carrier: the expiry of ${id} was not recorded: ${error.message}`); }
|
|
346
307
|
}
|
|
347
308
|
|
|
348
|
-
/** One local delivery attempt in the carrier's record
|
|
309
|
+
/** One local delivery attempt in the carrier's record. */
|
|
349
310
|
#record({ to, id, path, carried }) {
|
|
350
311
|
this.#line({ t: Date.now(), to, id, path, outcome: carried.kind, reason: carried.reason ?? null });
|
|
351
312
|
}
|
|
352
313
|
|
|
353
|
-
#line(line) {
|
|
314
|
+
#line(line) { this.store.carrierLine(line); }
|
|
354
315
|
}
|