mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Make activated TORCH versions runnable
This commit is contained in:
1 parent
c535edf72c
commit
6740f330fd
7 files changed
+95
-10
No files matched your search
@@ -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
File mode changed.
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user