Make activated TORCH versions runnable

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 19:40:25 -04:00
1 parent c535edf72c
commit 6740f330fd
7 files changed
+95 -10

No files matched your search

+2
View File
@@ -325,6 +325,8 @@ active version. Candidate build runs the complete local acceptance, lint, and
syntax gates before writing its receipt. Git checkout staging is bound to a
clean exact commit, copies tracked source plus its local dependency tree,
excludes unrelated untracked files, and refuses escaping symbolic links:
Activation maintains an installation-owned stable `torch` launcher pointing
through the atomic active-version link; it refuses an unrelated existing path.
```bash
node bin/torch.mjs candidate plan --source . --json
Regular → Executable
View File
File mode changed.
+6
View File
@@ -1371,6 +1371,12 @@ active pointer. Running processes may finish on the old version. Rollback
restores the previous pointer without rewriting project state. State migrations
MUST declare forward and rollback compatibility.
Activation also maintains a stable executable launcher at
`~/.local/bin/torch` (or an explicitly configured installation bin directory)
that resolves through the atomic active-version pointer. Candidate validation
hashes executable modes as well as bytes, rejects a non-executable TORCH binary,
and refuses to replace a launcher path not owned by that installation.
## 27. User interfaces
### 27.1 CLI
@@ -121,7 +121,7 @@ test_integrity_note:
- id: SCN-candidate-isolation
given: an inactive candidate in a namespace separate from project and stable runtime state
when: acceptance is incomplete, complete, or the accepted files are later modified
then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, stale validation blocks activation, Git staging binds to a clean exact commit, local dependencies remain runnable, and unrelated untracked files never enter the artifact
then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, stale validation blocks activation, Git staging binds to a clean exact commit, local dependencies remain runnable, unrelated untracked files never enter the artifact, activation exposes a runnable stable launcher, and an owner path conflict blocks before pointer mutation
- id: SCN-atomic-upgrade-rollback
given: accepted forward-and-rollback-compatible versions installed side by side
when: activation is interrupted after the atomic pointer swap and the service restarts
+52 -3
View File
@@ -4,6 +4,7 @@ import {
cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync,
renameSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { TorchError } from '../kernel/errors.mjs';
import { torchDataHome } from '../kernel/paths.mjs';
@@ -39,6 +40,13 @@ function json(path) {
}
}
function nodeExists(path) {
try { lstatSync(path); return true; } catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}
function atomicJson(path, value) {
mkdirSync(dirname(path), { recursive: true });
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
@@ -78,10 +86,12 @@ function inventory(root, { excludeValidation = false, include = () => true } = {
code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name, target: link },
});
}
records.push({ path: name, bytes: Buffer.byteLength(link), type: 'symlink', link });
records.push({ path: name, bytes: Buffer.byteLength(link), type: 'symlink', link, mode: stat.mode & 0o777 });
}
if (stat.isDirectory()) visit(path);
else if (stat.isFile()) records.push({ path: name, bytes: stat.size, type: 'file' });
else if (stat.isFile()) records.push({
path: name, bytes: stat.size, type: 'file', mode: stat.mode & 0o777,
});
else if (stat.isSymbolicLink()) { /* recorded above */ }
else throw new TorchError(`Unsupported candidate artifact entry: ${name}`, {
code: 'CANDIDATE_ENTRY_REFUSED', details: { path: name },
@@ -95,7 +105,7 @@ function inventory(root, { excludeValidation = false, include = () => true } = {
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.path).update('\0').update(record.type).update('\0').update(String(record.mode)).update('\0');
digest.update(record.type === 'symlink' ? record.link : readFileSync(join(root, record.path))).update('\0');
}
return digest.digest('hex');
@@ -163,6 +173,11 @@ function validateReleaseMetadata(root, requestedVersion) {
code: 'CANDIDATE_BINARY_MISSING', details: { bin: bin ?? null },
});
}
if ((lstatSync(join(root, bin)).mode & 0o111) === 0) {
throw new TorchError('Candidate TORCH binary is not executable', {
code: 'CANDIDATE_BINARY_NOT_EXECUTABLE', details: { bin },
});
}
return { version, packageMetadata, release, bin };
}
@@ -179,6 +194,8 @@ export class VersionService {
this.activePath = join(this.root, 'active');
this.registryPath = join(this.root, 'registry.json');
this.journalPath = join(this.root, 'activation-journal.json');
this.binRoot = env.TORCH_BIN_HOME || join(env.HOME || homedir(), '.local', 'bin');
this.launcherPath = join(this.binRoot, 'torch');
this.now = now;
this.afterPointerSwap = afterPointerSwap;
}
@@ -295,6 +312,7 @@ export class VersionService {
const activeVersion = this.#activeVersion();
const registry = this.#registry();
if (activeVersion === journal.to) {
this.#ensureLauncher();
atomicJson(this.registryPath, {
...registry, activeVersion: journal.to, previousVersion: journal.from,
generation: registry.generation + 1, updatedAt: this.now().toISOString(),
@@ -307,6 +325,31 @@ export class VersionService {
rmSync(this.journalPath, { force: true });
}
#launcherTarget() {
return relative(this.binRoot, join(this.activePath, 'bin', 'torch.mjs'));
}
#assertLauncherAvailable() {
const target = this.#launcherTarget();
if (nodeExists(this.launcherPath)) {
let actual = null;
try { actual = readlinkSync(this.launcherPath); } catch { /* conflict reported below */ }
if (actual !== target) throw new TorchError('TORCH launcher path is not owned by this installation', {
code: 'LAUNCHER_PATH_CONFLICT', details: { path: this.launcherPath, expected: target, actual },
});
}
return target;
}
#ensureLauncher() {
const target = this.#assertLauncherAvailable();
if (nodeExists(this.launcherPath)) return;
mkdirSync(this.binRoot, { recursive: true });
const temporary = `${this.launcherPath}.tmp-${process.pid}-${randomUUID()}`;
symlinkSync(target, temporary, 'file');
renameSync(temporary, this.launcherPath);
}
status() {
this.#reconcile();
const registry = this.#registry();
@@ -320,6 +363,11 @@ export class VersionService {
root: this.root, activeVersion, previousVersion: registry.previousVersion,
generation: registry.generation, installed,
consistent: activeVersion === registry.activeVersion,
launcher: {
path: this.launcherPath,
installed: nodeExists(this.launcherPath),
expectedTarget: this.#launcherTarget(),
},
};
}
@@ -355,6 +403,7 @@ export class VersionService {
});
}
}
this.#assertLauncherAvailable();
atomicJson(this.journalPath, {
schema: 'torch.dev/activation-journal/v1alpha1', from, to: version, startedAt: this.now().toISOString(),
});
+7 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import test from 'node:test';
@@ -21,7 +21,11 @@ function repo() {
function run(root, args) {
return spawnSync(process.execPath, [CLI, ...args], {
cwd: root,
env: { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-data`) },
env: {
...process.env,
XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-data`),
TORCH_BIN_HOME: join(tmpdir(), `${basename(root)}-bin`),
},
encoding: 'utf8',
});
}
@@ -30,6 +34,7 @@ function releaseCandidate(root, version) {
const candidateRoot = join(root, `release-${version}`);
mkdirSync(join(candidateRoot, 'bin'), { recursive: true });
writeFileSync(join(candidateRoot, 'bin', 'torch.mjs'), '#!/usr/bin/env node\n');
chmodSync(join(candidateRoot, 'bin', 'torch.mjs'), 0o755);
writeFileSync(join(candidateRoot, 'acceptance.mjs'), `console.log(${JSON.stringify([
'SCN-init-read-only', 'SCN-cli-domain-review', 'SCN-worktree-bootstrap', 'SCN-mixed-runtime',
'SCN-spec-fleet-design', 'SCN-cli-spec-design',
+27 -4
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync,
chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -20,7 +20,8 @@ function candidate(parent, version, marker) {
schema: 'torch.dev/release/v1alpha1', version,
state: { reads: ['torch.dev/state/v1alpha1'], writes: 'torch.dev/state/v1alpha1', rollback_safe: true },
}, null, 2)}\n`);
writeFileSync(join(root, 'bin', 'torch.mjs'), `export const marker = ${JSON.stringify(marker)};\n`);
writeFileSync(join(root, 'bin', 'torch.mjs'), `#!/usr/bin/env node\nexport const marker = ${JSON.stringify(marker)};\nconsole.log(marker);\n`);
chmodSync(join(root, 'bin', 'torch.mjs'), 0o755);
return root;
}
@@ -34,7 +35,7 @@ function accepted(candidateRoot) {
test('SCN-candidate-isolation: staging requires complete acceptance and never changes the active version', () => {
const root = mkdtempSync(join(tmpdir(), 'torch-self-host-'));
const env = { ...process.env, XDG_DATA_HOME: join(root, 'xdg') };
const env = { ...process.env, XDG_DATA_HOME: join(root, 'xdg'), TORCH_BIN_HOME: join(root, 'bin-home') };
const service = createVersionService({ env, now: () => new Date('2026-09-27T00:00:00Z') });
const source = candidate(root, '1.0.0', 'stable');
const plan = service.planCandidate({ source });
@@ -86,17 +87,37 @@ test('SCN-candidate-isolation: staging requires complete acceptance and never ch
assert.deepEqual(JSON.parse(readFileSync(bounded.validation, 'utf8')).source, {
mode: 'git-commit', commit: boundedPlan.sourceCommit,
});
const activated = service.activate('1.1.0');
assert.equal(activated.launcher.installed, true);
assert.equal(execFileSync(activated.launcher.path, [], { encoding: 'utf8' }).trim(), 'bounded');
writeFileSync(join(checkout, 'bin', 'torch.mjs'), 'export const marker = "dirty";\n');
assert.throws(
() => service.planCandidate({ source: checkout }),
(error) => error.code === 'CANDIDATE_SOURCE_DIRTY',
);
const conflictRoot = mkdtempSync(join(tmpdir(), 'torch-self-host-conflict-'));
const conflictEnv = {
...process.env,
XDG_DATA_HOME: join(conflictRoot, 'xdg'),
TORCH_BIN_HOME: join(conflictRoot, 'bin-home'),
};
const conflictService = createVersionService({ env: conflictEnv });
const conflictCandidate = candidate(conflictRoot, '2.0.0', 'conflict');
conflictService.installCandidate({ source: conflictCandidate, validate: accepted });
mkdirSync(conflictEnv.TORCH_BIN_HOME, { recursive: true });
writeFileSync(join(conflictEnv.TORCH_BIN_HOME, 'torch'), 'owner executable\n');
assert.throws(
() => conflictService.activate('2.0.0'),
(error) => error.code === 'LAUNCHER_PATH_CONFLICT',
);
assert.equal(conflictService.status().activeVersion, null);
});
test('SCN-atomic-upgrade-rollback: a crash after pointer swap reconciles and rollback restores the exact prior version', () => {
const root = mkdtempSync(join(tmpdir(), 'torch-self-host-'));
const env = { ...process.env, XDG_DATA_HOME: join(root, 'xdg') };
const env = { ...process.env, XDG_DATA_HOME: join(root, 'xdg'), TORCH_BIN_HOME: join(root, 'bin-home') };
const v1 = candidate(root, '1.0.0', 'stable');
const v2 = candidate(root, '1.1.0', 'candidate');
const service = createVersionService({ env, now: () => new Date('2026-09-27T01:00:00Z') });
@@ -117,6 +138,8 @@ test('SCN-atomic-upgrade-rollback: a crash after pointer swap reconciles and rol
assert.equal(status.activeVersion, '1.1.0');
assert.equal(status.previousVersion, '1.0.0');
assert.equal(existsSync(join(status.root, 'activation-journal.json')), false);
assert.equal(status.launcher.installed, true);
assert.equal(execFileSync(status.launcher.path, [], { encoding: 'utf8' }).trim(), 'candidate');
const rolledBack = recovered.rollback();
assert.equal(rolledBack.activeVersion, '1.0.0');