@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.
@@ -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.184",
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",
@@ -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
- && !(source.kind === 'stores' ? targets : [...source.aliases]).some((target) => pathUnder(path, target) || pathUnder(target, path))
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
- if ((await sourceStat(path))?.isDirectory() || source.directories.has(path)) return this.#reconcile(source, path);
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
- /** One directory watch per physical directory, shared by all selected homes.
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 directory = await normalizedSourcePath(at);
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
- for (const [id, targets] of value.sources) this.changed(id, { directory, name, event, targets: [...targets] });
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.