@volter/supercode-teams 0.3.185 → 0.3.188
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/bin/teams-cli.mjs +608 -0
- package/bin/teams-supervised.mjs +5 -0
- package/bin/teams.mjs +49 -582
- package/bin/workspace/published-install.mjs +46 -0
- package/bin/workspace/release-state.mjs +82 -0
- package/bin/workspace/retain-release.mjs +186 -0
- package/bin/workspace/upgrade-supervisor.mjs +234 -0
- package/bin/workspace/upgrade-watch.mjs +5 -2
- package/bin/workspace.mjs +19 -6
- package/machine.mjs +119 -31
- package/mail/import-maildir.mjs +8 -7
- package/mail/recovery-view.mjs +36 -0
- package/mail/retire-maildir.mjs +57 -68
- package/mail/send.mjs +2 -2
- package/mail/sqlite-store.mjs +15 -5
- package/mail/upgrade-admission.mjs +44 -0
- package/mail/upgrade-readiness.mjs +59 -0
- package/mail/upgrade-state.mjs +78 -0
- package/package.json +1 -1
- package/session-index-owner.mjs +8 -1
package/mail/sqlite-store.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
6
6
|
import { blake3Hex } from './envelope.mjs';
|
|
7
7
|
import { MailDatabase, MailOwner, RETAINED_BODY } from './sqlite.mjs';
|
|
8
8
|
import { migrateMaildir } from './retire-maildir.mjs';
|
|
9
|
+
import { recoveryMailView } from './recovery-view.mjs';
|
|
9
10
|
|
|
10
11
|
export const MIN_PREFIX_DIGITS = 4;
|
|
11
12
|
export { RETAINED_BODY };
|
|
@@ -30,13 +31,15 @@ const validId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id);
|
|
|
30
31
|
const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
|
|
31
32
|
|
|
32
33
|
export class SqliteMailStore extends EventEmitter {
|
|
33
|
-
constructor({ mailRoot, machine = null, log = () => {}, database = null, retention } = {}) {
|
|
34
|
+
constructor({ mailRoot, machine = null, log = () => {}, database = null, retention, committed = false, startupRecovery = false } = {}) {
|
|
34
35
|
super();
|
|
35
36
|
this.root = resolve(mailRoot);
|
|
36
37
|
this.machine = machine;
|
|
37
38
|
this.log = log;
|
|
38
39
|
this.database = database;
|
|
39
40
|
this.retention = retention;
|
|
41
|
+
this.committed = committed;
|
|
42
|
+
this.startupRecovery = startupRecovery;
|
|
40
43
|
this.unread = null;
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -50,14 +53,21 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
50
53
|
async start() {
|
|
51
54
|
if (!this.owner) throw new Error('mail cannot open without this daemon\'s writer lease');
|
|
52
55
|
this.database ??= new MailDatabase(join(dirname(this.root), 'mail.sqlite'), { retention: this.retention });
|
|
53
|
-
this.imported = await migrateMaildir(this.database, this.root, { log: this.log });
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
try { this.imported = await migrateMaildir(this.database, this.root, { log: this.log, committed: this.committed || (this.startupRecovery && this.database.metadata('maildir_retirement')?.complete === true) }); }
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!this.startupRecovery) throw error;
|
|
59
|
+
this.log(`startup migration refused: ${error.message}`);
|
|
60
|
+
this.database.close();
|
|
61
|
+
this.database = await recoveryMailView(this.root, { log: this.log });
|
|
62
|
+
this.deferred = true;
|
|
63
|
+
}
|
|
64
|
+
if (!this.deferred) this.database.retain();
|
|
65
|
+
if (!this.deferred) this.reconcileDeliverySchedule();
|
|
56
66
|
this.database.on('message', (address) => this.emit('changed', address));
|
|
57
67
|
this.database.on('wake', ({ address, id }) => this.emit('wake', { address, id }));
|
|
58
68
|
this.database.on('subscriptions', (address) => this.emit('subscriptions', address));
|
|
59
69
|
this.retentionTimer = setInterval(() => {
|
|
60
|
-
try { this.database.retain(); }
|
|
70
|
+
try { if (!this.deferred) this.database.retain(); }
|
|
61
71
|
catch (error) { this.log(`mail retention failed: ${error.message}`); }
|
|
62
72
|
}, 3_600_000);
|
|
63
73
|
this.retentionTimer.unref?.();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// All upgrade entrypoints ask the candidate release, never the old daemon's
|
|
2
|
+
// cached modules. The candidate cannot modify the serving home's state.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolve, join } from 'node:path';
|
|
6
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
|
|
9
|
+
export async function requireUpgradeAdmission({ home, entry = new URL('../bin/teams.mjs', import.meta.url), env = process.env } = {}) {
|
|
10
|
+
const path = entry instanceof URL ? fileURLToPath(entry) : resolve(entry);
|
|
11
|
+
const scratch = await mkdtemp(join(tmpdir(), 'supercode-upgrade-'));
|
|
12
|
+
try { return await new Promise((done, reject) => {
|
|
13
|
+
const child = spawn(process.execPath, [path, 'mail', 'upgrade-inspect', '--home', resolve(home), '--json'],
|
|
14
|
+
{ env: { ...env, SUPERCODE_UPGRADE_SCRATCH: scratch }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
15
|
+
let stdout = '', stderr = '', refused = false;
|
|
16
|
+
const refuse = (reason) => {
|
|
17
|
+
if (refused) return;
|
|
18
|
+
refused = true;
|
|
19
|
+
// Only the admission child we just started is ended, by its handle.
|
|
20
|
+
child.kill('SIGKILL');
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
const error = Object.assign(new Error(`upgrade admission refused: ${reason}; the serving daemon must remain running`), { code: 'upgrade_not_ready' });
|
|
23
|
+
if (child.exitCode !== null || child.signalCode !== null || !child.pid) reject(error);
|
|
24
|
+
else child.once('close', () => reject(error));
|
|
25
|
+
};
|
|
26
|
+
const timer = setTimeout(() => refuse('candidate did not complete the existing-state check within 8 seconds'), 8000);
|
|
27
|
+
const collect = (which, bytes) => {
|
|
28
|
+
if (which === 'out') stdout += bytes; else stderr += bytes;
|
|
29
|
+
if (stdout.length + stderr.length > 64 * 1024) refuse('candidate returned an oversized admission response');
|
|
30
|
+
};
|
|
31
|
+
child.stdout.on('data', bytes => collect('out', bytes));
|
|
32
|
+
child.stderr.on('data', bytes => collect('err', bytes));
|
|
33
|
+
child.once('error', error => refuse(error.message));
|
|
34
|
+
child.once('close', (code, signal) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
if (refused) return;
|
|
37
|
+
if (code !== 0 || signal) return refuse(stderr.trim() || `candidate exited ${code ?? signal}`);
|
|
38
|
+
let receipt;
|
|
39
|
+
try { receipt = JSON.parse(stdout); } catch { return refuse('candidate returned no admission receipt'); }
|
|
40
|
+
if (receipt.stage !== 'upgrade_admission' || receipt.admitted !== true || receipt.home !== resolve(home, '..')) return refuse('candidate returned an invalid admission receipt');
|
|
41
|
+
done(receipt);
|
|
42
|
+
});
|
|
43
|
+
}); } finally { await rm(scratch, { recursive: true, force: true }); }
|
|
44
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Upgrade admission is a candidate command, before the serving daemon exits.
|
|
2
|
+
// It opens live state read-only and applies this release's import/schema work
|
|
3
|
+
// only to a private database. A refusal leaves the current service serving.
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { lstat } from 'node:fs/promises';
|
|
6
|
+
import { join, dirname, resolve } from 'node:path';
|
|
7
|
+
import { MailDatabase } from './sqlite.mjs';
|
|
8
|
+
import { importMaildir } from './import-maildir.mjs';
|
|
9
|
+
import { requireLegacyMaildirReady } from './migration-readiness.mjs';
|
|
10
|
+
import { verifyImportedMaildir } from './retire-maildir.mjs';
|
|
11
|
+
import { requireSessionIndexRuntime } from '../session-index-owner.mjs';
|
|
12
|
+
|
|
13
|
+
const exists = async (path) => {
|
|
14
|
+
try { return await lstat(path); }
|
|
15
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function inspectUpgradeReadiness(teamsHome, directory) {
|
|
19
|
+
requireSessionIndexRuntime();
|
|
20
|
+
// Resolve all daemon modules before admission, without starting its carrier,
|
|
21
|
+
// binding a socket or executing any retained delivery against a real session.
|
|
22
|
+
await import('../machine.mjs');
|
|
23
|
+
const home = dirname(teamsHome), root = join(home, 'mail');
|
|
24
|
+
const guard = await requireLegacyMaildirReady(root);
|
|
25
|
+
if (!directory) throw new Error('upgrade inspection requires its private admission directory');
|
|
26
|
+
let database;
|
|
27
|
+
try {
|
|
28
|
+
const live = join(home, 'mail.sqlite'), candidate = join(directory, 'mail.sqlite');
|
|
29
|
+
if (await exists(live)) {
|
|
30
|
+
const reader = new DatabaseSync(live, { readOnly: true });
|
|
31
|
+
try {
|
|
32
|
+
// SQLite creates a coherent snapshot, including committed WAL pages;
|
|
33
|
+
// copying the db/WAL files independently cannot establish that.
|
|
34
|
+
reader.prepare('VACUUM INTO ?').run(candidate);
|
|
35
|
+
} finally { reader.close(); }
|
|
36
|
+
}
|
|
37
|
+
database = new MailDatabase(candidate);
|
|
38
|
+
const retirement = database.metadata('maildir_retirement');
|
|
39
|
+
const source = await exists(root);
|
|
40
|
+
let input = root;
|
|
41
|
+
if (retirement?.source && retirement.source !== root) throw new Error('mail migration source differs from its stored receipt');
|
|
42
|
+
if (retirement?.archive && dirname(resolve(retirement.archive)) !== resolve(home, 'mail-retired')) throw new Error('migration snapshot is outside this home');
|
|
43
|
+
if (retirement?.archive && await exists(retirement.archive)) {
|
|
44
|
+
if (source) throw new Error('both the original migration snapshot and a recreated Maildir exist; explicit recovery is required before activation');
|
|
45
|
+
input = retirement.archive;
|
|
46
|
+
} else if (retirement?.complete && source) {
|
|
47
|
+
throw new Error('a retired Maildir was recreated; explicit recovery is required before activation');
|
|
48
|
+
} else if (retirement?.archive && !source && !retirement.complete) {
|
|
49
|
+
throw new Error('the incomplete mail migration source is missing');
|
|
50
|
+
}
|
|
51
|
+
const imported = await importMaildir(database, input, { legacyRoot: root });
|
|
52
|
+
if (!retirement?.complete && await exists(input)) await verifyImportedMaildir(database, input);
|
|
53
|
+
return { version: 1, stage: 'upgrade_admission', admitted: true, home,
|
|
54
|
+
messages: imported.messages, records: imported.records, retained_diagnostics: imported.skipped,
|
|
55
|
+
legacy_wrappers: guard.live.length, schema: database.metadata('schema_version') };
|
|
56
|
+
} finally {
|
|
57
|
+
database?.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
import { copyFile, lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const json = async path => { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
6
|
+
const present = async path => { try { return await lstat(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
7
|
+
const answers = socket => new Promise(done => {
|
|
8
|
+
if (typeof socket !== 'string') return done(false);
|
|
9
|
+
const probe = connect(socket), timer = setTimeout(() => { probe.destroy(); done(false); }, 200);
|
|
10
|
+
probe.once('connect', () => { clearTimeout(timer); probe.destroy(); done(true); });
|
|
11
|
+
probe.once('error', () => { clearTimeout(timer); done(false); });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export async function checkpoint(home, directory) {
|
|
15
|
+
const path = join(home, 'mail.sqlite');
|
|
16
|
+
let database = false;
|
|
17
|
+
if (await present(path)) {
|
|
18
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
19
|
+
const reader = new DatabaseSync(path, { readOnly: true });
|
|
20
|
+
try { reader.prepare('VACUUM INTO ?').run(join(directory, 'mail.sqlite')); database = true; }
|
|
21
|
+
finally { reader.close(); }
|
|
22
|
+
}
|
|
23
|
+
const legacy = await present(join(home, 'mail'));
|
|
24
|
+
if (legacy && !legacy.isDirectory()) throw new Error('startup checkpoint requires a regular original Maildir');
|
|
25
|
+
const before = { database, legacy: legacy ? { device: legacy.dev, inode: legacy.ino } : null };
|
|
26
|
+
await writeFile(join(directory, 'before.json'), JSON.stringify(before) + '\n', { mode: 0o600 });
|
|
27
|
+
return before;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function mergeLegacy(source, target) {
|
|
31
|
+
// Older writers can recreate the path during startup. Merge only records
|
|
32
|
+
// that do not conflict; keep both complete trees if a choice is required.
|
|
33
|
+
for (const item of await readdir(source, { withFileTypes: true })) {
|
|
34
|
+
const from = join(source, item.name), to = join(target, item.name), saved = await present(to);
|
|
35
|
+
if (item.isDirectory()) {
|
|
36
|
+
if (saved && !saved.isDirectory()) throw new Error('startup recovery found conflicting legacy record types');
|
|
37
|
+
if (!saved) await mkdir(to, { mode: 0o700 });
|
|
38
|
+
await mergeLegacy(from, to);
|
|
39
|
+
} else if (item.isFile()) {
|
|
40
|
+
if (!saved) await copyFile(from, to);
|
|
41
|
+
else if (!saved.isFile() || !(await readFile(from)).equals(await readFile(to))) throw new Error('startup recovery found conflicting legacy record bytes; both originals are retained');
|
|
42
|
+
} else throw new Error('startup recovery found a non-regular legacy record');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function restore(home, directory, before, teamsHome) {
|
|
47
|
+
const serving = await json(join(teamsHome, 'machine.json'));
|
|
48
|
+
if (serving && await answers(serving.socket)) throw new Error('startup recovery refused: another daemon is serving this home');
|
|
49
|
+
const path = join(home, 'mail.sqlite');
|
|
50
|
+
if (before.legacy) {
|
|
51
|
+
const root = join(home, 'mail'), current = await present(root);
|
|
52
|
+
const original = item => item?.isDirectory() && item.dev === before.legacy.device && item.ino === before.legacy.inode;
|
|
53
|
+
if (!original(current)) {
|
|
54
|
+
const archives = join(home, 'mail-retired');
|
|
55
|
+
let matches = [];
|
|
56
|
+
if (await present(archives)) for (const name of await readdir(archives)) {
|
|
57
|
+
const path = join(archives, name);
|
|
58
|
+
if (original(await present(path))) matches.push(path);
|
|
59
|
+
}
|
|
60
|
+
// Locate the pre-start inode even if a failed candidate's database cannot
|
|
61
|
+
// be opened or its metadata transaction never committed.
|
|
62
|
+
if (matches.length !== 1) throw new Error('startup recovery cannot locate its retained original Maildir');
|
|
63
|
+
if (current) {
|
|
64
|
+
if (!current.isDirectory()) throw new Error('startup recovery found a replaced Maildir path');
|
|
65
|
+
await mergeLegacy(root, matches[0]);
|
|
66
|
+
await rename(root, join(directory, 'recreated-mail'));
|
|
67
|
+
}
|
|
68
|
+
await rename(matches[0], root);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Startup failure never served a caller. Preserve the failed candidate's
|
|
72
|
+
// database bytes for diagnosis before restoring the previous schema snapshot.
|
|
73
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
74
|
+
if (await present(path + suffix)) await rename(path + suffix, join(directory, 'failed-mail.sqlite' + suffix));
|
|
75
|
+
}
|
|
76
|
+
if (before.database) await copyFile(join(directory, 'mail.sqlite'), path);
|
|
77
|
+
}
|
|
78
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.188",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|
package/session-index-owner.mjs
CHANGED
|
@@ -273,7 +273,8 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
273
273
|
// Lifecycle parents cover replacement of the selected root, not every
|
|
274
274
|
// sibling below that parent. Only selected physical directory bindings
|
|
275
275
|
// extend file-source admission beyond its logical aliases.
|
|
276
|
-
const parents = new Set([dirname(source.root), ...[...source.aliases].map(dirname)
|
|
276
|
+
const parents = new Set([dirname(source.root), ...[...source.aliases].map(dirname),
|
|
277
|
+
...(source.registryPath ? [dirname(source.registryPath)] : [])]);
|
|
277
278
|
const selectedTargets = source.kind === 'stores' ? targets : targets.filter((target) => !parents.has(target));
|
|
278
279
|
if (path != null && !pathUnder(source.root, path) && !pathUnder(path, source.root)
|
|
279
280
|
&& ![...selectedTargets, ...source.aliases].some((target) => pathUnder(path, target) || pathUnder(target, path))
|
|
@@ -349,6 +350,12 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
349
350
|
});
|
|
350
351
|
if (this.closed) return;
|
|
351
352
|
for (const part of source.parts.values()) if (part.kind === 'file' && (folder === source.root || pathUnder(folder, part.path)) && !listing.parts.has(part.path)) this.#remove(source, part.path);
|
|
353
|
+
// A previous watcher may have admitted a sibling of an aliased registry.
|
|
354
|
+
// Only a completed full listing proves which transcript paths belong here;
|
|
355
|
+
// malformed files still selected retain their failure and retry evidence.
|
|
356
|
+
if (folder === source.root) for (const failure of this.store.failures([source.id])) {
|
|
357
|
+
if (failure.kind === 'file' && !listing.parts.has(failure.path)) this.store.clearFailure(source.id, failure.path, failure.kind);
|
|
358
|
+
}
|
|
352
359
|
for (const path of listing.parts) { if (this.closed) return; await this.#file(source, path); }
|
|
353
360
|
if (this.closed) return;
|
|
354
361
|
this.store.clearFailure(source.id, folder, 'folder');
|