523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
import { lstatSync } from 'node:fs';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import YAML from 'yaml';
|
|
import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
|
|
|
|
interface RawFleetRoster {
|
|
version?: unknown;
|
|
transport?: unknown;
|
|
tmux?: {
|
|
socket_name?: unknown;
|
|
socketName?: unknown;
|
|
holder_session?: unknown;
|
|
holderSession?: unknown;
|
|
};
|
|
defaults?: {
|
|
working_directory?: unknown;
|
|
workingDirectory?: unknown;
|
|
};
|
|
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
|
agents?: Array<{
|
|
name?: unknown;
|
|
alias?: unknown;
|
|
provider?: unknown;
|
|
runtime?: unknown;
|
|
class?: unknown;
|
|
host?: unknown;
|
|
ssh?: unknown;
|
|
socket?: unknown;
|
|
working_directory?: unknown;
|
|
workingDirectory?: unknown;
|
|
model_hint?: unknown;
|
|
modelHint?: unknown;
|
|
reasoning_level?: unknown;
|
|
reasoningLevel?: unknown;
|
|
tool_policy?: unknown;
|
|
toolPolicy?: unknown;
|
|
persistent_persona?: unknown;
|
|
persistentPersona?: unknown;
|
|
reset_between_tasks?: unknown;
|
|
resetBetweenTasks?: unknown;
|
|
kickstart_template?: unknown;
|
|
kickstartTemplate?: unknown;
|
|
}>;
|
|
connector?: {
|
|
kind?: unknown;
|
|
matrix?: {
|
|
homeserver_url?: unknown;
|
|
user_id?: unknown;
|
|
room_id?: unknown;
|
|
};
|
|
discord?: {
|
|
channel_id?: unknown;
|
|
};
|
|
};
|
|
}
|
|
|
|
export interface FleetAgent {
|
|
name: string;
|
|
alias?: string;
|
|
provider?: string;
|
|
runtime: string;
|
|
className: string;
|
|
/** Resolved host identity. Absent means the caller's authoritative local host. */
|
|
host?: string;
|
|
/** Explicit SSH destination for a cross-host inventory peer. */
|
|
ssh?: string;
|
|
/** Compatibility declaration; when set it must equal fleet-wide tmux.socketName. */
|
|
socket?: string;
|
|
workingDirectory?: string;
|
|
modelHint?: string;
|
|
reasoningLevel?: string;
|
|
toolPolicy?: string;
|
|
persistentPersona?: boolean | string;
|
|
resetBetweenTasks?: boolean;
|
|
kickstartTemplate?: string;
|
|
}
|
|
|
|
export type FleetConnector =
|
|
| { kind: 'tmux' }
|
|
| {
|
|
kind: 'discord';
|
|
discord: { channelId: string };
|
|
}
|
|
| {
|
|
kind: 'matrix';
|
|
matrix: { homeserverUrl: string; userId: string; roomId: string };
|
|
};
|
|
|
|
export interface FleetRoster {
|
|
version: 1;
|
|
transport: 'tmux';
|
|
tmux: {
|
|
socketName: string;
|
|
holderSession: string;
|
|
};
|
|
defaults: {
|
|
workingDirectory: string;
|
|
};
|
|
runtimes: Record<string, { resetCommand: string }>;
|
|
agents: FleetAgent[];
|
|
connector?: FleetConnector;
|
|
}
|
|
|
|
export type FleetRosterInputFormat = 'yaml' | 'json';
|
|
|
|
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
|
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
try {
|
|
lstatSync(yamlPath);
|
|
return yamlPath;
|
|
} catch (error) {
|
|
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
|
return join(mosaicHome, 'fleet', 'roster.json');
|
|
}
|
|
}
|
|
|
|
const DEFAULT_HOLDER_SESSION = '_holder';
|
|
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
|
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
|
claude: { resetCommand: '/clear' },
|
|
codex: { resetCommand: '/clear' },
|
|
opencode: { resetCommand: '/clear' },
|
|
pi: { resetCommand: '/new' },
|
|
};
|
|
|
|
/** One structural v1 resolver used by fleet commands and runtime comms composition. */
|
|
export function parseFleetRosterV1(
|
|
source: string,
|
|
format: FleetRosterInputFormat = 'yaml',
|
|
): FleetRoster {
|
|
const trimmed = source.trim();
|
|
const parsed =
|
|
format === 'json'
|
|
? (JSON.parse(trimmed) as RawFleetRoster)
|
|
: (YAML.parse(trimmed) as RawFleetRoster);
|
|
return normalizeFleetRosterV1(parsed);
|
|
}
|
|
|
|
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
|
const source = await readFile(path, 'utf8');
|
|
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
|
}
|
|
|
|
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
|
const agent = roster.agents.find((candidate) => candidate.name === name);
|
|
if (!agent) throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
|
return agent;
|
|
}
|
|
|
|
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
|
assertObject(raw, 'Fleet roster');
|
|
assertKnownKeys(raw, 'Fleet roster', [
|
|
'version',
|
|
'transport',
|
|
'tmux',
|
|
'defaults',
|
|
'runtimes',
|
|
'agents',
|
|
'connector',
|
|
]);
|
|
if (raw.tmux !== undefined) {
|
|
assertObject(raw.tmux, 'Fleet roster tmux');
|
|
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
|
'socket_name',
|
|
'socketName',
|
|
'holder_session',
|
|
'holderSession',
|
|
]);
|
|
}
|
|
if (raw.defaults !== undefined) {
|
|
assertObject(raw.defaults, 'Fleet roster defaults');
|
|
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
|
'working_directory',
|
|
'workingDirectory',
|
|
]);
|
|
}
|
|
if (raw.runtimes !== undefined) {
|
|
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
|
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
|
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
|
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
|
'reset_command',
|
|
'resetCommand',
|
|
]);
|
|
}
|
|
}
|
|
if (raw.version !== 1) throw new Error('Fleet roster version must be 1.');
|
|
if (raw.transport !== 'tmux') throw new Error('Fleet roster transport must be "tmux".');
|
|
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
|
throw new Error('Fleet roster must define at least one agent.');
|
|
}
|
|
|
|
const socketName = targetingString(
|
|
aliasValue(raw.tmux, 'socket_name', 'socketName', 'Fleet roster tmux socket'),
|
|
'',
|
|
'Fleet roster tmux socket_name',
|
|
/^[A-Za-z0-9_.-]+$/,
|
|
);
|
|
const agents = raw.agents.map(normalizeAgent);
|
|
assertUniqueAgentNames(agents);
|
|
for (const agent of agents) {
|
|
if (agent.socket !== undefined && agent.socket !== socketName) {
|
|
throw new Error(
|
|
`Fleet agent "${agent.name}" socket must equal the fleet-wide tmux socket_name; independent per-agent sockets are not supported.`,
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
version: 1,
|
|
transport: 'tmux',
|
|
tmux: {
|
|
socketName,
|
|
holderSession: stringValue(
|
|
aliasValue(raw.tmux, 'holder_session', 'holderSession', 'Fleet roster tmux holder'),
|
|
DEFAULT_HOLDER_SESSION,
|
|
'Fleet roster tmux holder_session',
|
|
),
|
|
},
|
|
defaults: {
|
|
workingDirectory: stringValue(
|
|
aliasValue(
|
|
raw.defaults,
|
|
'working_directory',
|
|
'workingDirectory',
|
|
'Fleet roster defaults working directory',
|
|
),
|
|
DEFAULT_WORKING_DIRECTORY,
|
|
'Fleet roster defaults working_directory',
|
|
),
|
|
},
|
|
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
|
agents,
|
|
connector: normalizeConnector(raw.connector as RawFleetRoster['connector']),
|
|
};
|
|
}
|
|
|
|
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
|
assertObject(raw, 'Fleet roster agent');
|
|
assertKnownKeys(raw, 'Fleet roster agent', [
|
|
'name',
|
|
'alias',
|
|
'provider',
|
|
'runtime',
|
|
'class',
|
|
'host',
|
|
'ssh',
|
|
'socket',
|
|
'working_directory',
|
|
'workingDirectory',
|
|
'model_hint',
|
|
'modelHint',
|
|
'reasoning_level',
|
|
'reasoningLevel',
|
|
'tool_policy',
|
|
'toolPolicy',
|
|
'persistent_persona',
|
|
'persistentPersona',
|
|
'reset_between_tasks',
|
|
'resetBetweenTasks',
|
|
'kickstart_template',
|
|
'kickstartTemplate',
|
|
]);
|
|
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
|
const runtime = stringValue(
|
|
raw.runtime,
|
|
'',
|
|
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
|
);
|
|
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
|
}
|
|
if (!runtime) throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
|
return {
|
|
name,
|
|
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
|
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
|
runtime,
|
|
className: canonicalizeRoleClass(
|
|
stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
|
).canonicalClass,
|
|
host: optionalTargetingString(
|
|
raw.host,
|
|
`Fleet roster agent "${name}" host`,
|
|
/^[A-Za-z0-9_.:[\]-]+$/,
|
|
),
|
|
ssh: optionalTargetingString(
|
|
raw.ssh,
|
|
`Fleet roster agent "${name}" ssh`,
|
|
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_.:[\]-]+$/,
|
|
),
|
|
socket: optionalTargetingString(
|
|
raw.socket,
|
|
`Fleet roster agent "${name}" socket`,
|
|
/^[A-Za-z0-9_.-]+$/,
|
|
),
|
|
workingDirectory: optionalString(
|
|
aliasValue(
|
|
raw,
|
|
'working_directory',
|
|
'workingDirectory',
|
|
`Fleet roster agent "${name}" working directory`,
|
|
),
|
|
`Fleet roster agent "${name}" working_directory`,
|
|
),
|
|
modelHint: optionalString(
|
|
aliasValue(raw, 'model_hint', 'modelHint', `Fleet roster agent "${name}" model hint`),
|
|
`Fleet roster agent "${name}" model_hint`,
|
|
),
|
|
reasoningLevel: optionalString(
|
|
aliasValue(
|
|
raw,
|
|
'reasoning_level',
|
|
'reasoningLevel',
|
|
`Fleet roster agent "${name}" reasoning level`,
|
|
),
|
|
`Fleet roster agent "${name}" reasoning_level`,
|
|
),
|
|
toolPolicy: optionalString(
|
|
aliasValue(raw, 'tool_policy', 'toolPolicy', `Fleet roster agent "${name}" tool policy`),
|
|
`Fleet roster agent "${name}" tool_policy`,
|
|
),
|
|
persistentPersona: optionalBooleanOrString(
|
|
aliasValue(
|
|
raw,
|
|
'persistent_persona',
|
|
'persistentPersona',
|
|
`Fleet roster agent "${name}" persistent persona`,
|
|
),
|
|
`Fleet roster agent "${name}" persistent_persona`,
|
|
),
|
|
resetBetweenTasks: optionalBoolean(
|
|
aliasValue(
|
|
raw,
|
|
'reset_between_tasks',
|
|
'resetBetweenTasks',
|
|
`Fleet roster agent "${name}" reset between tasks`,
|
|
),
|
|
`Fleet roster agent "${name}" reset_between_tasks`,
|
|
),
|
|
kickstartTemplate: optionalString(
|
|
aliasValue(
|
|
raw,
|
|
'kickstart_template',
|
|
'kickstartTemplate',
|
|
`Fleet roster agent "${name}" kickstart template`,
|
|
),
|
|
`Fleet roster agent "${name}" kickstart_template`,
|
|
),
|
|
};
|
|
}
|
|
|
|
function normalizeRuntimes(
|
|
raw: RawFleetRoster['runtimes'] | undefined,
|
|
): Record<string, { resetCommand: string }> {
|
|
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
|
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
|
result[runtime] = {
|
|
resetCommand: stringValue(
|
|
aliasValue(
|
|
config,
|
|
'reset_command',
|
|
'resetCommand',
|
|
`Fleet roster runtime "${runtime}" reset command`,
|
|
),
|
|
'/clear',
|
|
`Fleet roster runtime "${runtime}" reset_command`,
|
|
),
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeConnector(raw: RawFleetRoster['connector']): FleetConnector | undefined {
|
|
if (raw === undefined) return undefined;
|
|
assertObject(raw, 'Fleet roster connector');
|
|
assertKnownKeys(raw, 'Fleet roster connector', ['kind', 'matrix', 'discord']);
|
|
const kind = stringValue(raw.kind, '', 'Fleet roster connector kind');
|
|
if (kind === 'tmux') {
|
|
if (raw.matrix !== undefined || raw.discord !== undefined) {
|
|
throw new Error('Fleet roster tmux connector must not define matrix or discord settings.');
|
|
}
|
|
return { kind };
|
|
}
|
|
if (kind === 'discord') {
|
|
if (raw.matrix !== undefined) {
|
|
throw new Error('Fleet roster discord connector must not define matrix settings.');
|
|
}
|
|
assertObject(raw.discord, 'Fleet roster connector discord');
|
|
assertKnownKeys(raw.discord, 'Fleet roster connector discord', ['channel_id']);
|
|
return {
|
|
kind,
|
|
discord: {
|
|
channelId: requiredString(
|
|
raw.discord.channel_id,
|
|
'Fleet roster connector discord channel_id',
|
|
),
|
|
},
|
|
};
|
|
}
|
|
if (kind === 'matrix') {
|
|
if (raw.discord !== undefined) {
|
|
throw new Error('Fleet roster matrix connector must not define discord settings.');
|
|
}
|
|
assertObject(raw.matrix, 'Fleet roster connector matrix');
|
|
assertKnownKeys(raw.matrix, 'Fleet roster connector matrix', [
|
|
'homeserver_url',
|
|
'user_id',
|
|
'room_id',
|
|
]);
|
|
return {
|
|
kind,
|
|
matrix: {
|
|
homeserverUrl: requiredString(
|
|
raw.matrix.homeserver_url,
|
|
'Fleet roster connector matrix homeserver_url',
|
|
),
|
|
userId: requiredString(raw.matrix.user_id, 'Fleet roster connector matrix user_id'),
|
|
roomId: requiredString(raw.matrix.room_id, 'Fleet roster connector matrix room_id'),
|
|
},
|
|
};
|
|
}
|
|
throw new Error('Fleet roster connector kind must be one of: tmux, discord, matrix.');
|
|
}
|
|
|
|
function aliasValue<T extends Record<string, unknown>>(
|
|
source: T | undefined,
|
|
snake: keyof T,
|
|
camel: keyof T,
|
|
label: string,
|
|
): unknown {
|
|
const snakeValue = source?.[snake];
|
|
const camelValue = source?.[camel];
|
|
if (snakeValue !== undefined && camelValue !== undefined && snakeValue !== camelValue) {
|
|
throw new Error(`${label} aliases ${String(snake)} and ${String(camel)} conflict.`);
|
|
}
|
|
return snakeValue ?? camelValue;
|
|
}
|
|
|
|
function isNodeErrorCode(error: unknown, code: string): boolean {
|
|
return error instanceof Error && 'code' in error && error.code === code;
|
|
}
|
|
|
|
function requiredString(value: unknown, label: string): string {
|
|
const resolved = stringValue(value, '', label).trim();
|
|
if (!resolved) throw new Error(`${label} is required.`);
|
|
return resolved;
|
|
}
|
|
|
|
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error(`${label} must be an object.`);
|
|
}
|
|
}
|
|
|
|
function assertKnownKeys(
|
|
value: Record<string, unknown>,
|
|
label: string,
|
|
allowedKeys: readonly string[],
|
|
): void {
|
|
const allowed = new Set(allowedKeys);
|
|
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
|
if (unknownKeys.length > 0) {
|
|
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
|
}
|
|
}
|
|
|
|
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
|
const seen = new Set<string>();
|
|
for (const agent of agents) {
|
|
if (seen.has(agent.name)) {
|
|
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
|
}
|
|
seen.add(agent.name);
|
|
}
|
|
}
|
|
|
|
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
|
if (value === undefined) return fallback;
|
|
if (typeof value !== 'string') throw new Error(`${label} must be a string.`);
|
|
return value;
|
|
}
|
|
|
|
function targetingString(value: unknown, fallback: string, label: string, pattern: RegExp): string {
|
|
const resolved = stringValue(value, fallback, label);
|
|
if (resolved && !pattern.test(resolved)) {
|
|
throw new Error(`${label} contains unsupported targeting characters.`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function optionalTargetingString(
|
|
value: unknown,
|
|
label: string,
|
|
pattern: RegExp,
|
|
): string | undefined {
|
|
const resolved = optionalString(value, label);
|
|
if (resolved !== undefined && (!resolved || !pattern.test(resolved))) {
|
|
throw new Error(`${label} contains unsupported targeting characters.`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (typeof value !== 'string') throw new Error(`${label} must be a string.`);
|
|
return value;
|
|
}
|
|
|
|
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (typeof value !== 'boolean') throw new Error(`${label} must be a boolean.`);
|
|
return value;
|
|
}
|
|
|
|
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
|
throw new Error(`${label} must be a boolean or string.`);
|
|
}
|
|
return value;
|
|
}
|