mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 04:42:24 +00:00
Add owner-gated fleet evolution
This commit is contained in:
1 parent
acb4dc401d
commit
d1cd79cccb
22 files changed
+888
-11
No files matched your search
+78
-1
@@ -19,13 +19,14 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeNewFile } from './kernel/files.mjs';
|
||||
import { createWorktrees, planWorktrees } from './kernel/worktrees.mjs';
|
||||
import { planFleetDown, planFleetUp, startFleet, stopFleet } from './runtime/lifecycle.mjs';
|
||||
import { planAreaUp, 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';
|
||||
import { ContextTelemetryService } from './telemetry/context.mjs';
|
||||
import { ScheduleService } from './schedules/service.mjs';
|
||||
import { createFleetDesignBrief } from './design/brief.mjs';
|
||||
import { FleetEvolutionService } from './evolution/service.mjs';
|
||||
|
||||
const HELP = `TORCH — portable agent fleet
|
||||
|
||||
@@ -90,6 +91,13 @@ Usage:
|
||||
torch schedules runs [--id <schedule>] [--json]
|
||||
torch schedules plan --id <schedule> --actor <id|owner> [--json]
|
||||
torch schedules run --id <schedule> --actor <id|owner> [--yes] [--json]
|
||||
torch fleet changes [--state <state>] [--json]
|
||||
torch fleet get --change <id> [--json]
|
||||
torch fleet propose --from session-manager --proposal <path> [--json]
|
||||
torch fleet approve --change <id> --by <owner> --yes [--json]
|
||||
torch fleet plan --change <id> [--json]
|
||||
torch fleet activate --change <id> --by <owner> --yes [--json]
|
||||
torch fleet start --change <id> [--fresh] [--dry-run] --yes [--json]
|
||||
torch doctor [--json]
|
||||
torch uninstall [--dry-run] [--purge] [--json]
|
||||
`;
|
||||
@@ -132,6 +140,13 @@ function loadProposal(cwd, proposalPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadJsonFile(cwd, inputPath, { label = 'JSON input', code = 'INPUT_INVALID' } = {}) {
|
||||
if (!inputPath) throw new TorchError(`${label} path is required`, { code });
|
||||
try { return JSON.parse(readFileSync(resolve(cwd, inputPath), 'utf8')); } catch (error) {
|
||||
throw new TorchError(`Cannot read ${label}: ${inputPath}`, { code, details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
function withControlPlane(repository, env, callback) {
|
||||
const controlPlane = openControlPlane({ repositoryRoot: repository.root, env });
|
||||
try {
|
||||
@@ -168,6 +183,7 @@ function runCandidateAcceptance(candidateRoot, spawn) {
|
||||
['init-analyze', ['SCN-init-read-only']],
|
||||
['spec-aware-design', ['SCN-spec-fleet-design', 'SCN-cli-spec-design']],
|
||||
['ai-fleet-bootstrap', ['SCN-ai-fleet-bootstrap']],
|
||||
['fleet-evolution', ['SCN-fleet-evolution', 'SCN-cli-fleet-evolution']],
|
||||
['review-install-roster', ['SCN-cli-domain-review']],
|
||||
['branches-worktrees', ['SCN-worktree-bootstrap']],
|
||||
['runtime-identities', ['SCN-mixed-runtime']],
|
||||
@@ -534,6 +550,67 @@ export async function runCli(argv = process.argv.slice(2), {
|
||||
})), { json });
|
||||
return 0;
|
||||
}
|
||||
if (command === 'fleet') {
|
||||
const operation = argv[1] ?? 'changes';
|
||||
const control = openControlPlane({ repositoryRoot: repository.root, env });
|
||||
try {
|
||||
const evolution = new FleetEvolutionService({ repositoryRoot: repository.root, controlPlane: control });
|
||||
const changeId = optionValue(argv, '--change');
|
||||
if (operation === 'changes') print({ changes: evolution.list({ state: optionValue(argv, '--state') }) }, { json });
|
||||
else if (operation === 'get') print(evolution.get(changeId), { json });
|
||||
else if (operation === 'propose') {
|
||||
const input = loadJsonFile(cwd, optionValue(argv, '--proposal'), {
|
||||
label: 'Fleet domain proposal', code: 'FLEET_PROPOSAL_INVALID',
|
||||
});
|
||||
print(evolution.proposeDomain({
|
||||
...input, proposer: optionValue(argv, '--from') ?? input.proposer,
|
||||
}), { json });
|
||||
} else if (operation === 'approve') {
|
||||
if (!argv.includes('--yes')) {
|
||||
throw new TorchError('Owner approval of a Fleet change requires explicit --yes.', { code: 'APPROVAL_REQUIRED' });
|
||||
}
|
||||
print(evolution.approve({ changeId, approvedBy: optionValue(argv, '--by') }), { json });
|
||||
} else if (operation === 'plan') print(evolution.planActivation(changeId), { json });
|
||||
else if (operation === 'activate') {
|
||||
if (!argv.includes('--yes')) {
|
||||
throw new TorchError('Fleet activation commits configuration and creates a worktree. Review fleet plan, then use --yes.', {
|
||||
code: 'APPROVAL_REQUIRED',
|
||||
});
|
||||
}
|
||||
print(evolution.activate({ changeId, approvedBy: optionValue(argv, '--by') }), { json });
|
||||
} else if (operation === 'start') {
|
||||
const change = evolution.get(changeId);
|
||||
if (change.state !== 'active') {
|
||||
throw new TorchError('Only an active Fleet domain can start a runtime session', {
|
||||
code: 'FLEET_CHANGE_STATE_CONFLICT', details: { state: change.state },
|
||||
});
|
||||
}
|
||||
const adapters = runtimeAdapters();
|
||||
const plan = planAreaUp({
|
||||
repositoryRoot: repository.root, controlPlane: control, areaId: change.domain.id,
|
||||
adapters, fresh: argv.includes('--fresh'),
|
||||
});
|
||||
if (argv.includes('--dry-run')) {
|
||||
print(plan, { json });
|
||||
return plan.canProceed ? 0 : 1;
|
||||
}
|
||||
if (!argv.includes('--yes')) {
|
||||
throw new TorchError('Starting the new domain invokes its configured AI runtime. Review --dry-run, then use --yes.', {
|
||||
code: 'APPROVAL_REQUIRED',
|
||||
});
|
||||
}
|
||||
print(startFleet({
|
||||
plan, controlPlane: control, adapters,
|
||||
executor: (launch) => spawn(launch.command, launch.args, {
|
||||
cwd: launch.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}),
|
||||
}), { json });
|
||||
} else throw new TorchError(`Unknown fleet operation: ${operation}`, { code: 'UNKNOWN_COMMAND' });
|
||||
return 0;
|
||||
} finally {
|
||||
control.close();
|
||||
}
|
||||
}
|
||||
if (command === 'backlog') {
|
||||
const operation = argv[1] ?? 'list';
|
||||
const control = openControlPlane({ repositoryRoot: repository.root, env });
|
||||
|
||||
@@ -78,7 +78,9 @@ function rowToAgent(row, rosterAgent) {
|
||||
sharedPaths: rosterAgent.shared_paths ?? [],
|
||||
neighbours: rosterAgent.neighbours ?? [],
|
||||
requiredChecks: rosterAgent.required_checks ?? [],
|
||||
resources: rosterAgent.resources ?? [],
|
||||
runtime: row?.runtime ?? null,
|
||||
model: rosterAgent.model ?? null,
|
||||
runtimeSessionId: row?.runtime_session_id ?? null,
|
||||
state: row?.state ?? 'offline',
|
||||
summary: row?.summary ?? null,
|
||||
@@ -224,11 +226,27 @@ export class ControlPlane {
|
||||
for (const areaId of this.agents.keys()) insert.run(areaId, 'offline');
|
||||
}
|
||||
|
||||
refreshRoster() {
|
||||
const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'INVALID_TORCH_ROSTER');
|
||||
const agents = new Map((roster.areas ?? []).map((area) => [area.id, area]));
|
||||
if (!agents.has('session-manager')) {
|
||||
throw new TorchError('Roster does not contain the required session-manager identity', {
|
||||
code: 'INVALID_TORCH_ROSTER',
|
||||
});
|
||||
}
|
||||
const insert = this.database.prepare('INSERT OR IGNORE INTO identities (area_id, state) VALUES (?, ?)');
|
||||
for (const areaId of agents.keys()) insert.run(areaId, 'offline');
|
||||
this.roster = roster;
|
||||
this.agents = agents;
|
||||
return { schema: roster.schema, areaIds: [...agents.keys()] };
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
assertIdentity(areaId) {
|
||||
this.refreshRoster();
|
||||
const normalized = requiredText(areaId, 'areaId');
|
||||
if (!this.agents.has(normalized)) {
|
||||
throw new TorchError(`Unknown Fleet identity: ${normalized}`, {
|
||||
|
||||
@@ -77,6 +77,7 @@ export function createFleetDesignBrief({ repository, analysis, baseline } = {})
|
||||
domainFields: [
|
||||
'id', 'title', 'kind', 'scope', 'not_scope', 'owned_paths', 'shared_paths',
|
||||
'neighbours', 'required_checks', 'runtime', 'evidence',
|
||||
'resources',
|
||||
],
|
||||
topLevelFields: [
|
||||
'schema', 'generatedAt', 'repository', 'review', 'specifications', 'domains',
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
import { readInstallManifest } from '../kernel/install.mjs';
|
||||
import { ensureTaskIgnored, removeTaskIgnore } from '../kernel/worktrees.mjs';
|
||||
|
||||
const CHANGE_STATES = Object.freeze(['proposed', 'approved', 'provisioning', 'active', 'rejected']);
|
||||
|
||||
function requiredText(value, name) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TorchError(`${name} must be a non-empty string`, {
|
||||
code: 'INVALID_FLEET_CHANGE', details: { field: name },
|
||||
});
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function textList(value, name, { required = false } = {}) {
|
||||
if (!Array.isArray(value) || (required && value.length === 0)
|
||||
|| value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
|
||||
throw new TorchError(`${name} must be ${required ? 'a non-empty' : 'an'} array of non-empty strings`, {
|
||||
code: 'INVALID_FLEET_CHANGE', details: { field: name },
|
||||
});
|
||||
}
|
||||
return [...new Set(value.map((entry) => entry.trim()))];
|
||||
}
|
||||
|
||||
function readJson(path, code) {
|
||||
try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) {
|
||||
throw new TorchError(`Cannot read ${path}`, { code, details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function git(root, args, { optional = false } = {}) {
|
||||
try {
|
||||
return execFileSync('git', ['-C', root, ...args], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
if (optional) return '';
|
||||
throw new TorchError(`Git command failed: git ${args.join(' ')}`, {
|
||||
code: 'GIT_COMMAND_FAILED', details: error.stderr?.toString().trim() || error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function expandPath(value, root) {
|
||||
if (value === '~') return homedir();
|
||||
if (value.startsWith('~/')) return join(homedir(), value.slice(2));
|
||||
return isAbsolute(value) ? value : resolve(root, value);
|
||||
}
|
||||
|
||||
function ownershipPrefixes(pattern) {
|
||||
const normalized = pattern.replaceAll('\\', '/');
|
||||
const wildcard = normalized.search(/[?*[]/);
|
||||
return (wildcard === -1 ? normalized : normalized.slice(0, wildcard)).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function pathsOverlap(left, right) {
|
||||
if (left === right) return true;
|
||||
const a = ownershipPrefixes(left);
|
||||
const b = ownershipPrefixes(right);
|
||||
return Boolean(a && b && (a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
|
||||
}
|
||||
|
||||
function normalizeDomain(input) {
|
||||
const id = requiredText(input?.id, 'domain.id');
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id) || id === 'session-manager') {
|
||||
throw new TorchError(`Invalid proposed domain ID: ${id}`, {
|
||||
code: 'INVALID_FLEET_CHANGE', details: { field: 'domain.id' },
|
||||
});
|
||||
}
|
||||
const runtime = requiredText(input.runtime ?? 'claude', 'domain.runtime');
|
||||
const model = input.model === undefined || input.model === null
|
||||
? null : requiredText(input.model, 'domain.model');
|
||||
const branch = input.branch === undefined || input.branch === null
|
||||
? null : requiredText(input.branch, 'domain.branch');
|
||||
const worktreeName = input.worktree_name === undefined || input.worktree_name === null
|
||||
? null : requiredText(input.worktree_name, 'domain.worktree_name');
|
||||
if (worktreeName && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(worktreeName)) {
|
||||
throw new TorchError(`Invalid worktree name: ${worktreeName}`, {
|
||||
code: 'INVALID_FLEET_CHANGE', details: { field: 'domain.worktree_name' },
|
||||
});
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: requiredText(input.title, 'domain.title'),
|
||||
kind: requiredText(input.kind ?? 'development', 'domain.kind'),
|
||||
scope: textList(input.scope, 'domain.scope', { required: true }),
|
||||
not_scope: textList(input.not_scope ?? [], 'domain.not_scope'),
|
||||
owned_paths: textList(input.owned_paths, 'domain.owned_paths', { required: true }),
|
||||
shared_paths: textList(input.shared_paths ?? [], 'domain.shared_paths'),
|
||||
neighbours: textList(input.neighbours ?? [], 'domain.neighbours'),
|
||||
required_checks: textList(input.required_checks ?? [], 'domain.required_checks'),
|
||||
resources: textList(input.resources ?? [], 'domain.resources'),
|
||||
runtime,
|
||||
model,
|
||||
branch,
|
||||
worktree_name: worktreeName,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToChange(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id, state: row.state, proposer: row.proposer,
|
||||
createdAt: row.created_at, updatedAt: row.updated_at, baseCommit: row.base_commit,
|
||||
approvedBy: row.approved_by, approvedAt: row.approved_at,
|
||||
activationCommit: row.activation_commit, domain: JSON.parse(row.domain_json),
|
||||
rationale: row.rationale, expectedBenefit: JSON.parse(row.expected_benefit_json),
|
||||
evidence: JSON.parse(row.evidence_json),
|
||||
};
|
||||
}
|
||||
|
||||
function promptFor(domain) {
|
||||
return [
|
||||
`# ${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');
|
||||
}
|
||||
|
||||
function initialize(database) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS fleet_changes (
|
||||
id TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL,
|
||||
proposer TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
base_commit TEXT NOT NULL,
|
||||
approved_by TEXT,
|
||||
approved_at TEXT,
|
||||
activation_commit TEXT,
|
||||
domain_json TEXT NOT NULL,
|
||||
rationale TEXT NOT NULL,
|
||||
expected_benefit_json TEXT NOT NULL,
|
||||
evidence_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS fleet_changes_state_created
|
||||
ON fleet_changes(state, created_at, id);
|
||||
`);
|
||||
}
|
||||
|
||||
export class FleetEvolutionService {
|
||||
constructor({ repositoryRoot, controlPlane, clock = () => new Date(), idFactory = randomUUID } = {}) {
|
||||
if (!controlPlane) throw new TorchError('Fleet evolution requires the TORCH control plane', { code: 'CONTROL_PLANE_REQUIRED' });
|
||||
this.repositoryRoot = resolve(repositoryRoot ?? controlPlane.repositoryRoot);
|
||||
this.controlPlane = controlPlane;
|
||||
this.database = controlPlane.database;
|
||||
this.clock = clock;
|
||||
this.idFactory = idFactory;
|
||||
initialize(this.database);
|
||||
}
|
||||
|
||||
get(changeId) {
|
||||
const id = requiredText(changeId, 'changeId');
|
||||
const change = rowToChange(this.database.prepare('SELECT * FROM fleet_changes WHERE id = ?').get(id));
|
||||
if (!change) throw new TorchError(`Unknown Fleet change: ${id}`, { code: 'FLEET_CHANGE_NOT_FOUND' });
|
||||
return change;
|
||||
}
|
||||
|
||||
list({ state } = {}) {
|
||||
if (state && !CHANGE_STATES.includes(state)) {
|
||||
throw new TorchError(`Invalid Fleet change state: ${state}`, { code: 'INVALID_FLEET_CHANGE_STATE' });
|
||||
}
|
||||
const rows = state
|
||||
? this.database.prepare('SELECT * FROM fleet_changes WHERE state = ? ORDER BY created_at, id').all(state)
|
||||
: this.database.prepare('SELECT * FROM fleet_changes ORDER BY created_at, id').all();
|
||||
return rows.map(rowToChange);
|
||||
}
|
||||
|
||||
proposeDomain({ proposer, domain: input, rationale, expectedBenefit, evidence } = {}) {
|
||||
const actor = this.controlPlane.assertIdentity(proposer);
|
||||
if (actor !== 'session-manager') {
|
||||
throw new TorchError('Only the Session Manager may propose a persistent Fleet domain', {
|
||||
code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { actor },
|
||||
});
|
||||
}
|
||||
const domain = normalizeDomain(input);
|
||||
const agents = this.controlPlane.listAgents();
|
||||
if (agents.some((agent) => agent.areaId === domain.id)) {
|
||||
throw new TorchError(`Fleet identity already exists: ${domain.id}`, { code: 'FLEET_IDENTITY_EXISTS' });
|
||||
}
|
||||
for (const neighbour of domain.neighbours) this.controlPlane.assertIdentity(neighbour);
|
||||
const benefit = {
|
||||
summary: requiredText(expectedBenefit?.summary, 'expectedBenefit.summary'),
|
||||
recurringWork: requiredText(expectedBenefit?.recurringWork, 'expectedBenefit.recurringWork'),
|
||||
contextLocality: requiredText(expectedBenefit?.contextLocality, 'expectedBenefit.contextLocality'),
|
||||
coordinationCost: requiredText(expectedBenefit?.coordinationCost, 'expectedBenefit.coordinationCost'),
|
||||
};
|
||||
const evidenceList = textList(evidence, 'evidence', { required: true });
|
||||
const collisions = agents.flatMap((agent) => domain.owned_paths.flatMap((proposedPath) =>
|
||||
agent.ownedPaths.filter((existingPath) => pathsOverlap(proposedPath, existingPath))
|
||||
.map((existingPath) => ({ areaId: agent.areaId, proposedPath, existingPath }))));
|
||||
if (collisions.length) {
|
||||
throw new TorchError('A new domain cannot silently overlap existing owned paths; propose a split or ownership change instead', {
|
||||
code: 'FLEET_OWNERSHIP_COLLISION', details: collisions,
|
||||
});
|
||||
}
|
||||
const config = readJson(join(this.repositoryRoot, '.torch', 'torch.yaml'), 'CONFIG_INVALID');
|
||||
if (!config.runtimes?.[domain.runtime]) {
|
||||
throw new TorchError(`Runtime is not configured for the Fleet: ${domain.runtime}`, {
|
||||
code: 'FLEET_RUNTIME_NOT_CONFIGURED', details: { runtime: domain.runtime },
|
||||
});
|
||||
}
|
||||
const knownChecks = new Set((config.checks ?? []).map((check) => check.id));
|
||||
const unknownChecks = domain.required_checks.filter((check) => !knownChecks.has(check));
|
||||
if (unknownChecks.length) {
|
||||
throw new TorchError('Proposed domain references unknown checks', {
|
||||
code: 'FLEET_CHECK_NOT_CONFIGURED', details: { checks: unknownChecks },
|
||||
});
|
||||
}
|
||||
const knownResources = new Set((config.resources ?? []).map((resource) => resource.id));
|
||||
const unknownResources = domain.resources.filter((resource) => !knownResources.has(resource));
|
||||
if (unknownResources.length) {
|
||||
throw new TorchError('Proposed domain references unknown resources', {
|
||||
code: 'FLEET_RESOURCE_NOT_CONFIGURED', details: { resources: unknownResources },
|
||||
});
|
||||
}
|
||||
const now = this.clock().toISOString();
|
||||
const record = {
|
||||
id: this.idFactory(), state: 'proposed', proposer: actor, createdAt: now, updatedAt: now,
|
||||
baseCommit: git(this.repositoryRoot, ['rev-parse', 'HEAD']), approvedBy: null,
|
||||
approvedAt: null, activationCommit: null, domain,
|
||||
rationale: requiredText(rationale, 'rationale'), expectedBenefit: benefit, evidence: evidenceList,
|
||||
};
|
||||
this.database.prepare(`
|
||||
INSERT INTO fleet_changes (
|
||||
id, state, proposer, created_at, updated_at, base_commit, approved_by,
|
||||
approved_at, activation_commit, domain_json, rationale,
|
||||
expected_benefit_json, evidence_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?)
|
||||
`).run(
|
||||
record.id, record.state, record.proposer, record.createdAt, record.updatedAt,
|
||||
record.baseCommit, JSON.stringify(record.domain), record.rationale,
|
||||
JSON.stringify(record.expectedBenefit), JSON.stringify(record.evidence),
|
||||
);
|
||||
this.controlPlane.audit({
|
||||
actorId: actor, operation: 'fleet-change.propose-domain', entityType: 'fleet-change', entityId: record.id,
|
||||
details: { domainId: domain.id, baseCommit: record.baseCommit },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
approve({ changeId, approvedBy } = {}) {
|
||||
const change = this.get(changeId);
|
||||
if (change.state !== 'proposed') {
|
||||
throw new TorchError(`Fleet change ${change.id} is ${change.state}, not proposed`, { code: 'FLEET_CHANGE_STATE_CONFLICT' });
|
||||
}
|
||||
const owner = requiredText(approvedBy, 'approvedBy');
|
||||
const now = this.clock().toISOString();
|
||||
this.database.prepare(`
|
||||
UPDATE fleet_changes SET state = 'approved', approved_by = ?, approved_at = ?, updated_at = ?
|
||||
WHERE id = ? AND state = 'proposed'
|
||||
`).run(owner, now, now, change.id);
|
||||
this.controlPlane.audit({
|
||||
actorId: 'session-manager', operation: 'fleet-change.owner-approved', entityType: 'fleet-change', entityId: change.id,
|
||||
details: { approvedBy: owner },
|
||||
});
|
||||
return this.get(change.id);
|
||||
}
|
||||
|
||||
planActivation(changeId) {
|
||||
const change = this.get(changeId);
|
||||
const config = readJson(join(this.repositoryRoot, '.torch', 'torch.yaml'), 'CONFIG_INVALID');
|
||||
const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'ROSTER_INVALID');
|
||||
const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']);
|
||||
const dirtyEntries = git(this.repositoryRoot, ['status', '--porcelain']).split('\n').filter(Boolean);
|
||||
const trackedConfig = ['.torch/torch.yaml', '.torch/roster.yaml'].every((path) =>
|
||||
Boolean(git(this.repositoryRoot, ['ls-files', '--error-unmatch', path], { optional: true })));
|
||||
const prefix = config.git?.branch_prefix ?? 'torch/';
|
||||
const branch = change.domain.branch ?? `${prefix}${change.domain.id}`;
|
||||
const parent = expandPath(config.paths.worktree_parent, this.repositoryRoot);
|
||||
const worktreeName = change.domain.worktree_name ?? change.domain.id;
|
||||
const worktree = change.domain.worktree_name
|
||||
? join(parent, worktreeName) : join(parent, config.project.id, worktreeName);
|
||||
const blockers = [];
|
||||
if (change.state !== 'approved' && change.state !== 'provisioning') blockers.push({ type: 'state', actual: change.state });
|
||||
if (change.state === 'approved' && currentHead !== change.baseCommit) {
|
||||
blockers.push({ type: 'stale-head', expected: change.baseCommit, actual: currentHead });
|
||||
}
|
||||
if (change.state === 'approved' && dirtyEntries.length) blockers.push({ type: 'dirty-main', entries: dirtyEntries });
|
||||
if (!trackedConfig) blockers.push({ type: 'configuration-not-committed' });
|
||||
if (config.domains.some((domain) => domain.id === change.domain.id) && change.state !== 'provisioning') {
|
||||
blockers.push({ type: 'domain-already-configured', domainId: change.domain.id });
|
||||
}
|
||||
if (roster.areas.some((area) => area.id === change.domain.id) && change.state !== 'provisioning') {
|
||||
blockers.push({ type: 'identity-already-rostered', domainId: change.domain.id });
|
||||
}
|
||||
if (git(this.repositoryRoot, ['branch', '--list', branch], { optional: true })) {
|
||||
blockers.push({ type: 'branch-exists', branch });
|
||||
}
|
||||
if (existsSync(worktree)) blockers.push({ type: 'worktree-path-exists', worktree });
|
||||
return {
|
||||
action: 'activate-domain', changeId: change.id, domainId: change.domain.id,
|
||||
state: change.state, branch, worktree, currentHead, baseCommit: change.baseCommit,
|
||||
blockers, canProceed: blockers.length === 0, mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
activate({ changeId, approvedBy } = {}) {
|
||||
const change = this.get(changeId);
|
||||
if (change.approvedBy !== requiredText(approvedBy, 'approvedBy')) {
|
||||
throw new TorchError('Fleet activation must name the same owner who approved the change', {
|
||||
code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { approvedBy: change.approvedBy },
|
||||
});
|
||||
}
|
||||
const plan = this.planActivation(change.id);
|
||||
if (!plan.canProceed) {
|
||||
throw new TorchError('Fleet domain activation is blocked', {
|
||||
code: 'FLEET_CHANGE_BLOCKED', details: plan.blockers,
|
||||
});
|
||||
}
|
||||
if (change.state === 'approved') {
|
||||
const manifest = readInstallManifest(this.repositoryRoot);
|
||||
const ignore = ensureTaskIgnored(this.repositoryRoot, manifest.installationId);
|
||||
try {
|
||||
this.commitConfiguration(change, plan, ignore);
|
||||
} catch (error) {
|
||||
removeTaskIgnore(ignore, manifest.installationId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const current = this.get(change.id);
|
||||
mkdirSync(dirname(plan.worktree), { recursive: true });
|
||||
git(this.repositoryRoot, ['worktree', 'add', '-b', plan.branch, plan.worktree, current.activationCommit]);
|
||||
writeFileSync(join(plan.worktree, '.task'), `Unassigned · ${change.domain.id}\n`, { encoding: 'utf8', flag: 'wx' });
|
||||
const now = this.clock().toISOString();
|
||||
this.database.prepare("UPDATE fleet_changes SET state = 'active', updated_at = ? WHERE id = ?")
|
||||
.run(now, change.id);
|
||||
this.controlPlane.refreshRoster();
|
||||
this.controlPlane.audit({
|
||||
actorId: 'session-manager', operation: 'fleet-change.activate-domain', entityType: 'fleet-change', entityId: change.id,
|
||||
details: { domainId: change.domain.id, branch: plan.branch, worktree: plan.worktree },
|
||||
});
|
||||
return {
|
||||
...this.get(change.id), branch: plan.branch, worktree: plan.worktree,
|
||||
identity: this.controlPlane.identity(change.domain.id),
|
||||
runtime: { state: 'offline', next: `Start ${change.domain.id} through the approved ${change.domain.runtime} adapter.` },
|
||||
mutationPerformed: true,
|
||||
};
|
||||
}
|
||||
|
||||
commitConfiguration(change, plan, ignore) {
|
||||
const configPath = join(this.repositoryRoot, '.torch', 'torch.yaml');
|
||||
const rosterPath = join(this.repositoryRoot, '.torch', 'roster.yaml');
|
||||
const config = readJson(configPath, 'CONFIG_INVALID');
|
||||
const roster = readJson(rosterPath, 'ROSTER_INVALID');
|
||||
const manifest = readInstallManifest(this.repositoryRoot);
|
||||
const domain = change.domain;
|
||||
config.domains.push(domain);
|
||||
roster.areas.push(domain);
|
||||
for (const neighbourId of domain.neighbours) {
|
||||
const configured = config.domains.find((candidate) => candidate.id === neighbourId);
|
||||
const rostered = roster.areas.find((candidate) => candidate.id === neighbourId);
|
||||
if (configured && !configured.neighbours.includes(domain.id)) configured.neighbours.push(domain.id);
|
||||
if (rostered && !rostered.neighbours.includes(domain.id)) rostered.neighbours.push(domain.id);
|
||||
}
|
||||
const promptRelative = `.torch/prompts/${domain.id}.md`;
|
||||
const changeRelative = `.torch/fleet-changes/${change.id}.approved.json`;
|
||||
const approvedRecord = {
|
||||
schema: 'torch.dev/fleet-change/v1alpha1', ...change, state: 'approved',
|
||||
activation: { branch: plan.branch, worktree: plan.worktree },
|
||||
};
|
||||
const serialized = new Map([
|
||||
['.torch/torch.yaml', `${JSON.stringify(config, null, 2)}\n`],
|
||||
['.torch/roster.yaml', `${JSON.stringify(roster, null, 2)}\n`],
|
||||
[promptRelative, promptFor(domain)],
|
||||
[changeRelative, `${JSON.stringify(approvedRecord, null, 2)}\n`],
|
||||
]);
|
||||
manifest.external.push({ type: 'worktree', area: domain.id, path: plan.worktree, branch: plan.branch });
|
||||
if (ignore?.added) manifest.patched.push({
|
||||
type: 'git-info-exclude-line', path: ignore.path, line: '.task', installationId: manifest.installationId,
|
||||
});
|
||||
for (const [relativePath, content] of serialized) {
|
||||
const existing = manifest.created.find((record) => record.path === relativePath);
|
||||
if (existing) existing.sha256 = sha256(content);
|
||||
else manifest.created.push({ path: relativePath, sha256: sha256(content) });
|
||||
}
|
||||
serialized.set('.torch/install-manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
for (const [relativePath, content] of serialized) {
|
||||
const path = join(this.repositoryRoot, relativePath);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
||||
writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
renameSync(temporary, path);
|
||||
}
|
||||
git(this.repositoryRoot, ['add', ...serialized.keys()]);
|
||||
git(this.repositoryRoot, ['commit', '-m', `chore(torch): add ${domain.id} domain`]);
|
||||
const activationCommit = git(this.repositoryRoot, ['rev-parse', 'HEAD']);
|
||||
const now = this.clock().toISOString();
|
||||
this.database.prepare(`
|
||||
UPDATE fleet_changes SET state = 'provisioning', activation_commit = ?, updated_at = ? WHERE id = ?
|
||||
`).run(activationCommit, now, change.id);
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@ function domainFromComponent(component) {
|
||||
shared_paths: [],
|
||||
neighbours: [],
|
||||
required_checks: [],
|
||||
resources: [],
|
||||
runtime: 'claude',
|
||||
evidence: component.evidence,
|
||||
};
|
||||
@@ -162,7 +163,7 @@ function specDomain(signal, specification) {
|
||||
: [`responsibilities described by ${specification.path}:${signal.line}`],
|
||||
not_scope: [],
|
||||
owned_paths: signal.pathReferences,
|
||||
shared_paths: [], neighbours: [], required_checks: [], runtime: 'claude',
|
||||
shared_paths: [], neighbours: [], required_checks: [], resources: [], runtime: 'claude',
|
||||
evidence: [`${specification.path}:${signal.line}`],
|
||||
design_status: signal.pathReferences.length ? 'evidence-backed' : 'needs-owner-path-review',
|
||||
};
|
||||
@@ -200,6 +201,7 @@ export function proposeDomains({ repository, analysis }) {
|
||||
id: 'core', title: 'Core project', kind: 'development',
|
||||
scope: ['initial project implementation and architecture'],
|
||||
not_scope: [], owned_paths: ['**'], shared_paths: [], neighbours: [], required_checks: [],
|
||||
resources: [],
|
||||
runtime: 'claude',
|
||||
evidence: analysis.inventory.manifests,
|
||||
});
|
||||
@@ -212,6 +214,7 @@ export function proposeDomains({ repository, analysis }) {
|
||||
not_scope: ['implementing domain features solely to make checks pass'],
|
||||
owned_paths: ['test/**', 'tests/**', 'e2e/**'], shared_paths: [], neighbours: [],
|
||||
required_checks: [], evidence: graph.verificationSurfaces.slice(0, 12),
|
||||
resources: [],
|
||||
runtime: 'claude',
|
||||
});
|
||||
}
|
||||
@@ -222,6 +225,7 @@ export function proposeDomains({ repository, analysis }) {
|
||||
not_scope: ['feature implementation', 'deployment without configured authority'],
|
||||
owned_paths: graph.operationalSurfaces.filter((path) => /deploy|release|workflow/i.test(path)).slice(0, 20),
|
||||
shared_paths: [], neighbours: [], required_checks: [],
|
||||
resources: [],
|
||||
runtime: 'claude',
|
||||
evidence: graph.operationalSurfaces.slice(0, 12),
|
||||
});
|
||||
@@ -333,6 +337,7 @@ export function validateApprovedProposal({ proposal, repository }) {
|
||||
ids.add(domain.id);
|
||||
if (!domain.scope?.length) problems.push(`domain ${domain.id} has no scope`);
|
||||
if (!Array.isArray(domain.not_scope)) problems.push(`domain ${domain.id} has no not_scope list`);
|
||||
if (domain.resources !== undefined && !Array.isArray(domain.resources)) problems.push(`domain ${domain.id} has an invalid resources list`);
|
||||
if (!domain.owned_paths?.length) problems.push(`domain ${domain.id} has no owned paths`);
|
||||
if (domain.runtime !== undefined && (typeof domain.runtime !== 'string' || !domain.runtime.trim())) {
|
||||
problems.push(`domain ${domain.id} has an invalid runtime`);
|
||||
|
||||
+11
-4
@@ -67,7 +67,9 @@ function initialFiles({ repository, projectId, createdAt, proposal }) {
|
||||
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,
|
||||
})),
|
||||
@@ -85,6 +87,10 @@ function initialFiles({ repository, projectId, createdAt, proposal }) {
|
||||
}, ...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. Use \`torch_list_fleet_changes\` to follow its state. Never create, approve, or activate a persistent identity yourself; owner approval and CLI activation are separate. Also recommend merge or retirement when coordination cost exceeds the value of a persistent session.\n`;
|
||||
const files = new Map([
|
||||
['torch.yaml', jsonYaml(config)],
|
||||
['roster.yaml', jsonYaml(roster)],
|
||||
@@ -94,10 +100,7 @@ function initialFiles({ repository, projectId, createdAt, proposal }) {
|
||||
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', 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',
|
||||
)],
|
||||
['prompts/session-manager.md', managerPrompt],
|
||||
['backlog/.gitkeep', ''],
|
||||
['INSTALLATION.md', `# TORCH installation\n\nInstalled ${createdAt}. Run \`torch doctor\` before starting the fleet.\n`],
|
||||
]);
|
||||
@@ -119,6 +122,10 @@ function initialFiles({ repository, projectId, createdAt, proposal }) {
|
||||
'',
|
||||
...(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.',
|
||||
|
||||
@@ -116,7 +116,7 @@ export function planWorktrees({ repository, parentOverride } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function ensureTaskIgnored(root, installationId) {
|
||||
export function ensureTaskIgnored(root, installationId) {
|
||||
const common = git(root, ['rev-parse', '--git-common-dir']);
|
||||
const commonPath = isAbsolute(common) ? common : resolve(root, common);
|
||||
const excludePath = join(commonPath, 'info', 'exclude');
|
||||
@@ -127,7 +127,7 @@ function ensureTaskIgnored(root, installationId) {
|
||||
return { path: excludePath, added: true };
|
||||
}
|
||||
|
||||
function removeTaskIgnore(record, installationId) {
|
||||
export function removeTaskIgnore(record, installationId) {
|
||||
if (!record.added || !existsSync(record.path)) return;
|
||||
const marker = `# TORCH ${installationId}\n.task\n`;
|
||||
const content = readFileSync(record.path, 'utf8');
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ResourceService } from '../resources/service.mjs';
|
||||
import { createTorchMcpServer } from './tools.mjs';
|
||||
import { ContextTelemetryService } from '../telemetry/context.mjs';
|
||||
import { ScheduleService } from '../schedules/service.mjs';
|
||||
import { FleetEvolutionService } from '../evolution/service.mjs';
|
||||
|
||||
function optionValue(argv, name) {
|
||||
const direct = argv.find((argument) => argument.startsWith(`${name}=`));
|
||||
@@ -29,8 +30,10 @@ const backlogService = new BacklogService({
|
||||
});
|
||||
const contextTelemetryService = new ContextTelemetryService({ controlPlane });
|
||||
const scheduleService = new ScheduleService({ repositoryRoot, controlPlane });
|
||||
const evolutionService = new FleetEvolutionService({ repositoryRoot, controlPlane });
|
||||
const handle = serveStdio(() => createTorchMcpServer(controlPlane, {
|
||||
actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService,
|
||||
evolutionService,
|
||||
}), {
|
||||
onerror: (error) => console.error(`TORCH MCP: ${error.message}`),
|
||||
});
|
||||
|
||||
@@ -53,6 +53,9 @@ export const TORCH_MCP_TOOL_NAMES = Object.freeze([
|
||||
'torch_schedule_runs',
|
||||
'torch_plan_schedule',
|
||||
'torch_run_schedule',
|
||||
'torch_list_fleet_changes',
|
||||
'torch_get_fleet_change',
|
||||
'torch_propose_domain',
|
||||
]);
|
||||
|
||||
function claimedIdentity(controlPlane, actorId, claim, field) {
|
||||
@@ -72,6 +75,7 @@ function claimedIdentity(controlPlane, actorId, claim, field) {
|
||||
|
||||
export function createTorchToolset(controlPlane, {
|
||||
actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService,
|
||||
evolutionService,
|
||||
} = {}) {
|
||||
const tools = new Map([
|
||||
['torch_identity', {
|
||||
@@ -391,6 +395,48 @@ export function createTorchToolset(controlPlane, {
|
||||
invoke: ({ schedule_id: scheduleId }) => scheduleService.run({ scheduleId, actorId, approved: false }),
|
||||
});
|
||||
}
|
||||
if (evolutionService) {
|
||||
tools.set('torch_list_fleet_changes', {
|
||||
description: 'List durable proposed, approved, provisioning, or active Fleet organization changes.',
|
||||
schema: { state: z.enum(['proposed', 'approved', 'provisioning', 'active', 'rejected']).optional() },
|
||||
invoke: ({ state }) => ({ changes: evolutionService.list({ state }) }),
|
||||
});
|
||||
tools.set('torch_get_fleet_change', {
|
||||
description: 'Read one durable Fleet organization change and its owner-approval state.',
|
||||
schema: { change_id: z.string().min(1) },
|
||||
invoke: ({ change_id: changeId }) => evolutionService.get(changeId),
|
||||
});
|
||||
tools.set('torch_propose_domain', {
|
||||
description: 'Propose an evidence-backed persistent domain as the bound Session Manager. This never approves or activates it.',
|
||||
schema: {
|
||||
proposer: z.string().min(1).optional(),
|
||||
domain: z.object({
|
||||
id: z.string().min(1), title: z.string().min(1), kind: z.string().min(1).optional(),
|
||||
scope: z.array(z.string().min(1)).min(1), not_scope: z.array(z.string().min(1)).optional(),
|
||||
owned_paths: z.array(z.string().min(1)).min(1), shared_paths: z.array(z.string().min(1)).optional(),
|
||||
neighbours: z.array(z.string().min(1)).optional(), required_checks: z.array(z.string().min(1)).optional(),
|
||||
resources: z.array(z.string().min(1)).optional(),
|
||||
runtime: z.string().min(1).optional(), branch: z.string().min(1).optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
worktree_name: z.string().min(1).optional(),
|
||||
}),
|
||||
rationale: z.string().min(1),
|
||||
expected_benefit: z.object({
|
||||
summary: z.string().min(1), recurring_work: z.string().min(1),
|
||||
context_locality: z.string().min(1), coordination_cost: z.string().min(1),
|
||||
}),
|
||||
evidence: z.array(z.string().min(1)).min(1),
|
||||
},
|
||||
invoke: ({ proposer, expected_benefit: benefit, ...input }) => evolutionService.proposeDomain({
|
||||
...input,
|
||||
proposer: claimedIdentity(controlPlane, actorId, proposer, 'proposer'),
|
||||
expectedBenefit: {
|
||||
summary: benefit.summary, recurringWork: benefit.recurring_work,
|
||||
contextLocality: benefit.context_locality, coordinationCost: benefit.coordination_cost,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
@@ -403,6 +449,7 @@ export function callTorchTool(controlPlane, name, input = {}, options = {}) {
|
||||
|
||||
export function createTorchMcpServer(controlPlane, {
|
||||
actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService,
|
||||
evolutionService,
|
||||
} = {}) {
|
||||
controlPlane.assertIdentity(actorId);
|
||||
const server = new McpServer(
|
||||
@@ -413,6 +460,7 @@ export function createTorchMcpServer(controlPlane, {
|
||||
);
|
||||
for (const [name, tool] of createTorchToolset(controlPlane, {
|
||||
actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService,
|
||||
evolutionService,
|
||||
})) {
|
||||
server.registerTool(name, { description: tool.description, inputSchema: tool.schema }, async (input) => {
|
||||
try {
|
||||
|
||||
@@ -78,6 +78,7 @@ export function observeProject({ repositoryRoot, env = process.env, now = () =>
|
||||
checks: { definitions: config.checks ?? [], receipts: [] },
|
||||
resources: { definitions: config.resources ?? [], holders: [], waiters: [] },
|
||||
integration: [],
|
||||
fleetChanges: [],
|
||||
schedules: config.schedules ?? [],
|
||||
audit: [],
|
||||
contextLocality: {
|
||||
@@ -135,6 +136,13 @@ export function observeProject({ repositoryRoot, env = process.env, now = () =>
|
||||
SELECT id, source_area AS sourceArea, source_commit AS sourceCommit, target_branch AS targetBranch, state, reason, updated_at AS updatedAt
|
||||
FROM integration_requests ORDER BY updated_at DESC LIMIT 40
|
||||
`);
|
||||
result.fleetChanges = rows(database, 'fleet_changes', `
|
||||
SELECT id, state, proposer, json_extract(domain_json, '$.id') AS domainId,
|
||||
json_extract(domain_json, '$.title') AS title,
|
||||
json_extract(expected_benefit_json, '$.summary') AS expectedBenefit,
|
||||
approved_by AS approvedBy, updated_at AS updatedAt
|
||||
FROM fleet_changes ORDER BY updated_at DESC, id LIMIT 40
|
||||
`);
|
||||
result.audit = rows(database, 'audit_events', `
|
||||
SELECT id, actor_id AS actorId, operation, entity_type AS entityType, entity_id AS entityId, created_at AS createdAt
|
||||
FROM audit_events ORDER BY created_at DESC LIMIT 30
|
||||
|
||||
@@ -134,6 +134,63 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, f
|
||||
};
|
||||
}
|
||||
|
||||
export function planAreaUp({ repositoryRoot, controlPlane, areaId, adapter, adapters, fresh = false } = {}) {
|
||||
const { config, roster } = loadFleetDefinition(repositoryRoot);
|
||||
const manifest = readInstallManifest(repositoryRoot);
|
||||
const worktree = worktreesByArea(manifest).get(areaId);
|
||||
const area = roster.areas.find((candidate) => candidate.id === areaId);
|
||||
const blockers = [];
|
||||
if (!area) blockers.push({ areaId, code: 'FLEET_IDENTITY_MISSING' });
|
||||
if (!worktree) blockers.push({ areaId, code: 'WORKTREE_MISSING' });
|
||||
if (blockers.length) {
|
||||
return {
|
||||
action: 'area-up', projectId: manifest.projectId, areaId, fresh,
|
||||
actions: [], blockers, canProceed: false, mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
const runtimes = adapterMap({ adapter, adapters });
|
||||
const runtime = configuredRuntime(area, config);
|
||||
const runtimeAdapter = runtimes.get(runtime);
|
||||
if (!runtimeAdapter) blockers.push({ areaId, code: 'RUNTIME_ADAPTER_MISSING', runtime });
|
||||
else if (runtimeAdapter.name !== runtime) {
|
||||
blockers.push({ areaId, code: 'RUNTIME_ADAPTER_MISMATCH', runtime, adapter: runtimeAdapter.name });
|
||||
}
|
||||
if (blockers.length) {
|
||||
return {
|
||||
action: 'area-up', projectId: manifest.projectId, areaId, fresh,
|
||||
actions: [], blockers, canProceed: false, mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
validateRuntimeAdapter(runtimeAdapter);
|
||||
const identity = controlPlane.identity(area.id);
|
||||
const prompts = promptPaths(localStateRoot(manifest), worktree, area.id);
|
||||
const runtimeConfig = config.runtimes?.[runtime] ?? {};
|
||||
const model = area.model ?? runtimeConfig.model;
|
||||
const background = runtimeConfig.background ?? true;
|
||||
const shouldResume = !fresh && identity.runtime === runtime && Boolean(identity.runtimeSessionId);
|
||||
const runtimePlan = shouldResume
|
||||
? runtimeAdapter.resumeSession({
|
||||
areaId: area.id, runtimeSessionId: identity.runtimeSessionId, worktree: worktree.path,
|
||||
model, background, message: startupMessage(area.id, false),
|
||||
})
|
||||
: runtimeAdapter.createSession({
|
||||
areaId: area.id, title: area.title, worktree: worktree.path, promptFile: prompts.combined,
|
||||
firstMessage: startupMessage(area.id, true), model, background,
|
||||
});
|
||||
const action = {
|
||||
areaId: area.id, title: area.title, mode: shouldResume ? 'resume' : 'create',
|
||||
runtime: runtimeAdapter.name, runtimeSessionId: runtimePlan.runtimeSessionId,
|
||||
requiresRuntimeIdCapture: runtimePlan.requiresRuntimeIdCapture ?? false,
|
||||
completionState: runtimePlan.completionState ?? 'starting',
|
||||
worktree: worktree.path, branch: worktree.branch, promptFile: prompts.combined,
|
||||
promptSources: [prompts.common, prompts.area], launch: runtimePlan.launch,
|
||||
};
|
||||
return {
|
||||
action: 'area-up', projectId: manifest.projectId, areaId, fresh,
|
||||
actions: [action], blockers, canProceed: true, mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function writeCombinedPrompt(action) {
|
||||
const content = action.promptSources.map((path) => readFileSync(path, 'utf8').trimEnd()).join('\n\n');
|
||||
mkdirSync(dirname(action.promptFile), { recursive: true });
|
||||
|
||||
@@ -11,6 +11,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([
|
||||
'init-analyze',
|
||||
'spec-aware-design',
|
||||
'ai-fleet-bootstrap',
|
||||
'fleet-evolution',
|
||||
'review-install-roster',
|
||||
'branches-worktrees',
|
||||
'runtime-identities',
|
||||
|
||||
Reference in new issue
Block a user