Files
TORCH/src/kernel/install.mjs
T

421 lines
18 KiB
JavaScript

import { randomUUID } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import {
existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
} from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { TorchError } from './errors.mjs';
import { fileHash, writeNewFile } from './files.mjs';
import { projectStatePath } from './paths.mjs';
import { validateApprovedProposal } from './domains.mjs';
import { inspectManagedWorktree } from './worktree-state.mjs';
const TRACKED_DIR = '.torch';
function jsonYaml(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
const RUNTIME_DEFINITIONS = Object.freeze({
claude: { model: 'opus', background: true },
codex: { sandbox: 'workspace-write', approval: 'approve-for-me' },
});
function resolveInstallRuntimes(proposal, requested) {
const selected = requested === undefined
? Object.keys(RUNTIME_DEFINITIONS)
: [...new Set(requested)];
if (!selected.length || selected.some((runtime) => typeof runtime !== 'string' || !RUNTIME_DEFINITIONS[runtime])) {
throw new TorchError('Install runtimes must be one or more supported adapters', {
code: 'INSTALL_RUNTIME_INVALID', details: { requested: selected, supported: Object.keys(RUNTIME_DEFINITIONS) },
});
}
const required = new Set([
proposal.session_manager?.runtime ?? 'claude',
...proposal.domains.map((domain) => domain.runtime ?? 'claude'),
]);
const missing = [...required].filter((runtime) => !selected.includes(runtime));
if (missing.length) {
throw new TorchError('Selected install runtimes do not cover the approved Fleet', {
code: 'INSTALL_RUNTIME_MISSING', details: { selected, required: [...required], missing },
});
}
return selected;
}
function initialFiles({ repository, projectId, createdAt, proposal, runtimes }) {
const promptContent = (source, fallback) => source
? readFileSync(safeProjectPath(repository.root, source), 'utf8')
: fallback;
const config = {
schema: 'torch.dev/v1alpha1',
project: {
id: projectId,
name: repository.name,
main_branch: repository.branch || 'main',
},
paths: {
tracked_state: TRACKED_DIR,
worktree_parent: proposal.paths?.worktree_parent ?? '~/TORCHWorktrees',
},
repository: repository.canonicalRemote
? { canonical: { type: 'remote', remote: repository.canonicalRemote.name } }
: { canonical: { type: 'unconfigured' } },
forge: { provider: 'none' },
git: {
branch_prefix: 'torch/', convergence: 'merge', allow_rebase: false,
allow_force_push: false, allow_bare_stash: false,
},
synchronization: { strategy: 'dispatcher-managed', auto_merge_worktrees: false },
runtimes: Object.fromEntries([
['default', proposal.session_manager?.runtime ?? 'claude'],
...runtimes.map((runtime) => [runtime, RUNTIME_DEFINITIONS[runtime]]),
]),
checks: proposal.checks ?? [],
resources: proposal.resources ?? [],
schedules: proposal.schedules ?? [],
integration: {
provider: 'torch', target: repository.branch || 'main', require_current_main: true,
required_checks: (proposal.checks ?? []).map((check) => check.id),
landing_authority: ['session-manager'],
},
delivery: {
authority: {
implemented: ['$source'],
verified: ['$source', 'session-manager'],
integrated: ['session-manager'],
release_ready: ['session-manager'],
released: ['owner'],
deployed: ['owner'],
live_verified: ['owner'],
},
adapters: {
release: { provider: 'none' },
deployment: { provider: 'none' },
},
},
session_manager: {
id: 'session-manager', runtime: proposal.session_manager?.runtime ?? 'claude', start_last: true,
branch: proposal.session_manager?.branch ?? null,
worktree_name: proposal.session_manager?.worktree_name ?? null,
},
domains: proposal.domains.map((domain) => ({
id: domain.id,
title: domain.title,
scope: domain.scope,
not_scope: domain.not_scope,
owned_paths: domain.owned_paths,
shared_paths: domain.shared_paths ?? [],
neighbours: domain.neighbours ?? [],
required_checks: domain.required_checks ?? [],
resources: domain.resources ?? [],
runtime: domain.runtime ?? 'claude',
model: domain.model ?? null,
branch: domain.branch ?? null,
worktree_name: domain.worktree_name ?? null,
})),
};
const roster = {
schema: 'torch.dev/roster/v1alpha1',
areas: [{
id: 'session-manager', title: 'TORCH Session Manager',
scope: ['routing', 'priority', 'ownership rulings', 'fleet health'],
not_scope: ['project feature implementation by default'],
neighbours: ['all'],
runtime: config.session_manager.runtime,
branch: config.session_manager.branch,
worktree_name: config.session_manager.worktree_name,
}, ...config.domains],
};
const managerPrompt = `${promptContent(
proposal.session_manager?.prompt_source,
'# TORCH Session Manager\n\nRoute owner requests, establish ownership and priority, and keep routine coordination inside the fleet.\n',
).trimEnd()}\n\n## Fleet evolution\n\nWhen recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, use \`torch_propose_domain\` to submit an evidence-backed Fleet change. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, or activate a persistent identity yourself; owner approval and CLI activation are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`;
const files = new Map([
['torch.yaml', jsonYaml(config)],
['roster.yaml', jsonYaml(roster)],
['decisions.md', '# TORCH decisions\n\nNo project decisions recorded yet.\n'],
['RESUME-BRIEF.md', '# TORCH resume brief\n\nFleet not started yet.\n'],
['prompts/COMMON.md', promptContent(
proposal.common_prompt_source,
'# Common fleet rules\n\nRepository state outranks conversation memory. Query ownership before crossing a domain boundary.\n',
)],
['prompts/session-manager.md', managerPrompt],
['backlog/.gitkeep', ''],
['INSTALLATION.md', `# TORCH installation\n\nInstalled ${createdAt}. Run \`torch doctor\` before starting the fleet.\n`],
]);
for (const domain of config.domains) {
const generatedPrompt = [
`# ${domain.title}`,
'',
`Area ID: ${domain.id}`,
'',
'## Owns',
'',
...domain.scope.map((item) => `- ${item}`),
'',
'## Does not own',
'',
...(domain.not_scope.length ? domain.not_scope : ['No exclusions recorded.']).map((item) => `- ${item}`),
'',
'## Neighbours',
'',
...(domain.neighbours.length ? domain.neighbours : ['None identified.']).map((item) => `- ${item}`),
'',
'## Resources',
'',
...(domain.resources.length ? domain.resources : ['No scarce resources declared.']).map((item) => `- ${item}`),
'',
'## First move',
'',
'Query live ownership, read the assigned backlog item, and report current repository evidence to the Session Manager.',
'',
].join('\n');
const proposalDomain = proposal.domains.find((candidate) => candidate.id === domain.id);
files.set(`prompts/${domain.id}.md`, promptContent(proposalDomain?.prompt_source, generatedPrompt));
}
files.set('domain-proposal.approved.json', jsonYaml(proposal));
return files;
}
export function manifestPath(root) {
return join(root, TRACKED_DIR, 'install-manifest.json');
}
export function readInstallManifest(root) {
const path = manifestPath(root);
if (!existsSync(path)) {
throw new TorchError(`No TORCH ownership manifest at ${path}`, { code: 'NOT_INSTALLED' });
}
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch (error) {
throw new TorchError(`Invalid TORCH ownership manifest: ${path}`, {
code: 'INVALID_INSTALL_MANIFEST', details: error.message,
});
}
}
export function writeInstallManifest(root, manifest) {
const path = manifestPath(root);
const temporary = `${path}.tmp-${process.pid}`;
writeFileSync(temporary, jsonYaml(manifest), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
renameSync(temporary, path);
}
function safeProjectPath(root, relativePath) {
const target = resolve(root, relativePath);
const prefix = `${resolve(root)}${sep}`;
if (!target.startsWith(prefix)) {
throw new TorchError(`Manifest path escapes project root: ${relativePath}`, {
code: 'UNSAFE_MANIFEST_PATH', details: { relativePath },
});
}
return target;
}
export function planInstall({ repository, proposal, env = process.env, runtimes: requestedRuntimes } = {}) {
validateApprovedProposal({ proposal, repository });
const runtimes = resolveInstallRuntimes(proposal, requestedRuntimes);
const trackedRoot = join(repository.root, TRACKED_DIR);
return {
action: 'install',
projectRoot: repository.root,
trackedRoot,
stateParent: join(projectStatePath('<new-project-id>', env), '..'),
wouldCreateTrackedState: !existsSync(trackedRoot),
approvedDomainCount: proposal.domains.length,
runtimes,
mutationPerformed: false,
};
}
export function installProject({
repository,
proposal,
env = process.env,
projectId = randomUUID(),
now = () => new Date(),
runtimes: requestedRuntimes,
} = {}) {
validateApprovedProposal({ proposal, repository });
const runtimes = resolveInstallRuntimes(proposal, requestedRuntimes);
const trackedRoot = join(repository.root, TRACKED_DIR);
if (existsSync(trackedRoot)) {
throw new TorchError(`Refusing to replace existing ${trackedRoot}`, {
code: 'ALREADY_INSTALLED', details: { trackedRoot },
});
}
const createdAt = now().toISOString();
const stateRoot = projectStatePath(projectId, env);
if (existsSync(stateRoot)) {
throw new TorchError(`Refusing to replace existing TORCH state: ${stateRoot}`, {
code: 'STATE_ALREADY_EXISTS', details: { stateRoot },
});
}
const created = [];
try {
for (const [path, content] of initialFiles({ repository, projectId, createdAt, proposal, runtimes })) {
const absolute = join(trackedRoot, path);
const record = writeNewFile(absolute, content);
created.push({ path: relative(repository.root, record.path), sha256: record.sha256 });
}
mkdirSync(dirname(stateRoot), { recursive: true });
mkdirSync(stateRoot, { recursive: false });
for (const dir of ['sessions', 'messages', 'locks', 'leases', 'checks', 'integration', 'logs']) {
mkdirSync(join(stateRoot, dir));
}
writeFileSync(join(stateRoot, 'project.json'), jsonYaml({
schema: 'torch.dev/local-project/v1alpha1', projectId,
root: repository.root, createdAt, detachedAt: null,
}), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
const manifest = {
schema: 'torch.dev/install-manifest/v1alpha1',
installationId: randomUUID(),
projectId,
createdAt,
created,
patched: [],
external: [{ type: 'local-state', path: stateRoot }],
};
writeNewFile(manifestPath(repository.root), jsonYaml(manifest));
return { projectId, trackedRoot, stateRoot, manifest, mutationPerformed: true };
} catch (error) {
if (existsSync(trackedRoot)) rmSync(trackedRoot, { recursive: true, force: true });
if (existsSync(stateRoot)) rmSync(stateRoot, { recursive: true, force: true });
throw error;
}
}
function nonEmptyEntries(path, ignoredRoots = []) {
if (!existsSync(path)) return [];
return readdirSync(path, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => resolve(entry.parentPath, entry.name))
.filter((entry) => entry !== join(resolve(path), 'project.json'))
.filter((entry) => !ignoredRoots.some((root) =>
entry === resolve(root) || entry.startsWith(`${resolve(root)}${sep}`)))
.map((entry) => relative(path, entry));
}
function gitOptional(root, args) {
try {
return execFileSync('git', ['-C', root, ...args], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch { return null; }
}
function inspectCanonicalRemoval(repositoryRoot, entry, localStatePath) {
const problems = [];
const expectedRoot = `${resolve(localStatePath)}${sep}`;
if (!resolve(entry.path).startsWith(expectedRoot)) {
return [{ type: 'unsafe-canonical-path', path: entry.path, stateRoot: localStatePath }];
}
const remoteUrl = gitOptional(repositoryRoot, ['remote', 'get-url', entry.remote]);
if (remoteUrl === null) problems.push({ type: 'canonical-remote-missing', remote: entry.remote });
else if (resolve(repositoryRoot, remoteUrl) !== resolve(entry.path)) {
problems.push({ type: 'canonical-remote-changed', remote: entry.remote, expected: entry.path, actual: remoteUrl });
}
if (!existsSync(entry.path)) problems.push({ type: 'canonical-path-missing', path: entry.path });
else {
const heads = gitOptional(entry.path, ['for-each-ref', '--format=%(objectname)', 'refs/heads']);
for (const commit of (heads ?? '').split('\n').filter(Boolean)) {
if (!gitOptional(repositoryRoot, ['for-each-ref', '--format=%(refname)', '--contains', commit])) {
problems.push({ type: 'canonical-unique-commit', path: entry.path, commit });
}
}
}
return problems;
}
export function planUninstall({ repository, purge = false }) {
const manifest = readInstallManifest(repository.root);
const problems = [];
let mainBranch = repository.branch || 'main';
try {
const config = JSON.parse(readFileSync(join(repository.root, TRACKED_DIR, 'torch.yaml'), 'utf8'));
mainBranch = config.project?.main_branch ?? mainBranch;
} catch { /* created-file validation below reports the invalid config */ }
const localState = (manifest.external ?? []).find((entry) => entry.type === 'local-state');
if (localState && existsSync(join(localState.path, 'project.json'))) {
try {
const metadata = JSON.parse(readFileSync(join(localState.path, 'project.json'), 'utf8'));
if (resolve(metadata.root) !== resolve(repository.root)) {
problems.push({ type: 'not-installation-root', expected: metadata.root, actual: repository.root });
}
} catch {
problems.push({ type: 'local-state-metadata-invalid', path: localState.path });
}
}
for (const record of manifest.created) {
const path = safeProjectPath(repository.root, record.path);
if (!existsSync(path)) problems.push({ type: 'missing', path: record.path });
else if (fileHash(path) !== record.sha256) problems.push({ type: 'modified', path: record.path });
}
for (const entry of manifest.external ?? []) {
if (entry.type === 'local-state') {
const canonicalRoots = (manifest.external ?? [])
.filter((candidate) => candidate.type === 'canonical-remote')
.map((candidate) => candidate.path);
const extras = nonEmptyEntries(entry.path, canonicalRoots);
if (extras.length) problems.push({ type: 'local-state-not-empty', path: entry.path, entries: extras });
}
if (entry.type === 'canonical-remote') {
if (!localState?.path) problems.push({ type: 'local-state-missing-for-canonical', path: entry.path });
else problems.push(...inspectCanonicalRemoval(repository.root, entry, localState.path));
}
if (entry.type === 'worktree') {
const state = inspectManagedWorktree(repository.root, entry, mainBranch);
if (!state.safeToRemove) problems.push({ type: 'unsafe-worktree', path: entry.path, area: entry.area, problems: state.problems });
}
if (entry.type === 'systemd-user-unit') {
problems.push({ type: 'persistent-integration-installed', path: entry.path, name: entry.name });
}
}
return {
action: purge ? 'purge' : 'detach', manifest, problems,
canProceed: !purge || problems.length === 0,
mutationPerformed: false,
};
}
export function uninstallProject({ repository, purge = false, dryRun = false }) {
const plan = planUninstall({ repository, purge });
if (dryRun || !purge) return plan;
if (!plan.canProceed) {
throw new TorchError('TORCH purge stopped because managed state changed', {
code: 'UNSAFE_TO_PURGE', details: plan.problems,
});
}
for (const entry of (plan.manifest.external ?? []).filter((item) => item.type === 'worktree').reverse()) {
execFileSync('git', ['-C', repository.root, 'worktree', 'remove', entry.path], { stdio: 'ignore' });
execFileSync('git', ['-C', repository.root, 'branch', '-d', entry.branch], { stdio: 'ignore' });
}
for (const patch of plan.manifest.patched ?? []) {
if (patch.type !== 'git-info-exclude-line' || !existsSync(patch.path)) continue;
const marker = `# TORCH ${patch.installationId}\n${patch.line}\n`;
const content = readFileSync(patch.path, 'utf8');
if (content.includes(marker)) writeFileSync(patch.path, content.replace(marker, ''), 'utf8');
}
for (const entry of (plan.manifest.external ?? []).filter((item) => item.type === 'canonical-remote')) {
execFileSync('git', ['-C', repository.root, 'remote', 'remove', entry.remote], { stdio: 'ignore' });
}
for (const record of [...plan.manifest.created].reverse()) {
rmSync(safeProjectPath(repository.root, record.path));
}
rmSync(manifestPath(repository.root));
rmSync(join(repository.root, TRACKED_DIR), { recursive: true });
for (const entry of plan.manifest.external ?? []) {
if (entry.type === 'local-state' && existsSync(entry.path)) rmSync(entry.path, { recursive: true });
}
return { ...plan, mutationPerformed: true };
}