Add acceptance-gated self-host upgrades

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 16:34:44 -04:00
1 parent 1c9834e3d7
commit 4b4a0f38f9
9 files changed
+656 -11

No files matched your search

+23 -3
View File
@@ -44,9 +44,12 @@ and the first Claude reference-fleet path:
- a native integration queue with durable authorization and fast-forward main protection;
- a local bare canonical Git option with honest one-disk/local-remote grading;
- mutation audit events tied to project and Fleet identity.
- isolated candidate version stores with explicit compatibility declarations;
- acceptance-gated atomic activation, crash reconciliation, and exact rollback.
Live mixed-provider qualification, self-hosting, COMBATRIG re-import,
scheduling, richer observability, and the Fleet Console remain staged work.
Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG
re-import, scheduling, richer observability, and the Fleet Console remain
staged work.
The canonical architecture and delivery plan is
[docs/PORTABLE_AGENT_FLEET_SPEC.md](docs/PORTABLE_AGENT_FLEET_SPEC.md).
@@ -104,12 +107,29 @@ Local verification and convergence use `torch checks`, `torch resources`, and
`torch integrate`. A project without a forge can preview and create its bare
canonical store with `torch canonical plan` and `torch canonical create --yes`.
TORCH source checkouts can stage a self-host candidate without changing the
active version. Candidate build runs the complete local acceptance, lint, and
syntax gates before writing its receipt:
```bash
node bin/torch.mjs candidate plan --source . --json
node bin/torch.mjs candidate build --source . --yes --json
node bin/torch.mjs upgrade --version 0.1.0-alpha.0 --dry-run --json
node bin/torch.mjs upgrade --version 0.1.0-alpha.0 --yes --json
node bin/torch.mjs rollback --dry-run --json
```
The current source cannot yet satisfy its own complete candidate gate because
the durable backlog lifecycle is still staged work. This is an intentional
fail-closed dependency, not a waived acceptance check. Bootstrapping an
installed stable TORCH to manage this repository also remains open.
Persistent domain sessions also create a context-locality opportunity: each
specialist can keep its relevant working set hot instead of one general session
reloading unrelated project areas. TORCH treats cache/token savings as a
measurement hypothesis until provider usage and verified task outcomes prove it.
This is still an alpha. The lifecycle is scenario-tested with virtualized
This is still an alpha. Runtime lifecycle is scenario-tested with virtualized
Claude and Codex boundaries. The tests make no paid provider calls. A live
mixed-provider, multi-domain acceptance run remains an explicit
owner-authorized qualification gate.
+9
View File
@@ -1373,6 +1373,15 @@ the automated suite intentionally incurs no provider usage.
Exit gate: stable TORCH manages development, testing, and promotion of its
successor in local-only mode.
Implementation checkpoint (2026-09-27): isolated version installation,
reserved validation receipts, declared state compatibility, an atomic active
symlink, activation journaling, interruption reconciliation, exact rollback,
and approval-gated CLI operations are implemented and scenario-tested. The
candidate CLI requires named acceptance evidence and currently fails closed on
the not-yet-implemented durable backlog lifecycle. The exit gate remains open
until that lifecycle exists and an installed stable TORCH manages this source
repository; candidate code does not receive a hidden self-hosting exception.
### Stage 7: COMBATRIG re-import
- express COMBATRIG domains, prompts, resources, checks, schedules, and release
+7 -4
View File
@@ -18,7 +18,8 @@
"./integration": "./src/integration/service.mjs",
"./mcp": "./src/mcp/tools.mjs",
"./resources": "./src/resources/service.mjs",
"./runtime/lifecycle": "./src/runtime/lifecycle.mjs"
"./runtime/lifecycle": "./src/runtime/lifecycle.mjs",
"./self-host": "./src/self-host/service.mjs"
},
"bin": {
"torch": "./bin/torch.mjs",
@@ -52,16 +53,18 @@
"src/mcp/",
"src/resources/",
"src/runtime/",
"src/self-host/",
"docs/PORTABLE_AGENT_FLEET_SPEC.md",
"README.md",
"LICENSE"
"LICENSE",
"torch-release.json"
],
"scripts": {
"test": "node --test --test-timeout=30000 test/fleet/*.test.mjs",
"torch": "node bin/torch.mjs",
"check": "node --check bin/torch.mjs && node --check src/cli.mjs && node --check src/mcp/server.mjs",
"format": "eslint src/adapters src/canonical src/checks src/control-plane src/integration src/kernel src/mcp src/resources src/runtime src/cli.mjs bin/torch.mjs test/fleet --fix",
"lint": "eslint src/adapters src/canonical src/checks src/control-plane src/integration src/kernel src/mcp src/resources src/runtime src/cli.mjs bin/torch.mjs test/fleet"
"format": "eslint src/adapters src/canonical src/checks src/control-plane src/integration src/kernel src/mcp src/resources src/runtime src/self-host src/cli.mjs bin/torch.mjs test/fleet --fix",
"lint": "eslint src/adapters src/canonical src/checks src/control-plane src/integration src/kernel src/mcp src/resources src/runtime src/self-host src/cli.mjs bin/torch.mjs test/fleet"
},
"dependencies": {
"@modelcontextprotocol/server": "^2.1.0",
@@ -62,6 +62,10 @@ test_integrity_note:
given: an installed project using no MCP-capable runtime
when: an agent sends, reads, acknowledges, reports status, and queries ownership through CLI
then: each operation observes the same durable control-plane state
- id: SCN-cli-self-host
given: two isolated candidate artifacts with complete named acceptance evidence
when: an owner plans, builds, activates, upgrades, and rolls back through the CLI
then: build and pointer changes require approval, staging does not activate code, and rollback restores the exact prior version
- id: SCN-mcp-parity
given: the provider-independent control-plane service
when: all specified MCP tools invoke it
@@ -90,6 +94,14 @@ test_integrity_note:
given: one approved Codex worker domain and one Claude Session Manager with stable Fleet identities
when: the mixed fleet starts and winds down through virtualized runtime boundaries
then: provider-specific commands and runtime IDs remain separate, the manager starts last, Codex completes idle, and only resident processes are stopped
- 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, and stale validation blocks activation
- 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
then: the journal reconciles to the exact active version and rollback restores the previous pointer without rewriting project state
- id: SCN-exact-sha-checks
given: approved shell-free checks and a clean managed worktree
when: passing, failing, and dirty-input checks are requested
@@ -133,6 +145,8 @@ test_integrity_note:
- shell-free fixture checks with controlled exit codes
- injected resource clock and identifiers
- local-only bare Git remote with no network dependency
- isolated XDG version roots, synthetic artifacts, and injected acceptance results
- injected post-pointer interruption with deterministic recovery timestamps
anti_cheat_rationale:
prevents:
- init that silently writes files
@@ -153,6 +167,9 @@ test_integrity_note:
- unfair or automatically stolen resource leases
- integration based on branch labels, stale checks, or stale main
- a same-disk bare repository mislabeled as off-machine recovery
- a merely passing command being mislabeled as complete candidate acceptance
- candidate staging silently replacing stable or accepted code changing before activation
- interrupted pointer swaps leaving version metadata ambiguous
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
+132 -3
View File
@@ -17,6 +17,7 @@ import { writeNewFile } from './kernel/files.mjs';
import { createWorktrees, planWorktrees } from './kernel/worktrees.mjs';
import { planFleetDown, planFleetUp, startFleet, stopFleet } from './runtime/lifecycle.mjs';
import { ResourceService } from './resources/service.mjs';
import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-host/service.mjs';
const HELP = `TORCH — portable agent fleet
@@ -60,6 +61,12 @@ Usage:
torch canonical plan [--json]
torch canonical create --yes [--json]
torch recoverability [--commit <sha>] [--json]
torch candidate plan --source <path> [--version <version>] [--json]
torch candidate build --source <path> [--version <version>] --yes [--json]
torch candidate status [--json]
torch candidate test --version <version> [--json]
torch upgrade --version <version> [--dry-run] --yes [--json]
torch rollback [--dry-run] --yes [--json]
torch doctor [--json]
torch uninstall [--dry-run] [--purge] [--json]
`;
@@ -105,7 +112,51 @@ function runtimeAdapters() {
]);
}
export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd(), env = process.env } = {}) {
function runCandidateAcceptance(candidateRoot, spawn) {
const checks = [
{ id: 'test', args: ['test'] },
{ id: 'lint', args: ['run', 'lint'] },
{ id: 'syntax', args: ['run', 'check'] },
].map((check) => {
const result = spawn('npm', check.args, {
cwd: candidateRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 300_000,
});
return {
id: check.id, status: result.status ?? (result.error ? 1 : 0),
signal: result.signal ?? null, error: result.error?.message ?? null,
stdout: result.stdout ?? '',
};
});
const testOutput = checks.find((check) => check.id === 'test')?.stdout ?? '';
const evidenceByScenario = new Map([
['init-analyze', ['SCN-init-read-only']],
['review-install-roster', ['SCN-cli-domain-review']],
['branches-worktrees', ['SCN-worktree-bootstrap']],
['runtime-identities', ['SCN-mixed-runtime']],
['durable-messaging', ['SCN-durable-message']],
['ownership-query', ['SCN-ownership-handoff']],
['unsafe-worktree-detection', ['SCN-worktree-purge-safety']],
['backlog-integration', ['SCN-backlog-lifecycle', 'SCN-native-integration']],
['resource-lifecycle', ['SCN-resource-fifo']],
['capture-stop-resume', ['SCN-fleet-fresh-resume']],
['detach-uninstall', ['SCN-install-doctor-purge']],
]);
const scenarios = CANDIDATE_ACCEPTANCE_SCENARIOS.filter((scenario) =>
(evidenceByScenario.get(scenario) ?? []).every((marker) => testOutput.includes(marker)));
const passed = checks.every((check) => check.status === 0 && !check.signal && !check.error)
&& scenarios.length === CANDIDATE_ACCEPTANCE_SCENARIOS.length;
return {
passed,
scenarios,
evidence: checks.map(({ stdout, ...check }) => ({
...check, outputBytes: Buffer.byteLength(stdout),
})),
};
}
export async function runCli(argv = process.argv.slice(2), {
cwd = process.cwd(), env = process.env, spawn = spawnSync,
} = {}) {
const command = argv.find((arg) => !arg.startsWith('-')) ?? 'help';
const json = argv.includes('--json');
try {
@@ -113,6 +164,84 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd()
process.stdout.write(HELP);
return 0;
}
if (command === 'candidate') {
const operation = argv[1] ?? 'status';
const versions = createVersionService({ env });
if (operation === 'status') {
print(versions.status(), { json });
return 0;
}
if (operation === 'test') {
const version = optionValue(argv, '--version');
const status = versions.status();
const validationPath = resolve(status.root, 'versions', version ?? '', '.torch-validation.json');
if (!version || !status.installed.includes(version)) {
throw new TorchError('Candidate test receipt requires an installed version', {
code: 'VERSION_NOT_INSTALLED', details: { version: version ?? null },
});
}
print(JSON.parse(readFileSync(validationPath, 'utf8')), { json });
return 0;
}
const source = optionValue(argv, '--source');
const version = optionValue(argv, '--version');
if (operation === 'plan') {
print(versions.planCandidate({ source: resolve(cwd, source ?? ''), version }), { json });
return 0;
}
if (operation === 'build') {
if (!argv.includes('--yes')) {
throw new TorchError('Candidate build copies code and runs its acceptance suite. Review candidate plan, then re-run with --yes.', {
code: 'APPROVAL_REQUIRED',
});
}
print(versions.installCandidate({
source: resolve(cwd, source ?? ''), version,
validate: ({ candidateRoot }) => runCandidateAcceptance(candidateRoot, spawn),
}), { json });
return 0;
}
throw new TorchError(`Unknown candidate operation: ${operation}`, { code: 'UNKNOWN_COMMAND' });
}
if (command === 'upgrade') {
const versions = createVersionService({ env });
const version = optionValue(argv, '--version');
const status = versions.status();
const plan = {
action: 'upgrade', from: status.activeVersion, to: version ?? null,
canProceed: Boolean(version && status.installed.includes(version)), mutationPerformed: false,
};
if (argv.includes('--dry-run')) {
print(plan, { json });
return plan.canProceed ? 0 : 1;
}
if (!argv.includes('--yes')) {
throw new TorchError('Upgrade atomically changes the active TORCH version. Review --dry-run, then re-run with --yes.', {
code: 'APPROVAL_REQUIRED',
});
}
print(versions.activate(version), { json });
return 0;
}
if (command === 'rollback') {
const versions = createVersionService({ env });
const status = versions.status();
const plan = {
action: 'rollback', from: status.activeVersion, to: status.previousVersion,
canProceed: Boolean(status.previousVersion), mutationPerformed: false,
};
if (argv.includes('--dry-run')) {
print(plan, { json });
return plan.canProceed ? 0 : 1;
}
if (!argv.includes('--yes')) {
throw new TorchError('Rollback atomically restores the previous TORCH version. Review --dry-run, then re-run with --yes.', {
code: 'APPROVAL_REQUIRED',
});
}
print(versions.rollback(), { json });
return 0;
}
const repository = inspectRepository(cwd);
if (command === 'init' || command === 'analyze') {
const analysis = analyzeRepository(repository);
@@ -186,7 +315,7 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd()
}
print(startFleet({
plan, controlPlane: control, adapters,
executor: (launch) => spawnSync(launch.command, launch.args, {
executor: (launch) => spawn(launch.command, launch.args, {
cwd: launch.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}),
}), { json });
@@ -211,7 +340,7 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd()
}
const runtimeStoppers = new Map([
['claude', createClaudeAdapter({
runner: (executable, args) => spawnSync(executable, args, {
runner: (executable, args) => spawn(executable, args, {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}),
})],
+308
View File
@@ -0,0 +1,308 @@
import { createHash, randomUUID } from 'node:crypto';
import {
cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync,
renameSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { basename, dirname, 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',
'review-install-roster',
'branches-worktrees',
'runtime-identities',
'durable-messaging',
'ownership-query',
'unsafe-worktree-detection',
'backlog-integration',
'resource-lifecycle',
'capture-stop-resume',
'detach-uninstall',
]);
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 inventory(root, { excludeValidation = false } = {}) {
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 = relative(root, path);
if (excludeValidation && name === '.torch-validation.json') continue;
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
throw new TorchError(`Candidate artifacts may not contain symbolic links: ${name}`, {
code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name },
});
}
if (stat.isDirectory()) visit(path);
else if (stat.isFile()) records.push({ path: name, bytes: stat.size });
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(readFileSync(join(root, record.path))).update('\0');
}
return digest.digest('hex');
}
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 records = inventory(sourceRoot);
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),
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 {
cpSync(plan.source, staging, {
recursive: true, errorOnExist: true, force: false,
filter: (path) => basename(path) !== '.git',
});
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(),
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);
}
+58 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
@@ -26,6 +26,31 @@ function run(root, args) {
});
}
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');
writeFileSync(join(candidateRoot, 'acceptance.mjs'), `console.log(${JSON.stringify([
'SCN-init-read-only', 'SCN-cli-domain-review', 'SCN-worktree-bootstrap', 'SCN-mixed-runtime',
'SCN-durable-message', 'SCN-ownership-handoff', 'SCN-worktree-purge-safety',
'SCN-backlog-lifecycle', 'SCN-native-integration', 'SCN-resource-fifo',
'SCN-fleet-fresh-resume', 'SCN-install-doctor-purge',
].join('\n'))});\n`);
writeFileSync(join(candidateRoot, 'package.json'), `${JSON.stringify({
name: 'torch-agent-fleet', version, bin: { torch: 'bin/torch.mjs' },
scripts: {
test: 'node acceptance.mjs',
lint: 'node -e "process.exit(0)"',
check: 'node -e "process.exit(0)"',
},
}, null, 2)}\n`);
writeFileSync(join(candidateRoot, 'torch-release.json'), `${JSON.stringify({
schema: 'torch.dev/release/v1alpha1', version,
state: { reads: ['torch.dev/state/v1alpha1'], writes: 'torch.dev/state/v1alpha1', rollback_safe: true },
}, null, 2)}\n`);
return candidateRoot;
}
test('SCN-cli-init: init reports analysis without creating project state', () => {
const root = repo();
const result = run(root, ['init', '--json']);
@@ -99,3 +124,35 @@ test('SCN-cli-control-plane: CLI messages, acknowledgements, ownership, and stat
const ownership = JSON.parse(run(root, ['who-owns', '--path', 'README.md', '--json']).stdout);
assert.equal(ownership.owners.length > 0, true);
});
test('SCN-cli-self-host: candidate acceptance, atomic upgrade, and rollback require explicit approval', () => {
const root = repo();
const v1 = releaseCandidate(root, '1.0.0');
const v2 = releaseCandidate(root, '1.1.0');
const plan = run(root, ['candidate', 'plan', '--source', v1, '--json']);
assert.equal(plan.status, 0, plan.stderr || plan.stdout);
assert.equal(JSON.parse(plan.stdout).version, '1.0.0');
const refused = run(root, ['candidate', 'build', '--source', v1, '--json']);
assert.equal(JSON.parse(refused.stdout).error, 'APPROVAL_REQUIRED');
for (const source of [v1, v2]) {
const built = run(root, ['candidate', 'build', '--source', source, '--yes', '--json']);
assert.equal(built.status, 0, built.stderr || built.stdout);
assert.equal(JSON.parse(built.stdout).mutationPerformed, true);
}
const before = JSON.parse(run(root, ['candidate', 'status', '--json']).stdout);
assert.equal(before.activeVersion, null);
assert.deepEqual(before.installed, ['1.0.0', '1.1.0']);
assert.equal(run(root, ['upgrade', '--version', '1.0.0', '--dry-run', '--json']).status, 0);
assert.equal(JSON.parse(run(root, ['upgrade', '--version', '1.0.0', '--json']).stdout).error, 'APPROVAL_REQUIRED');
assert.equal(run(root, ['upgrade', '--version', '1.0.0', '--yes', '--json']).status, 0);
assert.equal(run(root, ['upgrade', '--version', '1.1.0', '--yes', '--json']).status, 0);
const rollbackPlan = JSON.parse(run(root, ['rollback', '--dry-run', '--json']).stdout);
assert.deepEqual({ from: rollbackPlan.from, to: rollbackPlan.to }, { from: '1.1.0', to: '1.0.0' });
assert.equal(JSON.parse(run(root, ['rollback', '--json']).stdout).error, 'APPROVAL_REQUIRED');
const rolledBack = run(root, ['rollback', '--yes', '--json']);
assert.equal(rolledBack.status, 0, rolledBack.stderr || rolledBack.stdout);
assert.equal(JSON.parse(rolledBack.stdout).activeVersion, '1.0.0');
});
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService,
} from '../../src/self-host/service.mjs';
function candidate(parent, version, marker) {
const root = join(parent, `candidate-${version}`);
mkdirSync(join(root, 'bin'), { recursive: true });
writeFileSync(join(root, 'package.json'), `${JSON.stringify({
name: 'torch-agent-fleet', version, type: 'module', bin: { torch: 'bin/torch.mjs' },
}, null, 2)}\n`);
writeFileSync(join(root, 'torch-release.json'), `${JSON.stringify({
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`);
return root;
}
function accepted(candidateRoot) {
return {
passed: true,
scenarios: [...CANDIDATE_ACCEPTANCE_SCENARIOS],
evidence: [{ type: 'fixture', 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 service = createVersionService({ env, now: () => new Date('2026-09-27T00:00:00Z') });
const source = candidate(root, '1.0.0', 'stable');
const plan = service.planCandidate({ source });
assert.equal(plan.mutationPerformed, false);
assert.equal(plan.files, 3);
assert.equal(service.status().activeVersion, null);
assert.equal(existsSync(join(root, 'xdg')), false, 'planning and empty status must remain read-only');
assert.throws(
() => service.installCandidate({
source,
validate: () => ({ passed: true, scenarios: CANDIDATE_ACCEPTANCE_SCENARIOS.slice(1) }),
}),
(error) => error.code === 'CANDIDATE_ACCEPTANCE_FAILED',
);
assert.equal(service.status().installed.length, 0);
assert.equal(service.status().activeVersion, null);
const installed = service.installCandidate({ source, validate: accepted });
assert.equal(installed.mutationPerformed, true);
assert.deepEqual(service.status().installed, ['1.0.0']);
assert.equal(service.status().activeVersion, null);
assert.equal(JSON.parse(readFileSync(installed.validation, 'utf8')).passed, true);
writeFileSync(join(installed.destination, 'bin', 'torch.mjs'), 'export const marker = "tampered";\n');
assert.throws(
() => service.activate('1.0.0'),
(error) => error.code === 'CANDIDATE_VALIDATION_STALE',
);
});
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 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') });
service.installCandidate({ source: v1, validate: accepted });
service.installCandidate({ source: v2, validate: accepted });
assert.equal(service.activate('1.0.0').activeVersion, '1.0.0');
const crashing = createVersionService({
env,
now: () => new Date('2026-09-27T02:00:00Z'),
afterPointerSwap: () => { throw new Error('simulated interruption'); },
});
assert.throws(() => crashing.activate('1.1.0'), /simulated interruption/);
const recovered = createVersionService({ env, now: () => new Date('2026-09-27T03:00:00Z') });
const status = recovered.status();
assert.equal(status.consistent, true);
assert.equal(status.activeVersion, '1.1.0');
assert.equal(status.previousVersion, '1.0.0');
assert.equal(existsSync(join(status.root, 'activation-journal.json')), false);
const rolledBack = recovered.rollback();
assert.equal(rolledBack.activeVersion, '1.0.0');
assert.equal(rolledBack.previousVersion, '1.1.0');
assert.equal(readFileSync(join(rolledBack.root, 'active', 'bin', 'torch.mjs'), 'utf8').includes('stable'), true);
});
+9
View File
@@ -0,0 +1,9 @@
{
"schema": "torch.dev/release/v1alpha1",
"version": "0.1.0-alpha.0",
"state": {
"reads": ["torch.dev/state/v1alpha1"],
"writes": "torch.dev/state/v1alpha1",
"rollback_safe": true
}
}