mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-09 06:12:15 +00:00
Add provider-neutral forge migration
This commit is contained in:
1 parent
e49267af8d
commit
73a4251054
9 files changed
+356
-2
No files matched your search
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user