@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
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// The only reader of the retired layout. It runs before the daemon exposes mail
|
|
2
|
+
// operations, imports idempotently, and records completion only after every input
|
|
3
|
+
// was visited. Failed imports can resume; ordinary reads never consult this tree.
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { basename, join, relative } from 'node:path';
|
|
8
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const alive = (pid) => {
|
|
11
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
12
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
13
|
+
};
|
|
14
|
+
const headerOf = ({ body, ...header }) => header;
|
|
15
|
+
const validId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id);
|
|
16
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
const isAddress = (value) => typeof value === 'string' && /^sc:[^:\s]+:[^:\s]+:\S+$/.test(value);
|
|
18
|
+
const validRecordName = (value) => typeof value === 'string' && /^[A-Za-z0-9._-]+$/.test(value);
|
|
19
|
+
|
|
20
|
+
export async function importMaildir(database, root, { log = () => {}, legacyRoot = root } = {}) {
|
|
21
|
+
const began = performance.now();
|
|
22
|
+
const work = { source_reads: 0, source_bytes: 0, committed_batches: 0, max_queued_operations: 0, elapsed_ms: 0 };
|
|
23
|
+
const complete = database.metadata('maildir_import');
|
|
24
|
+
if (complete?.complete === true) return complete;
|
|
25
|
+
let roots;
|
|
26
|
+
try { roots = await readdir(root, { withFileTypes: true }); }
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== 'ENOENT') throw error;
|
|
29
|
+
work.elapsed_ms = Math.round(performance.now() - began);
|
|
30
|
+
const result = { complete: true, source: root, at_ms: Date.now(), messages: 0, records: 0, skipped: 0, work };
|
|
31
|
+
database.setMetadata('maildir_import', result);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
const result = { complete: false, source: root, at_ms: Date.now(), messages: 0, records: 0, skipped: 0, work };
|
|
35
|
+
let pending = [];
|
|
36
|
+
let flushing = false;
|
|
37
|
+
const flush = () => {
|
|
38
|
+
if (!pending.length) return;
|
|
39
|
+
const writes = pending;
|
|
40
|
+
pending = [];
|
|
41
|
+
flushing = true;
|
|
42
|
+
try {
|
|
43
|
+
database.transaction(() => { for (const write of writes) write(); });
|
|
44
|
+
work.committed_batches++;
|
|
45
|
+
work.max_queued_operations = Math.max(work.max_queued_operations, writes.length);
|
|
46
|
+
}
|
|
47
|
+
finally { flushing = false; }
|
|
48
|
+
};
|
|
49
|
+
// Fingerprints, diagnostics and their imported rows share bounded commits.
|
|
50
|
+
// A diagnostic discovered while applying a batch belongs to that transaction.
|
|
51
|
+
const queue = (write) => {
|
|
52
|
+
if (flushing) { write(); return; }
|
|
53
|
+
pending.push(write);
|
|
54
|
+
if (pending.length >= 256) flush();
|
|
55
|
+
};
|
|
56
|
+
const errors = database.statement('INSERT INTO mail_import_errors(source, mailbox, detail, critical) VALUES (?, ?, ?, ?) ON CONFLICT(source) DO UPDATE SET mailbox = excluded.mailbox, detail = excluded.detail, critical = excluded.critical');
|
|
57
|
+
const failed = (path, error, mailbox = null) => {
|
|
58
|
+
const source = relative(root, path);
|
|
59
|
+
const parts = source.split(/[\\/]/);
|
|
60
|
+
const critical = ['agents', 'threads', 'native-questions', 'native-launches', 'relays', 'machines', 'sent'].includes(parts[0])
|
|
61
|
+
|| ['claims', 'subscriptions', 'wake'].includes(parts[1])
|
|
62
|
+
|| parts[0] === 'names.json' || parts[0] === 'relay-endpoint.json'
|
|
63
|
+
|| (parts[0] === 'terminal-delivery' && parts.at(-1) === 'queue.json');
|
|
64
|
+
queue(() => errors.run(source, mailbox, String(error.message ?? error), critical ? 1 : 0));
|
|
65
|
+
result.skipped++;
|
|
66
|
+
log(`mail import: retained unreadable source ${source}: ${error.message ?? error}`);
|
|
67
|
+
};
|
|
68
|
+
const sources = database.statement('INSERT INTO mail_import_sources(source, sha256) VALUES (?, ?) ON CONFLICT(source) DO UPDATE SET sha256 = excluded.sha256');
|
|
69
|
+
const readSource = async (path, encoding = 'utf8') => {
|
|
70
|
+
if (!(await lstat(path)).isFile()) throw new Error('record is not a regular file');
|
|
71
|
+
const bytes = await readFile(path);
|
|
72
|
+
work.source_reads++; work.source_bytes += bytes.length;
|
|
73
|
+
const source = relative(root, path), sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
74
|
+
queue(() => sources.run(source, sha256));
|
|
75
|
+
return encoding ? new TextDecoder('utf-8', { fatal: true }).decode(bytes) : bytes;
|
|
76
|
+
};
|
|
77
|
+
const json = async (path, mailbox = null, valid = record) => {
|
|
78
|
+
try {
|
|
79
|
+
const value = JSON.parse(await readSource(path));
|
|
80
|
+
if (!valid(value)) throw new Error('record has an unsupported JSON shape');
|
|
81
|
+
queue(() => database.statement('DELETE FROM mail_import_errors WHERE source = ?').run(relative(root, path)));
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
catch (error) { failed(path, error, mailbox); return null; }
|
|
85
|
+
};
|
|
86
|
+
const entries = async (dir) => {
|
|
87
|
+
try {
|
|
88
|
+
if (!(await lstat(dir)).isDirectory()) throw new Error(`mail import refuses a linked or non-directory record group: ${dir}`);
|
|
89
|
+
return await readdir(dir, { withFileTypes: true });
|
|
90
|
+
}
|
|
91
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
92
|
+
};
|
|
93
|
+
const recordDirectory = async (dir, collection, { nested = false, text = false } = {}) => {
|
|
94
|
+
for (const entry of await entries(dir)) {
|
|
95
|
+
const path = join(dir, entry.name);
|
|
96
|
+
if (entry.isDirectory() && nested) {
|
|
97
|
+
for (const child of await entries(path)) {
|
|
98
|
+
if (!child.isFile()) { failed(join(path, child.name), 'not a regular record'); continue; }
|
|
99
|
+
const value = await json(join(path, child.name));
|
|
100
|
+
if (value !== null) queue(() => { database.putRecord(collection, `${entry.name}/${child.name}`, value, { create: true }); result.records++; });
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!entry.isFile()) { if (!entry.isDirectory()) failed(path, 'not a regular record'); continue; }
|
|
105
|
+
if (/\.tmp(?:\.|$)|\.lock$/.test(entry.name)) continue;
|
|
106
|
+
let value;
|
|
107
|
+
if (text) {
|
|
108
|
+
try {
|
|
109
|
+
value = (await readSource(path)).trim();
|
|
110
|
+
if (!value || !Number.isSafeInteger(Number(value)) || Number(value) < 0) throw new Error('agent cursor is not a nonnegative integer timestamp');
|
|
111
|
+
}
|
|
112
|
+
catch (error) { failed(path, error); continue; }
|
|
113
|
+
} else value = await json(path, null, collection === 'native_questions' && entry.name.endsWith('.delivered')
|
|
114
|
+
? Number.isSafeInteger : record);
|
|
115
|
+
if (value !== null) queue(() => { database.putRecord(collection, entry.name, value, { create: true }); result.records++; });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Pass 1 imports envelopes before their claims, subscriptions and deliveries,
|
|
120
|
+
// so the database's foreign keys enforce the same message ownership throughout.
|
|
121
|
+
const boxes = [];
|
|
122
|
+
for (const entry of roots) {
|
|
123
|
+
if (!entry.isDirectory()) continue;
|
|
124
|
+
const dir = join(root, entry.name);
|
|
125
|
+
let address;
|
|
126
|
+
try { address = (await readSource(join(dir, 'address'))).trim(); }
|
|
127
|
+
catch (error) { if (error.code !== 'ENOENT') failed(join(dir, 'address'), error); continue; }
|
|
128
|
+
if (!/^sc:[^:\s]+:[^:\s]+:\S+$/.test(address)) { failed(join(dir, 'address'), 'invalid mailbox address'); continue; }
|
|
129
|
+
queue(() => database.statement('INSERT INTO mailboxes(address, folder) VALUES (?, ?) ON CONFLICT(address) DO NOTHING').run(address, entry.name));
|
|
130
|
+
boxes.push({ address, dir });
|
|
131
|
+
for (const part of ['new', 'claimed', 'cur']) {
|
|
132
|
+
for (const file of await entries(join(dir, part))) {
|
|
133
|
+
if (!file.isFile() || !file.name.endsWith('.json')) continue;
|
|
134
|
+
const path = join(dir, part, file.name);
|
|
135
|
+
const envelope = await json(path, address);
|
|
136
|
+
if (!envelope) continue;
|
|
137
|
+
if (!validId(envelope.id) || !Number.isSafeInteger(envelope.created_at_ms) || typeof envelope.body !== 'string') {
|
|
138
|
+
failed(path, 'invalid envelope identity, creation time or body', address); continue;
|
|
139
|
+
}
|
|
140
|
+
const pid = part === 'claimed' ? Number(file.name.split('.')[0]) : null;
|
|
141
|
+
const state = part === 'claimed' && !alive(pid) ? 'new' : part;
|
|
142
|
+
const token = state === 'claimed' ? randomBytes(16).toString('hex') : null;
|
|
143
|
+
const line = Array.from(envelope.body.split('\n', 1)[0]).slice(0, 400).join('');
|
|
144
|
+
queue(() => {
|
|
145
|
+
const existing = database.statement('SELECT header, body FROM messages WHERE mailbox = ? AND id = ?').get(address, envelope.id);
|
|
146
|
+
if (existing) {
|
|
147
|
+
// Retain an inconsistent duplicate for repair; never overwrite a
|
|
148
|
+
// previously imported original or choose from its newer mtime.
|
|
149
|
+
if (existing.header !== JSON.stringify(headerOf(envelope)) || existing.body !== envelope.body) failed(path, 'conflicting duplicate message id', address);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
database.statement(`INSERT INTO messages(mailbox, id, thread, sender, kind, subject, created_at_ms, state, header, body, first_line, read_at_ms, claim_pid, claim_token)
|
|
153
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
154
|
+
.run(address, envelope.id, envelope.thread ?? envelope.id, envelope.from ?? '', envelope.kind ?? 'peer', envelope.subject ?? null,
|
|
155
|
+
envelope.created_at_ms, state, JSON.stringify(headerOf(envelope)), envelope.body, line, state === 'cur' ? envelope.created_at_ms : null,
|
|
156
|
+
state === 'claimed' ? pid : null, token);
|
|
157
|
+
result.messages++;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
flush();
|
|
163
|
+
|
|
164
|
+
for (const { address, dir } of boxes) {
|
|
165
|
+
const present = (id) => Boolean(database.statement('SELECT 1 FROM messages WHERE mailbox = ? AND id = ?').get(address, id));
|
|
166
|
+
for (const entry of await entries(join(dir, 'claims'))) {
|
|
167
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
168
|
+
const path = join(dir, 'claims', entry.name), id = basename(entry.name, '.json');
|
|
169
|
+
const claim = await json(path, address);
|
|
170
|
+
if (claim && !present(id)) { failed(path, 'claim has no imported message', address); continue; }
|
|
171
|
+
if (claim) queue(() => database.statement('INSERT INTO claims(mailbox, message, record) VALUES (?, ?, ?) ON CONFLICT(mailbox, message) DO NOTHING').run(address, id, JSON.stringify(claim)));
|
|
172
|
+
}
|
|
173
|
+
for (const entry of await entries(join(dir, 'subscriptions'))) {
|
|
174
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
175
|
+
const path = join(dir, 'subscriptions', entry.name), subscription = await json(path, address, (value) => record(value)
|
|
176
|
+
&& validId(value.message_id) && isAddress(value.subscriber)
|
|
177
|
+
&& (value.created_at_ms == null || Number.isSafeInteger(value.created_at_ms)));
|
|
178
|
+
if (!subscription?.message_id || !present(subscription.message_id)) { if (subscription) failed(path, 'subscription has no imported message', address); continue; }
|
|
179
|
+
queue(() => database.statement('INSERT INTO subscriptions(mailbox, message, record) VALUES (?, ?, ?) ON CONFLICT(mailbox, message) DO NOTHING').run(address, subscription.message_id, JSON.stringify(subscription)));
|
|
180
|
+
}
|
|
181
|
+
for (const entry of await entries(join(dir, 'wake'))) {
|
|
182
|
+
if (!entry.isFile()) continue;
|
|
183
|
+
const path = join(dir, 'wake', entry.name);
|
|
184
|
+
if (!present(entry.name)) { failed(path, 'wake has no imported message', address); continue; }
|
|
185
|
+
let state;
|
|
186
|
+
try {
|
|
187
|
+
const text = await readSource(path);
|
|
188
|
+
state = text.trim() ? JSON.parse(text) : {};
|
|
189
|
+
} catch (error) { failed(path, error, address); continue; }
|
|
190
|
+
if (!record(state) || !Number.isSafeInteger(state.next_at_ms ?? 0) || !Number.isSafeInteger(state.attempts ?? 0)) {
|
|
191
|
+
failed(path, 'invalid wake deadline or attempts', address); continue;
|
|
192
|
+
}
|
|
193
|
+
queue(() => database.statement('INSERT INTO deliveries(mailbox, message, due_at_ms, attempts, record) VALUES (?, ?, ?, ?, ?) ON CONFLICT(mailbox, message) DO NOTHING')
|
|
194
|
+
.run(address, entry.name, state.next_at_ms ?? 0, state.attempts ?? 0, JSON.stringify(state)));
|
|
195
|
+
}
|
|
196
|
+
for (const entry of await entries(join(dir, 'expired'))) {
|
|
197
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
198
|
+
const expiry = await json(join(dir, 'expired', entry.name), address, (value) => record(value) && validId(value.id));
|
|
199
|
+
if (expiry?.id) queue(() => { database.putRecord('expired', `${address}/${expiry.id}`, expiry, { create: true }); result.records++; });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const agents = join(root, 'agents');
|
|
204
|
+
for (const entry of await entries(agents)) {
|
|
205
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
206
|
+
const agent = await json(join(agents, entry.name), null, (value) => record(value)
|
|
207
|
+
&& validRecordName(value.name) && value.name === basename(entry.name, '.json') && isAddress(value.main_session)
|
|
208
|
+
&& (value.folder == null || typeof value.folder === 'string')
|
|
209
|
+
&& (value.harness == null || typeof value.harness === 'string')
|
|
210
|
+
&& (value.idle_minutes == null || Number.isFinite(value.idle_minutes)));
|
|
211
|
+
if (agent) queue(() => { database.putRecord('agents', basename(entry.name, '.json'), agent, { create: true }); result.records++; });
|
|
212
|
+
}
|
|
213
|
+
for (const name of ['typed', 'resumed']) await recordDirectory(join(agents, name), `agent_${name}`, { text: true });
|
|
214
|
+
try {
|
|
215
|
+
const name = (await readSource(join(agents, 'owners-account-manager'))).trim();
|
|
216
|
+
if (!validRecordName(name)) throw new Error('owner account-manager selector is not an agent name');
|
|
217
|
+
queue(() => database.putRecord('agent_settings', 'owners-account-manager', name, { create: true }));
|
|
218
|
+
}
|
|
219
|
+
catch (error) { if (error.code !== 'ENOENT') failed(join(agents, 'owners-account-manager'), error); }
|
|
220
|
+
for (const entry of await entries(join(root, 'threads'))) {
|
|
221
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
222
|
+
const thread = await json(join(root, 'threads', entry.name), null, (value) => record(value)
|
|
223
|
+
&& validRecordName(value.id) && value.id === basename(entry.name, '.json') && validRecordName(value.agent) && isAddress(value.holder)
|
|
224
|
+
&& Number.isSafeInteger(value.created_at_ms) && Array.isArray(value.participants)
|
|
225
|
+
&& value.participants.every((participant) => record(participant) && isAddress(participant.address) && ['to', 'cc'].includes(participant.role))
|
|
226
|
+
&& (value.markers == null || record(value.markers)));
|
|
227
|
+
if (thread) queue(() => { database.putRecord('threads', basename(entry.name, '.json'), thread, { create: true }); result.records++; });
|
|
228
|
+
}
|
|
229
|
+
await recordDirectory(join(root, 'native-questions'), 'native_questions');
|
|
230
|
+
await recordDirectory(join(root, 'native-launches'), 'native_launches');
|
|
231
|
+
await recordDirectory(join(root, 'machines'), 'machine_waits');
|
|
232
|
+
const endpointFile = join(root, 'relay-endpoint.json');
|
|
233
|
+
try { await lstat(endpointFile); const endpoint = await json(endpointFile); if (endpoint) queue(() => database.putRecord('relay_endpoint', 'port', endpoint, { create: true })); }
|
|
234
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
235
|
+
// Relay state is imported as rows. The runtime's settings are application
|
|
236
|
+
// configuration, copied to its new runtime home when that runtime is adopted.
|
|
237
|
+
for (const relay of await entries(join(root, 'relays'))) {
|
|
238
|
+
if (!relay.isDirectory()) continue;
|
|
239
|
+
for (const entry of await entries(join(root, 'relays', relay.name))) {
|
|
240
|
+
if (!entry.isFile() || !/^(?:relay|queue|receipt|sent|settings)\.json$|^turn\.txt$/.test(entry.name)) continue;
|
|
241
|
+
const path = join(root, 'relays', relay.name, entry.name);
|
|
242
|
+
let value;
|
|
243
|
+
if (entry.name === 'turn.txt') {
|
|
244
|
+
try { value = await readSource(path); } catch (error) { failed(path, error); continue; }
|
|
245
|
+
} else value = await json(path);
|
|
246
|
+
const at = (await lstat(path)).mtimeMs;
|
|
247
|
+
if (entry.name === 'relay.json' && value && typeof value === 'object') value.runtime_directory ??= join(legacyRoot, 'relays', relay.name);
|
|
248
|
+
if (value !== null) queue(() => { database.putRecord('relays', `${relay.name}/${entry.name}`, value, { create: true, at: Math.trunc(at) }); result.records++; });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const namesFile = join(root, 'names.json');
|
|
252
|
+
try {
|
|
253
|
+
const names = JSON.parse(await readSource(namesFile));
|
|
254
|
+
if (!record(names)) throw new Error('names ledger is not a JSON object');
|
|
255
|
+
for (const [name, value] of Object.entries(names)) queue(() => database.putRecord('names', name, value, { create: true }));
|
|
256
|
+
} catch (error) { if (error.code !== 'ENOENT') failed(namesFile, error); }
|
|
257
|
+
|
|
258
|
+
const lines = async (path, accept) => {
|
|
259
|
+
try {
|
|
260
|
+
const stat = await lstat(path);
|
|
261
|
+
if (!stat.isFile()) throw new Error(`mail import requires a regular record: ${path}`);
|
|
262
|
+
const key = `maildir_lines:${relative(root, path)}`;
|
|
263
|
+
const progress = database.metadata(key);
|
|
264
|
+
const stamp = { size: stat.size, mtime: stat.mtimeMs, inode: stat.ino };
|
|
265
|
+
if (progress && Object.keys(stamp).some((field) => progress[field] !== stamp[field])) {
|
|
266
|
+
throw new Error(`mail import source changed during an interrupted import: ${path}`);
|
|
267
|
+
}
|
|
268
|
+
let number = 0;
|
|
269
|
+
const hash = createHash('sha256');
|
|
270
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
271
|
+
work.source_reads++;
|
|
272
|
+
const stream = createReadStream(path);
|
|
273
|
+
stream.on('data', (bytes) => {
|
|
274
|
+
hash.update(bytes);
|
|
275
|
+
work.source_bytes += bytes.length;
|
|
276
|
+
try { utf8.decode(bytes, { stream: true }); }
|
|
277
|
+
catch (error) { stream.destroy(error); }
|
|
278
|
+
});
|
|
279
|
+
const reader = createInterface({ input: stream, crlfDelay: Infinity });
|
|
280
|
+
stream.on('error', (error) => reader.close());
|
|
281
|
+
try {
|
|
282
|
+
for await (const line of reader) {
|
|
283
|
+
number++;
|
|
284
|
+
if (number <= (progress?.line ?? 0)) continue;
|
|
285
|
+
const position = number;
|
|
286
|
+
let entry = null;
|
|
287
|
+
if (line.trim()) {
|
|
288
|
+
try {
|
|
289
|
+
entry = JSON.parse(line);
|
|
290
|
+
if (!record(entry)) throw new Error('log entry is not a JSON object');
|
|
291
|
+
} catch (error) { entry = null; failed(path, error); }
|
|
292
|
+
}
|
|
293
|
+
queue(() => {
|
|
294
|
+
if (entry !== null) { try { accept(entry); } catch (error) { failed(path, error); } }
|
|
295
|
+
database.setMetadata(key, { ...stamp, line: position });
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (stream.errored) throw stream.errored;
|
|
299
|
+
utf8.decode();
|
|
300
|
+
const source = relative(root, path), sha256 = hash.digest('hex');
|
|
301
|
+
queue(() => sources.run(source, sha256));
|
|
302
|
+
flush();
|
|
303
|
+
} finally { reader.close(); stream.destroy(); }
|
|
304
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
305
|
+
};
|
|
306
|
+
for (const folder of await entries(join(root, 'terminal-delivery'))) {
|
|
307
|
+
if (!folder.isDirectory()) continue;
|
|
308
|
+
const dir = join(root, 'terminal-delivery', folder.name);
|
|
309
|
+
for (const entry of await entries(dir)) {
|
|
310
|
+
const path = join(dir, entry.name);
|
|
311
|
+
if (entry.name === 'queue.json') {
|
|
312
|
+
const state = await json(path);
|
|
313
|
+
if (state) queue(() => { database.putRecord('terminal_delivery', `${folder.name}/queue.json`, state, { create: true }); result.records++; });
|
|
314
|
+
} else if (entry.name === 'receipts.jsonl' || entry.name === 'attempts.jsonl') {
|
|
315
|
+
await lines(path, (record) => {
|
|
316
|
+
database.statement('INSERT INTO terminal_log(recipient, kind, record) VALUES (?, ?, ?)').run(folder.name, entry.name === 'receipts.jsonl' ? 'receipt' : 'attempt', JSON.stringify(record));
|
|
317
|
+
database.statement('DELETE FROM terminal_log WHERE sequence <= (SELECT sequence FROM terminal_log ORDER BY sequence DESC LIMIT 1 OFFSET ?)').run(database.retention.maxTerminalLogRows);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (const sender of await entries(join(root, 'sent'))) {
|
|
323
|
+
if (!sender.isDirectory()) continue;
|
|
324
|
+
const dir = join(root, 'sent', sender.name);
|
|
325
|
+
await lines(join(dir, 'log.jsonl'), (entry) => {
|
|
326
|
+
if (!validId(entry.id) || typeof entry.to !== 'string' || !Number.isSafeInteger(entry.at_ms)) throw new Error('invalid sent message record');
|
|
327
|
+
database.statement('INSERT INTO sent_messages(sender_key, id, recipient, thread, at_ms, record) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(sender_key, id, recipient) DO NOTHING')
|
|
328
|
+
.run(sender.name, entry.id, entry.to, entry.thread ?? null, entry.at_ms, JSON.stringify(entry));
|
|
329
|
+
});
|
|
330
|
+
for (const marker of await entries(dir)) {
|
|
331
|
+
if (!marker.isFile() || marker.name === 'log.jsonl' || !validId(marker.name)) continue;
|
|
332
|
+
const value = await readSource(join(dir, marker.name));
|
|
333
|
+
queue(() => database.putRecord('sent_receipts', `${sender.name}/${marker.name}`, value, { create: true }));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
await lines(join(root, 'carrier.jsonl'), (entry) => database.appendCarrier(entry));
|
|
337
|
+
flush();
|
|
338
|
+
result.messages = database.statement('SELECT count(*) AS count FROM messages').get().count;
|
|
339
|
+
result.records = database.statement('SELECT count(*) AS count FROM mail_records').get().count;
|
|
340
|
+
result.skipped = database.statement('SELECT count(*) AS count FROM mail_import_errors').get().count;
|
|
341
|
+
work.elapsed_ms = Math.round(performance.now() - began);
|
|
342
|
+
const blocked = database.statement('SELECT source FROM mail_import_errors WHERE critical = 1 ORDER BY source').all();
|
|
343
|
+
if (blocked.length) {
|
|
344
|
+
throw Object.assign(new Error(`mail migration cannot activate with ${blocked.length} unreadable operational records (${blocked.slice(0, 3).map(({ source }) => source).join(', ')}); original files are retained for repair`), { code: 'mail_import_incomplete' });
|
|
345
|
+
}
|
|
346
|
+
result.complete = true;
|
|
347
|
+
database.setMetadata('maildir_import', result);
|
|
348
|
+
log(`mail import complete: ${result.messages} envelopes, ${result.records} operational records, ${result.skipped} retained unreadable sources; ${work.source_reads} source reads, ${work.source_bytes} bytes, ${work.committed_batches} batches in ${work.elapsed_ms} ms`);
|
|
349
|
+
return result;
|
|
350
|
+
}
|
package/mail/questions.mjs
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
// Native Codex questions as mail (step 4c; mail_question.rs `poll`, deleted): every POLL_MS the daemon asks each Codex
|
|
2
2
|
// pane it launched (its native-launches seed: a socket of its own, its owner alive) for the threads it runs and the
|
|
3
3
|
// questions waiting on them, records each thread's creator (`native-questions/binding-<blake3(address)>.json`) and files
|
|
4
|
-
// each question once
|
|
5
|
-
// hands it over (or carries it to the creator's machine). A reply is answered by mail/send.mjs;
|
|
6
|
-
//
|
|
4
|
+
// each question once in the session's mailbox, read, and in its creator's, with a wake, so the carrier
|
|
5
|
+
// hands it over (or carries it to the creator's machine). A reply is answered by mail/send.mjs; question and binding
|
|
6
|
+
// records are kept by the same database as the mail. Native request
|
|
7
7
|
// identities decide which question a reply answers, never screen text or terminal keys.
|
|
8
|
-
import {
|
|
9
|
-
import { join } from 'node:path';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
10
9
|
import { blake3Hex } from './envelope.mjs';
|
|
11
10
|
import { envelope as makeEnvelope, makeAddress } from './compose.mjs';
|
|
12
11
|
|
|
13
12
|
const POLL_MS = 10_000;
|
|
14
13
|
|
|
15
|
-
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
16
14
|
const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
17
15
|
|
|
18
16
|
export class NativeQuestions {
|
|
19
17
|
/** `codexQuestions(request)` the in-process native question client (native-questions.mjs). */
|
|
20
18
|
constructor({ machine, mailRoot, store, codexQuestions, log = () => {} }) {
|
|
21
19
|
Object.assign(this, { machine, mailRoot, store, codexQuestions, log });
|
|
22
|
-
this.
|
|
20
|
+
this.stopped = false;
|
|
23
21
|
this.running = false;
|
|
24
22
|
}
|
|
25
23
|
|
|
@@ -30,26 +28,23 @@ export class NativeQuestions {
|
|
|
30
28
|
return this;
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
stop() { clearInterval(this.timer); }
|
|
31
|
+
stop() { this.stopped = true; clearInterval(this.timer); }
|
|
34
32
|
|
|
35
33
|
/** One pass over the launched Codex panes; a pass still running is not started twice. Answers how many panes were
|
|
36
34
|
* asked and how many questions they hold, or null when a pass was already running. */
|
|
37
35
|
async poll() {
|
|
38
|
-
if (this.running) return null;
|
|
36
|
+
if (this.stopped || this.running) return null;
|
|
39
37
|
this.running = true;
|
|
40
38
|
const counted = { panes: 0, questions: 0 };
|
|
41
39
|
try {
|
|
42
|
-
|
|
43
|
-
try { names = readdirSync(join(this.mailRoot, 'native-launches')); } catch { return counted; }
|
|
44
|
-
for (const name of names) {
|
|
40
|
+
for (const { key: name, value: seed } of this.store.records('native_launches')) {
|
|
45
41
|
if (!name.endsWith('.json') || name.endsWith('.session.json')) continue;
|
|
46
|
-
const seedPath = join(this.mailRoot, 'native-launches', name);
|
|
47
|
-
const seed = readJson(seedPath);
|
|
48
42
|
// only a socket belonging to a launched pane whose owner lives: a retained socket file is not a live endpoint,
|
|
49
43
|
// and a global shared app-server or a cwd is never guessed at
|
|
50
44
|
if (seed?.kind !== 'codex' || typeof seed.socketPath !== 'string' || !alive(seed.ownerPid) || !existsSync(seed.socketPath)) continue;
|
|
51
45
|
let reply;
|
|
52
46
|
try { reply = await this.codexQuestions({ socketPath: seed.socketPath, discover: true }); } catch { continue; }
|
|
47
|
+
if (this.stopped) return counted;
|
|
53
48
|
counted.panes++; counted.questions += reply?.questions?.length ?? 0;
|
|
54
49
|
for (const thread of reply?.threads ?? []) {
|
|
55
50
|
try { this.bind(seed, { session_id: thread.id, transcript_path: thread.path ?? null }); }
|
|
@@ -64,16 +59,9 @@ export class NativeQuestions {
|
|
|
64
59
|
} finally { this.running = false; }
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
// ----
|
|
62
|
+
// ---- native question records -----------------------------------------------------------------------------------
|
|
68
63
|
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
/** Written whole and synced, never over an existing file (mail_question.rs `create`). */
|
|
72
|
-
create(path, value) {
|
|
73
|
-
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
74
|
-
const fd = openSync(path, 'wx', 0o600);
|
|
75
|
-
try { writeSync(fd, JSON.stringify(value)); fsyncSync(fd); } finally { closeSync(fd); }
|
|
76
|
-
}
|
|
64
|
+
#bindingKey(address) { return `binding-${blake3Hex(address)}.json`; }
|
|
77
65
|
|
|
78
66
|
/** A session's binding: its creator recorded at its first launch, its pane, socket and transcript kept current. */
|
|
79
67
|
bind(seed, input, harness = 'codex') {
|
|
@@ -81,19 +69,18 @@ export class NativeQuestions {
|
|
|
81
69
|
if (!session) throw new Error('the thread has no session id');
|
|
82
70
|
const creator = typeof seed.by === 'string' ? seed.by : makeAddress(this.machine, 'operator', 'user');
|
|
83
71
|
const binding = { session, creator, pane: seed.pane ?? '', socket_path: seed.socketPath ?? null, transcript_path: input.transcript_path ?? null };
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
return existing;
|
|
72
|
+
const key = this.#bindingKey(session);
|
|
73
|
+
return this.store.transaction(() => {
|
|
74
|
+
if (this.store.putRecord('native_questions', key, binding, { create: true })) return binding;
|
|
75
|
+
const existing = this.store.record('native_questions', key);
|
|
76
|
+
if (!existing?.session || typeof existing.creator !== 'string') throw new Error(`its binding ${key} could not be read`);
|
|
77
|
+
if (existing.pane !== binding.pane || existing.socket_path !== binding.socket_path || (binding.transcript_path != null && existing.transcript_path !== binding.transcript_path)) {
|
|
78
|
+
existing.pane = binding.pane; existing.socket_path = binding.socket_path;
|
|
79
|
+
if (binding.transcript_path != null) existing.transcript_path = binding.transcript_path;
|
|
80
|
+
this.store.putRecord('native_questions', key, existing);
|
|
81
|
+
}
|
|
82
|
+
return existing;
|
|
83
|
+
});
|
|
97
84
|
}
|
|
98
85
|
|
|
99
86
|
/** A question's id, from its native identity (mail_question.rs `question_id`): each poll and hook finds the same one. */
|
|
@@ -111,21 +98,26 @@ export class NativeQuestions {
|
|
|
111
98
|
}
|
|
112
99
|
body += '\nReply with the answer text for one question, or a JSON object mapping each question id to its answer for several questions.';
|
|
113
100
|
let question = { binding, native, envelope: makeEnvelope({ id, from: binding.session, from_name: binding.session, kind: 'question', reply_via: { mode: 'command' }, body }) };
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
101
|
+
this.store.transaction(() => {
|
|
102
|
+
if (!this.store.putRecord('native_questions', `${id}.json`, question, { create: true })) question = this.store.record('native_questions', `${id}.json`);
|
|
103
|
+
if (!question?.envelope) throw new Error(`question ${id} could not be read`);
|
|
104
|
+
if (this.store.record('native_questions', `${id}.delivered`) != null) return;
|
|
105
|
+
this.store.deliverRead(binding.session, question.envelope);
|
|
106
|
+
// Its creator's copy and wake commit with the question, so interruption
|
|
107
|
+
// cannot publish half of a new question.
|
|
108
|
+
if (!this.store.findIn(binding.creator, id)) { this.store.deliver(binding.creator, question.envelope); this.store.requestWake(binding.creator, id); }
|
|
109
|
+
});
|
|
120
110
|
}
|
|
121
111
|
|
|
122
112
|
/** A reply the native harness used confirmed (mail_question.rs `confirm`): the reply read in the session's mailbox, and
|
|
123
113
|
* when it was used recorded once (`<q-id>.delivered`, which a listing reads as its delivery). */
|
|
124
114
|
confirm(id, reply) {
|
|
125
|
-
const question =
|
|
115
|
+
const question = this.store.record('native_questions', `${id}.json`);
|
|
126
116
|
if (!question) throw new Error(`question ${id} is not on file`);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
117
|
+
this.store.transaction(() => {
|
|
118
|
+
const found = reply?.envelope?.id ? this.store.findIn(question.binding.session, reply.envelope.id) : null;
|
|
119
|
+
if (found && found.state === 'unread') this.store.markRead(question.binding.session, found.path);
|
|
120
|
+
this.store.putRecord('native_questions', `${id}.delivered`, Date.now(), { create: true });
|
|
121
|
+
});
|
|
130
122
|
}
|
|
131
123
|
}
|
package/mail/reads.mjs
CHANGED
|
@@ -237,8 +237,8 @@ export class MailReads {
|
|
|
237
237
|
const stored = this.#whole(found[0].stored);
|
|
238
238
|
if (!stored) return { id, found: false };
|
|
239
239
|
if (!transcripts.has(address)) transcripts.set(address, readTranscript(address, this.homes));
|
|
240
|
-
const delivered = deliveries(address, [stored], await transcripts.get(address), (envelope) => questionDeliveredAt(envelope, this.
|
|
241
|
-
return { id, found: true, machine: this.machine, message: messageRow(address, stored, delivered, this.
|
|
240
|
+
const delivered = deliveries(address, [stored], await transcripts.get(address), (envelope) => questionDeliveredAt(envelope, this.store));
|
|
241
|
+
return { id, found: true, machine: this.machine, message: messageRow(address, stored, delivered, this.store) };
|
|
242
242
|
}));
|
|
243
243
|
}
|
|
244
244
|
|
|
@@ -304,9 +304,7 @@ export class MailReads {
|
|
|
304
304
|
const found = this.store.find(id);
|
|
305
305
|
if (found.length !== 1) return { id, found: false };
|
|
306
306
|
const asked = found[0].stored.envelope;
|
|
307
|
-
const
|
|
308
|
-
const replies = this.store.inventory(asked.from).stored
|
|
309
|
-
.filter((stored) => typeof stored.envelope.in_reply_to === 'string' && namesIt(stored.envelope.in_reply_to))
|
|
307
|
+
const replies = this.store.replies(asked.from, asked.id)
|
|
310
308
|
.map((stored) => this.#whole(stored)).filter(Boolean)
|
|
311
309
|
.map(({ envelope }) => ({ id: envelope.id, from: envelope.from, from_name: envelope.from_name, created_at_ms: envelope.created_at_ms, body: envelope.body }));
|
|
312
310
|
return { id, found: true, message: asked.id, session: found[0].address, asker: asked.from, replies };
|
|
@@ -326,17 +324,14 @@ export class MailReads {
|
|
|
326
324
|
const transcript = all ? await readTranscript(address, this.homes) : null;
|
|
327
325
|
let stored;
|
|
328
326
|
if (all) {
|
|
329
|
-
stored = this.store.
|
|
330
|
-
.filter((item) => item.state === 'read' || (item.envelope.kind === 'user' && item.part === 'claimed'))
|
|
331
|
-
.map((item) => this.#whole(item)).filter(Boolean);
|
|
332
|
-
if (last !== null) stored = stored.slice(Math.max(0, stored.length - last));
|
|
327
|
+
stored = this.store.history(address, last).map((item) => this.#whole(item)).filter(Boolean);
|
|
333
328
|
stored.push(...claimed);
|
|
334
329
|
stored.sort((a, b) => a.envelope.created_at_ms - b.envelope.created_at_ms);
|
|
335
330
|
} else stored = claimed;
|
|
336
|
-
const delivered = deliveries(address, stored, transcript, (envelope) => questionDeliveredAt(envelope, this.
|
|
331
|
+
const delivered = deliveries(address, stored, transcript, (envelope) => questionDeliveredAt(envelope, this.store));
|
|
337
332
|
const now = Date.now();
|
|
338
333
|
for (const item of claimed) delivered.set(item.envelope.id, { state: 'by_this_read', at_ms: now });
|
|
339
|
-
return { stored, deliveries: delivered, claimed, rendered: stored.map((item) => listed(item.envelope, delivered, this.
|
|
334
|
+
return { stored, deliveries: delivered, claimed, rendered: stored.map((item) => listed(item.envelope, delivered, this.store)), rows: stored.map((item) => messageRow(address, item, delivered, this.store)) };
|
|
340
335
|
}
|
|
341
336
|
|
|
342
337
|
/** The live session a name or address names here (mail_route.rs `resolve`, without the ledger of earlier names). */
|
|
@@ -356,25 +351,19 @@ export class MailReads {
|
|
|
356
351
|
* `inbox --session` rows (`inbox_rows`), the newest `last` only when given. */
|
|
357
352
|
async inboxRows(address, last = null) {
|
|
358
353
|
const transcript = await readTranscript(address, this.homes);
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
if (to === address) continue;
|
|
364
|
-
for (const item of this.store.inventory(to).stored) if (item.envelope.from === address) { const whole = this.#whole(item); if (whole) sent.push([to, whole]); }
|
|
365
|
-
}
|
|
366
|
-
let timeline = [...received.map((item) => [address, item, 'in']), ...sent.map(([to, item]) => [to, item, 'out'])];
|
|
367
|
-
timeline.sort((a, b) => a[1].envelope.created_at_ms - b[1].envelope.created_at_ms);
|
|
368
|
-
if (last !== null) timeline = timeline.slice(Math.max(0, timeline.length - last));
|
|
354
|
+
const timeline = this.store.timeline(address, last)
|
|
355
|
+
.map((row) => [row.address, this.#whole(row.stored), row.direction]).filter(([, item]) => item);
|
|
356
|
+
const received = timeline.filter(([, , direction]) => direction === 'in').map(([, item]) => item);
|
|
357
|
+
const delivered = deliveries(address, received, transcript, (envelope) => questionDeliveredAt(envelope, this.store));
|
|
369
358
|
// where a message it sent stands is read from its receiver's transcript, the newest receivers' only
|
|
370
359
|
const receivers = [];
|
|
371
360
|
for (const [to, , direction] of [...timeline].reverse()) if (direction === 'out' && !receivers.includes(to) && receivers.length < MAX_RECEIVERS_READ) receivers.push(to);
|
|
372
361
|
for (const to of receivers) {
|
|
373
362
|
const theirs = timeline.filter(([receiver, , direction]) => direction === 'out' && receiver === to).map(([, item]) => item);
|
|
374
|
-
for (const [id, delivery] of deliveries(to, theirs, await readTranscript(to, this.homes), (envelope) => questionDeliveredAt(envelope, this.
|
|
363
|
+
for (const [id, delivery] of deliveries(to, theirs, await readTranscript(to, this.homes), (envelope) => questionDeliveredAt(envelope, this.store))) delivered.set(id, delivery);
|
|
375
364
|
}
|
|
376
365
|
for (const [, item, direction] of timeline) if (direction === 'out' && !delivered.has(item.envelope.id)) delivered.set(item.envelope.id, { state: 'not_read' });
|
|
377
|
-
return timeline.map(([box, item, direction]) => ({ ...messageRow(box, item, delivered, this.
|
|
366
|
+
return timeline.map(([box, item, direction]) => ({ ...messageRow(box, item, delivered, this.store), direction }));
|
|
378
367
|
}
|
|
379
368
|
|
|
380
369
|
// ---- the machine record's mail ---------------------------------------------------------------------------------
|
|
@@ -385,12 +374,12 @@ export class MailReads {
|
|
|
385
374
|
const header = (item) => ({ id: item.envelope.id, from: item.envelope.from, from_name: item.envelope.from_name, kind: item.envelope.kind, subject: item.envelope.subject ?? null, thread: item.envelope.thread ?? item.envelope.id, in_reply_to: item.envelope.in_reply_to ?? null, created_at_ms: item.envelope.created_at_ms, unread: item.state === 'unread' });
|
|
386
375
|
const mail = {};
|
|
387
376
|
for (const address of mailboxes ?? this.store.addresses()) {
|
|
388
|
-
const
|
|
389
|
-
const
|
|
377
|
+
const window = this.store.headerWindow(address, recent);
|
|
378
|
+
const { skipped } = window;
|
|
379
|
+
const answer = { unread: window.unread.map(header), recent: window.recent.map(header), count: window.count };
|
|
390
380
|
if (skipped.length) answer.partial = `${skipped.length} envelope file${skipped.length === 1 ? '' : 's'} could not be read (${skipped.slice(0, 5).join(', ')}); its unread mail may be more than listed`;
|
|
391
381
|
mail[address] = answer;
|
|
392
382
|
}
|
|
393
383
|
return { mail, unread_boxes: this.store.unread ? [this.store.unread] : [] };
|
|
394
384
|
}
|
|
395
385
|
}
|
|
396
|
-
|