From d625e62e894b322dc98da7a0746dff2fddb940de Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 14 Jul 2026 15:19:36 -0500 Subject: [PATCH] feat(fleet): add roster v2 structural compiler --- docs/fleet/reference/roster-v2-fields.md | 95 ++++ docs/fleet/reference/roster-v2.schema.json | 156 +++++++ packages/mosaic/src/fleet/roster-v2.spec.ts | 242 ++++++++++ packages/mosaic/src/fleet/roster-v2.ts | 473 ++++++++++++++++++++ 4 files changed, 966 insertions(+) create mode 100644 docs/fleet/reference/roster-v2-fields.md create mode 100644 docs/fleet/reference/roster-v2.schema.json create mode 100644 packages/mosaic/src/fleet/roster-v2.spec.ts create mode 100644 packages/mosaic/src/fleet/roster-v2.ts diff --git a/docs/fleet/reference/roster-v2-fields.md b/docs/fleet/reference/roster-v2-fields.md new file mode 100644 index 0000000..0e92d6c --- /dev/null +++ b/docs/fleet/reference/roster-v2-fields.md @@ -0,0 +1,95 @@ +# Fleet Roster v2 Structural Contract + +**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing, +strict structural validation, normalized in-memory representation, and deterministic rendering only. +It does not authorize role resolution, lifecycle reconciliation, mutation, migration, remote +placement, connector configuration, secret references, arbitrary commands, channels, gateway +mapping, or any live-fleet change. + +The executable schema is [`roster-v2.schema.json`](./roster-v2.schema.json). The compiler exports +the same schema and its test parses this file and compares it structurally with the executable contract. + +## Format and canonical shape + +The compiler accepts YAML or JSON. It reads only snake_case source fields and renders canonical, +snake_case YAML. Rendering sorts runtime keys and agents by stable name. Agent names, class names, +and tool-policy names are structural identifiers; whether a class or policy resolves is a later +shared-resolver concern. + +```yaml +version: 2 +generation: 1 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: coder0 + alias: Coder 0 + class: code + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: high + tool_policy: code + working_directory: ~/src + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true +``` + +## Root fields + +| Field | Required | Constraint | Meaning | +| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. | +| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. | +| `transport` | yes | constant `tmux` | M1–M5 support local tmux only. | +| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. | +| `defaults` | yes | strict object | Default work directory and one supported local runtime. | +| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. | +| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. | + +## Nested fields + +| Path | Required | Constraint | +| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | +| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity | +| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` | +| `defaults.working_directory` | yes | non-empty string | +| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | +| `runtimes..reset_command` | yes | non-empty string; runtime key must be a supported local runtime | +| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity | +| `agents[].alias` | yes | non-empty display string | +| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 | +| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | +| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card | +| `agents[].reasoning` | yes | `low`, `medium`, or `high` | +| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 | +| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans | +| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 | +| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` | +| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch | + +## Fail-closed boundary + +Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed, +and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket +per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields, +and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently +ignore v1 camelCase input, version `1`, or a source that does not parse to an object. + +The v2 compiler is intentionally isolated from the existing v1 loader. Existing v1 rosters and +current examples/profiles continue on their current path; FCM-M4 owns explicit inventory, preview, +migration, and rollback. FCM-M2 owns generated-file/local-override quarantine, and FCM-M3 owns +runtime lifecycle and reconciliation. diff --git a/docs/fleet/reference/roster-v2.schema.json b/docs/fleet/reference/roster-v2.schema.json new file mode 100644 index 0000000..6e62a8b --- /dev/null +++ b/docs/fleet/reference/roster-v2.schema.json @@ -0,0 +1,156 @@ +{ + "$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": 9007199254740991 + }, + "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": ["claude", "codex", "opencode", "pi"] + } + } + }, + "runtimes": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "enum": ["claude", "codex", "opencode", "pi"] + }, + "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": ["claude", "codex", "opencode", "pi"] + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "reasoning": { + "enum": ["low", "medium", "high"] + }, + "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": ["running", "stopped"] + } + } + }, + "launch": { + "type": "object", + "additionalProperties": false, + "required": ["yolo"], + "properties": { + "yolo": { + "type": "boolean" + } + } + } + } + } + } + } +} diff --git a/packages/mosaic/src/fleet/roster-v2.spec.ts b/packages/mosaic/src/fleet/roster-v2.spec.ts new file mode 100644 index 0000000..358aebc --- /dev/null +++ b/packages/mosaic/src/fleet/roster-v2.spec.ts @@ -0,0 +1,242 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + ROSTER_V2_JSON_SCHEMA, + RosterV2ValidationError, + parseRosterV2, + renderRosterV2Yaml, +} from './roster-v2.js'; + +const validRoster = ` +version: 2 +generation: 7 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: coder0 + alias: Coder 0 + class: code + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: high + tool_policy: code + working_directory: ~/src + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true +`; + +describe('roster v2 structural compiler', (): void => { + it('parses YAML into a normalized typed model and renders canonical YAML', (): void => { + const roster = parseRosterV2(validRoster, 'yaml'); + + expect(roster).toEqual({ + version: 2, + generation: 7, + transport: 'tmux', + tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' }, + defaults: { workingDirectory: '~/src', runtime: 'pi' }, + runtimes: { pi: { resetCommand: '/new' } }, + agents: [ + { + name: 'coder0', + alias: 'Coder 0', + className: 'code', + runtime: 'pi', + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + toolPolicy: 'code', + workingDirectory: '~/src', + persistentPersona: false, + resetBetweenTasks: true, + lifecycle: { enabled: true, desiredState: 'stopped' }, + launch: { yolo: true }, + }, + ], + }); + expect(renderRosterV2Yaml(roster)).toBe(validRoster.trimStart()); + }); + + it('parses JSON and produces the same normalized model', (): void => { + const yaml = parseRosterV2(validRoster, 'yaml'); + const json = JSON.stringify({ + version: 2, + generation: 7, + transport: 'tmux', + tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' }, + defaults: { working_directory: '~/src', runtime: 'pi' }, + runtimes: { pi: { reset_command: '/new' } }, + agents: [ + { + name: 'coder0', + alias: 'Coder 0', + class: 'code', + runtime: 'pi', + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + tool_policy: 'code', + working_directory: '~/src', + persistent_persona: false, + reset_between_tasks: true, + lifecycle: { enabled: true, desired_state: 'stopped' }, + launch: { yolo: true }, + }, + ], + }); + + expect(parseRosterV2(json, 'json')).toEqual(yaml); + }); + + it('sorts runtime and agent maps in the deterministic renderer', (): void => { + const roster = parseRosterV2( + validRoster + .replace( + 'runtimes:\n pi:\n reset_command: /new', + 'runtimes:\n pi:\n reset_command: /new\n codex:\n reset_command: /clear', + ) + .replace( + 'agents:\n - name: coder0', + 'agents:\n - name: reviewer\n alias: Reviewer\n class: review\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: medium\n tool_policy: review\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0', + ), + 'yaml', + ); + + const rendered = renderRosterV2Yaml(roster); + expect(rendered.indexOf(' codex:')).toBeLessThan(rendered.indexOf(' pi:')); + expect(rendered.indexOf(' - name: coder0')).toBeLessThan( + rendered.indexOf(' - name: reviewer'), + ); + }); + + it.each([ + ['v1 document', validRoster.replace('version: 2', 'version: 1'), /v1.*existing v1 path/i], + [ + 'unknown connector', + `${validRoster}\nconnector:\n kind: matrix\n`, + /unsupported field.*connector/i, + ], + [ + 'remote host', + validRoster.replace( + 'runtime: pi\n provider', + 'runtime: pi\n host: remote\n provider', + ), + /unsupported field.*host/i, + ], + [ + 'secret reference', + validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n secret_ref: vault://x'), + /unsupported field.*secret_ref/i, + ], + [ + 'channel override', + validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n channels: discord'), + /unsupported field.*channels/i, + ], + [ + 'arbitrary command', + validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n command: whoami'), + /unsupported field.*command/i, + ], + [ + 'gateway field', + `${validRoster}\ngateway:\n url: https://gateway.example\n`, + /unsupported field.*gateway/i, + ], + [ + 'missing required field', + validRoster.replace(' model: gpt-5.6-sol\n', ''), + /model.*required/i, + ], + [ + 'invalid type', + validRoster.replace('generation: 7', 'generation: seven'), + /generation.*integer/i, + ], + [ + 'unsafe generation', + validRoster.replace('generation: 7', 'generation: 9007199254740992'), + /generation.*integer/i, + ], + [ + 'duplicate names', + validRoster.replace( + ' - name: coder0', + ' - name: coder0\n alias: Duplicate\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0', + ), + /duplicate agent name/i, + ], + ['invalid name', validRoster.replace('name: coder0', 'name: ../coder0'), /invalid agent name/i], + [ + 'invalid transport', + validRoster.replace('transport: tmux', 'transport: matrix'), + /transport.*tmux/i, + ], + [ + 'invalid runtime', + validRoster.replace('runtime: pi', 'runtime: matrix'), + /runtime.*supported/i, + ], + [ + 'invalid reasoning', + validRoster.replace('reasoning: high', 'reasoning: extreme'), + /reasoning.*low.*medium.*high/i, + ], + [ + 'ambiguous socket', + validRoster.replace('socket_name: mosaic-fleet', 'socket_name: default/socket'), + /socket_name/i, + ], + [ + 'agent socket override', + validRoster.replace( + 'runtime: pi\n provider', + 'runtime: pi\n socket: another\n provider', + ), + /unsupported field.*socket/i, + ], + ])('rejects %s', (_name: string, source: string, expected: RegExp): void => { + expect((): void => { + parseRosterV2(source, 'yaml'); + }).toThrow(expected); + }); + + it('rejects malformed JSON as a validation error', (): void => { + expect((): void => { + parseRosterV2('{', 'json'); + }).toThrow(RosterV2ValidationError); + }); + + it('declares supported runtime map keys in the executable schema', (): void => { + expect(ROSTER_V2_JSON_SCHEMA).toMatchObject({ + properties: { + runtimes: { propertyNames: { enum: ['claude', 'codex', 'opencode', 'pi'] } }, + }, + }); + }); + + it('keeps the checked-in documentation schema structurally identical to the executable schema', async (): Promise => { + const schemaPath = fileURLToPath( + new URL('../../../../docs/fleet/reference/roster-v2.schema.json', import.meta.url), + ); + const documented = await readFile(schemaPath, 'utf8'); + + expect(JSON.parse(documented) as unknown).toEqual(ROSTER_V2_JSON_SCHEMA); + }); +}); diff --git a/packages/mosaic/src/fleet/roster-v2.ts b/packages/mosaic/src/fleet/roster-v2.ts new file mode 100644 index 0000000..87df60c --- /dev/null +++ b/packages/mosaic/src/fleet/roster-v2.ts @@ -0,0 +1,473 @@ +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>; + 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> { + 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 = {}; + 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>, +): 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(); + 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 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new RosterV2ValidationError(`${label} must be an object.`); + } + return value as Record; +} + +function assertKnownKeys( + value: Record, + 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(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 { + 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 => ({ + 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 }, + }), + ), + }; +} -- 2.49.1