Add provider-neutral forge migration

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 18:38:27 -04:00
1 parent e49267af8d
commit 73a4251054
9 files changed
+356 -2

No files matched your search

+18
View File
@@ -250,6 +250,24 @@ 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`.
An equivalent Git remote can later become the optional canonical forge without
changing TORCH identity or coordination semantics:
```bash
torch forge plan --remote origin --json
torch forge attach --remote origin --provider generic-git --yes --json
torch forge status --json
torch forge detach --dry-run --json
torch forge detach --yes --json
```
Attach requires the selected remote's main ref to equal local canonical main
exactly and never pushes or fetches implicitly. During a forge outage, status
and doctor expose pending synchronization while local identities, messages,
checks, backlog, worktrees, and policy remain operational. Detach works during
that outage and restores the prior local canonical configuration; it does not
delete a user-owned remote.
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:
+4 -2
View File
@@ -16,6 +16,7 @@
"./design/brief": "./src/design/brief.mjs",
"./design/architect": "./src/design/architect.mjs",
"./evolution": "./src/evolution/service.mjs",
"./forge": "./src/forge/service.mjs",
"./control-plane": "./src/control-plane/service.mjs",
"./kernel/analyze": "./src/kernel/analyze.mjs",
"./kernel/config": "./src/kernel/config.mjs",
@@ -66,6 +67,7 @@
"src/control-plane/",
"src/design/",
"src/evolution/",
"src/forge/",
"src/kernel/",
"src/integration/",
"src/importers/",
@@ -88,8 +90,8 @@
"torch": "node bin/torch.mjs",
"check": "node --check bin/torch.mjs && node --check src/cli.mjs && node --check src/mcp/server.mjs && node --check src/console/server.mjs && node --check site/app.js && node --check site/console.js",
"site:serve": "node bin/torch.mjs console serve",
"format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix",
"lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet"
"format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix",
"lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet"
},
"dependencies": {
"@modelcontextprotocol/server": "^2.1.0",
@@ -142,6 +142,10 @@ test_integrity_note:
given: an installed project with no forge or remote
when: the owner creates a local canonical Git store and later makes and pushes another commit
then: TORCH distinguishes one-disk from local-remote recoverability without claiming off-machine backup
- id: SCN-forge-migration
given: a healthy local-only Fleet and an equivalent optional Git remote
when: the owner attaches the forge, the remote becomes unavailable, and the owner detaches it
then: ref mismatch fails closed while identities, worktrees, backlog, messages, and local operation survive every mode
- id: SCN-canonical-reversal
given: a TORCH-owned local canonical remote and bare repository
when: purge is planned before and after a remote-only commit becomes recoverable locally
+51
View File
@@ -35,6 +35,9 @@ import {
} from './design/architect.mjs';
import { FleetEvolutionService } from './evolution/service.mjs';
import { ConvergenceService } from './convergence/service.mjs';
import {
attachForge, detachForge, forgeStatus, planForgeAttach, planForgeDetach,
} from './forge/service.mjs';
const HELP = `TORCH — portable agent fleet
@@ -96,6 +99,10 @@ Usage:
torch canonical plan [--json]
torch canonical create --yes [--json]
torch recoverability [--commit <sha>] [--json]
torch forge plan --remote <name> [--provider <name>] [--json]
torch forge attach --remote <name> [--provider <name>] --yes [--json]
torch forge status [--json]
torch forge detach [--dry-run] --yes [--json]
torch candidate plan --source <path> [--version <version>] [--json]
torch candidate build --source <path> [--version <version>] --yes [--json]
torch candidate status [--json]
@@ -231,6 +238,7 @@ function runCandidateAcceptance(candidateRoot, spawn) {
['fleet-evolution', ['SCN-fleet-evolution', 'SCN-cli-fleet-evolution']],
['fleet-boundary-evolution', ['SCN-fleet-boundary-evolution']],
['cli-lifecycle-surface', ['SCN-cli-lifecycle-surface']],
['forge-migration', ['SCN-forge-migration']],
['review-install-roster', ['SCN-cli-domain-review']],
['branches-worktrees', ['SCN-worktree-bootstrap']],
['runtime-identities', ['SCN-mixed-runtime']],
@@ -1027,6 +1035,49 @@ export async function runCli(argv = process.argv.slice(2), {
}), { json });
return 0;
}
if (command === 'forge') {
const operation = argv[1] ?? 'status';
if (operation === 'status') {
const status = forgeStatus({ repositoryRoot: repository.root });
print(status, { json });
return status.available === false ? 1 : 0;
}
if (operation === 'plan') {
const plan = planForgeAttach({
repositoryRoot: repository.root, remote: optionValue(argv, '--remote'),
provider: optionValue(argv, '--provider') ?? 'generic-git',
});
print(plan, { json });
return plan.canProceed ? 0 : 1;
}
if (operation === 'attach') {
if (!argv.includes('--yes')) {
throw new TorchError('Forge attach changes tracked canonical-repository policy. Review forge plan, then use --yes.', {
code: 'APPROVAL_REQUIRED',
});
}
print(attachForge({
repositoryRoot: repository.root, remote: optionValue(argv, '--remote'),
provider: optionValue(argv, '--provider') ?? 'generic-git',
}), { json });
return 0;
}
if (operation === 'detach') {
if (argv.includes('--dry-run')) {
const plan = planForgeDetach({ repositoryRoot: repository.root });
print(plan, { json });
return plan.canProceed ? 0 : 1;
}
if (!argv.includes('--yes')) {
throw new TorchError('Forge detach changes tracked canonical-repository policy. Review the current status, then use --yes.', {
code: 'APPROVAL_REQUIRED',
});
}
print(detachForge({ repositoryRoot: repository.root }), { json });
return 0;
}
throw new TorchError(`Unknown forge operation: ${operation}`, { code: 'UNKNOWN_COMMAND' });
}
if (command === 'uninstall') {
const result = uninstallProject({
repository,
+161
View File
@@ -0,0 +1,161 @@
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TorchError } from '../kernel/errors.mjs';
import { fileHash } from '../kernel/files.mjs';
import { loadProjectConfig } from '../kernel/config.mjs';
import { readInstallManifest, writeInstallManifest } from '../kernel/install.mjs';
function text(value, name) {
if (typeof value !== 'string' || !value.trim()) {
throw new TorchError(`${name} must be a non-empty string`, {
code: 'FORGE_INPUT_INVALID', details: { field: name },
});
}
return value.trim();
}
function git(root, args, { timeout = 5_000 } = {}) {
const result = spawnSync('git', ['-C', root, ...args], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout,
});
return {
status: result.status ?? 1, stdout: result.stdout?.trim() ?? '', stderr: result.stderr?.trim() ?? '',
error: result.error?.message ?? null,
};
}
function configPaths(repositoryRoot) {
return {
config: join(repositoryRoot, '.torch', 'torch.yaml'),
manifest: join(repositoryRoot, '.torch', 'install-manifest.json'),
};
}
function configurationOwned(repositoryRoot, manifest) {
const record = manifest.created.find((entry) => entry.path === '.torch/torch.yaml');
return Boolean(record && fileHash(configPaths(repositoryRoot).config) === record.sha256);
}
function remoteHead(repositoryRoot, remote, branch) {
const result = git(repositoryRoot, ['ls-remote', '--heads', remote, `refs/heads/${branch}`]);
if (result.status !== 0) return { available: false, commit: null, error: result.error ?? result.stderr };
const commit = result.stdout.split(/\s+/)[0] || null;
return { available: true, commit, error: null };
}
function writeConfiguration(repositoryRoot, config, manifest) {
const paths = configPaths(repositoryRoot);
const temporary = `${paths.config}.tmp-${process.pid}-${randomUUID()}`;
writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, {
encoding: 'utf8', mode: 0o600, flag: 'wx',
});
renameSync(temporary, paths.config);
const record = manifest.created.find((entry) => entry.path === '.torch/torch.yaml');
if (!record) throw new TorchError('Installation manifest does not own project configuration', { code: 'MANIFEST_INVALID' });
record.sha256 = fileHash(paths.config);
writeInstallManifest(repositoryRoot, manifest);
}
export function forgeStatus({ repositoryRoot } = {}) {
const config = loadProjectConfig(repositoryRoot);
const provider = config.forge?.provider ?? 'none';
if (provider === 'none') {
return {
mode: 'local-only', provider: 'none', remote: null, available: null,
synchronized: null, pendingSynchronization: false, localOperational: true,
mutationPerformed: false,
};
}
const remote = config.forge.remote;
const localCommit = git(repositoryRoot, ['rev-parse', config.project.main_branch]).stdout;
const observed = remoteHead(repositoryRoot, remote, config.project.main_branch);
return {
mode: observed.available ? 'forge-backed' : 'forge-degraded', provider, remote,
available: observed.available, localCommit, remoteCommit: observed.commit,
synchronized: observed.available && observed.commit === localCommit,
pendingSynchronization: !observed.available || observed.commit !== localCommit,
error: observed.error, localOperational: true, mutationPerformed: false,
};
}
export function planForgeAttach({ repositoryRoot, remote, provider = 'generic-git' } = {}) {
const selectedRemote = text(remote, 'remote');
const selectedProvider = text(provider, 'provider');
if (selectedProvider === 'none') throw new TorchError('Forge provider cannot be none during attach', { code: 'FORGE_INPUT_INVALID' });
const config = loadProjectConfig(repositoryRoot);
const manifest = readInstallManifest(repositoryRoot);
const blockers = [];
const remotes = git(repositoryRoot, ['remote']).stdout.split('\n').filter(Boolean);
if (!remotes.includes(selectedRemote)) blockers.push({ code: 'FORGE_REMOTE_MISSING', remote: selectedRemote });
if (config.forge.provider !== 'none') blockers.push({ code: 'FORGE_ALREADY_ATTACHED', provider: config.forge.provider });
if (!configurationOwned(repositoryRoot, manifest)) blockers.push({ code: 'FORGE_CONFIGURATION_MODIFIED' });
const localCommit = git(repositoryRoot, ['rev-parse', config.project.main_branch]).stdout;
const observed = remotes.includes(selectedRemote)
? remoteHead(repositoryRoot, selectedRemote, config.project.main_branch)
: { available: false, commit: null, error: 'remote missing' };
if (!observed.available) blockers.push({ code: 'FORGE_UNAVAILABLE', remote: selectedRemote, error: observed.error });
else if (!observed.commit) blockers.push({ code: 'FORGE_BRANCH_MISSING', remote: selectedRemote, branch: config.project.main_branch });
else if (observed.commit !== localCommit) blockers.push({
code: 'FORGE_REF_MISMATCH', remote: selectedRemote, branch: config.project.main_branch,
localCommit, remoteCommit: observed.commit,
});
return {
action: 'forge-attach', provider: selectedProvider, remote: selectedRemote,
branch: config.project.main_branch, localCommit, remoteCommit: observed.commit,
previousCanonical: config.repository.canonical,
blockers, canProceed: blockers.length === 0, mutationPerformed: false,
};
}
export function attachForge(input = {}) {
const plan = planForgeAttach(input);
if (!plan.canProceed) throw new TorchError('Forge attach is blocked', { code: 'FORGE_ATTACH_BLOCKED', details: plan.blockers });
const config = loadProjectConfig(input.repositoryRoot);
const manifest = readInstallManifest(input.repositoryRoot);
const attachedAt = new Date().toISOString();
config.forge = {
provider: plan.provider, remote: plan.remote, attached_at: attachedAt,
previous_canonical: plan.previousCanonical,
};
config.repository.canonical = { type: 'remote', remote: plan.remote };
writeConfiguration(input.repositoryRoot, config, manifest);
return { ...plan, attachedAt, mutationPerformed: true };
}
export function planForgeDetach({ repositoryRoot } = {}) {
const config = loadProjectConfig(repositoryRoot);
const manifest = readInstallManifest(repositoryRoot);
const blockers = [];
if (config.forge.provider === 'none') blockers.push({ code: 'FORGE_NOT_ATTACHED' });
if (!configurationOwned(repositoryRoot, manifest)) blockers.push({ code: 'FORGE_CONFIGURATION_MODIFIED' });
if (config.repository.canonical.type !== 'remote'
|| config.repository.canonical.remote !== config.forge.remote) {
blockers.push({ code: 'FORGE_CANONICAL_CHANGED', canonical: config.repository.canonical });
}
const restoreCanonical = config.forge.previous_canonical ?? { type: 'unconfigured' };
return {
action: 'forge-detach', provider: config.forge.provider, remote: config.forge.remote ?? null,
restoreCanonical, blockers, canProceed: blockers.length === 0, mutationPerformed: false,
};
}
export function detachForge({ repositoryRoot } = {}) {
const plan = planForgeDetach({ repositoryRoot });
if (!plan.canProceed) throw new TorchError('Forge detach is blocked', { code: 'FORGE_DETACH_BLOCKED', details: plan.blockers });
const config = loadProjectConfig(repositoryRoot);
const manifest = readInstallManifest(repositoryRoot);
const detachedAt = new Date().toISOString();
config.repository.canonical = plan.restoreCanonical;
config.forge = {
provider: 'none', last_provider: plan.provider, last_remote: plan.remote,
detached_at: detachedAt,
};
writeConfiguration(repositoryRoot, config, manifest);
return {
...plan, detachedAt, localOperational: true,
preserved: ['identities', 'worktrees', 'backlog', 'decisions', 'messages', 'local state'],
mutationPerformed: true,
};
}
+16
View File
@@ -5,6 +5,7 @@ import { fileHash } from './files.mjs';
import { projectStatePath } from './paths.mjs';
import { inspectManagedWorktree } from './worktree-state.mjs';
import { validateProjectConfig } from './config.mjs';
import { forgeStatus } from '../forge/service.mjs';
function parseJsonYaml(path) {
return JSON.parse(readFileSync(path, 'utf8'));
@@ -168,6 +169,21 @@ export function diagnoseProject({ repository, env = process.env }) {
if (repository.dirtyEntries.length) {
findings.push({ severity: 'info', code: 'WORKTREE_DIRTY', count: repository.dirtyEntries.length });
}
if (config?.forge?.provider && config.forge.provider !== 'none') {
try {
const forge = forgeStatus({ repositoryRoot: repository.root });
if (!forge.available) findings.push({
severity: 'warning', code: 'FORGE_UNAVAILABLE', remote: forge.remote,
pendingSynchronization: true, localOperational: true, message: forge.error,
});
else if (!forge.synchronized) findings.push({
severity: 'info', code: 'FORGE_SYNCHRONIZATION_PENDING', remote: forge.remote,
localCommit: forge.localCommit, remoteCommit: forge.remoteCommit,
});
} catch (error) {
findings.push({ severity: 'error', code: 'FORGE_STATUS_INVALID', message: error.message });
}
}
return {
status: findings.some((finding) => finding.severity === 'error') ? 'broken' : 'ok',
+1
View File
@@ -15,6 +15,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([
'fleet-evolution',
'fleet-boundary-evolution',
'cli-lifecycle-surface',
'forge-migration',
'review-install-roster',
'branches-worktrees',
'runtime-identities',
+1
View File
@@ -38,6 +38,7 @@ function releaseCandidate(root, version) {
'SCN-fleet-evolution', 'SCN-cli-fleet-evolution',
'SCN-fleet-boundary-evolution',
'SCN-cli-lifecycle-surface',
'SCN-forge-migration',
'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',
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createLocalCanonical } from '../../src/canonical/local.mjs';
import { openControlPlane } from '../../src/control-plane/service.mjs';
import { analyzeRepository } from '../../src/kernel/analyze.mjs';
import { proposeDomains } from '../../src/kernel/domains.mjs';
import { inspectRepository } from '../../src/kernel/git.mjs';
import { installProject } from '../../src/kernel/install.mjs';
import { createWorktrees } from '../../src/kernel/worktrees.mjs';
const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname;
function run(root, env, args) {
return spawnSync(process.execPath, [CLI, ...args], { cwd: root, env, encoding: 'utf8' });
}
test('SCN-forge-migration: attach, outage, and detach preserve the local Fleet', () => {
const root = mkdtempSync(join(tmpdir(), 'torch-forge-migration-'));
const stateHome = mkdtempSync(join(tmpdir(), 'torch-forge-state-'));
const worktreeParent = mkdtempSync(join(tmpdir(), 'torch-forge-worktrees-'));
const forgePath = mkdtempSync(join(tmpdir(), 'torch-forge-remote-'));
const offlinePath = `${forgePath}-offline`;
const env = { ...process.env, XDG_DATA_HOME: stateHome };
execFileSync('git', ['init', '-b', 'main', root]);
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
writeFileSync(join(root, 'app.js'), 'export const app = true;\n');
execFileSync('git', ['-C', root, 'add', '.']);
execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']);
let repository = inspectRepository(root);
const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) });
proposal.paths = { worktree_parent: worktreeParent };
proposal.review = {
status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [],
};
installProject({ repository, proposal, env, projectId: 'forge-migration' });
execFileSync('git', ['-C', root, 'add', '.torch']);
execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']);
repository = inspectRepository(root);
createWorktrees({ repository, parentOverride: worktreeParent });
execFileSync('git', ['-C', root, 'add', '.torch']);
execFileSync('git', ['-C', root, 'commit', '-m', 'record worktrees']);
const localCanonical = createLocalCanonical({ repositoryRoot: root });
execFileSync('git', ['-C', root, 'add', '.torch']);
execFileSync('git', ['-C', root, 'commit', '-m', 'record local canonical']);
execFileSync('git', ['-C', root, 'push', 'torch-canonical', 'main']);
execFileSync('git', ['init', '--bare', forgePath]);
execFileSync('git', ['-C', root, 'remote', 'add', 'origin', forgePath]);
execFileSync('git', ['-C', root, 'push', 'origin', 'main']);
const manifestBefore = JSON.parse(readFileSync(join(root, '.torch', 'install-manifest.json'), 'utf8'));
const worktreesBefore = manifestBefore.external.filter((entry) => entry.type === 'worktree');
const control = openControlPlane({ repositoryRoot: root, env });
const worker = proposal.domains[0].id;
const message = control.sendMessage({ sender: 'session-manager', recipient: worker, body: 'Survive forge migration.' });
const agentsBefore = control.listAgents().map((agent) => agent.areaId);
control.close();
const planned = run(root, env, ['forge', 'plan', '--remote', 'origin', '--json']);
assert.equal(planned.status, 0, planned.stderr || planned.stdout);
assert.equal(JSON.parse(planned.stdout).previousCanonical.path, localCanonical.path);
assert.equal(JSON.parse(run(root, env, ['forge', 'attach', '--remote', 'origin', '--json']).stdout).error, 'APPROVAL_REQUIRED');
const attached = run(root, env, ['forge', 'attach', '--remote', 'origin', '--yes', '--json']);
assert.equal(attached.status, 0, attached.stderr || attached.stdout);
const attachedConfig = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8'));
assert.equal(attachedConfig.forge.provider, 'generic-git');
assert.deepEqual(attachedConfig.repository.canonical, { type: 'remote', remote: 'origin' });
assert.equal(JSON.parse(run(root, env, ['forge', 'status', '--json']).stdout).synchronized, true);
renameSync(forgePath, offlinePath);
const degraded = run(root, env, ['forge', 'status', '--json']);
assert.equal(degraded.status, 1);
assert.equal(JSON.parse(degraded.stdout).mode, 'forge-degraded');
assert.equal(JSON.parse(degraded.stdout).localOperational, true);
const diagnosis = run(root, env, ['doctor', '--json']);
assert.equal(diagnosis.status, 0, diagnosis.stderr || diagnosis.stdout);
assert.equal(JSON.parse(diagnosis.stdout).findings.some((finding) =>
finding.code === 'FORGE_UNAVAILABLE' && finding.localOperational === true), true);
const detachPlan = run(root, env, ['forge', 'detach', '--dry-run', '--json']);
assert.equal(detachPlan.status, 0, detachPlan.stderr || detachPlan.stdout);
const detached = run(root, env, ['forge', 'detach', '--yes', '--json']);
assert.equal(detached.status, 0, detached.stderr || detached.stdout);
const detachedConfig = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8'));
assert.equal(detachedConfig.forge.provider, 'none');
assert.deepEqual(detachedConfig.repository.canonical, {
type: 'local', remote: 'torch-canonical', path: localCanonical.path,
});
const manifestAfter = JSON.parse(readFileSync(join(root, '.torch', 'install-manifest.json'), 'utf8'));
assert.deepEqual(manifestAfter.external.filter((entry) => entry.type === 'worktree'), worktreesBefore);
const reopened = openControlPlane({ repositoryRoot: root, env });
assert.deepEqual(reopened.listAgents().map((agent) => agent.areaId), agentsBefore);
assert.equal(reopened.readMessages({ recipient: worker }).some((entry) => entry.id === message.id), true);
reopened.close();
renameSync(offlinePath, forgePath);
});