Compare commits
1 Commits
feat/769-k
...
feat/758-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d625e62e89 |
95
docs/fleet/reference/roster-v2-fields.md
Normal file
95
docs/fleet/reference/roster-v2-fields.md
Normal file
@@ -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.<runtime>.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.
|
||||
156
docs/fleet/reference/roster-v2.schema.json
Normal file
156
docs/fleet/reference/roster-v2.schema.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
242
packages/mosaic/src/fleet/roster-v2.spec.ts
Normal file
242
packages/mosaic/src/fleet/roster-v2.spec.ts
Normal file
@@ -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<void> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
473
packages/mosaic/src/fleet/roster-v2.ts
Normal file
473
packages/mosaic/src/fleet/roster-v2.ts
Normal file
@@ -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<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 },
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user