Files
TORCH/test/fleet/forge-migration.test.mjs

148 lines
9.5 KiB
JavaScript

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 / SCN-forge-sync: publish only an owner-confirmed fast-forward and preserve local operation', () => {
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);
const remoteBeforeSync = execFileSync('git', ['--git-dir', forgePath, 'rev-parse', 'refs/heads/main'], { encoding: 'utf8' }).trim();
writeFileSync(join(root, 'app.js'), 'export const app = "ready to publish";\n');
execFileSync('git', ['-C', root, 'add', 'app.js']);
execFileSync('git', ['-C', root, 'commit', '-m', 'canonical work ready to publish']);
const syncPlan = run(root, env, ['forge', 'sync', 'plan', '--json']);
assert.equal(syncPlan.status, 0, syncPlan.stderr || syncPlan.stdout);
assert.equal(JSON.parse(syncPlan.stdout).disposition, 'fast-forward-remote');
assert.equal(JSON.parse(syncPlan.stdout).remoteCommit, remoteBeforeSync);
const unconfirmedSync = run(root, env, ['forge', 'sync', '--json']);
assert.notEqual(unconfirmedSync.status, 0);
assert.equal(JSON.parse(unconfirmedSync.stdout).error, 'APPROVAL_REQUIRED');
assert.equal(execFileSync('git', ['--git-dir', forgePath, 'rev-parse', 'refs/heads/main'], { encoding: 'utf8' }).trim(), remoteBeforeSync);
const published = run(root, env, ['forge', 'sync', '--yes', '--json']);
assert.equal(published.status, 0, published.stderr || published.stdout);
assert.equal(JSON.parse(published.stdout).synchronized, true);
const localMain = execFileSync('git', ['-C', root, 'rev-parse', 'main'], { encoding: 'utf8' }).trim();
assert.equal(execFileSync('git', ['--git-dir', forgePath, 'rev-parse', 'refs/heads/main'], { encoding: 'utf8' }).trim(), localMain);
const audit = openControlPlane({ repositoryRoot: root, env });
assert.equal(audit.database.prepare(`
SELECT actor_id FROM audit_events WHERE operation = 'forge.sync' ORDER BY created_at DESC LIMIT 1
`).get().actor_id, 'owner');
audit.close();
const remoteWriter = mkdtempSync(join(tmpdir(), 'torch-forge-side-writer-'));
execFileSync('git', ['clone', '--branch', 'main', forgePath, remoteWriter]);
execFileSync('git', ['-C', remoteWriter, 'config', 'user.email', '[email protected]']);
execFileSync('git', ['-C', remoteWriter, 'config', 'user.name', 'TORCH Remote Writer']);
writeFileSync(join(remoteWriter, 'app.js'), 'export const app = "remote advanced";\n');
execFileSync('git', ['-C', remoteWriter, 'add', 'app.js']);
execFileSync('git', ['-C', remoteWriter, 'commit', '-m', 'remote moved independently']);
execFileSync('git', ['-C', remoteWriter, 'push', 'origin', 'main']);
const remoteAheadCommit = execFileSync('git', ['-C', remoteWriter, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const staleRemotePlan = run(root, env, ['forge', 'sync', 'plan', '--json']);
assert.notEqual(staleRemotePlan.status, 0);
assert.equal(JSON.parse(staleRemotePlan.stdout).blockers[0].code, 'FORGE_SYNC_FETCH_REQUIRED');
const fetched = execFileSync('git', ['-C', root, 'fetch', 'origin', 'main'], { encoding: 'utf8' });
assert.equal(typeof fetched, 'string');
const remoteAheadPlan = run(root, env, ['forge', 'sync', 'plan', '--json']);
assert.notEqual(remoteAheadPlan.status, 0);
assert.equal(JSON.parse(remoteAheadPlan.stdout).blockers[0].code, 'FORGE_REMOTE_AHEAD');
const refused = run(root, env, ['forge', 'sync', '--yes', '--json']);
assert.notEqual(refused.status, 0);
assert.equal(JSON.parse(refused.stdout).error, 'FORGE_SYNC_BLOCKED');
assert.equal(execFileSync('git', ['--git-dir', forgePath, 'rev-parse', 'refs/heads/main'], { encoding: 'utf8' }).trim(), remoteAheadCommit,
'remote-ahead history is never force-pushed or rewritten');
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);
});