@volter/supercode-teams 0.3.184 → 0.3.187
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 +4 -1
- package/app-door.mjs +23 -3
- 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 +41 -22
- package/mail/import-maildir.mjs +8 -7
- package/mail/recovery-view.mjs +36 -0
- package/mail/retire-maildir.mjs +57 -68
- 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 +20 -8
- package/session-index-watches.mjs +28 -10
|
@@ -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.187",
|
|
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
|
@@ -270,8 +270,13 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
270
270
|
if (!source) return;
|
|
271
271
|
const path = name == null ? null : join(directory, name);
|
|
272
272
|
if (path?.endsWith('-shm')) return;
|
|
273
|
+
// Lifecycle parents cover replacement of the selected root, not every
|
|
274
|
+
// sibling below that parent. Only selected physical directory bindings
|
|
275
|
+
// extend file-source admission beyond its logical aliases.
|
|
276
|
+
const parents = new Set([dirname(source.root), ...[...source.aliases].map(dirname)]);
|
|
277
|
+
const selectedTargets = source.kind === 'stores' ? targets : targets.filter((target) => !parents.has(target));
|
|
273
278
|
if (path != null && !pathUnder(source.root, path) && !pathUnder(path, source.root)
|
|
274
|
-
&& !
|
|
279
|
+
&& ![...selectedTargets, ...source.aliases].some((target) => pathUnder(path, target) || pathUnder(target, path))
|
|
275
280
|
&& !(source.registryPath && (pathUnder(source.registryPath, path) || pathUnder(path, source.registryPath)))
|
|
276
281
|
&& ![source.namesPath, source.historyPath].includes(path)) return;
|
|
277
282
|
if (path == null || pathUnder(path, source.root) || path === source.registryPath
|
|
@@ -306,8 +311,10 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
306
311
|
finally { this.running = null; if (!this.closed && this.pending.size) this.#schedule(this.pending.keys().next().value, null); }
|
|
307
312
|
}
|
|
308
313
|
|
|
309
|
-
async #watch(source, path) {
|
|
310
|
-
const directory = await this.watches.want(source.id, path
|
|
314
|
+
async #watch(source, path, tree = false) {
|
|
315
|
+
const directory = await this.watches.want(source.id, path, {
|
|
316
|
+
tree, physicalParent: tree && !pathUnder(source.root, path),
|
|
317
|
+
});
|
|
311
318
|
source.wanted.add(path);
|
|
312
319
|
if (!this.closed) {
|
|
313
320
|
this.store.clearFailure(source.id, path, 'watch');
|
|
@@ -320,7 +327,7 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
320
327
|
this.metrics.reconciliations = (this.metrics.reconciliations ?? 0) + 1;
|
|
321
328
|
for (const root of new Set([source.root, ...source.aliases])) {
|
|
322
329
|
await this.#watch(source, dirname(root));
|
|
323
|
-
await this.#watch(source, root);
|
|
330
|
+
await this.#watch(source, root, source.kind === 'files');
|
|
324
331
|
}
|
|
325
332
|
for (const failure of this.store.failures([source.id])) if (failure.kind === 'registry-selection') {
|
|
326
333
|
try {
|
|
@@ -338,14 +345,14 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
338
345
|
}
|
|
339
346
|
if (source.harness === 'codex') await this.#codexLedgers(source);
|
|
340
347
|
const listing = await listSessionParts(source.harness, source.root, {
|
|
341
|
-
folder, metrics: this.metrics, beforeDirectory: (path) => this.#watch(source, path),
|
|
348
|
+
folder, metrics: this.metrics, beforeDirectory: (path) => this.#watch(source, path, true),
|
|
342
349
|
});
|
|
343
350
|
if (this.closed) return;
|
|
344
|
-
for (const part of source.parts.values()) if (part.kind === 'file' && pathUnder(folder, part.path) && !listing.parts.has(part.path)) this.#remove(source, part.path);
|
|
351
|
+
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);
|
|
345
352
|
for (const path of listing.parts) { if (this.closed) return; await this.#file(source, path); }
|
|
346
353
|
if (this.closed) return;
|
|
347
354
|
this.store.clearFailure(source.id, folder, 'folder');
|
|
348
|
-
for (const directory of source.directories) if (pathUnder(folder, directory)) source.directories.delete(directory);
|
|
355
|
+
for (const directory of source.directories) if (folder === source.root || pathUnder(folder, directory)) source.directories.delete(directory);
|
|
349
356
|
for (const directory of listing.directories) source.directories.add(directory);
|
|
350
357
|
const wanted = new Set([...source.aliases, ...[...source.aliases].map(dirname), source.root, dirname(source.root), ...source.directories,
|
|
351
358
|
...(source.registryPath ? [source.registryPath, dirname(source.registryPath)] : []),
|
|
@@ -539,7 +546,12 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
539
546
|
const part = await sessionPartFor(source.harness, source.root, path);
|
|
540
547
|
if (part != null) return this.#file(source, part);
|
|
541
548
|
if (path === source.root || !pathUnder(source.root, path)) return this.#reconcile(source);
|
|
542
|
-
|
|
549
|
+
// Directory topology belongs to the whole selection: canonical transcript
|
|
550
|
+
// paths can sit outside a logical symlink's subtree. A removed alias no
|
|
551
|
+
// longer has stat metadata and is absent from the physical directory set,
|
|
552
|
+
// but must still retire those rows and their watch bindings.
|
|
553
|
+
const info = await sourceStat(path);
|
|
554
|
+
if (!info || info.isDirectory() || source.directories.has(path)) return this.#reconcile(source);
|
|
543
555
|
}
|
|
544
556
|
|
|
545
557
|
#retryTimer() {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { watch } from 'node:fs';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
3
|
-
import { normalizedSourcePath, sourceStat } from '@volter/supercode-interchange';
|
|
2
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
+
import { normalizedSourcePath, pathUnder, sourceStat } from '@volter/supercode-interchange';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const NATIVE_RECURSIVE = ['darwin', 'win32'].includes(process.platform);
|
|
6
|
+
|
|
7
|
+
/** Native recursive trees on macOS/Windows, shared by physical coverage.
|
|
8
|
+
* Linux retains directory watches over its shared inotify descriptor.
|
|
6
9
|
* Missing paths keep an ancestor watch. Reconciliation installs closer
|
|
7
10
|
* watches before listing newly created directories. */
|
|
8
11
|
export class SessionIndexWatches {
|
|
@@ -11,7 +14,7 @@ export class SessionIndexWatches {
|
|
|
11
14
|
this.directories = new Map(); this.closed = false;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
async want(source, path) {
|
|
17
|
+
async want(source, path, { tree = false, physicalParent = false } = {}) {
|
|
15
18
|
if (this.closed) return;
|
|
16
19
|
let at = path;
|
|
17
20
|
for (;;) {
|
|
@@ -21,27 +24,42 @@ export class SessionIndexWatches {
|
|
|
21
24
|
if (parent === at) throw new Error(`no existing directory can watch ${path}`);
|
|
22
25
|
at = parent;
|
|
23
26
|
}
|
|
24
|
-
const
|
|
27
|
+
const target = await normalizedSourcePath(at);
|
|
28
|
+
let recursive = NATIVE_RECURSIVE && tree && at === path;
|
|
29
|
+
let directory = recursive && physicalParent ? dirname(target) : target;
|
|
30
|
+
// Descendants (including a symlink's physical tree) share an existing
|
|
31
|
+
// native stream. Shallow lifecycle/registry watches need no new stream
|
|
32
|
+
// when a selected tree already covers them.
|
|
33
|
+
for (const [root, value] of this.directories) if (value.recursive && pathUnder(root, target)) {
|
|
34
|
+
directory = root; recursive = true; break;
|
|
35
|
+
}
|
|
25
36
|
const info = await sourceStat(directory);
|
|
26
37
|
if (this.closed) return;
|
|
27
38
|
if (!info?.isDirectory()) throw new Error(`directory changed while binding a watch for ${path}`);
|
|
28
39
|
let held = this.directories.get(directory);
|
|
29
|
-
const renew = !held || held.dev !== info.dev || held.ino !== info.ino;
|
|
40
|
+
const renew = !held || held.dev !== info.dev || held.ino !== info.ino || recursive && !held.recursive;
|
|
30
41
|
if (renew) {
|
|
31
42
|
// A pathname can now name another filesystem object. Preserve every
|
|
32
43
|
// source binding while replacing the watch attached to the old object.
|
|
33
44
|
const old = held;
|
|
34
|
-
const value = { sources: old?.sources ?? new Map(), watcher: null, dev: info.dev, ino: info.ino };
|
|
45
|
+
const value = { sources: old?.sources ?? new Map(), watcher: null, dev: info.dev, ino: info.ino, recursive: recursive || old?.recursive || false };
|
|
35
46
|
// Register before returning to the caller's first listing. A nameless
|
|
36
47
|
// event and a watcher error both request a reconciliation, never removals.
|
|
37
|
-
value.watcher = watch(directory, { persistent: false, encoding: 'buffer' }, (event, filename) => {
|
|
48
|
+
value.watcher = watch(directory, { persistent: false, encoding: 'buffer', recursive: value.recursive }, (event, filename) => {
|
|
38
49
|
if (this.closed || this.directories.get(directory) !== value) return;
|
|
39
50
|
let name = null;
|
|
40
51
|
if (filename != null) {
|
|
41
52
|
try { name = new TextDecoder('utf-8', { fatal: true }).decode(filename); }
|
|
42
53
|
catch { /* an undecodable path requires a whole directory read */ }
|
|
43
54
|
}
|
|
44
|
-
|
|
55
|
+
const changed = name == null ? null : resolve(directory, name);
|
|
56
|
+
// Recursive filenames name a path below the tree. Keep the owner's
|
|
57
|
+
// directory/basename contract, and reconcile an undecodable event.
|
|
58
|
+
const known = changed != null && pathUnder(directory, changed);
|
|
59
|
+
for (const [id, targets] of value.sources) this.changed(id, {
|
|
60
|
+
directory: known ? dirname(changed) : directory,
|
|
61
|
+
name: known ? basename(changed) : null, event, targets: [...targets],
|
|
62
|
+
});
|
|
45
63
|
});
|
|
46
64
|
value.watcher.on('error', (error) => {
|
|
47
65
|
this.#refuse(directory, value, error);
|
|
@@ -74,7 +92,7 @@ export class SessionIndexWatches {
|
|
|
74
92
|
// Parent first closes the replacement/creation gap around root's stat.
|
|
75
93
|
const parent = dirname(root);
|
|
76
94
|
if (parent !== root) await this.want(source, parent);
|
|
77
|
-
await this.want(source, root);
|
|
95
|
+
await this.want(source, root, { tree: true });
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
/** Successful reconciliation supplies the complete wanted directory set.
|