474 lines
17 KiB
TypeScript
474 lines
17 KiB
TypeScript
import YAML from 'yaml';
|
|
|
|
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
|
|
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
|
export const ROSTER_V2_DESIRED_STATES = ['running', 'stopped'] as const;
|
|
|
|
export type RosterV2RuntimeName = (typeof ROSTER_V2_SUPPORTED_RUNTIMES)[number];
|
|
export type RosterV2ReasoningLevel = (typeof ROSTER_V2_REASONING_LEVELS)[number];
|
|
export type RosterV2DesiredState = (typeof ROSTER_V2_DESIRED_STATES)[number];
|
|
export type RosterV2InputFormat = 'json' | 'yaml';
|
|
|
|
export interface FleetRosterV2Tmux {
|
|
readonly socketName: string;
|
|
readonly holderSession: string;
|
|
}
|
|
|
|
export interface FleetRosterV2Defaults {
|
|
readonly workingDirectory: string;
|
|
readonly runtime: RosterV2RuntimeName;
|
|
}
|
|
|
|
export interface FleetRosterV2Runtime {
|
|
readonly resetCommand: string;
|
|
}
|
|
|
|
export interface FleetRosterV2Lifecycle {
|
|
readonly enabled: boolean;
|
|
readonly desiredState: RosterV2DesiredState;
|
|
}
|
|
|
|
export interface FleetRosterV2Launch {
|
|
readonly yolo: boolean;
|
|
}
|
|
|
|
export interface FleetRosterV2Agent {
|
|
readonly name: string;
|
|
readonly alias: string;
|
|
readonly className: string;
|
|
readonly runtime: RosterV2RuntimeName;
|
|
readonly provider: string;
|
|
readonly model: string;
|
|
readonly reasoning: RosterV2ReasoningLevel;
|
|
readonly toolPolicy: string;
|
|
readonly workingDirectory: string;
|
|
readonly persistentPersona: boolean;
|
|
readonly resetBetweenTasks: boolean;
|
|
readonly lifecycle: FleetRosterV2Lifecycle;
|
|
readonly launch: FleetRosterV2Launch;
|
|
}
|
|
|
|
export interface FleetRosterV2 {
|
|
readonly version: 2;
|
|
readonly generation: number;
|
|
readonly transport: 'tmux';
|
|
readonly tmux: FleetRosterV2Tmux;
|
|
readonly defaults: FleetRosterV2Defaults;
|
|
readonly runtimes: Readonly<Record<string, FleetRosterV2Runtime>>;
|
|
readonly agents: readonly FleetRosterV2Agent[];
|
|
}
|
|
|
|
export class RosterV2ValidationError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'RosterV2ValidationError';
|
|
}
|
|
}
|
|
|
|
type JsonSchema = string | number | boolean | null | JsonSchema[] | { [key: string]: JsonSchema };
|
|
|
|
/**
|
|
* Executable v2 structural contract. The checked-in documentation schema is
|
|
* structurally compared to this value in roster-v2.spec.ts.
|
|
*/
|
|
export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
|
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
$id: 'https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json',
|
|
title: 'Mosaic local tmux fleet roster v2',
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'],
|
|
properties: {
|
|
version: { const: 2 },
|
|
generation: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
|
|
transport: { const: 'tmux' },
|
|
tmux: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['socket_name', 'holder_session'],
|
|
properties: {
|
|
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
|
|
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
|
|
},
|
|
},
|
|
defaults: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['working_directory', 'runtime'],
|
|
properties: {
|
|
working_directory: { type: 'string', minLength: 1 },
|
|
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
|
|
},
|
|
},
|
|
runtimes: {
|
|
type: 'object',
|
|
minProperties: 1,
|
|
propertyNames: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
|
|
additionalProperties: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['reset_command'],
|
|
properties: { reset_command: { type: 'string', minLength: 1 } },
|
|
},
|
|
},
|
|
agents: {
|
|
type: 'array',
|
|
minItems: 1,
|
|
items: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: [
|
|
'name',
|
|
'alias',
|
|
'class',
|
|
'runtime',
|
|
'provider',
|
|
'model',
|
|
'reasoning',
|
|
'tool_policy',
|
|
'working_directory',
|
|
'persistent_persona',
|
|
'reset_between_tasks',
|
|
'lifecycle',
|
|
'launch',
|
|
],
|
|
properties: {
|
|
name: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$' },
|
|
alias: { type: 'string', minLength: 1 },
|
|
class: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
|
|
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
|
|
provider: { type: 'string', minLength: 1 },
|
|
model: { type: 'string', minLength: 1 },
|
|
reasoning: { enum: [...ROSTER_V2_REASONING_LEVELS] },
|
|
tool_policy: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
|
|
working_directory: { type: 'string', minLength: 1 },
|
|
persistent_persona: { type: 'boolean' },
|
|
reset_between_tasks: { type: 'boolean' },
|
|
lifecycle: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['enabled', 'desired_state'],
|
|
properties: {
|
|
enabled: { type: 'boolean' },
|
|
desired_state: { enum: [...ROSTER_V2_DESIRED_STATES] },
|
|
},
|
|
},
|
|
launch: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['yolo'],
|
|
properties: { yolo: { type: 'boolean' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const ROOT_KEYS = ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'];
|
|
const TMUX_KEYS = ['socket_name', 'holder_session'];
|
|
const DEFAULT_KEYS = ['working_directory', 'runtime'];
|
|
const RUNTIME_KEYS = ['reset_command'];
|
|
const AGENT_KEYS = [
|
|
'name',
|
|
'alias',
|
|
'class',
|
|
'runtime',
|
|
'provider',
|
|
'model',
|
|
'reasoning',
|
|
'tool_policy',
|
|
'working_directory',
|
|
'persistent_persona',
|
|
'reset_between_tasks',
|
|
'lifecycle',
|
|
'launch',
|
|
];
|
|
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
|
|
const LAUNCH_KEYS = ['yolo'];
|
|
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
|
|
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
|
|
|
|
/** Parses YAML or JSON and compiles only the local-tmux roster v2 contract. */
|
|
export function parseRosterV2(source: string, format?: RosterV2InputFormat): FleetRosterV2 {
|
|
const parsed = parseSource(source, format);
|
|
return normalizeRosterV2(parsed);
|
|
}
|
|
|
|
/** Renders canonical snake_case YAML with sorted runtime and agent entries. */
|
|
export function renderRosterV2Yaml(roster: FleetRosterV2): string {
|
|
const normalized = normalizeRosterV2(toSourceShape(roster));
|
|
return YAML.stringify(toSourceShape(normalized));
|
|
}
|
|
|
|
function parseSource(source: string, format?: RosterV2InputFormat): unknown {
|
|
const resolvedFormat = format ?? (source.trimStart().startsWith('{') ? 'json' : 'yaml');
|
|
try {
|
|
if (resolvedFormat === 'json') return JSON.parse(source) as unknown;
|
|
return YAML.parse(source) as unknown;
|
|
} catch (error: unknown) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new RosterV2ValidationError(`Roster v2 ${resolvedFormat} parse failed: ${detail}`);
|
|
}
|
|
}
|
|
|
|
export function normalizeRosterV2(raw: unknown): FleetRosterV2 {
|
|
const root = requiredObject(raw, 'Roster v2');
|
|
assertKnownKeys(root, 'Roster v2', ROOT_KEYS);
|
|
if (root.version === 1) {
|
|
throw new RosterV2ValidationError(
|
|
'Roster v2 compiler rejects v1 input; use the existing v1 path until migration.',
|
|
);
|
|
}
|
|
if (root.version !== 2) throw new RosterV2ValidationError('Roster v2 version must be 2.');
|
|
|
|
const generation = requiredPositiveInteger(root.generation, 'Roster v2 generation');
|
|
const transport = requiredEnum(root.transport, 'Roster v2 transport', ['tmux'] as const);
|
|
const tmux = normalizeTmux(root.tmux);
|
|
const defaults = normalizeDefaults(root.defaults);
|
|
const runtimes = normalizeRuntimes(root.runtimes);
|
|
const agents = normalizeAgents(root.agents, runtimes);
|
|
|
|
if (!runtimes[defaults.runtime]) {
|
|
throw new RosterV2ValidationError(
|
|
`Roster v2 defaults runtime "${defaults.runtime}" must be declared in runtimes.`,
|
|
);
|
|
}
|
|
return { version: 2, generation, transport, tmux, defaults, runtimes, agents };
|
|
}
|
|
|
|
function normalizeTmux(value: unknown): FleetRosterV2Tmux {
|
|
const raw = requiredObject(value, 'Roster v2 tmux');
|
|
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
|
|
return {
|
|
socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'),
|
|
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
|
|
};
|
|
}
|
|
|
|
function normalizeDefaults(value: unknown): FleetRosterV2Defaults {
|
|
const raw = requiredObject(value, 'Roster v2 defaults');
|
|
assertKnownKeys(raw, 'Roster v2 defaults', DEFAULT_KEYS);
|
|
return {
|
|
workingDirectory: requiredString(raw.working_directory, 'Roster v2 defaults working_directory'),
|
|
runtime: requiredRuntime(raw.runtime, 'Roster v2 defaults runtime'),
|
|
};
|
|
}
|
|
|
|
function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV2Runtime>> {
|
|
const raw = requiredObject(value, 'Roster v2 runtimes');
|
|
const names = Object.keys(raw);
|
|
if (names.length === 0)
|
|
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
|
|
|
|
const result: Record<string, FleetRosterV2Runtime> = {};
|
|
for (const name of names.sort()) {
|
|
const runtime = requiredRuntime(name, 'Roster v2 runtime name');
|
|
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
|
|
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
|
|
result[runtime] = {
|
|
resetCommand: requiredString(
|
|
config.reset_command,
|
|
`Roster v2 runtime "${runtime}" reset_command`,
|
|
),
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeAgents(
|
|
value: unknown,
|
|
runtimes: Readonly<Record<string, FleetRosterV2Runtime>>,
|
|
): readonly FleetRosterV2Agent[] {
|
|
if (!Array.isArray(value) || value.length === 0) {
|
|
throw new RosterV2ValidationError('Roster v2 agents must be a non-empty array.');
|
|
}
|
|
const seen = new Set<string>();
|
|
const agents = value.map((candidate: unknown, index: number): FleetRosterV2Agent => {
|
|
const raw = requiredObject(candidate, `Roster v2 agents[${index}]`);
|
|
assertKnownKeys(raw, `Roster v2 agents[${index}]`, AGENT_KEYS);
|
|
const name = requiredIdentifier(raw.name, `Roster v2 agents[${index}] name`);
|
|
if (seen.has(name))
|
|
throw new RosterV2ValidationError(`Roster v2 has duplicate agent name: ${name}.`);
|
|
seen.add(name);
|
|
const runtime = requiredRuntime(raw.runtime, `Roster v2 agent "${name}" runtime`);
|
|
if (!runtimes[runtime]) {
|
|
throw new RosterV2ValidationError(
|
|
`Roster v2 agent "${name}" runtime "${runtime}" must be declared in runtimes.`,
|
|
);
|
|
}
|
|
return {
|
|
name,
|
|
alias: requiredString(raw.alias, `Roster v2 agent "${name}" alias`),
|
|
className: requiredPolicyIdentifier(raw.class, `Roster v2 agent "${name}" class`),
|
|
runtime,
|
|
provider: requiredString(raw.provider, `Roster v2 agent "${name}" provider`),
|
|
model: requiredString(raw.model, `Roster v2 agent "${name}" model`),
|
|
reasoning: requiredEnum(
|
|
raw.reasoning,
|
|
`Roster v2 agent "${name}" reasoning`,
|
|
ROSTER_V2_REASONING_LEVELS,
|
|
),
|
|
toolPolicy: requiredPolicyIdentifier(
|
|
raw.tool_policy,
|
|
`Roster v2 agent "${name}" tool_policy`,
|
|
),
|
|
workingDirectory: requiredString(
|
|
raw.working_directory,
|
|
`Roster v2 agent "${name}" working_directory`,
|
|
),
|
|
persistentPersona: requiredBoolean(
|
|
raw.persistent_persona,
|
|
`Roster v2 agent "${name}" persistent_persona`,
|
|
),
|
|
resetBetweenTasks: requiredBoolean(
|
|
raw.reset_between_tasks,
|
|
`Roster v2 agent "${name}" reset_between_tasks`,
|
|
),
|
|
lifecycle: normalizeLifecycle(raw.lifecycle, name),
|
|
launch: normalizeLaunch(raw.launch, name),
|
|
};
|
|
});
|
|
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
|
left.name.localeCompare(right.name),
|
|
);
|
|
}
|
|
|
|
function normalizeLifecycle(value: unknown, agentName: string): FleetRosterV2Lifecycle {
|
|
const raw = requiredObject(value, `Roster v2 agent "${agentName}" lifecycle`);
|
|
assertKnownKeys(raw, `Roster v2 agent "${agentName}" lifecycle`, LIFECYCLE_KEYS);
|
|
return {
|
|
enabled: requiredBoolean(raw.enabled, `Roster v2 agent "${agentName}" lifecycle enabled`),
|
|
desiredState: requiredEnum(
|
|
raw.desired_state,
|
|
`Roster v2 agent "${agentName}" lifecycle desired_state`,
|
|
ROSTER_V2_DESIRED_STATES,
|
|
),
|
|
};
|
|
}
|
|
|
|
function normalizeLaunch(value: unknown, agentName: string): FleetRosterV2Launch {
|
|
const raw = requiredObject(value, `Roster v2 agent "${agentName}" launch`);
|
|
assertKnownKeys(raw, `Roster v2 agent "${agentName}" launch`, LAUNCH_KEYS);
|
|
return { yolo: requiredBoolean(raw.yolo, `Roster v2 agent "${agentName}" launch yolo`) };
|
|
}
|
|
|
|
function requiredObject(value: unknown, label: string): Record<string, unknown> {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
throw new RosterV2ValidationError(`${label} must be an object.`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function assertKnownKeys(
|
|
value: Record<string, unknown>,
|
|
label: string,
|
|
allowedKeys: readonly string[],
|
|
): void {
|
|
const allowed = new Set(allowedKeys);
|
|
const unknown = Object.keys(value).filter((key: string): boolean => !allowed.has(key));
|
|
if (unknown.length > 0) {
|
|
throw new RosterV2ValidationError(`${label} has unsupported field(s): ${unknown.join(', ')}.`);
|
|
}
|
|
}
|
|
|
|
function requiredString(value: unknown, label: string): string {
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
throw new RosterV2ValidationError(`${label} is required and must be a non-empty string.`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function requiredIdentifier(value: unknown, label: string): string {
|
|
const result = requiredString(value, label);
|
|
if (!IDENTIFIER.test(result)) {
|
|
throw new RosterV2ValidationError(`Invalid agent name (${label}): ${result}.`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function requiredTmuxIdentifier(value: unknown, label: string): string {
|
|
const result = requiredString(value, label);
|
|
if (!TMUX_IDENTIFIER.test(result))
|
|
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
|
|
return result;
|
|
}
|
|
|
|
function requiredPolicyIdentifier(value: unknown, label: string): string {
|
|
const result = requiredString(value, label);
|
|
if (!POLICY_IDENTIFIER.test(result))
|
|
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
|
|
return result;
|
|
}
|
|
|
|
function requiredPositiveInteger(value: unknown, label: string): number {
|
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
|
throw new RosterV2ValidationError(`${label} must be a positive integer.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requiredBoolean(value: unknown, label: string): boolean {
|
|
if (typeof value !== 'boolean') throw new RosterV2ValidationError(`${label} must be a boolean.`);
|
|
return value;
|
|
}
|
|
|
|
function requiredRuntime(value: unknown, label: string): RosterV2RuntimeName {
|
|
return requiredEnum(value, label, ROSTER_V2_SUPPORTED_RUNTIMES);
|
|
}
|
|
|
|
function requiredEnum<T extends string>(value: unknown, label: string, allowed: readonly T[]): T {
|
|
if (typeof value !== 'string' || !allowed.includes(value as T)) {
|
|
throw new RosterV2ValidationError(
|
|
`${label} must be one of the supported values: ${allowed.join(', ')}.`,
|
|
);
|
|
}
|
|
return value as T;
|
|
}
|
|
|
|
function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
|
|
return {
|
|
version: roster.version,
|
|
generation: roster.generation,
|
|
transport: roster.transport,
|
|
tmux: { socket_name: roster.tmux.socketName, holder_session: roster.tmux.holderSession },
|
|
defaults: {
|
|
working_directory: roster.defaults.workingDirectory,
|
|
runtime: roster.defaults.runtime,
|
|
},
|
|
runtimes: Object.fromEntries(
|
|
Object.entries(roster.runtimes)
|
|
.sort(([left], [right]): number => left.localeCompare(right))
|
|
.map(([name, runtime]): [string, unknown] => [
|
|
name,
|
|
{ reset_command: runtime.resetCommand },
|
|
]),
|
|
),
|
|
agents: [...roster.agents]
|
|
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
|
left.name.localeCompare(right.name),
|
|
)
|
|
.map(
|
|
(agent: FleetRosterV2Agent): Record<string, unknown> => ({
|
|
name: agent.name,
|
|
alias: agent.alias,
|
|
class: agent.className,
|
|
runtime: agent.runtime,
|
|
provider: agent.provider,
|
|
model: agent.model,
|
|
reasoning: agent.reasoning,
|
|
tool_policy: agent.toolPolicy,
|
|
working_directory: agent.workingDirectory,
|
|
persistent_persona: agent.persistentPersona,
|
|
reset_between_tasks: agent.resetBetweenTasks,
|
|
lifecycle: {
|
|
enabled: agent.lifecycle.enabled,
|
|
desired_state: agent.lifecycle.desiredState,
|
|
},
|
|
launch: { yolo: agent.launch.yolo },
|
|
}),
|
|
),
|
|
};
|
|
}
|