Files
TORCH/src/self-host/service.mjs
T

381 lines
16 KiB
JavaScript

import { createHash, randomUUID } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import {
cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync,
renameSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { TorchError } from '../kernel/errors.mjs';
import { torchDataHome } from '../kernel/paths.mjs';
export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([
'init-analyze',
'spec-aware-design',
'ai-fleet-bootstrap',
'ai-fleet-planning',
'fleet-evolution',
'fleet-boundary-evolution',
'cli-lifecycle-surface',
'forge-migration',
'delivery-lifecycle',
'review-install-roster',
'branches-worktrees',
'runtime-identities',
'durable-messaging',
'ownership-query',
'unsafe-worktree-detection',
'backlog-integration',
'resource-lifecycle',
'capture-stop-resume',
'detach-uninstall',
'combatrig-compatibility',
'product-surface',
'operations-observability',
]);
function json(path) {
try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) {
throw new TorchError(`Cannot read ${path}`, { code: 'SELF_HOST_METADATA_INVALID', details: error.message });
}
}
function atomicJson(path, value) {
mkdirSync(dirname(path), { recursive: true });
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
renameSync(temporary, path);
}
function validVersion(version) {
return typeof version === 'string'
&& /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version);
}
function inside(parent, child) {
const root = `${resolve(parent)}${sep}`;
return resolve(child).startsWith(root);
}
function artifactPath(root, path) {
return relative(root, path).split(sep).join('/');
}
function inventory(root, { excludeValidation = false, include = () => true } = {}) {
const records = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name === '.git') continue;
const path = join(directory, entry.name);
const name = artifactPath(root, path);
if (excludeValidation && name === '.torch-validation.json') continue;
if (!include(name, entry)) continue;
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
const link = readlinkSync(path);
const target = resolve(dirname(path), link);
if (isAbsolute(link) || !inside(root, target) || !existsSync(target)) {
throw new TorchError(`Candidate symbolic link must resolve inside the artifact: ${name}`, {
code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name, target: link },
});
}
records.push({ path: name, bytes: Buffer.byteLength(link), type: 'symlink', link });
}
if (stat.isDirectory()) visit(path);
else if (stat.isFile()) records.push({ path: name, bytes: stat.size, type: 'file' });
else if (stat.isSymbolicLink()) { /* recorded above */ }
else throw new TorchError(`Unsupported candidate artifact entry: ${name}`, {
code: 'CANDIDATE_ENTRY_REFUSED', details: { path: name },
});
}
};
visit(root);
return records;
}
function treeHash(root, records) {
const digest = createHash('sha256');
for (const record of records) {
digest.update(record.path).update('\0').update(record.type).update('\0');
digest.update(record.type === 'symlink' ? record.link : readFileSync(join(root, record.path))).update('\0');
}
return digest.digest('hex');
}
function gitSourceDescriptor(root) {
let topLevel;
try {
topLevel = execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch { return null; }
if (resolve(topLevel) !== resolve(root)) return null;
const dirty = execFileSync('git', ['-C', root, 'status', '--porcelain=v1', '--untracked-files=no'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
if (dirty) throw new TorchError('Candidate source has modified tracked files', {
code: 'CANDIDATE_SOURCE_DIRTY', details: dirty.split('\n'),
});
const commit = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const tracked = new Set(execFileSync('git', ['-C', root, 'ls-files', '-z'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).split('\0').filter(Boolean));
const include = (name, entry) => name === 'node_modules'
|| name.startsWith('node_modules/')
|| tracked.has(name)
|| (entry.isDirectory() && [...tracked].some((path) => path.startsWith(`${name}/`)));
return { mode: 'git-commit', commit, include };
}
function sourceDescriptor(root) {
return gitSourceDescriptor(root) ?? { mode: 'artifact', commit: null, include: () => true };
}
function validateReleaseMetadata(root, requestedVersion) {
const packagePath = join(root, 'package.json');
const releasePath = join(root, 'torch-release.json');
if (!existsSync(packagePath) || !existsSync(releasePath)) {
throw new TorchError('Candidate requires package.json and torch-release.json', {
code: 'CANDIDATE_METADATA_MISSING', details: { packagePath, releasePath },
});
}
const packageMetadata = json(packagePath);
const release = json(releasePath);
const version = requestedVersion ?? release.version ?? packageMetadata.version;
if (!validVersion(version) || packageMetadata.version !== version || release.version !== version) {
throw new TorchError('Candidate version metadata does not agree', {
code: 'CANDIDATE_VERSION_INVALID',
details: { requestedVersion, packageVersion: packageMetadata.version, releaseVersion: release.version },
});
}
if (release.schema !== 'torch.dev/release/v1alpha1'
|| !Array.isArray(release.state?.reads)
|| typeof release.state?.writes !== 'string'
|| release.state?.rollback_safe !== true) {
throw new TorchError('Candidate state compatibility declaration is incomplete or not rollback-safe', {
code: 'CANDIDATE_COMPATIBILITY_INVALID', details: release.state ?? null,
});
}
const bin = typeof packageMetadata.bin === 'string' ? packageMetadata.bin : packageMetadata.bin?.torch;
if (!bin || !existsSync(join(root, bin))) {
throw new TorchError('Candidate package has no runnable TORCH binary', {
code: 'CANDIDATE_BINARY_MISSING', details: { bin: bin ?? null },
});
}
return { version, packageMetadata, release, bin };
}
function completeAcceptance(result) {
const completed = new Set(result?.scenarios ?? []);
return result?.passed === true
&& CANDIDATE_ACCEPTANCE_SCENARIOS.every((scenario) => completed.has(scenario));
}
export class VersionService {
constructor({ env = process.env, now = () => new Date(), afterPointerSwap = null } = {}) {
this.root = join(torchDataHome(env), 'runtime');
this.versionsRoot = join(this.root, 'versions');
this.activePath = join(this.root, 'active');
this.registryPath = join(this.root, 'registry.json');
this.journalPath = join(this.root, 'activation-journal.json');
this.now = now;
this.afterPointerSwap = afterPointerSwap;
}
planCandidate({ source, version } = {}) {
const sourceRoot = resolve(source ?? '');
if (!existsSync(sourceRoot) || !lstatSync(sourceRoot).isDirectory()) {
throw new TorchError('Candidate source must be an existing directory', {
code: 'CANDIDATE_SOURCE_INVALID', details: { source: sourceRoot },
});
}
if (inside(sourceRoot, this.root) || inside(this.root, sourceRoot) || resolve(sourceRoot) === resolve(this.root)) {
throw new TorchError('Candidate source and version store must be separate', {
code: 'CANDIDATE_NAMESPACE_COLLISION', details: { source: sourceRoot, store: this.root },
});
}
const metadata = validateReleaseMetadata(sourceRoot, version);
const descriptor = sourceDescriptor(sourceRoot);
const records = inventory(sourceRoot, { include: descriptor.include });
if (records.some((record) => record.path === '.torch-validation.json')) {
throw new TorchError('Candidate source contains TORCH-owned validation state', {
code: 'CANDIDATE_RESERVED_FILE', details: { path: '.torch-validation.json' },
});
}
const destination = join(this.versionsRoot, metadata.version);
return {
action: 'candidate-install', source: sourceRoot, destination, version: metadata.version,
digest: treeHash(sourceRoot, records), files: records.length,
bytes: records.reduce((total, record) => total + record.bytes, 0),
sourceMode: descriptor.mode, sourceCommit: descriptor.commit,
compatibility: metadata.release.state,
conflicts: existsSync(destination) ? [{ code: 'VERSION_ALREADY_INSTALLED', version: metadata.version }] : [],
mutationPerformed: false,
};
}
installCandidate({ source, version, validate } = {}) {
if (typeof validate !== 'function') {
throw new TorchError('Candidate installation requires an acceptance validator', {
code: 'CANDIDATE_VALIDATOR_REQUIRED',
});
}
const plan = this.planCandidate({ source, version });
if (plan.conflicts.length) {
throw new TorchError('Candidate installation has conflicts', {
code: 'CANDIDATE_INSTALL_CONFLICT', details: plan.conflicts,
});
}
mkdirSync(this.versionsRoot, { recursive: true });
const staging = join(this.versionsRoot, `.candidate-${plan.version}-${randomUUID()}`);
try {
const descriptor = sourceDescriptor(plan.source);
if (descriptor.mode !== plan.sourceMode || descriptor.commit !== plan.sourceCommit) {
throw new TorchError('Candidate source identity changed after planning', {
code: 'CANDIDATE_SOURCE_CHANGED',
details: {
planned: { mode: plan.sourceMode, commit: plan.sourceCommit },
actual: { mode: descriptor.mode, commit: descriptor.commit },
},
});
}
cpSync(plan.source, staging, {
recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true,
filter: (path) => {
if (resolve(path) === resolve(plan.source)) return true;
const name = artifactPath(plan.source, path);
return basename(path) !== '.git' && descriptor.include(name, lstatSync(path));
},
});
validateReleaseMetadata(staging, plan.version);
const copiedRecords = inventory(staging);
const copiedDigest = treeHash(staging, copiedRecords);
if (copiedDigest !== plan.digest) {
throw new TorchError('Candidate changed while it was being staged', {
code: 'CANDIDATE_CHANGED_DURING_COPY', details: { before: plan.digest, after: copiedDigest },
});
}
const acceptance = validate({ candidateRoot: staging, version: plan.version });
if (!completeAcceptance(acceptance)) {
throw new TorchError('Candidate did not complete the required acceptance scenarios', {
code: 'CANDIDATE_ACCEPTANCE_FAILED',
details: { required: CANDIDATE_ACCEPTANCE_SCENARIOS, result: acceptance ?? null },
});
}
atomicJson(join(staging, '.torch-validation.json'), {
schema: 'torch.dev/candidate-validation/v1alpha1', version: plan.version,
digest: plan.digest, validatedAt: this.now().toISOString(),
source: { mode: plan.sourceMode, commit: plan.sourceCommit },
scenarios: CANDIDATE_ACCEPTANCE_SCENARIOS, evidence: acceptance.evidence ?? [], passed: true,
});
renameSync(staging, plan.destination);
return { ...plan, validation: join(plan.destination, '.torch-validation.json'), mutationPerformed: true };
} catch (error) {
if (existsSync(staging)) rmSync(staging, { recursive: true, force: true });
throw error;
}
}
#activeVersion() {
if (!existsSync(this.activePath)) return null;
try { return basename(resolve(dirname(this.activePath), readlinkSync(this.activePath))); } catch { return null; }
}
#registry() {
return existsSync(this.registryPath) ? json(this.registryPath) : {
schema: 'torch.dev/version-registry/v1alpha1', activeVersion: null, previousVersion: null,
generation: 0, updatedAt: null,
};
}
#reconcile() {
if (!existsSync(this.journalPath)) return;
const journal = json(this.journalPath);
const activeVersion = this.#activeVersion();
const registry = this.#registry();
if (activeVersion === journal.to) {
atomicJson(this.registryPath, {
...registry, activeVersion: journal.to, previousVersion: journal.from,
generation: registry.generation + 1, updatedAt: this.now().toISOString(),
});
} else if (activeVersion !== journal.from) {
throw new TorchError('Activation journal does not match the active version pointer', {
code: 'ACTIVATION_JOURNAL_INCONSISTENT', details: { journal, activeVersion },
});
}
rmSync(this.journalPath, { force: true });
}
status() {
this.#reconcile();
const registry = this.#registry();
const activeVersion = this.#activeVersion();
const installed = existsSync(this.versionsRoot)
? readdirSync(this.versionsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.candidate-'))
.map((entry) => entry.name).sort()
: [];
return {
root: this.root, activeVersion, previousVersion: registry.previousVersion,
generation: registry.generation, installed,
consistent: activeVersion === registry.activeVersion,
};
}
activate(version) {
if (!validVersion(version)) throw new TorchError('Invalid TORCH version', { code: 'VERSION_INVALID' });
mkdirSync(this.root, { recursive: true });
this.#reconcile();
const destination = join(this.versionsRoot, version);
const validationPath = join(destination, '.torch-validation.json');
if (!existsSync(destination) || !existsSync(validationPath) || json(validationPath).passed !== true) {
throw new TorchError('Only an installed, accepted candidate may become active', {
code: 'CANDIDATE_NOT_ACCEPTED', details: { version },
});
}
const validation = json(validationPath);
const installedDigest = treeHash(destination, inventory(destination, { excludeValidation: true }));
if (validation.digest !== installedDigest) {
throw new TorchError('Accepted candidate contents changed after validation', {
code: 'CANDIDATE_VALIDATION_STALE',
details: { version, expected: validation.digest, actual: installedDigest },
});
}
const candidate = validateReleaseMetadata(destination, version).release;
const from = this.#activeVersion();
if (from === version) return { ...this.status(), changed: false, mutationPerformed: false };
if (from) {
const current = validateReleaseMetadata(join(this.versionsRoot, from), from).release;
if (!candidate.state.reads.includes(current.state.writes)
|| candidate.state.writes !== current.state.writes
|| current.state.rollback_safe !== true) {
throw new TorchError('Candidate is not forward-and-rollback compatible with the active version', {
code: 'STATE_COMPATIBILITY_REFUSED', details: { from, to: version },
});
}
}
atomicJson(this.journalPath, {
schema: 'torch.dev/activation-journal/v1alpha1', from, to: version, startedAt: this.now().toISOString(),
});
const temporary = `${this.activePath}.tmp-${process.pid}-${randomUUID()}`;
symlinkSync(relative(this.root, destination), temporary, 'dir');
renameSync(temporary, this.activePath);
this.afterPointerSwap?.({ from, to: version });
this.#reconcile();
return { ...this.status(), changed: true, mutationPerformed: true };
}
rollback() {
const status = this.status();
if (!status.previousVersion) {
throw new TorchError('No previously active TORCH version is available', { code: 'ROLLBACK_UNAVAILABLE' });
}
return this.activate(status.previousVersion);
}
}
export function createVersionService(options) {
return new VersionService(options);
}