diff --git a/README.md b/README.md index f4cea1e..61e7daa 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 1cc865c..6ed6746 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -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 diff --git a/package.json b/package.json index 3ee508b..2190f26 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 880f36f..ddb6da2 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -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: "" diff --git a/src/cli.mjs b/src/cli.mjs index 621afa2..13b88ad 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -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 ] [--json] + torch candidate plan --source [--version ] [--json] + torch candidate build --source [--version ] --yes [--json] + torch candidate status [--json] + torch candidate test --version [--json] + torch upgrade --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'], }), })], diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs new file mode 100644 index 0000000..9ecbd4e --- /dev/null +++ b/src/self-host/service.mjs @@ -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); +} diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index 4d49f39..a46ad1a 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -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'); +}); diff --git a/test/fleet/self-host.test.mjs b/test/fleet/self-host.test.mjs new file mode 100644 index 0000000..2cb9c7c --- /dev/null +++ b/test/fleet/self-host.test.mjs @@ -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); +}); diff --git a/torch-release.json b/torch-release.json new file mode 100644 index 0000000..22d894d --- /dev/null +++ b/torch-release.json @@ -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 + } +}