Complete public fleet lifecycle commands

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 18:28:49 -04:00
1 parent c714e2602d
commit 17aec17afc
10 files changed
+365 -41

No files matched your search

+74 -16
View File
@@ -19,7 +19,10 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { writeNewFile } from './kernel/files.mjs';
import { createWorktrees, planWorktrees } from './kernel/worktrees.mjs';
import { planAreaUp, planFleetDown, planFleetUp, startFleet, stopFleet } from './runtime/lifecycle.mjs';
import {
captureFleet, createFleetBrief, detachFleet, planAreaUp, planFleetDetach,
planFleetDown, planFleetUp, startFleet, stopFleet,
} from './runtime/lifecycle.mjs';
import { ResourceService } from './resources/service.mjs';
import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-host/service.mjs';
import { observeProject } from './observability/snapshot.mjs';
@@ -44,10 +47,14 @@ Usage:
torch architect validate --brief <path> --response <path> [--json]
torch architect run --brief <path> --provider <claude|codex> --output <path> [--model <model>] [--max-budget-usd <amount>] --yes [--json]
torch domains [--repo <path>] [--spec <path>] [--output <path>] [--json]
torch install --proposal <path> [--dry-run] [--yes] [--json]
torch install --proposal <path> [--runtime <claude|codex|claude,codex>] [--dry-run] [--yes] [--json]
torch worktrees [--parent <path>] [--dry-run] [--yes] [--json]
torch up [--fresh] [--dry-run] [--yes] [--json]
torch up [--fresh] [--only <id,id>] [--dry-run] [--yes] [--json]
torch down [--dry-run] [--yes] [--json]
torch capture [--json]
torch detach [--dry-run] [--yes] [--json]
torch list [--json]
torch brief [--area <id>] [--json]
torch identity --area <id> [--json]
torch agents [--json]
torch agent --area <id> [--json]
@@ -181,6 +188,25 @@ function runtimeAdapters() {
]);
}
function runtimeStoppers(spawn) {
return new Map([
['claude', createClaudeAdapter({
runner: (executable, args) => spawn(executable, args, {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}),
})],
['codex', createCodexAdapter()],
]);
}
function stopThrough(adapters, action) {
const adapter = adapters.get(action.runtime);
if (!adapter) throw new TorchError(`No runtime stopper for ${action.runtime}`, {
code: 'RUNTIME_ADAPTER_MISSING', details: { runtime: action.runtime },
});
return adapter.stopSession({ runtimeSessionId: action.runtimeSessionId });
}
function runCandidateAcceptance(candidateRoot, spawn) {
const checks = [
{ id: 'test', args: ['test'] },
@@ -204,6 +230,7 @@ function runCandidateAcceptance(candidateRoot, spawn) {
['ai-fleet-planning', ['SCN-ai-fleet-planning']],
['fleet-evolution', ['SCN-fleet-evolution', 'SCN-cli-fleet-evolution']],
['fleet-boundary-evolution', ['SCN-fleet-boundary-evolution']],
['cli-lifecycle-surface', ['SCN-cli-lifecycle-surface']],
['review-install-roster', ['SCN-cli-domain-review']],
['branches-worktrees', ['SCN-worktree-bootstrap']],
['runtime-identities', ['SCN-mixed-runtime']],
@@ -439,9 +466,11 @@ export async function runCli(argv = process.argv.slice(2), {
}
if (command === 'install') {
const proposalPath = optionValue(argv, '--proposal');
const runtimeOption = optionValue(argv, '--runtime');
const runtimes = runtimeOption === undefined ? undefined : commaList(runtimeOption);
if (argv.includes('--dry-run')) {
const proposal = loadProposal(cwd, proposalPath);
print({ ...planInstall({ repository, proposal, env }), proposal: resolve(cwd, proposalPath) }, { json });
print({ ...planInstall({ repository, proposal, env, runtimes }), proposal: resolve(cwd, proposalPath) }, { json });
return 0;
}
if (!argv.includes('--yes')) {
@@ -450,7 +479,7 @@ export async function runCli(argv = process.argv.slice(2), {
});
}
const proposal = loadProposal(cwd, proposalPath);
print(installProject({ repository, proposal, env }), { json });
print(installProject({ repository, proposal, env, runtimes }), { json });
return 0;
}
if (command === 'doctor') {
@@ -479,6 +508,7 @@ export async function runCli(argv = process.argv.slice(2), {
const adapters = runtimeAdapters();
const plan = planFleetUp({
repositoryRoot: repository.root, controlPlane: control, adapters, fresh: argv.includes('--fresh'),
only: optionValue(argv, '--only') === undefined ? undefined : commaList(optionValue(argv, '--only')),
});
if (argv.includes('--dry-run')) {
print(plan, { json });
@@ -514,18 +544,40 @@ export async function runCli(argv = process.argv.slice(2), {
code: 'APPROVAL_REQUIRED',
});
}
const runtimeStoppers = new Map([
['claude', createClaudeAdapter({
runner: (executable, args) => spawn(executable, args, {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}),
})],
['codex', createCodexAdapter()],
]);
const stoppers = runtimeStoppers(spawn);
print(stopFleet({
plan, controlPlane: control,
stopRuntime: (action) => runtimeStoppers.get(action.runtime)
.stopSession({ runtimeSessionId: action.runtimeSessionId }),
stopRuntime: (action) => stopThrough(stoppers, action),
}), { json });
return 0;
} finally {
control.close();
}
}
if (command === 'capture') {
print(withControlPlane(repository, env, (control) => captureFleet({
repositoryRoot: repository.root, controlPlane: control,
})), { json });
return 0;
}
if (command === 'detach') {
const control = openControlPlane({ repositoryRoot: repository.root, env });
try {
const stoppers = runtimeStoppers(spawn);
const plan = planFleetDetach({
repositoryRoot: repository.root, controlPlane: control, adapters: stoppers,
});
if (argv.includes('--dry-run')) {
print(plan, { json });
return plan.canProceed ? 0 : 1;
}
if (!argv.includes('--yes')) {
throw new TorchError('Fleet detach stops runtime sessions while preserving organization and worktrees. Review --dry-run, then use --yes.', {
code: 'APPROVAL_REQUIRED',
});
}
print(detachFleet({
plan, controlPlane: control, stopRuntime: (action) => stopThrough(stoppers, action),
}), { json });
return 0;
} finally {
@@ -536,10 +588,16 @@ export async function runCli(argv = process.argv.slice(2), {
print(withControlPlane(repository, env, (control) => control.identity(optionValue(argv, '--area'))), { json });
return 0;
}
if (command === 'agents') {
if (command === 'agents' || command === 'list') {
print(withControlPlane(repository, env, (control) => ({ agents: control.listAgents() })), { json });
return 0;
}
if (command === 'brief') {
print(withControlPlane(repository, env, (control) => createFleetBrief({
repositoryRoot: repository.root, controlPlane: control, areaId: optionValue(argv, '--area'),
})), { json });
return 0;
}
if (command === 'agent') {
print(withControlPlane(repository, env, (control) => control.getAgent(optionValue(argv, '--area'))), { json });
return 0;
+12
View File
@@ -48,6 +48,18 @@ export function diagnoseProject({ repository, env = process.env }) {
}
const stateRoot = expectedStateRoot;
if (!existsSync(stateRoot)) findings.push({ severity: 'error', code: 'LOCAL_STATE_MISSING', path: stateRoot });
const projectMetadataPath = join(stateRoot, 'project.json');
if (existsSync(projectMetadataPath)) {
try {
const metadata = parseJsonYaml(projectMetadataPath);
if (metadata.detachedAt) findings.push({
severity: 'info', code: 'FLEET_DETACHED', detachedAt: metadata.detachedAt,
recommendation: 'Run torch up after reviewing its dry-run plan to attach runtime integrations again.',
});
} catch (error) {
findings.push({ severity: 'error', code: 'LOCAL_STATE_METADATA_INVALID', message: error.message });
}
}
const databasePath = join(stateRoot, 'state.db');
if (existsSync(databasePath)) {
try {
+38 -8
View File
@@ -16,7 +16,34 @@ function jsonYaml(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function initialFiles({ repository, projectId, createdAt, proposal }) {
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;
@@ -40,11 +67,10 @@ function initialFiles({ repository, projectId, createdAt, proposal }) {
allow_force_push: false, allow_bare_stash: false,
},
synchronization: { strategy: 'dispatcher-managed', auto_merge_worktrees: false },
runtimes: {
default: 'claude',
claude: { model: 'opus', background: true },
codex: { sandbox: 'workspace-write', approval: 'approve-for-me' },
},
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 ?? [],
@@ -174,8 +200,9 @@ function safeProjectPath(root, relativePath) {
return target;
}
export function planInstall({ repository, proposal, env = process.env }) {
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',
@@ -184,6 +211,7 @@ export function planInstall({ repository, proposal, env = process.env }) {
stateParent: join(projectStatePath('<new-project-id>', env), '..'),
wouldCreateTrackedState: !existsSync(trackedRoot),
approvedDomainCount: proposal.domains.length,
runtimes,
mutationPerformed: false,
};
}
@@ -194,8 +222,10 @@ export function installProject({
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}`, {
@@ -213,7 +243,7 @@ export function installProject({
const created = [];
try {
for (const [path, content] of initialFiles({ repository, projectId, createdAt, proposal })) {
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 });
+102 -11
View File
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { TorchError } from '../kernel/errors.mjs';
import { readInstallManifest } from '../kernel/install.mjs';
@@ -23,6 +23,24 @@ function orderedAreas(roster) {
...roster.areas.filter((area) => area.id === 'session-manager')];
}
function selectedAreas(roster, only, blockers) {
const ordered = orderedAreas(roster);
if (only === undefined) return ordered;
const selected = Array.isArray(only)
? [...new Set(only.map((id) => typeof id === 'string' ? id.trim() : '').filter(Boolean))]
: [];
if (!selected.length) {
blockers.push({ code: 'FLEET_SELECTION_EMPTY' });
return [];
}
const known = new Set(ordered.map((area) => area.id));
for (const areaId of selected.filter((id) => !known.has(id))) {
blockers.push({ areaId, code: 'FLEET_IDENTITY_MISSING' });
}
const wanted = new Set(selected);
return ordered.filter((area) => wanted.has(area.id));
}
function adapterMap({ adapter, adapters } = {}) {
if (adapters instanceof Map) return adapters;
if (adapters && typeof adapters === 'object') return new Map(Object.entries(adapters));
@@ -64,6 +82,24 @@ function atomicJson(path, value) {
renameSync(temporary, path);
}
function projectMetadata(stateRoot) {
const path = join(stateRoot, 'project.json');
if (!existsSync(path)) throw new TorchError('TORCH local project metadata is missing', { code: 'LOCAL_STATE_MISSING' });
try { return { path, value: JSON.parse(readFileSync(path, 'utf8')) }; } catch (error) {
throw new TorchError('TORCH local project metadata is invalid', { code: 'LOCAL_STATE_INVALID', details: error.message });
}
}
function captureSnapshot({ plan, controlPlane, now = () => new Date() }) {
const snapshotPath = join(plan.stateRoot, 'sessions', 'resume.json');
atomicJson(snapshotPath, {
schema: 'torch.dev/runtime-snapshot/v1alpha1', projectId: plan.projectId,
capturedAt: now().toISOString(), agents: controlPlane.listAgents(),
worktrees: plan.actions.map((action) => action.worktree).filter(Boolean),
});
return snapshotPath;
}
function inspectWorktree(worktree) {
const git = (args) => execFileSync('git', ['-C', worktree.path, ...args], { encoding: 'utf8' }).trim();
return {
@@ -75,16 +111,16 @@ function inspectWorktree(worktree) {
};
}
export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, fresh = false } = {}) {
export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, fresh = false, only } = {}) {
const { config, roster } = loadFleetDefinition(repositoryRoot);
const manifest = readInstallManifest(repositoryRoot);
const worktrees = worktreesByArea(manifest);
const stateRoot = localStateRoot(manifest);
const runtimes = adapterMap({ adapter, adapters });
const actions = [];
const blockers = [];
const actions = [];
for (const area of orderedAreas(roster)) {
for (const area of selectedAreas(roster, only, blockers)) {
const worktree = worktrees.get(area.id);
if (!worktree) {
blockers.push({ areaId: area.id, code: 'WORKTREE_MISSING' });
@@ -129,7 +165,8 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, f
}
return {
action: 'fleet-up', projectId: manifest.projectId, fresh, actions, blockers,
action: 'fleet-up', projectId: manifest.projectId, stateRoot, fresh,
only: only ?? null, actions, blockers,
canProceed: blockers.length === 0, mutationPerformed: false,
};
}
@@ -258,6 +295,12 @@ export function startFleet({ plan, controlPlane, executor, adapters } = {}) {
});
started.push({ areaId: action.areaId, runtimeSessionId: identity.runtimeSessionId, mode: action.mode });
}
if (plan.stateRoot) {
const metadata = projectMetadata(plan.stateRoot);
if (metadata.value.detachedAt) {
atomicJson(metadata.path, { ...metadata.value, detachedAt: null, attachedAt: new Date().toISOString() });
}
}
return { projectId: plan.projectId, started, mutationPerformed: true };
}
@@ -328,11 +371,59 @@ export function stopFleet({ plan, controlPlane, stopRuntime, now = () => new Dat
controlPlane.reportStatus({ areaId: action.areaId, state: 'offline', summary: 'Runtime stopped cleanly.' });
stopped.push({ areaId: action.areaId, runtimeSessionId: action.runtimeSessionId, alreadyOffline: false });
}
const snapshotPath = join(plan.stateRoot, 'sessions', 'resume.json');
atomicJson(snapshotPath, {
schema: 'torch.dev/runtime-snapshot/v1alpha1', projectId: plan.projectId,
capturedAt: now().toISOString(), agents: controlPlane.listAgents(),
worktrees: plan.actions.map((action) => action.worktree).filter(Boolean),
});
const snapshotPath = captureSnapshot({ plan, controlPlane, now });
return { projectId: plan.projectId, stopped, snapshotPath, mutationPerformed: true };
}
export function captureFleet({ repositoryRoot, controlPlane, now = () => new Date() } = {}) {
const plan = planFleetDown({ repositoryRoot, controlPlane });
const snapshotPath = captureSnapshot({ plan, controlPlane, now });
return {
action: 'capture', projectId: plan.projectId, snapshotPath,
agents: controlPlane.listAgents(), mutationPerformed: true,
};
}
export function createFleetBrief({ repositoryRoot, controlPlane, areaId } = {}) {
const { roster } = loadFleetDefinition(repositoryRoot);
const selected = areaId
? roster.areas.filter((area) => area.id === areaId)
: orderedAreas(roster);
if (!selected.length) {
throw new TorchError(`Unknown Fleet identity: ${areaId}`, { code: 'UNKNOWN_FLEET_IDENTITY' });
}
const commonPath = join(repositoryRoot, '.torch', 'prompts', 'COMMON.md');
const common = readFileSync(commonPath, 'utf8').trimEnd();
return {
schema: 'torch.dev/fleet-brief/v1alpha1', mutationPerformed: false,
areas: selected.map((area) => {
const promptPath = join(repositoryRoot, '.torch', 'prompts', `${area.id}.md`);
return {
areaId: area.id, title: area.title, identity: controlPlane.identity(area.id),
promptSources: [commonPath, promptPath],
prompt: `${common}\n\n${readFileSync(promptPath, 'utf8').trimEnd()}\n`,
};
}),
};
}
export function planFleetDetach({ repositoryRoot, controlPlane, adapters } = {}) {
const plan = planFleetDown({ repositoryRoot, controlPlane, adapters });
return { ...plan, action: 'fleet-detach' };
}
export function detachFleet({ plan, controlPlane, stopRuntime, now = () => new Date() } = {}) {
if (plan?.action !== 'fleet-detach') {
throw new TorchError('Fleet detach requires a reviewed detach plan', { code: 'FLEET_DETACH_PLAN_REQUIRED' });
}
const stopped = stopFleet({ plan, controlPlane, stopRuntime, now });
const metadata = projectMetadata(plan.stateRoot);
const detachedAt = now().toISOString();
atomicJson(metadata.path, { ...metadata.value, detachedAt });
return {
action: 'fleet-detach', projectId: plan.projectId, detachedAt,
stopped: stopped.stopped, snapshotPath: stopped.snapshotPath,
preserved: ['tracked organization', 'worktrees', 'branches', 'local state'],
mutationPerformed: true,
};
}
+1
View File
@@ -14,6 +14,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([
'ai-fleet-planning',
'fleet-evolution',
'fleet-boundary-evolution',
'cli-lifecycle-surface',
'review-install-roster',
'branches-worktrees',
'runtime-identities',