@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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The registry input writer owns a lease independently of the installer that
|
|
2
|
+
// started it. Installer loss cannot release cleanup over a still-running npm.
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { releaseStateLease } from './release-state.mjs';
|
|
8
|
+
|
|
9
|
+
const [service, directory, version] = process.argv.slice(2);
|
|
10
|
+
if (!service || !directory || !/^\d+\.\d+\.\d+$/.test(version)
|
|
11
|
+
|| dirname(resolve(directory)) !== resolve(service, 'published')) throw new Error('invalid published input');
|
|
12
|
+
const close = await releaseStateLease(join(service, 'published'));
|
|
13
|
+
let child;
|
|
14
|
+
let killed = false;
|
|
15
|
+
const kill = () => {
|
|
16
|
+
if (!child?.pid || killed) return;
|
|
17
|
+
killed = true;
|
|
18
|
+
try {
|
|
19
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
20
|
+
else process.kill(-child.pid, 'SIGKILL');
|
|
21
|
+
} catch {}
|
|
22
|
+
};
|
|
23
|
+
process.once('exit', () => { kill(); close(); });
|
|
24
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP', ...(process.platform === 'win32' ? [] : ['SIGALRM'])]) {
|
|
25
|
+
process.once(signal, () => { kill(); process.exit(1); });
|
|
26
|
+
}
|
|
27
|
+
const marker = join(directory, 'published-input.json');
|
|
28
|
+
const original = JSON.parse(await readFile(marker, 'utf8'));
|
|
29
|
+
if (original.version !== 1 || original.service !== resolve(service) || original.release !== version) throw new Error('published input ownership differs');
|
|
30
|
+
const receipt = async state => {
|
|
31
|
+
const temporary = `${marker}.${randomUUID()}.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
await writeFile(temporary, JSON.stringify({ ...original, owner: process.pid, state }) + '\n', { mode: 0o600 });
|
|
34
|
+
await rename(temporary, marker);
|
|
35
|
+
} finally { await rm(temporary, { force: true }); }
|
|
36
|
+
};
|
|
37
|
+
await receipt('installing');
|
|
38
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
39
|
+
child = spawn(npm, ['install', '--prefix', directory, '--install-strategy=nested', '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', `@volter/supercode@${version}`],
|
|
40
|
+
{ stdio: 'ignore', detached: process.platform !== 'win32', windowsHide: true, shell: process.platform === 'win32' });
|
|
41
|
+
const timer = setTimeout(kill, 8000);
|
|
42
|
+
const code = await new Promise(done => { child.once('error', () => done(1)); child.once('close', code => done(code ?? 1)); });
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
child = null;
|
|
45
|
+
await receipt(code === 0 ? 'complete' : 'failed');
|
|
46
|
+
process.exitCode = code;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Service-owned artifacts, serialized with release preparation. Never prune
|
|
2
|
+
// mail originals, unknown directories, a pending writer or a referenced release.
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const json = async path => { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
8
|
+
const entries = async path => { try { return await readdir(path, { withFileTypes: true }); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } };
|
|
9
|
+
const alive = pid => { if (!Number.isSafeInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; } };
|
|
10
|
+
|
|
11
|
+
export async function releaseStateLease(service) {
|
|
12
|
+
await mkdir(service, { recursive: true, mode: 0o700 });
|
|
13
|
+
const owner = new DatabaseSync(join(service, 'release-state-owner.sqlite'));
|
|
14
|
+
try { owner.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE'); }
|
|
15
|
+
catch (error) { owner.close(); throw new Error(`another release preparation owns this service: ${error.message}`); }
|
|
16
|
+
return () => owner.close();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Caller holds releaseStateLease. An activation checkpoint is needed only
|
|
20
|
+
// until readiness or restoration. Retain one completed failure for diagnosis;
|
|
21
|
+
// unresolved failures remain untouched until explicit recovery.
|
|
22
|
+
export async function pruneReleaseState(service, { pinned = null, activation = null } = {}) {
|
|
23
|
+
const history = await json(join(service, 'release-history.json'));
|
|
24
|
+
const active = await json(join(service, 'active-release.json'));
|
|
25
|
+
const keep = new Set([pinned, history?.candidate, history?.previous, history?.refused, active?.release]
|
|
26
|
+
.filter(Boolean).map(release => resolve(release.root)));
|
|
27
|
+
for (const item of await entries(join(service, 'releases'))) {
|
|
28
|
+
if (!item.isDirectory()) continue;
|
|
29
|
+
if (item.name.includes('.pending-')) {
|
|
30
|
+
const directory = join(service, 'releases', item.name), pending = await json(join(directory, 'pending-release.json'));
|
|
31
|
+
if (pending?.version === 1 && pending.service === resolve(service) && !alive(pending.owner)) await rm(directory, { recursive: true });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const directory = join(service, 'releases', item.name), receipt = await json(join(directory, 'retained-release.json'));
|
|
35
|
+
if (receipt?.version !== 3 || resolve(receipt.root) !== resolve(directory, 'package') || keep.has(resolve(receipt.root))) continue;
|
|
36
|
+
await rm(directory, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
// An npm child can outlive its installer. Its independent input lease,
|
|
39
|
+
// rather than the parent's service lease, protects both complete and partial
|
|
40
|
+
// input trees. Unknown old partial inputs remain untouched.
|
|
41
|
+
let inputsClose;
|
|
42
|
+
try { inputsClose = await releaseStateLease(join(service, 'published')); } catch {}
|
|
43
|
+
if (inputsClose) {
|
|
44
|
+
try {
|
|
45
|
+
for (const item of await entries(join(service, 'published'))) {
|
|
46
|
+
if (!item.isDirectory()) continue;
|
|
47
|
+
const directory = join(service, 'published', item.name);
|
|
48
|
+
const input = await json(join(directory, 'published-input.json'));
|
|
49
|
+
if (input?.version === 1 && input.service === resolve(service) && /^\d+\.\d+\.\d+$/.test(input.release) && !alive(input.owner)) {
|
|
50
|
+
await rm(directory, { recursive: true });
|
|
51
|
+
} else if (/^\d+\.\d+\.\d+$/.test(item.name)) {
|
|
52
|
+
const packageJson = await json(join(directory, 'node_modules', '@volter', 'supercode', 'package.json'));
|
|
53
|
+
if (packageJson?.name === '@volter/supercode' && packageJson.version === item.name) await rm(directory, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} finally { inputsClose(); }
|
|
57
|
+
}
|
|
58
|
+
const failures = [];
|
|
59
|
+
for (const item of await entries(join(service, 'activations'))) {
|
|
60
|
+
if (!item.isDirectory()) continue;
|
|
61
|
+
const directory = join(service, 'activations', item.name);
|
|
62
|
+
if (resolve(directory) === activation) continue;
|
|
63
|
+
const receipt = await json(join(directory, 'receipt.json'));
|
|
64
|
+
if (!receipt) continue;
|
|
65
|
+
if (receipt.state === 'activated') await rm(directory, { recursive: true });
|
|
66
|
+
else if (receipt.restored === true) failures.push({ directory, at: receipt.at_ms });
|
|
67
|
+
}
|
|
68
|
+
failures.sort((a, b) => b.at - a.at);
|
|
69
|
+
for (const failure of failures.slice(activation ? 0 : 1)) await rm(failure.directory, { recursive: true });
|
|
70
|
+
for (const item of await entries(join(service, 'handoffs'))) {
|
|
71
|
+
if (!item.isFile() || !item.name.endsWith('.json')) continue;
|
|
72
|
+
const path = join(service, 'handoffs', item.name), receipt = await json(path);
|
|
73
|
+
if (Number.isSafeInteger(receipt?.supervisor) && Number.isSafeInteger(receipt.previous_pid) && !alive(receipt.supervisor)) await rm(path);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function cleanupReleaseState(service, options) {
|
|
78
|
+
let close;
|
|
79
|
+
try { close = await releaseStateLease(service); await pruneReleaseState(service, options); }
|
|
80
|
+
catch (error) { process.stderr.write(`supercode: service artifact cleanup deferred: ${error.message}\n`); }
|
|
81
|
+
finally { close?.(); }
|
|
82
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// A service must not execute the replaceable npm tree. Retain the full release
|
|
2
|
+
// and its dependencies, and retain the observed previous published version.
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { copyFile, mkdir, readFile, readdir, readlink, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { constants, createReadStream, existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { releaseStateLease, pruneReleaseState } from './release-state.mjs';
|
|
11
|
+
|
|
12
|
+
const json = async path => { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
13
|
+
const nativeName = process.platform === 'win32' ? 'supercode.exe' : 'supercode';
|
|
14
|
+
const platformPackage = `@volter/supercode-cli-${process.platform}-${process.arch}`;
|
|
15
|
+
|
|
16
|
+
async function copyEntries(entries, copy) {
|
|
17
|
+
let next = 0;
|
|
18
|
+
const workers = Array.from({ length: Math.min(32, entries.length) }, async () => {
|
|
19
|
+
while (next < entries.length) await copy(entries[next++]);
|
|
20
|
+
});
|
|
21
|
+
// Every copy must settle before cleanup or publication, including when one
|
|
22
|
+
// fails. A rejected copy cannot leave other writers racing the pending tree.
|
|
23
|
+
const outcomes = await Promise.allSettled(workers);
|
|
24
|
+
const failed = outcomes.find(outcome => outcome.status === 'rejected');
|
|
25
|
+
if (failed) throw failed.reason;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function releaseRoot(entry) {
|
|
29
|
+
for (let dir = dirname(entry); dirname(dir) !== dir; dir = dirname(dir)) {
|
|
30
|
+
try { if (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name === '@volter/supercode') return dir; } catch {}
|
|
31
|
+
}
|
|
32
|
+
throw new Error('retained connector installation requires a complete @volter/supercode release');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function retain(root, native, service) {
|
|
36
|
+
root = await realpath(root);
|
|
37
|
+
native = await realpath(native);
|
|
38
|
+
const version = (await json(join(root, 'package.json'))).version;
|
|
39
|
+
// The complete dependency closure is immutable, including Node modules.
|
|
40
|
+
// A version or the native alone cannot distinguish changed daemon code.
|
|
41
|
+
const hash = createHash('sha256').update('retained-release-v3');
|
|
42
|
+
const links = [], directories = [''], files = [];
|
|
43
|
+
const fingerprint = async directory => {
|
|
44
|
+
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
45
|
+
for (const item of entries) {
|
|
46
|
+
const path = join(directory, item.name);
|
|
47
|
+
hash.update(JSON.stringify([relative(root, path), item.isDirectory() ? 'directory' : item.isSymbolicLink() ? 'link' : 'file']));
|
|
48
|
+
if (item.isDirectory()) {
|
|
49
|
+
directories.push(relative(root, path));
|
|
50
|
+
await fingerprint(path);
|
|
51
|
+
}
|
|
52
|
+
else if (item.isSymbolicLink()) {
|
|
53
|
+
const link = await readlink(path), target = await realpath(resolve(dirname(path), link));
|
|
54
|
+
if (!target.startsWith(root + sep)) throw new Error('a retained release dependency links outside its complete installation');
|
|
55
|
+
const replacement = relative(dirname(path), target);
|
|
56
|
+
hash.update(JSON.stringify(replacement));
|
|
57
|
+
links.push({ path: relative(root, path), replacement });
|
|
58
|
+
} else if (item.isFile()) {
|
|
59
|
+
files.push(relative(root, path));
|
|
60
|
+
for await (const bytes of createReadStream(path)) hash.update(bytes);
|
|
61
|
+
} else throw new Error('a retained release contains a non-regular dependency');
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
await fingerprint(root);
|
|
65
|
+
const digest = hash.digest('hex').slice(0, 24);
|
|
66
|
+
const target = join(service, 'releases', `${version}-${digest}`);
|
|
67
|
+
const marker = join(target, 'retained-release.json');
|
|
68
|
+
const kept = await json(marker);
|
|
69
|
+
if (kept) return kept;
|
|
70
|
+
const temporary = `${target}.pending-${randomUUID()}`;
|
|
71
|
+
await mkdir(dirname(temporary), { recursive: true, mode: 0o700 });
|
|
72
|
+
try {
|
|
73
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
74
|
+
await writeFile(join(temporary, 'pending-release.json'), JSON.stringify({ version: 1, service: resolve(service), owner: process.pid }) + '\n', { mode: 0o600 });
|
|
75
|
+
const packageRoot = join(temporary, 'package');
|
|
76
|
+
// fingerprint already enumerated and validated the complete closure.
|
|
77
|
+
// fs.cp repeated serial metadata/realpath/timestamp work for every file on
|
|
78
|
+
// first startup. Copy that manifest directly with bounded concurrency;
|
|
79
|
+
// clone where supported, falling back to an ordinary exact byte copy.
|
|
80
|
+
await copyEntries(directories, path => mkdir(join(packageRoot, path), { recursive: true, mode: 0o700 }));
|
|
81
|
+
await copyEntries(files, path => copyFile(join(root, path), join(packageRoot, path), constants.COPYFILE_FICLONE));
|
|
82
|
+
await copyEntries(links, link => symlink(link.replacement, join(packageRoot, link.path)));
|
|
83
|
+
// Give the retained release its own PATH front door. The native's stable
|
|
84
|
+
// command ownership check can then resolve this complete installation.
|
|
85
|
+
const launchers = join(temporary, 'package', 'bin');
|
|
86
|
+
if (process.platform === 'win32') {
|
|
87
|
+
const quote = value => value.replaceAll('%', '%%');
|
|
88
|
+
await writeFile(join(launchers, 'supercode.cmd'), `@"${quote(process.execPath)}" "${quote(join(target, 'package', 'bin', 'supercode.js'))}" %*\r\n`);
|
|
89
|
+
} else {
|
|
90
|
+
await rm(join(launchers, 'supercode'), { force: true });
|
|
91
|
+
await symlink('supercode.js', join(launchers, 'supercode'));
|
|
92
|
+
}
|
|
93
|
+
const sourceEntry = createRequire(join(root, 'package.json')).resolve('@volter/supercode-teams/bin');
|
|
94
|
+
if (!sourceEntry.startsWith(root + sep)) throw new Error('the daemon dependency is outside its complete release');
|
|
95
|
+
const entry = join(target, 'package', relative(root, sourceEntry));
|
|
96
|
+
const binary = join(target, 'package', relative(root, native));
|
|
97
|
+
if (relative(root, native).startsWith('..')) throw new Error('the native binary is outside its complete release');
|
|
98
|
+
const supportsReady = existsSync(join(dirname(sourceEntry), 'teams-supervised.mjs'));
|
|
99
|
+
const cli = existsSync(join(dirname(sourceEntry), 'teams-cli.mjs')) ? 'teams-cli.mjs' : 'teams-original.mjs';
|
|
100
|
+
if (supportsReady) {
|
|
101
|
+
const stagedEntry = join(temporary, 'package', relative(root, sourceEntry));
|
|
102
|
+
if (cli === 'teams-original.mjs' && !existsSync(join(dirname(sourceEntry), cli))) {
|
|
103
|
+
await rename(stagedEntry, join(dirname(stagedEntry), 'teams-original.mjs'));
|
|
104
|
+
}
|
|
105
|
+
// These imports belong to the generated bin entry, not this workspace
|
|
106
|
+
// module. Quote its paths as source values; keep the supervisor named
|
|
107
|
+
// beside this module so packed-file inspection still checks that asset.
|
|
108
|
+
const supervisor = new URL('./upgrade-supervisor.mjs', import.meta.url);
|
|
109
|
+
const supervisorImport = JSON.stringify(`./workspace/${supervisor.pathname.split('/').at(-1)}`);
|
|
110
|
+
const cliImport = JSON.stringify(`./${cli}`);
|
|
111
|
+
await writeFile(stagedEntry, `#!/usr/bin/env node\nconst args = process.argv.slice(2);\nconst verbs = args[0] === 'teams' ? args.slice(1) : args;\nif ((verbs[0] === 'connect' && !verbs.includes('--install') && !verbs.includes('--add-root')) || (verbs[0] === 'machine' && verbs[1] === 'start')) {\n const { runRetainedConnector } = await import(${supervisorImport});\n await runRetainedConnector(args);\n} else await import(${cliImport});\n`);
|
|
112
|
+
}
|
|
113
|
+
const receipt = { version: 3, release: version, root: join(target, 'package'),
|
|
114
|
+
entry: supportsReady ? join(dirname(entry), cli) : entry,
|
|
115
|
+
native: binary, supervised: entry, supports_ready: supportsReady };
|
|
116
|
+
await writeFile(join(temporary, 'retained-release.json'), JSON.stringify(receipt) + '\n', { mode: 0o600 });
|
|
117
|
+
try { await rename(temporary, target); } catch (error) { if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error; }
|
|
118
|
+
return await json(marker);
|
|
119
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function published(version, service) {
|
|
123
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error('the serving release version is unreadable; activation is deferred');
|
|
124
|
+
const inputs = join(service, 'published');
|
|
125
|
+
let prefix = join(inputs, version);
|
|
126
|
+
const releaseGuard = await releaseStateLease(inputs);
|
|
127
|
+
try {
|
|
128
|
+
if ((await json(join(prefix, 'node_modules', '@volter', 'supercode', 'package.json')))?.version !== version) {
|
|
129
|
+
prefix = null;
|
|
130
|
+
for (const name of await readdir(inputs)) {
|
|
131
|
+
if (!name.startsWith(`${version}.pending-`)) continue;
|
|
132
|
+
const directory = join(inputs, name), receipt = await json(join(directory, 'published-input.json'));
|
|
133
|
+
if (receipt?.version === 1 && receipt.service === resolve(service) && receipt.release === version && receipt.state === 'complete'
|
|
134
|
+
&& (await json(join(directory, 'node_modules', '@volter', 'supercode', 'package.json')))?.version === version) { prefix = directory; break; }
|
|
135
|
+
}
|
|
136
|
+
if (!prefix) {
|
|
137
|
+
prefix = join(inputs, `${version}.pending-${randomUUID()}`);
|
|
138
|
+
await mkdir(prefix, { mode: 0o700 });
|
|
139
|
+
await writeFile(join(prefix, 'published-input.json'), JSON.stringify({ version: 1, service: resolve(service), release: version, owner: process.pid, state: 'prepared' }) + '\n', { mode: 0o600 });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} finally { releaseGuard(); }
|
|
143
|
+
const root = join(prefix, 'node_modules', '@volter', 'supercode');
|
|
144
|
+
if (!(await json(join(root, 'package.json')))?.version) {
|
|
145
|
+
await new Promise((done, reject) => {
|
|
146
|
+
const worker = fileURLToPath(new URL('./published-install.mjs', import.meta.url));
|
|
147
|
+
const child = spawn(process.execPath, [worker, service, prefix, version], { stdio: 'ignore', windowsHide: true });
|
|
148
|
+
child.once('error', reject);
|
|
149
|
+
child.once('close', code => code === 0 ? done() : reject(new Error(`previous published release could not be retained: input worker exited ${code}; ${prefix}`)));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if ((await json(join(root, 'package.json')))?.version !== version) throw new Error('the retained published version differs from the serving receipt');
|
|
153
|
+
const native = createRequire(join(root, 'package.json')).resolve(`${platformPackage}/bin/${nativeName}`);
|
|
154
|
+
return retain(root, native, service);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function prepareRetainedRelease({ teamsHome, native, entry = fileURLToPath(new URL('../teams.mjs', import.meta.url)), allowFetch = true }) {
|
|
158
|
+
const service = join(resolve(teamsHome), 'service');
|
|
159
|
+
const close = await releaseStateLease(service);
|
|
160
|
+
try {
|
|
161
|
+
const historyPath = join(service, 'release-history.json');
|
|
162
|
+
const history = await json(historyPath);
|
|
163
|
+
const active = await json(join(service, 'active-release.json'));
|
|
164
|
+
const running = await json(join(teamsHome, 'machine.json'));
|
|
165
|
+
let previous = active?.release ?? history?.previous ?? history?.candidate ?? null;
|
|
166
|
+
// TypeScript-only releases deliberately reuse an older native binary.
|
|
167
|
+
const observed = running?.release?.supercode ?? /^supercode (\d+\.\d+\.\d+)\b/.exec(running?.release?.native ?? '')?.[1];
|
|
168
|
+
const root = releaseRoot(entry);
|
|
169
|
+
const candidateVersion = (await json(join(root, 'package.json'))).version;
|
|
170
|
+
if (observed && observed !== candidateVersion && previous?.release !== observed) {
|
|
171
|
+
previous = history?.candidate?.release === observed ? history.candidate
|
|
172
|
+
: history?.previous?.release === observed ? history.previous : allowFetch ? await published(observed, service) : null;
|
|
173
|
+
}
|
|
174
|
+
if (allowFetch && running && !observed && !previous) throw new Error('the serving release has no retained version receipt; activation is deferred');
|
|
175
|
+
if (previous && previous.version !== 3) previous = allowFetch ? await published(previous.release, service) : null;
|
|
176
|
+
const candidate = await retain(root, native, service);
|
|
177
|
+
if (!candidate.supports_ready || !existsSync(candidate.supervised)) throw new Error('candidate has no retained startup supervisor');
|
|
178
|
+
if (previous?.native === candidate.native) previous = history?.previous ?? null;
|
|
179
|
+
await mkdir(service, { recursive: true, mode: 0o700 });
|
|
180
|
+
const temporary = `${historyPath}.${randomUUID()}.tmp`;
|
|
181
|
+
await writeFile(temporary, JSON.stringify({ version: 1, candidate, previous, ...(history?.refused ? { refused: history.refused } : {}) }) + '\n', { mode: 0o600 });
|
|
182
|
+
await rename(temporary, historyPath);
|
|
183
|
+
await pruneReleaseState(service, { pinned: candidate });
|
|
184
|
+
return { ...candidate, previous };
|
|
185
|
+
} finally { close(); }
|
|
186
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { connect } from 'node:net';
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile as writeNewFile } from 'node:fs/promises';
|
|
4
|
+
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { requireUpgradeAdmission } from '../../mail/upgrade-admission.mjs';
|
|
9
|
+
import { checkpoint, restore } from '../../mail/upgrade-state.mjs';
|
|
10
|
+
import { MailOwner } from '../../mail/sqlite.mjs';
|
|
11
|
+
import { cleanupReleaseState } from './release-state.mjs';
|
|
12
|
+
|
|
13
|
+
const writeFile = async (path, bytes, options) => {
|
|
14
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
15
|
+
try { await writeNewFile(temporary, bytes, options); await rename(temporary, path); }
|
|
16
|
+
finally { await rm(temporary, { force: true }); }
|
|
17
|
+
};
|
|
18
|
+
const json = async path => { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
19
|
+
const answers = socket => new Promise(done => {
|
|
20
|
+
if (typeof socket !== 'string') return done(false);
|
|
21
|
+
const probe = connect(socket), timer = setTimeout(() => { probe.destroy(); done(false); }, 200);
|
|
22
|
+
probe.once('connect', () => { clearTimeout(timer); probe.destroy(); done(true); });
|
|
23
|
+
probe.once('error', () => { clearTimeout(timer); done(false); });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export async function runRetainedConnector(args) {
|
|
27
|
+
const explicitHome = args.indexOf('--home');
|
|
28
|
+
const inlineHome = args.find(arg => arg.startsWith('--home='));
|
|
29
|
+
const defaultHome = process.env.SUPERCODE_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode');
|
|
30
|
+
const teamsHome = resolve(explicitHome >= 0 ? args[explicitHome + 1] : inlineHome ? inlineHome.slice(7) : process.env.SUPERCODE_TEAMS_HOME || join(defaultHome, 'teams'));
|
|
31
|
+
const home = dirname(teamsHome);
|
|
32
|
+
const readyFile = join(teamsHome, 'service', 'active-release.json');
|
|
33
|
+
let pinned = null;
|
|
34
|
+
for (let parent = dirname(fileURLToPath(import.meta.url)); dirname(parent) !== parent; parent = dirname(parent)) {
|
|
35
|
+
pinned = await json(join(parent, 'retained-release.json'));
|
|
36
|
+
if (pinned) break;
|
|
37
|
+
}
|
|
38
|
+
if (!pinned?.entry) throw new Error('connector supervisor is outside a retained complete release');
|
|
39
|
+
let history = await json(join(teamsHome, 'service', 'release-history.json'));
|
|
40
|
+
if (!history?.candidate?.entry) throw new Error('retained connector has no release-history receipt');
|
|
41
|
+
// Preparing a new release cannot change what an existing unit executes.
|
|
42
|
+
// A failed activation explicitly redirects only its own retained wrapper.
|
|
43
|
+
if (history.candidate.native !== pinned.native && history.refused?.native !== pinned.native) {
|
|
44
|
+
history = { ...history, candidate: pinned,
|
|
45
|
+
previous: history.previous?.native === pinned.native ? null : history.previous };
|
|
46
|
+
}
|
|
47
|
+
let active = await json(readyFile);
|
|
48
|
+
// Readiness commits a state floor. An older wrapper or stale history can
|
|
49
|
+
// never roll that state back on an ordinary service restart.
|
|
50
|
+
if (active?.release?.entry) {
|
|
51
|
+
if (history.candidate.native === active.release.native) history.previous = active.release;
|
|
52
|
+
else if (history.candidate.native !== pinned.native || history.previous?.native !== active.release.native) {
|
|
53
|
+
history = { ...history, candidate: active.release, previous: active.release };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const established = active?.release?.native === history.candidate.native;
|
|
57
|
+
const directory = join(teamsHome, 'service', 'activations', randomUUID());
|
|
58
|
+
let child = null, stopping = false;
|
|
59
|
+
const stop = () => { stopping = true; child?.kill('SIGTERM'); };
|
|
60
|
+
for (const signal of ['SIGINT', 'SIGTERM', ...(process.platform === 'win32' ? ['SIGBREAK'] : [])]) process.once(signal, stop);
|
|
61
|
+
const start = (release, activating = false, recovery = false) => {
|
|
62
|
+
const selectedArgs = [...args];
|
|
63
|
+
const nativeAt = selectedArgs.indexOf('--supercode');
|
|
64
|
+
if (nativeAt >= 0) selectedArgs[nativeAt + 1] = release.native;
|
|
65
|
+
const inlineNative = selectedArgs.findIndex(arg => arg.startsWith('--supercode='));
|
|
66
|
+
if (inlineNative >= 0) selectedArgs[inlineNative] = `--supercode=${release.native}`;
|
|
67
|
+
const env = { ...process.env };
|
|
68
|
+
delete env.SUPERCODE_UPGRADE_HANDOFF;
|
|
69
|
+
delete env.SUPERCODE_UPGRADE_OLD_PID;
|
|
70
|
+
child = spawn(process.execPath, [release.entry, ...selectedArgs], { stdio: 'inherit', env: { ...env,
|
|
71
|
+
SUPERCODE_BIN: release.native, SUPERCODE_TEAMS_ENTRY: release.entry,
|
|
72
|
+
SUPERCODE_RELEASE_VERSION: release.release,
|
|
73
|
+
SUPERCODE_UPGRADE_READY_FILE: readyFile,
|
|
74
|
+
SUPERCODE_UPGRADE_RELEASE_RECEIPT: JSON.stringify(release),
|
|
75
|
+
PATH: [join(release.root, 'bin'), process.env.PATH].filter(Boolean).join(delimiter),
|
|
76
|
+
SUPERCODE_UPGRADE_SUPERVISOR: String(process.pid),
|
|
77
|
+
SUPERCODE_UPGRADE_ACTIVATING: activating ? '1' : '',
|
|
78
|
+
SUPERCODE_MAIL_COMMITTED: release.native === active?.release?.native ? '1' : '',
|
|
79
|
+
SUPERCODE_MAIL_STARTUP_RECOVERY: !activating || recovery ? '1' : '' },
|
|
80
|
+
detached: process.platform !== 'win32', windowsHide: true });
|
|
81
|
+
return child;
|
|
82
|
+
};
|
|
83
|
+
const cleanupAfterServing = (daemon, release, activation = null) => {
|
|
84
|
+
void (async () => {
|
|
85
|
+
while (!stopping && daemon.exitCode === null && daemon.signalCode === null) {
|
|
86
|
+
const record = await json(join(teamsHome, 'machine.json'));
|
|
87
|
+
if (record?.pid === daemon.pid && (release.supports_ready ? record.mail_ready === true : await answers(record.socket))) {
|
|
88
|
+
await cleanupReleaseState(join(teamsHome, 'service'), { pinned, activation });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await new Promise(done => setTimeout(done, 50));
|
|
92
|
+
}
|
|
93
|
+
})().catch(error => process.stderr.write(`supercode: post-start cleanup deferred: ${error.message}\n`));
|
|
94
|
+
};
|
|
95
|
+
if (process.env.SUPERCODE_UPGRADE_HANDOFF) {
|
|
96
|
+
const handoff = resolve(process.env.SUPERCODE_UPGRADE_HANDOFF), previousPid = Number(process.env.SUPERCODE_UPGRADE_OLD_PID);
|
|
97
|
+
if (dirname(handoff) !== resolve(teamsHome, 'service', 'handoffs') || !Number.isSafeInteger(previousPid) || previousPid <= 0) throw new Error('invalid upgrade handoff');
|
|
98
|
+
await mkdir(dirname(handoff), { recursive: true, mode: 0o700 });
|
|
99
|
+
await writeFile(handoff, JSON.stringify({ supervisor: process.pid, previous_pid: previousPid }) + '\n', { mode: 0o600 });
|
|
100
|
+
const deadline = Date.now() + 8000;
|
|
101
|
+
while (!stopping && Date.now() < deadline) {
|
|
102
|
+
const serving = await json(join(teamsHome, 'machine.json'));
|
|
103
|
+
if (!serving || !(await answers(serving.socket))) break;
|
|
104
|
+
if (serving.pid !== previousPid) throw new Error('upgrade handoff refused: another daemon took this home');
|
|
105
|
+
await new Promise(done => setTimeout(done, 25));
|
|
106
|
+
}
|
|
107
|
+
await rm(handoff, { force: true });
|
|
108
|
+
}
|
|
109
|
+
if (stopping) return;
|
|
110
|
+
const serving = await json(join(teamsHome, 'machine.json'));
|
|
111
|
+
if (serving && await answers(serving.socket)) throw new Error('another daemon already serves this home; startup state is untouched');
|
|
112
|
+
let owner;
|
|
113
|
+
for (let attempt = 0; !owner; attempt++) {
|
|
114
|
+
try { owner = new MailOwner(join(teamsHome, 'service', 'activation-owner.sqlite')); }
|
|
115
|
+
catch (error) { if (attempt >= 40) throw error; await new Promise(done => setTimeout(done, 50)); }
|
|
116
|
+
}
|
|
117
|
+
process.once('exit', () => owner.close());
|
|
118
|
+
// A stopped first activation never becomes an ordinary downgrade. A
|
|
119
|
+
// durable commit wins; otherwise its checkpoint restores the pre-start state
|
|
120
|
+
// before a later start can choose a release.
|
|
121
|
+
let unfinished = [];
|
|
122
|
+
try { unfinished = await readdir(join(teamsHome, 'service', 'activations')); }
|
|
123
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
124
|
+
for (const name of unfinished) {
|
|
125
|
+
const prior = join(teamsHome, 'service', 'activations', name);
|
|
126
|
+
const receipt = await json(join(prior, 'receipt.json'));
|
|
127
|
+
if (!['checking', 'prepared', 'starting'].includes(receipt?.state)) continue;
|
|
128
|
+
let running = false;
|
|
129
|
+
try { process.kill(receipt.owner, 0); running = true; } catch (error) { running = error.code !== 'ESRCH'; }
|
|
130
|
+
if (running) continue;
|
|
131
|
+
active = await json(readyFile);
|
|
132
|
+
if (active?.supervisor === receipt.owner || (active?.release && receipt.previous?.native !== active.release.native)) {
|
|
133
|
+
await writeFile(join(prior, 'receipt.json'), JSON.stringify({ ...receipt, state: 'activated', at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const before = await json(join(prior, 'before.json'));
|
|
137
|
+
if (before) await restore(home, prior, before, teamsHome);
|
|
138
|
+
await writeFile(join(prior, 'receipt.json'), JSON.stringify({ ...receipt, state: 'interrupted', restored: true, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
139
|
+
}
|
|
140
|
+
const recover = async (state, outcome, before = null) => {
|
|
141
|
+
await writeFile(join(directory, 'receipt.json'), JSON.stringify({ version: 1, state, outcome,
|
|
142
|
+
candidate: history.candidate, previous: history.previous, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
143
|
+
if (before) await restore(home, directory, before, teamsHome);
|
|
144
|
+
await writeFile(join(directory, 'receipt.json'), JSON.stringify({ version: 1, state, outcome, restored: true,
|
|
145
|
+
candidate: history.candidate, previous: history.previous, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
146
|
+
if (!history.previous?.entry) {
|
|
147
|
+
process.stderr.write('supercode: no predecessor is retained; serving this release with a private mail recovery view\n');
|
|
148
|
+
const daemon = start(history.candidate, false, true);
|
|
149
|
+
const result = await new Promise(done => { daemon.once('error', () => done(1)); daemon.once('exit', code => done(code ?? 1)); });
|
|
150
|
+
process.exitCode = stopping ? 0 : result;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const historyPath = join(teamsHome, 'service', 'release-history.json');
|
|
154
|
+
if ((await json(historyPath))?.candidate?.native === history.candidate.native) {
|
|
155
|
+
const temporary = `${historyPath}.${randomUUID()}.tmp`;
|
|
156
|
+
await writeFile(temporary, JSON.stringify({ version: 1,
|
|
157
|
+
candidate: history.previous, previous: history.previous, refused: history.candidate, activation: directory }) + '\n', { mode: 0o600 });
|
|
158
|
+
await rename(temporary, historyPath);
|
|
159
|
+
}
|
|
160
|
+
process.stderr.write(`supercode: candidate ${state}; serving retained release ${history.previous.release}; evidence ${directory}\n`);
|
|
161
|
+
if (stopping) return;
|
|
162
|
+
const previous = start(history.previous);
|
|
163
|
+
cleanupAfterServing(previous, history.previous, resolve(directory));
|
|
164
|
+
const result = await new Promise(done => { previous.once('error', () => done(1)); previous.once('exit', code => done(code ?? 1)); });
|
|
165
|
+
process.exitCode = stopping ? 0 : result || 1;
|
|
166
|
+
};
|
|
167
|
+
if (established || !history.previous?.entry || history.candidate.native === history.previous.native) {
|
|
168
|
+
// No admission, checkpoint or first-activation deadline after commit.
|
|
169
|
+
// Native indexing is allowed to answer later while the same mail serves.
|
|
170
|
+
const daemon = start(history.candidate);
|
|
171
|
+
cleanupAfterServing(daemon, history.candidate);
|
|
172
|
+
const result = await new Promise(done => { daemon.once('error', () => done(1)); daemon.once('exit', code => done(code ?? 1)); });
|
|
173
|
+
process.exitCode = stopping ? 0 : result;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
177
|
+
const journal = async state => writeFile(join(directory, 'receipt.json'), JSON.stringify({ version: 1, state,
|
|
178
|
+
owner: process.pid, candidate: history.candidate, previous: history.previous, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
179
|
+
await journal('checking');
|
|
180
|
+
try { await requireUpgradeAdmission({ home: teamsHome, entry: history.candidate.entry }); }
|
|
181
|
+
catch (error) { await recover('admission_refused', { error: error.message }); return; }
|
|
182
|
+
if (stopping) return;
|
|
183
|
+
const before = await checkpoint(home, directory);
|
|
184
|
+
await journal('prepared');
|
|
185
|
+
await journal('starting');
|
|
186
|
+
const candidate = start(history.candidate, true);
|
|
187
|
+
let finished = false, ready = false;
|
|
188
|
+
const exit = new Promise(done => {
|
|
189
|
+
candidate.once('error', error => { finished = true; done({ error: error.message }); });
|
|
190
|
+
candidate.once('exit', (code, signal) => { finished = true; done({ code, signal }); });
|
|
191
|
+
});
|
|
192
|
+
const deadline = Date.now() + 8000;
|
|
193
|
+
while (!stopping && !finished && Date.now() < deadline) {
|
|
194
|
+
const record = await json(join(teamsHome, 'machine.json'));
|
|
195
|
+
if (record?.pid === candidate.pid && await answers(record.socket)) {
|
|
196
|
+
// The daemon publishes mail_ready only after its afterListen work; a
|
|
197
|
+
// listening socket alone is not a serving mailbox.
|
|
198
|
+
const committed = await json(readyFile);
|
|
199
|
+
if (record.mail_ready === true && committed?.pid === candidate.pid && committed.supervisor === process.pid) { ready = true; break; }
|
|
200
|
+
}
|
|
201
|
+
await new Promise(done => setTimeout(done, 50));
|
|
202
|
+
}
|
|
203
|
+
if (stopping) {
|
|
204
|
+
await exit;
|
|
205
|
+
if ((await json(readyFile))?.pid !== candidate.pid) {
|
|
206
|
+
await restore(home, directory, before, teamsHome);
|
|
207
|
+
await writeFile(join(directory, 'receipt.json'), JSON.stringify({ version: 1, state: 'stopped_before_ready', restored: true, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
208
|
+
}
|
|
209
|
+
await cleanupReleaseState(join(teamsHome, 'service'), { pinned });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (ready) {
|
|
213
|
+
await writeFile(join(directory, 'receipt.json'), JSON.stringify({ version: 1, state: 'activated', candidate: history.candidate, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
214
|
+
await writeFile(join(teamsHome, 'service', 'last-activation.json'), JSON.stringify({ version: 1, state: 'activated', candidate: history.candidate, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
215
|
+
await cleanupReleaseState(join(teamsHome, 'service'), { pinned });
|
|
216
|
+
// No rollback of state a caller could have used. Its retained release is
|
|
217
|
+
// restarted by the service's normal lifecycle on a later process failure.
|
|
218
|
+
const outcome = await exit;
|
|
219
|
+
process.exitCode = stopping ? 0 : outcome.code || 1;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
{
|
|
223
|
+
// This group was created by this supervisor at spawn; it includes only
|
|
224
|
+
// this candidate's children, never foreign harness serves or panes.
|
|
225
|
+
try { process.platform === 'win32' ? candidate.kill('SIGKILL') : process.kill(-candidate.pid, 'SIGKILL'); } catch {}
|
|
226
|
+
}
|
|
227
|
+
const outcome = await exit;
|
|
228
|
+
if ((await json(readyFile))?.pid === candidate.pid) {
|
|
229
|
+
process.stderr.write('supercode: candidate committed mail readiness before exit; its state is retained for service restart\n');
|
|
230
|
+
process.exitCode = 1;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
await recover('startup_failed', outcome, before);
|
|
234
|
+
}
|
|
@@ -38,7 +38,7 @@ export function watchCodeChange(files, onChange) {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Move this connector to the upgraded code: run `cli` (the upgraded CLI's install, in a session of its own), then
|
|
41
|
-
* leave `exit()` to run a minute on, for the service manager to restart this connector under what it installed. A CLI
|
|
41
|
+
* only on successful installer exit leave `exit()` to run a minute on, for the service manager to restart this connector under what it installed. A CLI
|
|
42
42
|
* that cannot be run (absent: ENOENT, raised after spawn returns; not a whole executable: EBADMACHO, thrown by it)
|
|
43
43
|
* moves nothing: the exit is not left, `resume()` watches on, and this connector keeps serving on its old code.
|
|
44
44
|
*/
|
|
@@ -48,8 +48,11 @@ export function moveToUpgrade(cli, args, options, { exit = null, resume, log })
|
|
|
48
48
|
let child;
|
|
49
49
|
try { child = spawn(cli, args, options); } catch (error) { failed(error); return; }
|
|
50
50
|
child.once('error', failed);
|
|
51
|
+
child.once('exit', (code, signal) => {
|
|
52
|
+
if (code !== 0 || signal) { failed(new Error(`installer exited ${code ?? signal}`)); return; }
|
|
53
|
+
if (exit) left = setTimeout(exit, 60_000);
|
|
54
|
+
});
|
|
51
55
|
child.unref();
|
|
52
|
-
if (exit) left = setTimeout(exit, 60_000);
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
package/bin/workspace.mjs
CHANGED
|
@@ -25,6 +25,8 @@ import { loadOrCreateKeyPair, publicKeyId } from '@volter/teams/identity';
|
|
|
25
25
|
import { duration, parseArgs, positiveInteger, requireArgs, usage } from './workspace/args.mjs';
|
|
26
26
|
import { reserveArtifactDirectory, reserveProtected, writeProtected } from './workspace/files.mjs';
|
|
27
27
|
import { acquireLifecycle, activeSocket, lifecycleHeld, requestStop } from './workspace/lifecycle.mjs';
|
|
28
|
+
import { prepareRetainedRelease } from './workspace/retain-release.mjs';
|
|
29
|
+
import { requireUpgradeAdmission } from '../mail/upgrade-admission.mjs';
|
|
28
30
|
import { moveToUpgrade, retireLinklessDaemon, watchCodeChange } from './workspace/upgrade-watch.mjs';
|
|
29
31
|
|
|
30
32
|
/** How long a service-run connector's machine link may stay down before the connector restarts itself. */
|
|
@@ -1108,20 +1110,22 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
1108
1110
|
if (flags.install || (!flags.foreground && process.stdin.isTTY && process.stdout.isTTY)) {
|
|
1109
1111
|
const serviceBin = process.env.SUPERCODE_BIN;
|
|
1110
1112
|
if (!serviceBin) unsupported('--install requires invocation through the native supercode CLI');
|
|
1111
|
-
|
|
1113
|
+
await requireUpgradeAdmission({ home: teamsHome() });
|
|
1114
|
+
const retained = await prepareRetainedRelease({ teamsHome: teamsHome(), native: serviceBin,
|
|
1115
|
+
entry: fileURLToPath(new URL('./teams.mjs', import.meta.url)) });
|
|
1116
|
+
const serviceEnv = { ...process.env, SUPERCODE_TEAMS_ENTRY: retained.supervised };
|
|
1112
1117
|
// Refuse source-backed installs before Windows stops the running connector.
|
|
1113
1118
|
try {
|
|
1114
|
-
execFileSync(
|
|
1119
|
+
execFileSync(retained.native, ['__teams-connector-service', 'validate', contextName, servicePath, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: serviceEnv });
|
|
1115
1120
|
} catch (error) {
|
|
1116
1121
|
// In particular, an older native CLI may not have the validation door.
|
|
1117
1122
|
// Its support-report child must not keep a refused Windows install alive.
|
|
1118
1123
|
await core.close();
|
|
1119
1124
|
throw error;
|
|
1120
1125
|
}
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
|
|
1124
|
-
try { service = JSON.parse(execFileSync(serviceBin, ['__teams-connector-service', 'install', contextName, servicePath, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: serviceEnv })); }
|
|
1126
|
+
// Installation performs candidate admission before changing the service.
|
|
1127
|
+
// The previous Windows connector is stopped only after that succeeds.
|
|
1128
|
+
try { service = JSON.parse(execFileSync(retained.native, ['__teams-connector-service', 'install', contextName, servicePath, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: serviceEnv })); }
|
|
1125
1129
|
catch (error) {
|
|
1126
1130
|
// A platform with no service manager still connects: here, for as long as this terminal runs it. Anything else is an error.
|
|
1127
1131
|
if (flags.install || !/no service manager/.test(String(error.stderr ?? error.message))) { core.close(); throw error; }
|
|
@@ -1129,6 +1133,9 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
1129
1133
|
}
|
|
1130
1134
|
}
|
|
1131
1135
|
if (service) {
|
|
1136
|
+
// Native installation admits the candidate before changing the task. End
|
|
1137
|
+
// the previous Windows process only after installation succeeds.
|
|
1138
|
+
if (process.platform === 'win32') await stopRunningConnector(saved);
|
|
1132
1139
|
saved = state.update(current => ({ ...current, service }));
|
|
1133
1140
|
core.close();
|
|
1134
1141
|
if (!flags.json) {
|
|
@@ -1257,6 +1264,12 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
1257
1264
|
// It exits non-zero, which every service manager here restarts.
|
|
1258
1265
|
// A service-run connector restarts on request through the machine's door: it exits non-zero, which every service
|
|
1259
1266
|
// manager here restarts (the Windows task's minute trigger starts it whatever the exit).
|
|
1267
|
+
if (saved.service) machine.restartAdmission = () => {
|
|
1268
|
+
let committed = null;
|
|
1269
|
+
try { committed = JSON.parse(readFileSync(join(daemonHome, 'service', 'active-release.json'), 'utf8')); } catch {}
|
|
1270
|
+
if (committed?.release?.native === machine.supercodeBin) return;
|
|
1271
|
+
return requireUpgradeAdmission({ home: daemonHome });
|
|
1272
|
+
};
|
|
1260
1273
|
if (saved.service) machine.restartHandler = (reason) => {
|
|
1261
1274
|
process.stderr.write(`supercode teams: restart requested through the machine door${reason ? ` (${reason})` : ''}\n`);
|
|
1262
1275
|
void (async () => { await stopConnector?.(); process.exit(75); })();
|