mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-28 08:21:53 +00:00
Complete public fleet lifecycle commands
This commit is contained in:
1 parent
c714e2602d
commit
17aec17afc
10 files changed
+365
-41
No files matched your search
@@ -176,8 +176,8 @@ Review the evidence in `fleet-proposal.json`, edit its `review.status` to
|
||||
`approved`, and identify the reviewer. Then inspect and apply the installation:
|
||||
|
||||
```bash
|
||||
node bin/torch.mjs install --proposal fleet-proposal.json --dry-run --json
|
||||
node bin/torch.mjs install --proposal fleet-proposal.json --yes --json
|
||||
node bin/torch.mjs install --proposal fleet-proposal.json --runtime claude --dry-run --json
|
||||
node bin/torch.mjs install --proposal fleet-proposal.json --runtime claude --yes --json
|
||||
node bin/torch.mjs doctor --json
|
||||
node bin/torch.mjs worktrees --dry-run --json
|
||||
node bin/torch.mjs worktrees --yes --json
|
||||
@@ -189,10 +189,22 @@ step:
|
||||
|
||||
```bash
|
||||
node bin/torch.mjs up --fresh --yes --json
|
||||
node bin/torch.mjs up --only backend,qa --dry-run --json
|
||||
node bin/torch.mjs list --json
|
||||
node bin/torch.mjs brief --area backend --json
|
||||
node bin/torch.mjs capture --json
|
||||
node bin/torch.mjs down --dry-run --json
|
||||
node bin/torch.mjs down --yes --json
|
||||
node bin/torch.mjs detach --dry-run --json
|
||||
node bin/torch.mjs detach --yes --json
|
||||
```
|
||||
|
||||
`capture` externalizes the current stable identities, runtime IDs, presence,
|
||||
and worktree commits without stopping sessions. `detach` first enforces normal
|
||||
wind-down safety, then marks runtime integration detached while preserving the
|
||||
tracked organization, local state, branches, and worktrees. A later explicit
|
||||
`up` reattaches the selected runtime sessions.
|
||||
|
||||
The Fleet can evolve when recurring work develops a coherent new boundary.
|
||||
The Session Manager may propose the domain through MCP or the CLI, but cannot
|
||||
approve or activate it. Owner approval and activation are deliberately
|
||||
@@ -213,6 +225,19 @@ rejects silent ownership overlap, unknown checks/runtimes, worker-created
|
||||
roster changes, stale activation commits, and activation without matching
|
||||
owner approval.
|
||||
|
||||
The manager can also submit evidence-backed boundary changes:
|
||||
|
||||
```bash
|
||||
torch fleet propose-merge --from session-manager --proposal merge.json --json
|
||||
torch fleet propose-split --from session-manager --proposal split.json --json
|
||||
torch fleet reject --change <change-id> --by <owner> --reason "Not yet" --yes --json
|
||||
```
|
||||
|
||||
Every existing owned-path pattern must be assigned exactly once. Approval
|
||||
records the owner's decision but does not activate a merge or split: TORCH
|
||||
returns a migration-required blocker until backlog, prompts, worktrees,
|
||||
branches, neighbours, and ownership transfer have a separately proven plan.
|
||||
|
||||
The CLI fallback exposes `agents`, `who-owns`, `message`, `inbox`, `ack`,
|
||||
`status`, `complete`, `blocked`, `coordinate`, `handoff`, and the tracked
|
||||
`backlog` lifecycle. MCP hosts launch
|
||||
@@ -280,9 +305,16 @@ torch schedules plan --id fleet-hygiene --actor session-manager --json
|
||||
torch schedules run --id fleet-hygiene --actor session-manager --json
|
||||
```
|
||||
|
||||
Mutating schedules additionally require `--yes`. Cron definitions are exposed
|
||||
for an external system launcher; TORCH does not silently install persistent
|
||||
timers.
|
||||
Mutating schedules additionally require `--yes`. TORCH can preview, install,
|
||||
inspect, and exactly remove its user-systemd launcher, but never installs the
|
||||
persistent timer implicitly:
|
||||
|
||||
```bash
|
||||
torch schedules launcher plan --json
|
||||
torch schedules launcher install --yes --json
|
||||
torch schedules launcher status --json
|
||||
torch schedules launcher remove --yes --json
|
||||
```
|
||||
|
||||
## Product principles
|
||||
|
||||
|
||||
@@ -182,6 +182,14 @@ test_integrity_note:
|
||||
given: an installed Fleet and a file-backed proposed domain
|
||||
when: the manager proposes it and the owner separately approves, plans, and activates it through the public CLI
|
||||
then: missing approval is refused and the activated identity is queryable through the ordinary Fleet CLI
|
||||
- id: SCN-cli-lifecycle-surface
|
||||
given: an approved installed Fleet with isolated worktrees
|
||||
when: an operator selects runtimes, previews selected startup, reads list and brief output, captures state, and detaches
|
||||
then: the documented lifecycle commands share durable state, preserve the organization and worktrees, and require approval before stopping integrations
|
||||
- id: SCN-install-runtime-selection
|
||||
given: an approved Fleet proposal with declared runtime assignments
|
||||
when: installation selects its enabled runtime adapters
|
||||
then: unsupported or incomplete selections fail closed and the installed configuration contains the reviewed adapters
|
||||
- id: SCN-product-site
|
||||
given: the redesigned TORCH public surface
|
||||
when: its product story and local-console boundary are inspected
|
||||
|
||||
+74
-16
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -37,6 +37,7 @@ function releaseCandidate(root, version) {
|
||||
'SCN-ai-fleet-planning',
|
||||
'SCN-fleet-evolution', 'SCN-cli-fleet-evolution',
|
||||
'SCN-fleet-boundary-evolution',
|
||||
'SCN-cli-lifecycle-surface',
|
||||
'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,6 +101,60 @@ test('SCN-cli-domain-review: a generated proposal must be approved before instal
|
||||
assert.equal(existsSync(join(root, '.torch', 'domain-proposal.approved.json')), true);
|
||||
});
|
||||
|
||||
test('SCN-cli-lifecycle-surface: runtime selection, selective startup, capture, brief, list, and detach are public', () => {
|
||||
const root = repo();
|
||||
const proposalPath = join(root, 'fleet-proposal.json');
|
||||
assert.equal(run(root, ['domains', '--output', proposalPath, '--json']).status, 0);
|
||||
const proposal = JSON.parse(readFileSync(proposalPath, 'utf8'));
|
||||
proposal.review = {
|
||||
status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [],
|
||||
};
|
||||
writeFileSync(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`);
|
||||
const preview = run(root, [
|
||||
'install', '--proposal', proposalPath, '--runtime', 'claude', '--dry-run', '--json',
|
||||
]);
|
||||
assert.equal(preview.status, 0, preview.stderr || preview.stdout);
|
||||
assert.deepEqual(JSON.parse(preview.stdout).runtimes, ['claude']);
|
||||
const installed = run(root, [
|
||||
'install', '--proposal', proposalPath, '--runtime', 'claude', '--yes', '--json',
|
||||
]);
|
||||
assert.equal(installed.status, 0, installed.stderr || installed.stdout);
|
||||
const config = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8'));
|
||||
assert.deepEqual(Object.keys(config.runtimes).sort(), ['claude', 'default']);
|
||||
execFileSync('git', ['-C', root, 'add', '.torch', 'fleet-proposal.json']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']);
|
||||
const worktreeParent = join(tmpdir(), `${basename(root)}-cli-worktrees`);
|
||||
const worktrees = run(root, ['worktrees', '--parent', worktreeParent, '--yes', '--json']);
|
||||
assert.equal(worktrees.status, 0, worktrees.stderr || worktrees.stdout);
|
||||
const worker = proposal.domains[0].id;
|
||||
|
||||
const listed = run(root, ['list', '--json']);
|
||||
assert.equal(listed.status, 0, listed.stderr || listed.stdout);
|
||||
assert.equal(JSON.parse(listed.stdout).agents.some((agent) => agent.areaId === worker), true);
|
||||
const brief = run(root, ['brief', '--area', worker, '--json']);
|
||||
assert.equal(brief.status, 0, brief.stderr || brief.stdout);
|
||||
assert.equal(JSON.parse(brief.stdout).areas[0].areaId, worker);
|
||||
assert.match(JSON.parse(brief.stdout).areas[0].prompt, /Common fleet rules/);
|
||||
const selective = run(root, ['up', '--only', worker, '--dry-run', '--json']);
|
||||
assert.equal(selective.status, 0, selective.stderr || selective.stdout);
|
||||
assert.deepEqual(JSON.parse(selective.stdout).actions.map((action) => action.areaId), [worker]);
|
||||
const unknown = run(root, ['up', '--only', 'not-a-domain', '--dry-run', '--json']);
|
||||
assert.equal(unknown.status, 1);
|
||||
assert.equal(JSON.parse(unknown.stdout).blockers[0].code, 'FLEET_IDENTITY_MISSING');
|
||||
const captured = run(root, ['capture', '--json']);
|
||||
assert.equal(captured.status, 0, captured.stderr || captured.stdout);
|
||||
assert.equal(existsSync(JSON.parse(captured.stdout).snapshotPath), true);
|
||||
assert.equal(JSON.parse(run(root, ['detach', '--json']).stdout).error, 'APPROVAL_REQUIRED');
|
||||
const detached = run(root, ['detach', '--yes', '--json']);
|
||||
assert.equal(detached.status, 0, detached.stderr || detached.stdout);
|
||||
assert.deepEqual(JSON.parse(detached.stdout).preserved, [
|
||||
'tracked organization', 'worktrees', 'branches', 'local state',
|
||||
]);
|
||||
const doctor = run(root, ['doctor', '--json']);
|
||||
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
||||
assert.equal(JSON.parse(doctor.stdout).findings.some((finding) => finding.code === 'FLEET_DETACHED'), true);
|
||||
});
|
||||
|
||||
test('SCN-cli-control-plane: CLI messages, acknowledgements, ownership, and status share durable state', () => {
|
||||
const root = repo();
|
||||
const proposalPath = join(root, 'fleet-proposal.json');
|
||||
|
||||
@@ -109,6 +109,28 @@ test('SCN-domain-approval: installation rejects stale or unapproved organization
|
||||
assert.equal(existsSync(join(root, '.torch')), false);
|
||||
});
|
||||
|
||||
test('SCN-install-runtime-selection: approved runtime choices are explicit and cover every identity', () => {
|
||||
const { root, env } = fixture();
|
||||
const repository = inspectRepository(root);
|
||||
const proposal = approvedProposal(repository);
|
||||
assert.deepEqual(planInstall({ repository, proposal, env, runtimes: ['claude'] }).runtimes, ['claude']);
|
||||
proposal.domains[0].runtime = 'codex';
|
||||
assert.throws(
|
||||
() => planInstall({ repository, proposal, env, runtimes: ['claude'] }),
|
||||
(error) => error.code === 'INSTALL_RUNTIME_MISSING' && error.details.missing.includes('codex'),
|
||||
);
|
||||
assert.throws(
|
||||
() => planInstall({ repository, proposal, env, runtimes: ['unknown'] }),
|
||||
(error) => error.code === 'INSTALL_RUNTIME_INVALID',
|
||||
);
|
||||
const installed = installProject({
|
||||
repository, proposal, env, projectId: 'runtime-selection', runtimes: ['claude', 'codex'],
|
||||
});
|
||||
assert.equal(installed.mutationPerformed, true);
|
||||
const config = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8'));
|
||||
assert.deepEqual(Object.keys(config.runtimes).sort(), ['claude', 'codex', 'default']);
|
||||
});
|
||||
|
||||
test('SCN-config-schema: installed configuration is strict, versioned, and migration-aware', () => {
|
||||
const { root, env } = fixture();
|
||||
const repository = inspectRepository(root);
|
||||
|
||||
@@ -12,7 +12,9 @@ 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';
|
||||
import { planFleetDown, planFleetUp, startFleet, stopFleet } from '../../src/runtime/lifecycle.mjs';
|
||||
import {
|
||||
detachFleet, planFleetDetach, planFleetDown, planFleetUp, startFleet, stopFleet,
|
||||
} from '../../src/runtime/lifecycle.mjs';
|
||||
|
||||
function fleetFixture({ workerRuntime = 'claude' } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'torch-lifecycle-'));
|
||||
@@ -70,12 +72,25 @@ test('SCN-fleet-fresh-resume: workers start before manager and stable identities
|
||||
assert.equal(stoppedIds.length, fresh.actions.length);
|
||||
assert.equal(existsSync(down.snapshotPath), true);
|
||||
|
||||
const detached = detachFleet({
|
||||
plan: planFleetDetach({ repositoryRoot: fixture.root, controlPlane: control }),
|
||||
controlPlane: control, stopRuntime: () => ({ stopped: true }),
|
||||
now: () => new Date('2026-09-27T13:00:00Z'),
|
||||
});
|
||||
assert.equal(detached.detachedAt, '2026-09-27T13:00:00.000Z');
|
||||
const metadataPath = join(planFleetDown({
|
||||
repositoryRoot: fixture.root, controlPlane: control,
|
||||
}).stateRoot, 'project.json');
|
||||
assert.equal(JSON.parse(readFileSync(metadataPath, 'utf8')).detachedAt, detached.detachedAt);
|
||||
|
||||
const resumed = planFleetUp({ repositoryRoot: fixture.root, controlPlane: control, adapter, fresh: false });
|
||||
assert.equal(resumed.actions.every((action) => action.mode === 'resume'), true);
|
||||
assert.deepEqual(
|
||||
resumed.actions.map((action) => action.runtimeSessionId),
|
||||
fresh.actions.map((action) => action.runtimeSessionId),
|
||||
);
|
||||
startFleet({ plan: resumed, controlPlane: control, executor: () => ({ status: 0 }) });
|
||||
assert.equal(JSON.parse(readFileSync(metadataPath, 'utf8')).detachedAt, null);
|
||||
control.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user