diff --git a/docs/fleet/migration/example-profile-disposition.md b/docs/fleet/migration/example-profile-disposition.md index dd7ba44..cc8ab65 100644 --- a/docs/fleet/migration/example-profile-disposition.md +++ b/docs/fleet/migration/example-profile-disposition.md @@ -41,6 +41,22 @@ artifact can be removed. | `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. | | `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. | +## M4 migration-preview evidence + +FCM-M4-001 layers an executable migration posture over the same 13-entry M1 inventory without +changing the retained artifact classification: + +- every `v1-fixture` is previewed only with explicit class and lifecycle evidence; +- every `canonical-profile` remains validated by the shared baseline-plus-`roles.local` resolver; +- the canonical service policy remains generic and uses only the approved tool-policy alias. + +`validateShippedFleetMigrationDispositions` first runs the existing executable M1 guard, then requires +explicit decisions and lifecycle observations and executes `previewV1ToV2Migration` for every shipped +v1 fixture. `collectShippedFleetMigrationDispositions` derives the 13-entry posture directly from +`SHIPPED_FLEET_ARTIFACT_DISPOSITIONS`, so additions or removals continue to fail the M1 guard rather +than creating a second artifact list. None of these dispositions claims a cutover, canary, or +rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md). + ## Running the guard ```bash diff --git a/docs/fleet/migration/v1-to-v2.md b/docs/fleet/migration/v1-to-v2.md new file mode 100644 index 0000000..7c9770b --- /dev/null +++ b/docs/fleet/migration/v1-to-v2.md @@ -0,0 +1,82 @@ +# Previewing a Fleet Roster v1-to-v2 Migration + +**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only + +`mosaic fleet migrate-v1 preview` inventories a v1 roster and emits a canonical v2 candidate plus +recovery evidence. It does not write a roster, apply environment projections, invoke systemd or +`tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback. +FCM-M4-002 owns reversible cutover and rollback. + +## Inputs + +```bash +mosaic fleet migrate-v1 preview \ + --source roster-v1.yaml \ + --decisions migration-decisions.json \ + --observations reviewed-observations.json +``` + +The command emits one JSON object and exits nonzero when the preview is blocked, including when any of +`--source`, `--decisions`, or `--observations` is missing. Decision and observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local +agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever +v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model, +reasoning, enabled state, and launch policy. A supported socket field declared by v1 remains authoritative, +including an explicit empty value representing the default tmux server; a matching `socketName` decision is +accepted, an incompatible decision blocks, and a decision supplies the socket only when both supported v1 +socket fields are genuinely absent. If v1 omitted `tool_policy`, decisions must supply an +explicit replacement; it is never derived from `class`. `model_hint` is never split or treated as +authority. + +Observations are separate reviewed evidence keyed by local agent name: + +```json +{ + "coder0": { "systemd": "inactive", "tmux": "missing" } +} +``` + +Only `active` plus `present` maps to `running`; only `inactive` plus `missing` maps to `stopped`. +Missing, extra, unknown, or contradictory evidence blocks output. An observed-running agent cannot +be marked disabled. Observed-stopped agents always remain stopped. + +## Field disposition + +| v1 field | v2 disposition | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted values retain v1 defaults, while present-empty holder/work-directory/reset values block for explicit disposition | +| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition, and `~` stays canonical while expanding only for environment preflight | +| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference | +| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation | +| `kickstart_template` | No v2 field; explicit inventory-only disposition required | +| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; ssh-only, missing fleet-host evidence, or contradictory targets block | +| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition | +| root `connector` | Inventory-only; never contacted or reconciled | +| unknown fields or snake/camel synonym collisions | Inventoried and block readiness | +| `.env.generated` | Rebuild from canonical roster data | +| no legacy `.env` | `absent`; no legacy action required | +| legacy `.env` containing generated keys only | `regenerate-only`; replace later from canonical roster data | +| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover | +| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 | + +The only automatic aliases are `implementer → code`, `reviewer → review`, and +`operator-interaction → interaction`. Similar or domain-specific names are never inferred. Automatic +classes do not accept competing disposition records. Semantic validation delegates to the existing +baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler. + +## Evidence and recovery boundary + +Ready output includes source and candidate SHA-256 identities, value-free field inventory, excluded +remote/connector entries, explicit environment dispositions with sanitized diagnostics, and the lifecycle +evidence used for each local candidate. Canonical evidence ordering compares Unicode code points directly +and does not depend on process locale. Recovery is marked non-executable and assigns the executable gate to +FCM-M4-002. + +Before any later cutover, preserve these artifacts: + +1. authoritative v1 roster backup; +2. agent environment backup, including `.env.local` and private quarantine inputs; +3. reviewed lifecycle observations; +4. canonical candidate v2 roster and its SHA-256. + +See [backup and restore](../operations/backup-restore.md). Preview output is migration-readiness +evidence, not proof that migration, canary, or rollback occurred. diff --git a/docs/fleet/operations/backup-restore.md b/docs/fleet/operations/backup-restore.md new file mode 100644 index 0000000..40c31f3 --- /dev/null +++ b/docs/fleet/operations/backup-restore.md @@ -0,0 +1,40 @@ +# Fleet Configuration Backup and Restore Boundary + +**Issue:** #758 · **Card:** FCM-M4-001 + +This page defines evidence that must exist before a roster v1-to-v2 cutover. FCM-M4-001 lists these +prerequisites in non-executable recovery evidence but does not validate that backups exist and performs +no backup, migration, canary, or restore. FCM-M4-002 owns the executable reversible canary and rollback +gates. + +## Preserve before cutover + +- The authoritative v1 roster, byte-for-byte, with a SHA-256 identity. +- Existing per-agent legacy `.env`, strict `.env.local`, and quarantine files under private + permissions. +- Reviewed per-local-agent systemd and exact-socket tmux observations. +- The canonical v2 candidate and its SHA-256 identity. +- Inventory-only remote agents and connector configuration as evidence, not local control-plane input. + +`.env.generated` is a rebuildable projection and is not restored as authority. It must be regenerated +from the selected authoritative roster. `.env.local` is operator-owned strict data and must not be +overwritten or absorbed into generated output. Quarantined source remains private evidence; public +diagnostics expose only rule code, key name, and SHA-256. + +## Restore requirements + +A later rollback implementation must restore the authoritative roster and operator-owned environment +files, regenerate managed projections, and preserve each reviewed pre-cutover stopped/running state. +It must never start an agent observed stopped and must never reconcile an inventory-only remote or +connector entry. + +The preview evidence deliberately records: + +- `executable: false`; +- required backup artifacts; +- source and candidate identities; +- lifecycle observations and resulting desired states; +- environment relocation/quarantine dispositions; +- FCM-M4-002 as the executable rollback gate owner. + +Do not interpret a ready preview as a completed backup, migration, canary, or rollback. diff --git a/docs/fleet/reference/cli.md b/docs/fleet/reference/cli.md index 2978876..00b0692 100644 --- a/docs/fleet/reference/cli.md +++ b/docs/fleet/reference/cli.md @@ -11,8 +11,15 @@ mosaic fleet restart [name] --expected-generation [--dry-run] mosaic fleet status [name] mosaic fleet verify mosaic fleet doctor +mosaic fleet migrate-v1 preview --source --decisions --observations ``` +`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically +validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery +evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable +blocked JSON object and a non-zero exit, rather than Commander text. See +[the migration preview contract](../migration/v1-to-v2.md). + `apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational. Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry. diff --git a/docs/fleet/reference/roster-v2-fields.md b/docs/fleet/reference/roster-v2-fields.md index 13179ae..fbf0b53 100644 --- a/docs/fleet/reference/roster-v2-fields.md +++ b/docs/fleet/reference/roster-v2-fields.md @@ -62,24 +62,24 @@ agents: ## 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 | +| Path | Required | Constraint | +| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | +| `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket | +| `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 | ## Semantic handoff diff --git a/docs/fleet/reference/roster-v2.schema.json b/docs/fleet/reference/roster-v2.schema.json index 6e62a8b..0e5deaa 100644 --- a/docs/fleet/reference/roster-v2.schema.json +++ b/docs/fleet/reference/roster-v2.schema.json @@ -24,7 +24,7 @@ "properties": { "socket_name": { "type": "string", - "pattern": "^[A-Za-z0-9_.-]+$" + "pattern": "^[A-Za-z0-9_.-]*$" }, "holder_session": { "type": "string", diff --git a/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md b/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md new file mode 100644 index 0000000..0dab162 --- /dev/null +++ b/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md @@ -0,0 +1,75 @@ +# FCM-M4-001 — v1-to-v2 inventory, preview, and migrator + +- **Task / issue:** FCM-M4-001 / mosaicstack/stack#758 +- **Branch / base:** `feat/758-v1-v2-migrator` from `origin/main` `c1aecfabe97a5dc81a72f44910cd4e626f41863f` +- **Base tree:** `46cdfbcdc1d1ff9c7b8b2b9cf3841086590bf774` +- **Scope:** field-complete inventory, non-mutating preview, canonical v2 migration output, and migration/recovery disposition evidence. All effects use injected fakes or temporary fixtures. +- **Budget:** 35K task estimate is the hard working cap. Keep one card/one PR and prefer focused reuse of the v2 compiler, shared role resolver, generated-env boundary, M1 executable disposition inventory, and reconciler observations. + +## Objective + +Implement preview-first v1 migration that never infers unresolved classes or lifecycle, preserves observed running/stopped state, quarantines forbidden legacy environment inputs with key-name/SHA-256-only diagnostics, inventories remote/connector/schema-only entries without reconciling them, covers every M1-classified shipped artifact, and emits deterministic recovery disposition evidence for the later M4-002 canary/rollback gate. + +## Acceptance mapping + +1. Field-by-field v1 inventory and no-mutation preview. +2. Canonical output compiled by `roster-v2.ts` and semantically validated by the existing baseline-plus-`roles.local` resolver. +3. Only approved deterministic aliases; every other noncanonical class requires an explicit disposition. +4. Observed stopped/running maps explicitly to persisted lifecycle; stopped observations never produce running targets. +5. Generated env is regenerated; strict local data is relocated; forbidden keys are quarantine inputs reported only by key name and SHA-256. +6. Remote/connector/schema-only entries are inventory-only and excluded from local reconciliation output. +7. Every shipped M1 example/profile/service preset has executable migration disposition evidence. +8. Deterministic migration/recovery evidence records source, output, exclusions, quarantine, and restore prerequisites without executing a canary or rollback. + +## Boundaries + +Out of scope: FCM-M4-002 executable canary/rollback and host fixture, #766 communications, #636 commands/channels, live fleet/systemd/tmux/session/migration/deploy/connector/remote/gateway effects, `docs/TASKS.md`, parent issue mutation, commit, push, and PR operations. + +## TDD plan + +Migration rules and redaction are critical data-mutation/security logic, so tests are written red-first for inventory completeness, explicit class disposition, observed-state preservation, quarantine redaction, inventory-only remote/schema entries, compiler/resolver reuse, artifact coverage, and recovery evidence. Production code follows only after the focused tests fail for the missing behavior. + +## Plan + +1. Map existing v1 loader, v2 compiler/resolver, env quarantine, reconciler observation, and M1 disposition guard. +2. Add behavior-oriented failing migration tests with temporary fixtures and injected observation/filesystem adapters only. +3. Implement the narrow migration module and CLI boundary without a second resolver or live command runner. +4. Add scoped M4 migration/recovery documentation and executable shipped-artifact evidence. +5. Run focused tests, full package tests, package/root typecheck and lint, formatting, diff checks, and adversarial redaction/no-effect verification. +6. Run independent code/security review, remediate findings, reconstruct the synthetic tree using a temporary index, and stop uncommitted. + +## Progress + +- Collision checks passed: no local/remote branch, worktree, target path, or open PR owned `feat/758-v1-v2-migrator`. +- Dedicated worktree created at the exact green `origin/main` base. +- Required global/repository guides and FCM requirements/evidence loaded. +- No matching migration skill exists under the configured skill directories; no unrelated skill loaded. +- Added a preview-only CLI and migration module that compile with the existing v2 parser/renderer and validate through the shared persona resolver. +- Added value-free raw-v1 inventory, strict unknown-field/synonym/duplicate detection, inventory-only remote and connector handling, and explicit class/tool-policy decisions. +- Added separate reviewed lifecycle observations with only unambiguous running/stopped mappings. +- Added sanitized, non-mutating environment preflight and recovery evidence explicitly marked non-executable. +- Added executable disposition evidence derived from the exact 13-entry M1 inventory and operator documentation. +- Tightened untrusted decisions/observations to reject unknown keys, invalid types/enums, extra local records, and competing automatic-alias dispositions. +- Remediated independent review findings: v1 runtime/reset defaults are preserved, `~` workdirs expand only at env preflight, malformed/required agent fields fail closed before remote exclusion, and all seven shipped v1 fixtures now execute real previews with explicit evidence. +- Remediated socket and locality authority blockers: socket-only agents stay local; `host == fleetHost` stays local; only `host != fleetHost` is inventory-only; ssh-only, missing reviewed fleet-host identity, and contradictory host/ssh targets block explicitly without lifecycle omission. +- Remediated final exact-tree blockers: a declared v1 root socket cannot be overridden; matching/conflicting socket decisions retain reviewed running evidence; canonical ordering uses a shared locale-independent Unicode code-point comparator; migration evidence preserves all four legacy environment dispositions; backup documentation no longer claims validation that M4-001 does not perform. +- Remediated immutable-review socket-presence blocker: both `socket_name` and `socketName` are field-presence-aware, so explicit empty/default-server declarations remain authoritative and incompatible named decisions block rather than replacing them. +- Remediated the replacement-tree blockers: the shared v2 compiler and reconciler accept an explicit empty socket as literal default-server identity; present-empty holder session, default/agent work directory, runtime reset command, and alias values block rather than defaulting; missing preview inputs emit one stable blocked JSON object with non-zero status. Snake/camel aliases and whitespace-only input have adversarial coverage. +- Remediated the subsequent authority blockers: each present-empty CLI path emits exactly one stable blocked JSON object with exit 1 before file reads, and reconciler `start`/`restart` or desired-state `apply`/`reconcile` fail closed before fixed `mosaic-fleet` systemd services can act on a default-server roster. + +## Verification evidence + +- Focused migration/compiler/environment/reconciler/CLI: 6 files, 138 tests passed. +- Full `@mosaicstack/mosaic`: 59 files, 883 tests passed. +- Workspace build: 23 tasks passed. +- Root typecheck: 42 tasks passed. +- Root lint: 23 tasks passed. +- Root format check and `git diff --check`: passed. +- Exact temporary-index synthetic tree includes every tracked and untracked changed path; immutable SHA, path count, and index path are recorded in the frozen handoff. +- Independent review: previous findings were remediated; per coordinator direction, stop before launching review of the next frozen tree. + +## Risks / blockers + +- M4-001 emits rollback prerequisites/evidence only; executable rollback/canary and the managed/unmanaged host fixture remain owned by M4-002. +- Remote/connector entries remain inventory-only; later federation or connector reconciliation requires separately reviewed work. +- Existing environment data is only preflighted. Cutover backup, quarantine write, legacy removal, and generated projection application remain later reviewed effects. diff --git a/packages/mosaic/src/commands/fleet-migration-command.spec.ts b/packages/mosaic/src/commands/fleet-migration-command.spec.ts new file mode 100644 index 0000000..d9e3b2b --- /dev/null +++ b/packages/mosaic/src/commands/fleet-migration-command.spec.ts @@ -0,0 +1,231 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerFleetMigrationCommand } from './fleet-migration-command.js'; + +let cleanup: string | undefined; + +afterEach(async (): Promise => { + if (cleanup) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +describe('fleet migrate-v1 preview command', (): void => { + it('emits one ready JSON result without any runtime or file mutation API', async () => { + cleanup = await mkdtemp(join(tmpdir(), 'fleet-migration-command-')); + const rolesDir = join(cleanup, 'roles'); + const overrideDir = join(cleanup, 'roles.local'); + await mkdir(rolesDir); + await mkdir(overrideDir); + await writeFile(join(rolesDir, 'code.md'), '# code\n'); + const files: Record = { + source: `version: 1\ntransport: tmux\ntmux:\n socket_name: test\ndefaults:\n working_directory: /srv\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n runtime: pi\n class: implementer\n`, + decisions: JSON.stringify({ + generation: 2, + defaultRuntime: 'pi', + agents: { + coder0: { + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + enabled: true, + launchYolo: false, + toolPolicyDisposition: { action: 'replace', className: 'code' }, + }, + }, + }), + observations: JSON.stringify({ coder0: { systemd: 'inactive', tmux: 'missing' } }), + }; + const printJson = vi.fn(); + const setExitCode = vi.fn(); + const program = new Command(); + const fleet = program.command('fleet').option('--mosaic-home ', '', cleanup); + registerFleetMigrationCommand(fleet, { + mosaicHome: cleanup, + rolesDir, + overrideDir, + readText: async (path): Promise => files[path] ?? '', + printJson, + setExitCode, + }); + + await program.parseAsync([ + 'node', + 'test', + 'fleet', + 'migrate-v1', + 'preview', + '--source', + 'source', + '--decisions', + 'decisions', + '--observations', + 'observations', + ]); + + expect(printJson).toHaveBeenCalledTimes(1); + expect(printJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'ready' })); + expect(setExitCode).not.toHaveBeenCalled(); + expect(fleet.helpInformation()).toContain('migrate-v1'); + const migration = fleet.commands.find((command) => command.name() === 'migrate-v1'); + expect(migration?.helpInformation()).not.toMatch(/--write|apply|canary|rollback/); + }); + + it('emits one stable blocked JSON result when --observations is missing', async () => { + const printJson = vi.fn(); + const setExitCode = vi.fn(); + const program = new Command().exitOverride(); + program.configureOutput({ + writeErr: vi.fn(), + writeOut: vi.fn(), + }); + const fleet = program.command('fleet').option('--mosaic-home ', '', '/unused'); + registerFleetMigrationCommand(fleet, { + mosaicHome: '/unused', + readText: vi.fn(), + printJson, + setExitCode, + }); + + await expect( + program.parseAsync([ + 'node', + 'test', + 'fleet', + 'migrate-v1', + 'preview', + '--source', + 'source', + '--decisions', + 'decisions', + ]), + ).resolves.toBe(program); + expect(printJson).toHaveBeenCalledTimes(1); + expect(printJson).toHaveBeenCalledWith({ + status: 'blocked', + blockers: [ + { + code: 'missing-migration-preview-option', + path: 'request.observations', + detail: 'Required migration preview option is missing.', + }, + ], + }); + expect(setExitCode).toHaveBeenCalledWith(1); + }); + + it.each(['source', 'decisions', 'observations'] as const)( + 'emits one stable blocked JSON result when --%s is present-empty', + async (emptyOption) => { + const printJson = vi.fn(); + const setExitCode = vi.fn(); + const readText = vi.fn(); + const program = new Command().exitOverride(); + program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn() }); + const fleet = program.command('fleet').option('--mosaic-home ', '', '/unused'); + registerFleetMigrationCommand(fleet, { + mosaicHome: '/unused', + readText, + printJson, + setExitCode, + }); + const paths = { source: 'source', decisions: 'decisions', observations: 'observations' }; + paths[emptyOption] = ''; + + await expect( + program.parseAsync([ + 'node', + 'test', + 'fleet', + 'migrate-v1', + 'preview', + `--source=${paths.source}`, + `--decisions=${paths.decisions}`, + `--observations=${paths.observations}`, + ]), + ).resolves.toBe(program); + + expect(printJson).toHaveBeenCalledTimes(1); + expect(printJson).toHaveBeenCalledWith({ + status: 'blocked', + blockers: [ + { + code: 'empty-migration-preview-option', + path: `request.${emptyOption}`, + detail: 'Required migration preview option must be a non-empty path.', + }, + ], + }); + expect(setExitCode).toHaveBeenCalledTimes(1); + expect(setExitCode).toHaveBeenCalledWith(1); + expect(readText).not.toHaveBeenCalled(); + }, + ); + + it('emits a blocked result and sets exit 1 for malformed evidence', async () => { + const printJson = vi.fn(); + const setExitCode = vi.fn(); + const program = new Command(); + const fleet = program.command('fleet').option('--mosaic-home ', '', '/unused'); + registerFleetMigrationCommand(fleet, { + mosaicHome: '/unused', + readText: async (path): Promise => (path === 'decisions' ? 'not-json' : '{}'), + printJson, + setExitCode, + }); + await program.parseAsync([ + 'node', + 'test', + 'fleet', + 'migrate-v1', + 'preview', + '--source', + 'source', + '--decisions', + 'decisions', + '--observations', + 'observations', + ]); + expect(printJson).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'blocked', + blockers: [expect.objectContaining({ code: 'migration-preview-failed' })], + }), + ); + expect(setExitCode).toHaveBeenCalledWith(1); + }); + + it('redacts adversarial values from validation failures', async () => { + const secret = 'never-print-command-or-token'; + const printJson = vi.fn(); + const setExitCode = vi.fn(); + const program = new Command(); + const fleet = program.command('fleet').option('--mosaic-home ', '', '/unused'); + registerFleetMigrationCommand(fleet, { + mosaicHome: '/unused', + readText: async (path): Promise => + path === 'decisions' + ? JSON.stringify({ generation: 2, agents: {}, [secret]: secret }) + : '{}', + printJson, + setExitCode, + }); + await program.parseAsync([ + 'node', + 'test', + 'fleet', + 'migrate-v1', + 'preview', + '--source', + 'source', + '--decisions', + 'decisions', + '--observations', + 'observations', + ]); + expect(JSON.stringify(printJson.mock.calls)).not.toContain(secret); + expect(setExitCode).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-migration-command.ts b/packages/mosaic/src/commands/fleet-migration-command.ts new file mode 100644 index 0000000..2235394 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-migration-command.ts @@ -0,0 +1,153 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { Command } from 'commander'; +import { + parseV1MigrationObservations, + parseV1ToV2MigrationDecisions, + previewV1ToV2Migration, +} from '../fleet/v1-v2-migration.js'; + +export interface FleetMigrationCommandDeps { + readonly mosaicHome?: string; + readonly rolesDir?: string; + readonly overrideDir?: string; + readonly readText?: (path: string) => Promise; + readonly printJson?: (value: unknown) => void; + readonly setExitCode?: (code: number) => void; +} + +interface PreviewOptions { + readonly source?: string; + readonly decisions?: string; + readonly observations?: string; +} + +/** Registers preview-only v1 migration. This command has no mutation verbs or runners. */ +export function registerFleetMigrationCommand( + fleetCommand: Command, + deps: FleetMigrationCommandDeps = {}, +): void { + fleetCommand + .command('migrate-v1') + .description('Preview a field-complete v1-to-v2 roster migration') + .command('preview') + .description('Compile migration evidence without writing files or changing runtimes') + .option('--source ', 'v1 roster YAML or JSON') + .option('--decisions ', 'explicit migration decisions JSON') + .option('--observations ', 'reviewed lifecycle observations JSON') + .action(async (options: PreviewOptions): Promise => { + const readText = deps.readText ?? ((path: string): Promise => readFile(path, 'utf8')); + const printJson = + deps.printJson ?? ((value: unknown): void => console.log(JSON.stringify(value))); + const setExitCode = + deps.setExitCode ?? ((code: number): void => void (process.exitCode = code)); + try { + const requiredOptionNames = ['source', 'decisions', 'observations'] as const; + const missingOption = requiredOptionNames.find((name) => options[name] === undefined); + if (missingOption !== undefined) { + printJson({ + status: 'blocked', + blockers: [ + { + code: 'missing-migration-preview-option', + path: `request.${missingOption}`, + detail: 'Required migration preview option is missing.', + }, + ], + }); + setExitCode(1); + return; + } + const emptyOption = requiredOptionNames.find((name) => options[name]?.trim() === ''); + if (emptyOption !== undefined) { + printJson({ + status: 'blocked', + blockers: [ + { + code: 'empty-migration-preview-option', + path: `request.${emptyOption}`, + detail: 'Required migration preview option must be a non-empty path.', + }, + ], + }); + setExitCode(1); + return; + } + const sourcePath = options.source; + const decisionsPath = options.decisions; + const observationsPath = options.observations; + if ( + sourcePath === undefined || + decisionsPath === undefined || + observationsPath === undefined + ) { + throw new Error('Validated migration preview options became unavailable.'); + } + const [source, decisionsSource, observationsSource] = await Promise.all([ + readText(sourcePath), + readText(decisionsPath), + readText(observationsPath), + ]); + const decisions = parseV1ToV2MigrationDecisions( + parseJsonObject(decisionsSource, 'migration decisions'), + ); + const observations = parseV1MigrationObservations( + parseJsonObject(observationsSource, 'lifecycle observations'), + ); + const mosaicHome = + deps.mosaicHome ?? fleetCommand.opts<{ mosaicHome: string }>().mosaicHome; + const preview = await previewV1ToV2Migration({ + source, + sourcePath, + decisions, + observations, + personaDirs: { + rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'), + overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'), + }, + environment: { + mosaicHome, + agentEnvDir: join(mosaicHome, 'fleet', 'agents'), + }, + }); + printJson(preview); + if (preview.status === 'blocked') setExitCode(1); + } catch (error: unknown) { + printJson({ + status: 'blocked', + blockers: [ + { + code: 'migration-preview-failed', + path: 'request', + detail: safeErrorDetail(error), + }, + ], + }); + setExitCode(1); + } + }); +} + +function parseJsonObject(source: string, label: string): unknown { + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch { + throw new Error(`${label} must be valid JSON.`); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object.`); + } + return value; +} + +function safeErrorDetail(error: unknown): string { + if (error instanceof Error && isPublishableValidationMessage(error.message)) return error.message; + return 'Migration preview failed without publishable detail.'; +} + +function isPublishableValidationMessage(message: string): boolean { + return /^(migration decisions|lifecycle observations) must be (valid JSON|a JSON object)\.$/.test( + message, + ); +} diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 3ed159e..cf7252c 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -90,6 +90,7 @@ describe('registerFleetCommand', () => { 'init', 'install', 'install-systemd', + 'migrate-v1', 'persona', 'plan', 'profile', diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index f2bdee5..8a9d2ed 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -22,6 +22,10 @@ import { registerFleetAgentCrudCommands, type FleetAgentCrudCommandDeps, } from './fleet-agent-crud-command.js'; +import { + registerFleetMigrationCommand, + type FleetMigrationCommandDeps, +} from './fleet-migration-command.js'; import { executeReconcilerCommandJson, registerFleetReconcilerCommands, @@ -84,6 +88,7 @@ export interface FleetCommandDeps { isStdinTTY?: boolean; projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier']; reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps']; + migrationDeps?: Omit; } interface RawFleetRoster { @@ -2124,6 +2129,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = // Roster-v2 desired-state mutations belong directly to the fleet control // plane; they do not share the root `mosaic agent` gateway-backed surface. registerFleetAgentCrudCommands(cmd, deps); + registerFleetMigrationCommand(cmd, { + ...deps.migrationDeps, + mosaicHome: deps.mosaicHome, + }); registerFleetReconcilerCommands(cmd, { runner, mosaicHome: deps.mosaicHome, diff --git a/packages/mosaic/src/fleet/deterministic-order.ts b/packages/mosaic/src/fleet/deterministic-order.ts new file mode 100644 index 0000000..303e025 --- /dev/null +++ b/packages/mosaic/src/fleet/deterministic-order.ts @@ -0,0 +1,18 @@ +/** Locale-independent Unicode code-point ordering for canonical fleet evidence. */ +export function compareCodePoints(left: string, right: string): number { + const leftPoints = Array.from(left, (character): number => character.codePointAt(0) ?? 0); + const rightPoints = Array.from(right, (character): number => character.codePointAt(0) ?? 0); + const sharedLength = Math.min(leftPoints.length, rightPoints.length); + + for (let index = 0; index < sharedLength; index += 1) { + const leftPoint = leftPoints[index]; + const rightPoint = rightPoints[index]; + if (leftPoint === undefined || rightPoint === undefined) continue; + if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1; + } + return leftPoints.length < rightPoints.length + ? -1 + : leftPoints.length > rightPoints.length + ? 1 + : 0; +} diff --git a/packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts b/packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts index 998f599..dc32cd2 100644 --- a/packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts +++ b/packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts @@ -303,25 +303,22 @@ describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => { expect(process.exitCode).toBe(0); }); - it.each([ - { - label: 'missing', - source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''), - }, - { - label: 'empty', - source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*$/m, ' socket_name: ""'), - }, - ])( - 'rejects a $label canonical roster-v2 tmux socket through parseRosterV2', - ({ source }): void => { - expect(() => parseRosterV2(source, 'yaml')).toThrow( - 'Roster v2 tmux socket_name is required and must be a non-empty string.', - ); - }, - ); + it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => { + const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''); + expect(() => parseRosterV2(source, 'yaml')).toThrow( + 'Roster v2 tmux socket_name is required and must be a string.', + ); + }); - it('uses the literal default tmux server only through the legacy-v1 loader and runtime transport boundary', async (): Promise => { + it('accepts an explicit empty canonical roster-v2 socket as the literal default server', (): void => { + const source = renderRosterV2Yaml(baseRoster).replace( + /^ socket_name:.*$/m, + ' socket_name: ""', + ); + expect(parseRosterV2(source, 'yaml').tmux.socketName).toBe(''); + }); + + it('omits -L for the literal default tmux server at the runtime transport boundary', async (): Promise => { const rosterPath = await fixtureLegacyRoster(); const runner = vi.fn( async (): Promise => ({ diff --git a/packages/mosaic/src/fleet/fleet-reconciler.spec.ts b/packages/mosaic/src/fleet/fleet-reconciler.spec.ts index a4f764e..aed8d41 100644 --- a/packages/mosaic/src/fleet/fleet-reconciler.spec.ts +++ b/packages/mosaic/src/fleet/fleet-reconciler.spec.ts @@ -274,6 +274,53 @@ describe('fleet roster-owned reconciler', (): void => { ]); }); + it.each(['start', 'apply'] as const)( + 'fails closed before %s can start fixed named-socket services for a default-server roster', + async (command) => { + const calls: string[][] = []; + const defaultServerRoster: FleetRosterV2 = { + ...roster, + tmux: { ...roster.tmux, socketName: '' }, + agents: [ + { + ...roster.agents[0]!, + lifecycle: { enabled: true, desiredState: 'running' }, + }, + ], + }; + + await expect( + executeFleetReconcile({ + roster: defaultServerRoster, + command, + expectedGeneration: 7, + deps: deps({ + readRoster: async () => defaultServerRoster, + runner: async (executable, args) => { + calls.push([executable, ...args]); + if (executable === 'tmux' && args.includes('list-sessions')) { + return { stdout: '', stderr: '', exitCode: 1 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }), + }), + ).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' }); + expect(calls).not.toContainEqual([ + 'systemctl', + '--user', + 'start', + 'mosaic-tmux-holder.service', + ]); + expect(calls).not.toContainEqual([ + 'systemctl', + '--user', + 'start', + 'mosaic-agent@coder0.service', + ]); + }, + ); + it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise => { const calls: string[][] = []; const runningRoster: FleetRosterV2 = { diff --git a/packages/mosaic/src/fleet/fleet-reconciler.ts b/packages/mosaic/src/fleet/fleet-reconciler.ts index 1dc2f17..5123e35 100644 --- a/packages/mosaic/src/fleet/fleet-reconciler.ts +++ b/packages/mosaic/src/fleet/fleet-reconciler.ts @@ -176,6 +176,7 @@ export async function executeFleetReconcile( assertLocalRosterOnly(request.roster); const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request); await validateRoster(request.roster); + assertLifecycleSocketAuthority(request); const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName); if (request.command === 'status' || request.command === 'doctor') { @@ -477,6 +478,23 @@ function assertVerificationSafe(plan: FleetReconcilePlan): void { } } +function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void { + const mayStart = + request.command === 'start' || + request.command === 'restart' || + ((request.command === 'apply' || request.command === 'reconcile') && + request.roster.agents.some( + (agent: FleetRosterV2Agent): boolean => + agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running', + )); + if (mayStart && request.roster.tmux.socketName === '') { + throw new FleetReconcileError( + 'lifecycle-precondition-failed', + 'Default-server lifecycle start is unsupported by the fixed named-socket systemd units.', + ); + } +} + function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] { if (agentName === undefined) return roster.agents; const agent = roster.agents.find( diff --git a/packages/mosaic/src/fleet/generated-env-boundary.spec.ts b/packages/mosaic/src/fleet/generated-env-boundary.spec.ts index dc13a13..9326324 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.spec.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.spec.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { AgentEnvBoundaryError, parseAgentEnvironment, + previewAgentEnvironmentProjection, renderGeneratedAgentEnvironment, writeAgentEnvironmentProjection, } from './generated-env-boundary.js'; @@ -86,6 +87,41 @@ describe('generated fleet agent environment boundary', (): void => { }).toThrow(AgentEnvBoundaryError); }); + it('previews legacy relocation and quarantine without exposing content or mutating files', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-')); + const mosaicHome = join(cleanup, 'mosaic'); + const agentEnvDir = join(mosaicHome, 'fleet', 'agents'); + const legacyPath = join(agentEnvDir, 'coder0.env'); + const legacy = 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\nMOSAIC_AGENT_COMMAND=never-print-command\n'; + await mkdir(agentEnvDir, { recursive: true, mode: 0o700 }); + await writeFile(legacyPath, legacy, { mode: 0o600 }); + + const preview = await previewAgentEnvironmentProjection({ + mosaicHome, + agentEnvDir, + agentName: 'coder0', + generated: generatedValues, + }); + + expect(preview).toMatchObject({ + agentName: 'coder0', + generated: 'rebuild', + legacy: 'quarantine', + relocatedKeys: ['MOSAIC_RUNTIME_BIN'], + diagnostics: [ + expect.objectContaining({ + code: 'unknown-key', + key: 'MOSAIC_AGENT_COMMAND', + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }), + ], + }); + expect(JSON.stringify(preview)).not.toContain('never-print-command'); + await expect(readFile(legacyPath, 'utf8')).resolves.toBe(legacy); + await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow(); + await expect(readFile(join(agentEnvDir, 'coder0.env.quarantine'), 'utf8')).rejects.toThrow(); + }); + it('creates missing managed directories privately before writing a projection', async (): Promise => { cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-')); const mosaicHome = join(cleanup, 'mosaic'); diff --git a/packages/mosaic/src/fleet/generated-env-boundary.ts b/packages/mosaic/src/fleet/generated-env-boundary.ts index 200cebe..1ec15f6 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; +import { compareCodePoints } from './deterministic-order.js'; export type AgentEnvironmentKind = 'generated' | 'local'; @@ -30,6 +31,15 @@ export interface AgentEnvironmentProjectionResult { readonly diagnostics: readonly AgentEnvironmentDiagnostic[]; } +/** Sanitized, non-mutating projection evidence safe for migration output. */ +export interface AgentEnvironmentProjectionPreview { + readonly agentName: string; + readonly generated: 'rebuild'; + readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine'; + readonly relocatedKeys: readonly string[]; + readonly diagnostics: readonly AgentEnvironmentDiagnostic[]; +} + /** A projection fully validated without changing managed files. */ export interface PreparedAgentEnvironmentProjection { readonly mosaicHome: string; @@ -40,6 +50,7 @@ export interface PreparedAgentEnvironmentProjection { readonly generated: string; readonly local: string; readonly legacy?: string; + readonly legacyRelocatedKeys: readonly string[]; readonly quarantinePath?: string; readonly diagnostics: readonly AgentEnvironmentDiagnostic[]; } @@ -186,6 +197,7 @@ export async function prepareAgentEnvironmentProjection( generated, local, ...(legacy === undefined ? {} : { legacy }), + legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints), ...(quarantinePath === undefined ? {} : { quarantinePath }), diagnostics: legacyDisposition.diagnostics, }; @@ -208,6 +220,33 @@ export async function prepareAgentGeneratedProjectionDeletion( return generatedPath; } +export async function previewAgentEnvironmentProjection( + options: AgentEnvironmentProjectionOptions, +): Promise { + const prepared = await prepareAgentEnvironmentProjection(options); + const relocatedKeys = prepared.legacyRelocatedKeys; + const legacy = + prepared.legacy === undefined + ? 'absent' + : prepared.diagnostics.length > 0 + ? 'quarantine' + : relocatedKeys.length > 0 + ? 'relocate-local' + : 'regenerate-only'; + return { + agentName: options.agentName, + generated: 'rebuild', + legacy, + relocatedKeys, + diagnostics: [...prepared.diagnostics].sort((left, right): number => + compareCodePoints( + `${left.key}:${left.code}:${left.sha256}`, + `${right.key}:${right.code}:${right.sha256}`, + ), + ), + }; +} + /** Applies a previously prepared deterministic projection. */ export async function applyPreparedAgentEnvironmentProjection( prepared: PreparedAgentEnvironmentProjection, @@ -292,13 +331,13 @@ function renderLocalAgentEnvironment(values: Readonly>): if (Object.keys(values).length === 0) return ''; const parsed = parseAgentEnvironment( Object.entries(values) - .sort(([left], [right]): number => left.localeCompare(right)) + .sort(([left], [right]): number => compareCodePoints(left, right)) .map(([key, value]): string => `${key}=${value}`) .join('\n'), 'local', ); return `${Object.entries(parsed) - .sort(([left], [right]): number => left.localeCompare(right)) + .sort(([left], [right]): number => compareCodePoints(left, right)) .map(([key, value]): string => `${key}=${value}`) .join('\n')}\n`; } diff --git a/packages/mosaic/src/fleet/roster-v2.spec.ts b/packages/mosaic/src/fleet/roster-v2.spec.ts index 1fc0b0b..7ffac8d 100644 --- a/packages/mosaic/src/fleet/roster-v2.spec.ts +++ b/packages/mosaic/src/fleet/roster-v2.spec.ts @@ -45,6 +45,12 @@ agents: let semanticTmp: string | undefined; +it('preserves an explicit empty socket as the literal default tmux server', () => { + const roster = parseRosterV2(validRoster.replace('socket_name: mosaic-fleet', "socket_name: ''")); + expect(roster.tmux.socketName).toBe(''); + expect(renderRosterV2Yaml(roster)).toContain('socket_name: ""'); +}); + afterEach(async (): Promise => { if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true }); semanticTmp = undefined; diff --git a/packages/mosaic/src/fleet/roster-v2.ts b/packages/mosaic/src/fleet/roster-v2.ts index f0056f1..651b170 100644 --- a/packages/mosaic/src/fleet/roster-v2.ts +++ b/packages/mosaic/src/fleet/roster-v2.ts @@ -10,6 +10,7 @@ import { type PersonaResolution, type RoleAuthority, } from '../commands/fleet-personas.js'; +import { compareCodePoints } from './deterministic-order.js'; export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const; export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const; @@ -172,7 +173,7 @@ export const ROSTER_V2_JSON_SCHEMA: JsonSchema = { additionalProperties: false, required: ['socket_name', 'holder_session'], properties: { - socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, + socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]*$' }, holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, }, }, @@ -272,6 +273,7 @@ const AGENT_KEYS = [ const LIFECYCLE_KEYS = ['enabled', 'desired_state']; const LAUNCH_KEYS = ['yolo']; const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const TMUX_SOCKET_IDENTIFIER = /^[A-Za-z0-9_.-]*$/; const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/; const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/; @@ -327,7 +329,7 @@ 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'), + socketName: requiredTmuxSocket(raw.socket_name, 'Roster v2 tmux socket_name'), holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'), }; } @@ -348,7 +350,7 @@ function normalizeRuntimes(value: unknown): Readonly = {}; - for (const name of names.sort()) { + for (const name of names.sort(compareCodePoints)) { 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); @@ -416,7 +418,7 @@ function normalizeAgents( }; }); return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => - left.name.localeCompare(right.name), + compareCodePoints(left.name, right.name), ); } @@ -473,6 +475,17 @@ function requiredIdentifier(value: unknown, label: string): string { return result; } +function requiredTmuxSocket(value: unknown, label: string): string { + if (typeof value !== 'string') { + throw new RosterV2ValidationError(`${label} is required and must be a string.`); + } + const result = value.trim(); + if (!TMUX_SOCKET_IDENTIFIER.test(result)) { + throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`); + } + return result; +} + function requiredTmuxIdentifier(value: unknown, label: string): string { const result = requiredString(value, label); if (!TMUX_IDENTIFIER.test(result)) @@ -524,7 +537,7 @@ function toSourceShape(roster: FleetRosterV2): Record { }, runtimes: Object.fromEntries( Object.entries(roster.runtimes) - .sort(([left], [right]): number => left.localeCompare(right)) + .sort(([left], [right]): number => compareCodePoints(left, right)) .map(([name, runtime]): [string, unknown] => [ name, { reset_command: runtime.resetCommand }, @@ -532,7 +545,7 @@ function toSourceShape(roster: FleetRosterV2): Record { ), agents: [...roster.agents] .sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => - left.name.localeCompare(right.name), + compareCodePoints(left.name, right.name), ) .map( (agent: FleetRosterV2Agent): Record => ({ diff --git a/packages/mosaic/src/fleet/v1-v2-migration.spec.ts b/packages/mosaic/src/fleet/v1-v2-migration.spec.ts new file mode 100644 index 0000000..38e20fa --- /dev/null +++ b/packages/mosaic/src/fleet/v1-v2-migration.spec.ts @@ -0,0 +1,1059 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { compareCodePoints } from './deterministic-order.js'; +import { SHIPPED_FLEET_ARTIFACT_DISPOSITIONS } from './example-profile-dispositions.js'; +import { + collectShippedFleetMigrationDispositions, + previewV1ToV2Migration, + validateShippedFleetMigrationDispositions, + type V1ToV2MigrationDecisions, +} from './v1-v2-migration.js'; + +const temporaryDirectories: string[] = []; +const frameworkFleet = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'framework', + 'fleet', +); + +afterEach(async (): Promise => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path): Promise => rm(path, { recursive: true, force: true })), + ); +}); + +async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'v1-v2-migration-roles-')); + temporaryDirectories.push(root); + const rolesDir = join(root, 'roles'); + const overrideDir = join(root, 'roles.local'); + await mkdir(rolesDir); + await mkdir(overrideDir); + for (const className of ['code', 'review', 'orchestrator', 'interaction', 'custom-domain']) { + await writeFile(join(rolesDir, `${className}.md`), `# ${className}\n`, 'utf8'); + } + return { rolesDir, overrideDir }; +} + +const v1Source = ` +version: 1 +transport: tmux +tmux: + socket_name: mosaic-v1 + holder_session: _holder +defaults: + working_directory: /srv/fleet +runtimes: + pi: + reset_command: /new +agents: + - name: coder0 + alias: Coder Zero + provider: openai + runtime: pi + class: implementer + working_directory: /srv/coder0 + model_hint: legacy/model:high + reasoning_level: high + tool_policy: implementer + persistent_persona: true + reset_between_tasks: false + kickstart_template: legacy-template + - name: remote0 + runtime: pi + class: reviewer + host: build.example.test + ssh: operator@build.example.test + socket: remote-fleet +connector: + kind: discord + discord: + channel_id: channel +`; + +function decisions(): V1ToV2MigrationDecisions { + return { + generation: 2, + fleetHost: 'fleet.example.test', + defaultRuntime: 'pi', + agents: { + coder0: { + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + enabled: true, + launchYolo: false, + kickstartTemplate: 'inventory-only', + }, + }, + }; +} + +function observations( + observation: { systemd: 'active' | 'inactive'; tmux: 'present' | 'missing' } = { + systemd: 'inactive', + tmux: 'missing', + }, +): Record { + return { coder0: observation }; +} + +async function shippedV1PreviewEvidence(): Promise< + Record< + string, + { + decisions: V1ToV2MigrationDecisions; + observations: Record; + } + > +> { + const evidence: Record< + string, + { + decisions: V1ToV2MigrationDecisions; + observations: Record; + } + > = {}; + const explicitReplacements: Readonly> = { + worker: 'code', + canary: 'code', + analyst: 'researcher', + }; + const aliases: Readonly> = { + implementer: 'code', + reviewer: 'review', + 'operator-interaction': 'interaction', + }; + const automatic = new Set(['orchestrator', 'enhancer', ...Object.keys(aliases)]); + for (const artifact of SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter( + ({ disposition }) => disposition === 'v1-fixture', + )) { + const source = await readFile(join(frameworkFleet, artifact.path), 'utf8'); + const raw = YAML.parse(source) as { + agents: Array<{ name: string; class?: string; tool_policy?: string }>; + }; + const agents: V1ToV2MigrationDecisions['agents'] = {}; + const lifecycle: Record = {}; + for (const agent of raw.agents) { + const requestedClass = agent.class ?? 'worker'; + const replacement = explicitReplacements[requestedClass]; + const finalClass = replacement ?? aliases[requestedClass] ?? requestedClass; + agents[agent.name] = { + provider: 'fixture', + model: 'fixture-model', + reasoning: 'low', + enabled: true, + launchYolo: false, + ...(!automatic.has(requestedClass) + ? { + classDisposition: replacement + ? ({ action: 'replace', className: replacement } as const) + : ({ action: 'preserve' } as const), + } + : {}), + ...(agent.tool_policy === undefined + ? { toolPolicyDisposition: { action: 'replace', className: finalClass } as const } + : {}), + }; + lifecycle[agent.name] = { systemd: 'inactive', tmux: 'missing' }; + } + evidence[artifact.path] = { + decisions: { generation: 2, defaultRuntime: 'pi', agents }, + observations: lifecycle, + }; + } + return evidence; +} + +describe('v1-to-v2 migration preview', (): void => { + it('inventories every v1 field without values and compiles only local agents through v2', async () => { + const dirs = await semanticDirs(); + const preview = await previewV1ToV2Migration({ + source: v1Source, + sourcePath: '/fixtures/roster.yaml', + decisions: decisions(), + observations: observations(), + personaDirs: dirs, + }); + + expect(preview.status).toBe('ready'); + expect(preview.blockers).toEqual([]); + expect(preview.inventory.map(({ path }) => path)).toEqual( + expect.arrayContaining([ + 'version', + 'tmux.socket_name', + 'defaults.working_directory', + 'runtimes.pi.reset_command', + 'agents[0].kickstart_template', + 'agents[1].host', + 'agents[1].ssh', + 'agents[1].socket', + 'connector.discord.channel_id', + ]), + ); + expect(preview.inventory.every((entry) => !Object.hasOwn(entry, 'value'))).toBe(true); + expect(preview.inventoryOnlyAgents).toEqual([ + { + name: 'remote0', + fields: ['host', 'socket', 'ssh'], + disposition: 'inventory-only', + }, + ]); + expect( + preview.inventory + .filter(({ path }) => path.startsWith('agents[1]')) + .every(({ disposition }) => disposition === 'inventory-only'), + ).toBe(true); + expect(preview.canonicalRoster?.agents).toHaveLength(1); + expect(preview.canonicalRoster?.agents[0]).toMatchObject({ + name: 'coder0', + className: 'code', + toolPolicy: 'code', + lifecycle: { enabled: true, desiredState: 'stopped' }, + }); + expect(preview.canonicalYaml).toContain('class: code'); + expect(preview.canonicalYaml).not.toContain('remote0'); + expect(preview.evidence.source.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(preview.evidence.candidate?.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(preview.evidence.recovery).toEqual({ + executable: false, + canaryExecuted: false, + rollbackExecuted: false, + requiredArtifacts: [ + 'v1-roster-backup', + 'environment-backup', + 'observed-lifecycle-evidence', + 'candidate-v2-roster', + ], + gateOwner: 'FCM-M4-002', + }); + }); + + it.each([ + { + label: 'host equal to reviewed fleet host', + agentFields: + ' host: fleet.example.test\n ssh: operator@fleet.example.test\n socket: mosaic-v1', + fleetHost: 'fleet.example.test', + expectedStatus: 'ready', + expectedInventoryOnly: false, + expectedBlocker: undefined, + }, + { + label: 'host different from reviewed fleet host', + agentFields: + ' host: build.example.test\n ssh: operator@build.example.test\n socket: remote-fleet', + fleetHost: 'fleet.example.test', + expectedStatus: 'ready', + expectedInventoryOnly: true, + expectedBlocker: undefined, + }, + { + label: 'ssh only', + agentFields: ' ssh: operator@build.example.test\n socket: mosaic-v1', + fleetHost: 'fleet.example.test', + expectedStatus: 'blocked', + expectedInventoryOnly: false, + expectedBlocker: 'agent-locality-disposition-required', + }, + { + label: 'contradictory host and ssh targets', + agentFields: + ' host: fleet.example.test\n ssh: operator@other.example.test\n socket: mosaic-v1', + fleetHost: 'fleet.example.test', + expectedStatus: 'blocked', + expectedInventoryOnly: false, + expectedBlocker: 'agent-locality-disposition-required', + }, + { + label: 'host evidence without reviewed fleet host', + agentFields: ' host: build.example.test\n socket: mosaic-v1', + fleetHost: undefined, + expectedStatus: 'blocked', + expectedInventoryOnly: false, + expectedBlocker: 'agent-locality-disposition-required', + }, + ])( + 'classifies $label without inferring locality', + async ({ agentFields, fleetHost, expectedStatus, expectedInventoryOnly, expectedBlocker }) => { + const source = v1Source.replace( + ' host: build.example.test\n ssh: operator@build.example.test\n socket: remote-fleet', + agentFields, + ); + const migrationDecisions = decisions(); + const baseRemoteDecision = { + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high' as const, + enabled: true, + launchYolo: false, + toolPolicyDisposition: { action: 'replace' as const, className: 'review' }, + }; + if (!expectedInventoryOnly) migrationDecisions.agents.remote0 = baseRemoteDecision; + const reviewedDecisions: V1ToV2MigrationDecisions = fleetHost + ? { ...migrationDecisions, fleetHost } + : { ...migrationDecisions, fleetHost: undefined }; + const preview = await previewV1ToV2Migration({ + source, + decisions: reviewedDecisions, + observations: expectedInventoryOnly + ? observations() + : { + ...observations(), + remote0: { systemd: 'inactive', tmux: 'missing' }, + }, + personaDirs: await semanticDirs(), + }); + + expect(preview.status).toBe(expectedStatus); + expect(preview.inventoryOnlyAgents.some(({ name }) => name === 'remote0')).toBe( + expectedInventoryOnly, + ); + if (expectedInventoryOnly) { + expect(preview.inventory.filter(({ path }) => path.startsWith('agents[1]'))).toSatisfy( + (entries: Array<{ disposition: string }>) => + entries.every(({ disposition }) => disposition === 'inventory-only'), + ); + expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']); + } else { + expect( + preview.inventory + .filter(({ path }) => path.startsWith('agents[1]')) + .every(({ disposition }) => disposition !== 'inventory-only'), + ).toBe(true); + } + if (expectedBlocker) { + expect(preview.blockers).toContainEqual( + expect.objectContaining({ code: expectedBlocker, agent: 'remote0' }), + ); + expect(preview.evidence.observations).toContainEqual({ + agent: 'remote0', + systemd: 'inactive', + tmux: 'missing', + preservedAs: 'stopped', + }); + } else if (!expectedInventoryOnly) { + expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual([ + 'coder0', + 'remote0', + ]); + } + }, + ); + + it.each([ + ['socket_name', " socket_name: ''\n holder_session: _holder"], + ['socketName', " socketName: ''\n holder_session: _holder"], + ] as const)( + 'preserves an explicit empty tmux.%s declaration as the literal default server without a decision', + async (_field, tmuxBlock) => { + const source = v1Source.replace( + ' socket_name: mosaic-v1\n holder_session: _holder', + tmuxBlock, + ); + const preview = await previewV1ToV2Migration({ + source, + decisions: decisions(), + observations: observations({ systemd: 'active', tmux: 'present' }), + personaDirs: await semanticDirs(), + }); + + expect(preview.status).toBe('ready'); + expect(preview.blockers).toEqual([]); + expect(preview.canonicalRoster?.tmux.socketName).toBe(''); + expect(preview.canonicalYaml).toContain('socket_name: ""'); + }, + ); + + it.each([ + ['socket_name', " socket_name: ''\n holder_session: _holder"], + ['socketName', " socketName: ''\n holder_session: _holder"], + ] as const)( + 'treats an explicit empty tmux.%s declaration as authoritative and blocks a named decision', + async (_field, tmuxBlock) => { + const source = v1Source.replace( + ' socket_name: mosaic-v1\n holder_session: _holder', + tmuxBlock, + ); + const preview = await previewV1ToV2Migration({ + source, + decisions: { ...decisions(), socketName: 'decision-socket' }, + observations: observations({ systemd: 'active', tmux: 'present' }), + personaDirs: await semanticDirs(), + }); + + expect(preview.status).toBe('blocked'); + expect(preview.canonicalRoster).toBeUndefined(); + expect(preview.blockers).toContainEqual( + expect.objectContaining({ + code: 'socket-name-conflict', + path: 'decisions.socketName', + }), + ); + expect(preview.evidence.observations).toContainEqual({ + agent: 'coder0', + systemd: 'active', + tmux: 'present', + preservedAs: 'running', + }); + }, + ); + + it.each([ + ['socket_name', 'mosaic-v1', 'mosaic-v1', 'ready'], + ['socket_name', 'mosaic-v1', 'other-socket', 'blocked'], + ['socketName', 'mosaic-v1', 'mosaic-v1', 'ready'], + ['socketName', 'mosaic-v1', 'other-socket', 'blocked'], + ] as const)( + '%s named declaration with a %s reviewed decision preserves declared authority', + async (field, declaredSocket, decisionSocket, expectedStatus) => { + const source = v1Source.replace(' socket_name: mosaic-v1', ` ${field}: ${declaredSocket}`); + const migrationDecisions: V1ToV2MigrationDecisions = { + ...decisions(), + socketName: decisionSocket, + }; + const preview = await previewV1ToV2Migration({ + source, + decisions: migrationDecisions, + observations: observations({ systemd: 'active', tmux: 'present' }), + personaDirs: await semanticDirs(), + }); + + expect(preview.status).toBe(expectedStatus); + expect(preview.evidence.observations).toContainEqual({ + agent: 'coder0', + systemd: 'active', + tmux: 'present', + preservedAs: 'running', + }); + if (expectedStatus === 'ready') { + expect(preview.canonicalRoster?.tmux.socketName).toBe('mosaic-v1'); + expect(preview.canonicalRoster?.agents[0]?.lifecycle.desiredState).toBe('running'); + } else { + expect(preview.canonicalRoster).toBeUndefined(); + expect(preview.blockers).toContainEqual( + expect.objectContaining({ + code: 'socket-name-conflict', + path: 'decisions.socketName', + }), + ); + } + }, + ); + + it('migrates a compatible socket-only local agent with reviewed lifecycle evidence', async () => { + const source = v1Source + .replace(' host: build.example.test\n', '') + .replace(' ssh: operator@build.example.test\n', '') + .replace(' socket: remote-fleet', ' socket: mosaic-v1'); + const migrationDecisions = decisions(); + migrationDecisions.agents.remote0 = { + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + enabled: true, + launchYolo: false, + toolPolicyDisposition: { action: 'replace', className: 'review' }, + }; + const preview = await previewV1ToV2Migration({ + source, + decisions: migrationDecisions, + observations: { + ...observations(), + remote0: { systemd: 'inactive', tmux: 'missing' }, + }, + personaDirs: await semanticDirs(), + }); + + expect(preview.status).toBe('ready'); + expect(preview.inventoryOnlyAgents).toEqual([]); + expect(preview.inventory).toContainEqual({ + path: 'agents[1].socket', + disposition: 'candidate', + }); + expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0', 'remote0']); + expect(preview.evidence.observations).toContainEqual({ + agent: 'remote0', + systemd: 'inactive', + tmux: 'missing', + preservedAs: 'stopped', + }); + }); + + it('blocks a socket-only local agent that conflicts with the canonical fleet socket', async () => { + const source = v1Source + .replace(' host: build.example.test\n', '') + .replace(' ssh: operator@build.example.test\n', ''); + const migrationDecisions = decisions(); + migrationDecisions.agents.remote0 = { + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + enabled: true, + launchYolo: false, + toolPolicyDisposition: { action: 'replace', className: 'review' }, + }; + const preview = await previewV1ToV2Migration({ + source, + decisions: migrationDecisions, + observations: { + ...observations(), + remote0: { systemd: 'active', tmux: 'present' }, + }, + personaDirs: await semanticDirs(), + }); + + expect(preview.status).toBe('blocked'); + expect(preview.inventoryOnlyAgents).toEqual([]); + expect(preview.blockers).toContainEqual( + expect.objectContaining({ + code: 'agent-socket-disposition-required', + path: 'agents[1].socket', + agent: 'remote0', + }), + ); + expect(preview.canonicalRoster).toBeUndefined(); + expect(preview.evidence.observations).toContainEqual({ + agent: 'remote0', + systemd: 'active', + tmux: 'present', + preservedAs: 'running', + }); + }); + + it('emits byte-identical canonical evidence regardless of locale collation', async () => { + expect(['😀', 'z', '', 'ä'].sort(compareCodePoints)).toEqual(['z', 'ä', '', '😀']); + const dirs = await semanticDirs(); + const renderUnderLocale = async (locale: string): Promise => { + const collator = new Intl.Collator(locale); + const localeCompare = vi + .spyOn(String.prototype, 'localeCompare') + .mockImplementation(function (this: string, other: string): number { + return collator.compare(this, other); + }); + try { + const preview = await previewV1ToV2Migration({ + source: v1Source, + decisions: decisions(), + observations: observations(), + personaDirs: dirs, + }); + return JSON.stringify(preview); + } finally { + localeCompare.mockRestore(); + } + }; + + expect(await renderUnderLocale('en')).toBe(await renderUnderLocale('sv')); + }); + + it.each([ + [{ systemd: 'active' as const, tmux: 'present' as const }, 'running'], + [{ systemd: 'inactive' as const, tmux: 'missing' as const }, 'stopped'], + ])('preserves an unambiguous observed lifecycle %j as %s', async (observation, expected) => { + const preview = await previewV1ToV2Migration({ + source: v1Source, + sourcePath: '/fixtures/roster.yaml', + decisions: decisions(), + observations: observations(observation), + personaDirs: await semanticDirs(), + }); + expect(preview.canonicalRoster?.agents[0]?.lifecycle.desiredState).toBe(expected); + expect(preview.evidence.observations[0]).toMatchObject({ + agent: 'coder0', + systemd: observation.systemd, + tmux: observation.tmux, + preservedAs: expected, + }); + }); + + it('blocks missing or contradictory lifecycle evidence instead of inferring it', async () => { + const missing = decisions(); + const missingPreview = await previewV1ToV2Migration({ + source: v1Source, + decisions: missing, + observations: {}, + personaDirs: await semanticDirs(), + }); + expect(missingPreview.status).toBe('blocked'); + expect(missingPreview.canonicalYaml).toBeUndefined(); + expect(missingPreview.canonicalRoster).toBeUndefined(); + expect(missingPreview.evidence.candidate).toBeUndefined(); + expect(missingPreview.blockers).toContainEqual( + expect.objectContaining({ code: 'missing-lifecycle-observation', agent: 'coder0' }), + ); + + const contradictory = await previewV1ToV2Migration({ + source: v1Source, + decisions: decisions(), + observations: observations({ systemd: 'active', tmux: 'missing' }), + personaDirs: await semanticDirs(), + }); + expect(contradictory.status).toBe('blocked'); + expect(contradictory.blockers).toContainEqual( + expect.objectContaining({ code: 'ambiguous-lifecycle-observation', agent: 'coder0' }), + ); + }); + + it('uses only approved aliases and requires explicit disposition for domain classes', async () => { + const source = v1Source.replace('class: implementer', 'class: custom-domain'); + const blocked = await previewV1ToV2Migration({ + source, + decisions: decisions(), + observations: observations(), + personaDirs: await semanticDirs(), + }); + expect(blocked.status).toBe('blocked'); + expect(blocked.blockers).toContainEqual( + expect.objectContaining({ code: 'class-disposition-required', agent: 'coder0' }), + ); + + const explicit = decisions(); + const coderDecision = explicit.agents.coder0; + if (!coderDecision) throw new Error('fixture decision missing'); + const explicitDecisions: V1ToV2MigrationDecisions = { + ...explicit, + agents: { + ...explicit.agents, + coder0: { + ...coderDecision, + classDisposition: { action: 'preserve' }, + }, + }, + }; + const ready = await previewV1ToV2Migration({ + source, + decisions: explicitDecisions, + observations: observations(), + personaDirs: await semanticDirs(), + }); + expect(ready.status).toBe('ready'); + expect(ready.canonicalRoster?.agents[0]?.className).toBe('custom-domain'); + }); + + it.each([ + [ + 'holder_session', + 'tmux:\n socket_name: mosaic-v1\n holder_session: _holder', + "tmux:\n socket_name: mosaic-v1\n holder_session: ''", + 'tmux.holder_session', + ], + [ + 'holderSession', + 'tmux:\n socket_name: mosaic-v1\n holder_session: _holder', + "tmux:\n socket_name: mosaic-v1\n holderSession: ''", + 'tmux.holderSession', + ], + [ + 'default working_directory', + 'defaults:\n working_directory: /srv/fleet', + "defaults:\n working_directory: ''", + 'defaults.working_directory', + ], + [ + 'default workingDirectory', + 'defaults:\n working_directory: /srv/fleet', + "defaults:\n workingDirectory: ''", + 'defaults.workingDirectory', + ], + [ + 'runtime reset_command', + 'runtimes:\n pi:\n reset_command: /new', + "runtimes:\n pi:\n reset_command: ''", + 'runtimes.pi.reset_command', + ], + [ + 'runtime resetCommand', + 'runtimes:\n pi:\n reset_command: /new', + "runtimes:\n pi:\n resetCommand: ''", + 'runtimes.pi.resetCommand', + ], + ['agent alias', ' alias: Coder Zero', " alias: ' '", 'agents[0].alias'], + [ + 'agent working_directory', + ' working_directory: /srv/coder0', + " working_directory: ''", + 'agents[0].working_directory', + ], + [ + 'agent workingDirectory', + ' working_directory: /srv/coder0', + " workingDirectory: ''", + 'agents[0].workingDirectory', + ], + ] as const)( + 'blocks present-empty %s instead of silently applying a fallback', + async (_case, anchor, replacement, expectedPath) => { + const source = v1Source.replace(anchor, replacement); + const preview = await previewV1ToV2Migration({ + source, + decisions: decisions(), + observations: observations(), + personaDirs: await semanticDirs(), + }); + + expect(preview.status).toBe('blocked'); + expect(preview.canonicalRoster).toBeUndefined(); + expect(preview.blockers).toContainEqual( + expect.objectContaining({ + code: 'empty-v1-field-disposition-required', + path: expectedPath, + }), + ); + }, + ); + + it.each([ + ['absent', undefined, []], + ['regenerate-only', 'MOSAIC_AGENT_NAME=legacy-generated\n', []], + ['relocate-local', 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', ['MOSAIC_RUNTIME_BIN']], + ['quarantine', 'MOSAIC_AGENT_COMMAND=never-publish\n', []], + ] as const)( + 'preserves the %s legacy environment disposition in migration evidence', + async (expectedLegacy, legacySource, relocatedKeys) => { + const dirs = await semanticDirs(); + const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-environment-disposition-')); + temporaryDirectories.push(mosaicHome); + const agentEnvDir = join(mosaicHome, 'fleet', 'agents'); + await mkdir(agentEnvDir, { recursive: true, mode: 0o700 }); + await chmod(mosaicHome, 0o700); + await chmod(join(mosaicHome, 'fleet'), 0o700); + await chmod(agentEnvDir, 0o700); + if (legacySource !== undefined) { + await writeFile(join(agentEnvDir, 'coder0.env'), legacySource, { mode: 0o600 }); + } + + const preview = await previewV1ToV2Migration({ + source: v1Source, + decisions: decisions(), + observations: observations(), + personaDirs: dirs, + environment: { mosaicHome, agentEnvDir }, + }); + + expect(preview.environment).toContainEqual( + expect.objectContaining({ + agent: 'coder0', + legacy: expectedLegacy, + relocatedKeys: [...relocatedKeys], + }), + ); + }, + ); + + it('preflights legacy env relocation and quarantine without exposing values or changing files', async () => { + const dirs = await semanticDirs(); + const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-migration-home-')); + temporaryDirectories.push(mosaicHome); + const fleetDir = join(mosaicHome, 'fleet'); + const agentEnvDir = join(fleetDir, 'agents'); + await mkdir(agentEnvDir, { recursive: true, mode: 0o700 }); + await chmod(mosaicHome, 0o700); + await chmod(fleetDir, 0o700); + await chmod(agentEnvDir, 0o700); + const forbiddenValue = 'run-privileged --token never-print-this'; + const legacyPath = join(agentEnvDir, 'coder0.env'); + await writeFile( + legacyPath, + `MOSAIC_RUNTIME_BIN=/usr/bin/pi\nMOSAIC_AGENT_COMMAND=${forbiddenValue}\n`, + { mode: 0o600 }, + ); + + const preview = await previewV1ToV2Migration({ + source: v1Source.replace('working_directory: /srv/coder0', 'working_directory: ~/src'), + decisions: decisions(), + observations: observations(), + personaDirs: dirs, + environment: { mosaicHome, agentEnvDir }, + }); + const serialized = JSON.stringify(preview); + expect(preview.status).toBe('ready'); + expect(preview.environment).toEqual([ + expect.objectContaining({ + agent: 'coder0', + generated: 'rebuild', + legacy: 'quarantine', + relocatedKeys: ['MOSAIC_RUNTIME_BIN'], + diagnostics: [ + expect.objectContaining({ + code: 'unknown-key', + key: 'MOSAIC_AGENT_COMMAND', + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }), + ], + }), + ]); + expect(serialized).not.toContain(forbiddenValue); + expect( + await import('node:fs/promises').then(({ readFile }) => readFile(legacyPath, 'utf8')), + ).toContain(forbiddenValue); + }); + + it('blocks undeclared v1 fields, synonym collisions, and duplicate names', async () => { + const baseOptions = { + decisions: decisions(), + observations: observations(), + personaDirs: await semanticDirs(), + }; + const unknown = await previewV1ToV2Migration({ + ...baseOptions, + source: v1Source.replace('version: 1', 'version: 1\nunknown_root: value'), + }); + expect(unknown.blockers).toContainEqual( + expect.objectContaining({ code: 'unsupported-v1-field', path: 'unknown_root' }), + ); + + const collision = await previewV1ToV2Migration({ + ...baseOptions, + source: v1Source.replace( + 'socket_name: mosaic-v1', + 'socket_name: mosaic-v1\n socketName: other', + ), + }); + expect(collision.blockers).toContainEqual( + expect.objectContaining({ code: 'v1-synonym-collision' }), + ); + + const duplicate = await previewV1ToV2Migration({ + ...baseOptions, + source: v1Source.replace('name: remote0', 'name: coder0'), + }); + expect(duplicate.blockers).toContainEqual( + expect.objectContaining({ code: 'duplicate-agent-name', agent: 'coder0' }), + ); + }); + + it('rejects extra observations and an observed-running disabled disposition', async () => { + const extra = await previewV1ToV2Migration({ + source: v1Source, + decisions: decisions(), + observations: { ...observations(), remote0: { systemd: 'active', tmux: 'present' } }, + personaDirs: await semanticDirs(), + }); + expect(extra.blockers).toContainEqual( + expect.objectContaining({ code: 'extra-lifecycle-observation', agent: 'remote0' }), + ); + + const base = decisions(); + const coder = base.agents.coder0; + if (!coder) throw new Error('fixture decision missing'); + const disabled = await previewV1ToV2Migration({ + source: v1Source, + decisions: { + ...base, + agents: { ...base.agents, coder0: { ...coder, enabled: false } }, + }, + observations: observations({ systemd: 'active', tmux: 'present' }), + personaDirs: await semanticDirs(), + }); + expect(disabled.blockers).toContainEqual( + expect.objectContaining({ code: 'disabled-running-observation', agent: 'coder0' }), + ); + }); + + it('rejects unknown or malformed decision and observation fields before preview', async () => { + const unknownDecision = decisions() as V1ToV2MigrationDecisions & { unexpected: string }; + unknownDecision.unexpected = 'value'; + await expect( + previewV1ToV2Migration({ + source: v1Source, + decisions: unknownDecision, + observations: observations(), + personaDirs: await semanticDirs(), + }), + ).rejects.toThrow('decisions.unexpected is not supported'); + + await expect( + previewV1ToV2Migration({ + source: v1Source, + decisions: decisions(), + observations: { + coder0: { systemd: 'active', tmux: 'missing', extra: true }, + } as never, + personaDirs: await semanticDirs(), + }), + ).rejects.toThrow('observations.coder0.extra is not supported'); + }); + + it('rejects extra agent decisions and never derives a missing tool policy from class', async () => { + const base = decisions(); + const coder = base.agents.coder0; + if (!coder) throw new Error('fixture decision missing'); + const noToolPolicy = v1Source.replace(' tool_policy: implementer\n', ''); + const blocked = await previewV1ToV2Migration({ + source: noToolPolicy, + decisions: { + ...base, + agents: { ...base.agents, extra0: coder }, + }, + observations: observations(), + personaDirs: await semanticDirs(), + }); + expect(blocked.blockers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'tool-policy-disposition-required', agent: 'coder0' }), + expect.objectContaining({ code: 'extra-agent-decision', agent: 'extra0' }), + ]), + ); + }); + + it('requires no competing disposition for automatic aliases', async () => { + const base = decisions(); + const coder = base.agents.coder0; + if (!coder) throw new Error('fixture decision missing'); + const blocked = await previewV1ToV2Migration({ + source: v1Source, + decisions: { + ...base, + agents: { + coder0: { ...coder, classDisposition: { action: 'replace', className: 'review' } }, + }, + }, + observations: observations(), + personaDirs: await semanticDirs(), + }); + expect(blocked.blockers).toContainEqual( + expect.objectContaining({ code: 'redundant-class-disposition', agent: 'coder0' }), + ); + }); + + it('produces byte-stable evidence for repeated YAML and equivalent JSON input', async () => { + const dirs = await semanticDirs(); + const options = { + source: v1Source, + decisions: decisions(), + observations: observations(), + personaDirs: dirs, + }; + const first = await previewV1ToV2Migration(options); + const second = await previewV1ToV2Migration(options); + expect(second).toEqual(first); + + const parsed = (await import('yaml')).default.parse(v1Source) as unknown; + const json = await previewV1ToV2Migration({ ...options, source: JSON.stringify(parsed) }); + expect(json.canonicalYaml).toBe(first.canonicalYaml); + expect(json.evidence.candidate).toEqual(first.evidence.candidate); + }); + + it('inventories Matrix connectors and remote agents without environment preflight', async () => { + const source = v1Source.replace( + /connector:\n[\s\S]*$/, + 'connector:\n kind: matrix\n matrix:\n homeserver_url: https://matrix.example.test\n user_id: "@bot:example.test"\n room_id: "!room:example.test"\n', + ); + const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-remote-no-preflight-')); + temporaryDirectories.push(mosaicHome); + const agentEnvDir = join(mosaicHome, 'fleet', 'agents'); + await mkdir(agentEnvDir, { recursive: true, mode: 0o700 }); + await chmod(mosaicHome, 0o700); + await chmod(join(mosaicHome, 'fleet'), 0o700); + await chmod(agentEnvDir, 0o700); + await writeFile(join(agentEnvDir, 'remote0.env'), 'MOSAIC_AGENT_COMMAND=never-publish\n', { + mode: 0o644, + }); + const preview = await previewV1ToV2Migration({ + source, + decisions: decisions(), + observations: observations(), + personaDirs: await semanticDirs(), + environment: { mosaicHome, agentEnvDir }, + }); + expect(preview.status).toBe('ready'); + expect(preview.evidence.excludedInventory).toEqual( + expect.arrayContaining(['connector', 'agents.remote0']), + ); + expect(preview.inventory).toContainEqual( + expect.objectContaining({ path: 'connector.matrix.room_id', disposition: 'inventory-only' }), + ); + expect(preview.environment.map(({ agent }) => agent)).toEqual(['coder0']); + }); + + it('blocks malformed schema-only remote and connector fields', async () => { + const source = v1Source + .replace('host: build.example.test', 'host: 42') + .replace('discord:\n channel_id: channel', 'discord: {}'); + const preview = await previewV1ToV2Migration({ + source, + decisions: decisions(), + observations: observations(), + personaDirs: await semanticDirs(), + }); + expect(preview.status).toBe('blocked'); + expect(preview.blockers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[1].host' }), + expect.objectContaining({ + code: 'invalid-v1-field-type', + path: 'connector.discord.channel_id', + }), + ]), + ); + const malformedRoot = await previewV1ToV2Migration({ + source: v1Source.replace( + 'tmux:\n socket_name: mosaic-v1\n holder_session: _holder', + 'tmux: []', + ), + decisions: decisions(), + observations: observations(), + personaDirs: await semanticDirs(), + }); + expect(malformedRoot.blockers).toContainEqual( + expect.objectContaining({ code: 'invalid-v1-field-type', path: 'tmux' }), + ); + + const malformedScalars = await previewV1ToV2Migration({ + source: v1Source + .replace('alias: Coder Zero', 'alias: 42') + .replace('reset_between_tasks: false', 'reset_between_tasks: "false"') + .replace( + ' runtime: pi\n class: reviewer\n host:', + ' runtime: ~\n class: reviewer\n host:', + ), + decisions: decisions(), + observations: observations(), + personaDirs: await semanticDirs(), + }); + expect(malformedScalars.blockers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[0].alias' }), + expect.objectContaining({ + code: 'invalid-v1-field-type', + path: 'agents[0].reset_between_tasks', + }), + expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[1].runtime' }), + ]), + ); + }); + + it('validates all 13 shipped artifacts and executes ready previews for every v1 fixture', async () => { + const dispositions = await validateShippedFleetMigrationDispositions({ + frameworkFleet, + v1Previews: await shippedV1PreviewEvidence(), + }); + expect(dispositions).toHaveLength(13); + expect(dispositions.map(({ path }) => path)).toEqual( + SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }) => path), + ); + await expect( + validateShippedFleetMigrationDispositions({ frameworkFleet, v1Previews: {} }), + ).rejects.toThrow('Missing explicit migration evidence'); + }); + + it('emits an explicit M4 disposition for every shipped M1 inventory entry', () => { + const dispositions = collectShippedFleetMigrationDispositions(); + expect(dispositions.map(({ path }) => path)).toEqual( + SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }) => path), + ); + expect(dispositions).toHaveLength(13); + expect(dispositions.every(({ migrationDisposition }) => migrationDisposition.length > 0)).toBe( + true, + ); + }); +}); diff --git a/packages/mosaic/src/fleet/v1-v2-migration.ts b/packages/mosaic/src/fleet/v1-v2-migration.ts new file mode 100644 index 0000000..fabf259 --- /dev/null +++ b/packages/mosaic/src/fleet/v1-v2-migration.ts @@ -0,0 +1,1390 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import YAML from 'yaml'; +import { canonicalizeRoleClass, type PersonaDirs } from '../commands/fleet-personas.js'; +import { compareCodePoints } from './deterministic-order.js'; +import { + previewAgentEnvironmentProjection, + type AgentEnvironmentDiagnostic, +} from './generated-env-boundary.js'; +import { + SHIPPED_FLEET_ARTIFACT_DISPOSITIONS, + validateShippedFleetArtifactDispositions, + type FleetArtifactDisposition, + type ValidateShippedFleetArtifactDispositionsOptions, +} from './example-profile-dispositions.js'; +import { + parseRosterV2, + renderRosterV2Yaml, + validateRosterV2Semantics, + type FleetRosterV2, + type RosterV2DesiredState, + type RosterV2ReasoningLevel, + type RosterV2RuntimeName, +} from './roster-v2.js'; + +const AUTOMATIC_CLASSES = new Set([ + 'code', + 'review', + 'interaction', + 'merge-gate', + 'validator', + 'orchestrator', + 'team-leader', + 'enhancer', +]); +const V1_RUNTIME_RESETS: Readonly> = Object.freeze({ + claude: '/clear', + codex: '/clear', + opencode: '/clear', + pi: '/new', +}); +const REMOTE_AGENT_INVENTORY_FIELDS = ['host', 'socket', 'ssh'] as const; +const ROOT_FIELDS = new Set([ + 'version', + 'transport', + 'tmux', + 'defaults', + 'runtimes', + 'agents', + 'connector', +]); +const TMUX_FIELDS = new Set(['socket_name', 'socketName', 'holder_session', 'holderSession']); +const DEFAULT_FIELDS = new Set(['working_directory', 'workingDirectory']); +const RUNTIME_FIELDS = new Set(['reset_command', 'resetCommand']); +const DECISION_ROOT_FIELDS = new Set([ + 'generation', + 'fleetHost', + 'socketName', + 'defaultRuntime', + 'agents', +]); +const AGENT_DECISION_FIELDS = new Set([ + 'provider', + 'model', + 'reasoning', + 'enabled', + 'launchYolo', + 'classDisposition', + 'toolPolicyDisposition', + 'kickstartTemplate', + 'persistentPersona', +]); +const CLASS_DISPOSITION_FIELDS = new Set(['action', 'className']); +const OBSERVATION_FIELDS = new Set(['systemd', 'tmux']); +const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; +const AGENT_FIELDS = new Set([ + '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', +]); + +export type MigrationObservationSystemd = 'active' | 'inactive' | 'unknown'; +export type MigrationObservationTmux = 'present' | 'missing'; + +export interface V1MigrationObservation { + readonly systemd: MigrationObservationSystemd; + readonly tmux: MigrationObservationTmux; +} + +export interface V1ClassDisposition { + readonly action: 'preserve' | 'replace'; + readonly className?: string; +} + +export interface V1AgentMigrationDecision { + readonly provider: string; + readonly model: string; + readonly reasoning: RosterV2ReasoningLevel; + readonly enabled: boolean; + readonly launchYolo: boolean; + readonly classDisposition?: V1ClassDisposition; + readonly toolPolicyDisposition?: V1ClassDisposition; + readonly kickstartTemplate?: 'inventory-only'; + readonly persistentPersona?: boolean; +} + +export interface V1ToV2MigrationDecisions { + readonly generation: number; + readonly fleetHost?: string; + readonly socketName?: string; + readonly defaultRuntime?: RosterV2RuntimeName; + readonly agents: Record; +} + +export interface V1ToV2MigrationPreviewOptions { + readonly source: string; + readonly sourcePath?: string; + readonly decisions: V1ToV2MigrationDecisions; + readonly observations: Readonly>; + readonly personaDirs?: PersonaDirs; + readonly environment?: { + readonly mosaicHome: string; + readonly agentEnvDir: string; + }; +} + +export interface MigrationInventoryEntry { + readonly path: string; + readonly disposition: 'candidate' | 'inventory-only' | 'requires-decision'; +} + +export interface MigrationBlocker { + readonly code: string; + readonly path: string; + readonly agent?: string; + readonly detail: string; +} + +export interface InventoryOnlyAgent { + readonly name: string; + readonly fields: readonly string[]; + readonly disposition: 'inventory-only'; +} + +export interface EnvironmentMigrationPreview { + readonly agent: string; + readonly generated: 'rebuild'; + readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine'; + readonly relocatedKeys: readonly string[]; + readonly diagnostics: readonly AgentEnvironmentDiagnostic[]; +} + +export interface V1ToV2MigrationEvidence { + readonly source: { readonly path?: string; readonly sha256: string }; + readonly candidate?: { readonly sha256: string; readonly generation: number }; + readonly observations: readonly { + readonly agent: string; + readonly systemd: MigrationObservationSystemd; + readonly tmux: MigrationObservationTmux; + readonly preservedAs: RosterV2DesiredState; + }[]; + readonly excludedInventory: readonly string[]; + readonly recovery: { + readonly executable: false; + readonly canaryExecuted: false; + readonly rollbackExecuted: false; + readonly requiredArtifacts: readonly string[]; + readonly gateOwner: 'FCM-M4-002'; + }; +} + +export interface V1ToV2MigrationPreview { + readonly status: 'ready' | 'blocked'; + readonly inventory: readonly MigrationInventoryEntry[]; + readonly inventoryOnlyAgents: readonly InventoryOnlyAgent[]; + readonly blockers: readonly MigrationBlocker[]; + readonly canonicalRoster?: FleetRosterV2; + readonly canonicalYaml?: string; + readonly environment: readonly EnvironmentMigrationPreview[]; + readonly evidence: V1ToV2MigrationEvidence; +} + +export interface ShippedFleetMigrationDisposition { + readonly path: string; + readonly currentDisposition: FleetArtifactDisposition; + readonly migrationDisposition: string; +} + +export interface ShippedV1MigrationPreviewEvidence { + readonly decisions: V1ToV2MigrationDecisions; + readonly observations: Readonly>; +} + +export interface ValidateShippedFleetMigrationDispositionsOptions extends ValidateShippedFleetArtifactDispositionsOptions { + readonly v1Previews: Readonly>; +} + +/** Strictly validates the untrusted JSON decision document before migration preview. */ +export function parseV1ToV2MigrationDecisions(value: unknown): V1ToV2MigrationDecisions { + const root = requireRecord(value, 'decisions'); + assertOnlyKeys(root, DECISION_ROOT_FIELDS, 'decisions'); + const generation = root.generation; + if (!Number.isSafeInteger(generation) || (generation as number) <= 0) { + throw new Error('decisions.generation must be a positive safe integer.'); + } + const defaultRuntime = root.defaultRuntime; + if (defaultRuntime !== undefined && !isRuntime(stringValue(defaultRuntime))) { + throw new Error('decisions.defaultRuntime must be a supported runtime.'); + } + const fleetHost = optionalNonEmptyString(root.fleetHost, 'decisions.fleetHost'); + const socketName = optionalNonEmptyString(root.socketName, 'decisions.socketName'); + const rawAgents = requireRecord(root.agents, 'decisions.agents'); + const agents: Record = {}; + for (const [name, rawDecision] of Object.entries(rawAgents)) { + if (!AGENT_NAME_PATTERN.test(name)) { + throw new Error('decisions.agents contains an invalid agent identifier.'); + } + const decision = requireRecord(rawDecision, `decisions.agents.${name}`); + assertOnlyKeys(decision, AGENT_DECISION_FIELDS, `decisions.agents.${name}`); + const provider = requiredNonEmptyString(decision.provider, `decisions.agents.${name}.provider`); + const model = requiredNonEmptyString(decision.model, `decisions.agents.${name}.model`); + const reasoning = decision.reasoning; + if (reasoning !== 'low' && reasoning !== 'medium' && reasoning !== 'high') { + throw new Error(`decisions.agents.${name}.reasoning must be low, medium, or high.`); + } + if (typeof decision.enabled !== 'boolean') { + throw new Error(`decisions.agents.${name}.enabled must be boolean.`); + } + if (typeof decision.launchYolo !== 'boolean') { + throw new Error(`decisions.agents.${name}.launchYolo must be boolean.`); + } + const kickstartTemplate = decision.kickstartTemplate; + if (kickstartTemplate !== undefined && kickstartTemplate !== 'inventory-only') { + throw new Error( + `decisions.agents.${name}.kickstartTemplate must be inventory-only when present.`, + ); + } + if ( + decision.persistentPersona !== undefined && + typeof decision.persistentPersona !== 'boolean' + ) { + throw new Error(`decisions.agents.${name}.persistentPersona must be boolean when present.`); + } + const classDisposition = parseClassDisposition( + decision.classDisposition, + `decisions.agents.${name}.classDisposition`, + ); + const toolPolicyDisposition = parseClassDisposition( + decision.toolPolicyDisposition, + `decisions.agents.${name}.toolPolicyDisposition`, + ); + agents[name] = { + provider, + model, + reasoning, + enabled: decision.enabled, + launchYolo: decision.launchYolo, + ...(classDisposition ? { classDisposition } : {}), + ...(toolPolicyDisposition ? { toolPolicyDisposition } : {}), + ...(kickstartTemplate ? { kickstartTemplate } : {}), + ...(typeof decision.persistentPersona === 'boolean' + ? { persistentPersona: decision.persistentPersona } + : {}), + }; + } + const normalizedDefaultRuntime = stringValue(defaultRuntime); + return { + generation: generation as number, + ...(fleetHost ? { fleetHost } : {}), + ...(socketName ? { socketName } : {}), + ...(isRuntime(normalizedDefaultRuntime) ? { defaultRuntime: normalizedDefaultRuntime } : {}), + agents, + }; +} + +/** Strictly validates the untrusted JSON lifecycle-observation document. */ +export function parseV1MigrationObservations( + value: unknown, +): Readonly> { + const root = requireRecord(value, 'observations'); + const observations: Record = {}; + for (const [name, rawObservation] of Object.entries(root)) { + if (!AGENT_NAME_PATTERN.test(name)) { + throw new Error('observations contains an invalid agent identifier.'); + } + const observation = requireRecord(rawObservation, `observations.${name}`); + assertOnlyKeys(observation, OBSERVATION_FIELDS, `observations.${name}`); + const systemd = observation.systemd; + const tmux = observation.tmux; + if (systemd !== 'active' && systemd !== 'inactive' && systemd !== 'unknown') { + throw new Error(`observations.${name}.systemd has an unsupported state.`); + } + if (tmux !== 'present' && tmux !== 'missing') { + throw new Error(`observations.${name}.tmux has an unsupported state.`); + } + observations[name] = { systemd, tmux }; + } + return observations; +} + +/** + * Builds a complete, non-mutating migration preview. It never runs systemd, + * tmux, a connector, or the reconciler, and it never writes prepared files. + */ +export async function previewV1ToV2Migration( + options: V1ToV2MigrationPreviewOptions, +): Promise { + const decisions = parseV1ToV2MigrationDecisions(options.decisions); + const reviewedObservations = parseV1MigrationObservations(options.observations); + const raw = parseV1Source(options.source); + const inventory = inventorySource(raw, decisions.fleetHost); + const blockers: MigrationBlocker[] = []; + const inventoryOnlyAgents: InventoryOnlyAgent[] = []; + const observations: Array<{ + agent: string; + systemd: MigrationObservationSystemd; + tmux: MigrationObservationTmux; + preservedAs: RosterV2DesiredState; + }> = []; + + assertV1Root(raw, blockers); + validateV1FieldCatalog(raw, blockers); + blockPresentEmptyV1Fields(raw, blockers); + const tmux = record(raw.tmux); + const defaults = record(raw.defaults); + const runtimes = normalizeRuntimes(record(raw.runtimes), blockers); + const hasDeclaredSnakeSocket = Object.hasOwn(tmux, 'socket_name'); + const hasDeclaredCamelSocket = Object.hasOwn(tmux, 'socketName'); + const hasDeclaredSocket = hasDeclaredSnakeSocket || hasDeclaredCamelSocket; + const declaredSocketName = hasDeclaredSnakeSocket + ? trimmedString(tmux.socket_name) + : hasDeclaredCamelSocket + ? trimmedString(tmux.socketName) + : undefined; + const socketName = hasDeclaredSocket ? declaredSocketName : decisions.socketName; + if ( + hasDeclaredSocket && + decisions.socketName !== undefined && + decisions.socketName !== declaredSocketName + ) { + blockers.push( + blocker( + 'socket-name-conflict', + 'decisions.socketName', + 'Decision socket must match the socket declared by the v1 roster.', + ), + ); + } + const rawAgents = Array.isArray(raw.agents) ? raw.agents : []; + const candidateAgents: Record[] = []; + + for (let index = 0; index < rawAgents.length; index += 1) { + const rawAgent = record(rawAgents[index]); + const name = stringValue(rawAgent.name); + const path = `agents[${index}]`; + if (!name) { + blockers.push(blocker('invalid-agent-name', `${path}.name`, 'Agent name is required.')); + continue; + } + const locality = classifyAgentLocality(rawAgent, decisions.fleetHost); + const localityFields = REMOTE_AGENT_INVENTORY_FIELDS.filter( + (field) => rawAgent[field] !== undefined, + ); + if (locality === 'remote') { + inventoryOnlyAgents.push({ + name, + fields: [...localityFields].sort(compareCodePoints), + disposition: 'inventory-only', + }); + continue; + } + if (locality === 'ambiguous') { + blockers.push( + blocker( + 'agent-locality-disposition-required', + `${path}.host`, + 'Agent locality is ambiguous or contradicts the reviewed fleet-host identity.', + name, + ), + ); + } + const agentSocket = stringValue(rawAgent.socket); + if (agentSocket && (!socketName || agentSocket !== socketName)) { + blockers.push( + blocker( + 'agent-socket-disposition-required', + `${path}.socket`, + 'Local agent socket must match the canonical v2 fleet socket or receive an explicit future disposition.', + name, + ), + ); + } + + const decision = decisions.agents[name]; + if (!decision) { + blockers.push( + blocker( + 'agent-disposition-required', + path, + 'Local agent requires explicit migration decisions.', + name, + ), + ); + continue; + } + + const runtime = stringValue(rawAgent.runtime); + if (!isRuntime(runtime)) { + blockers.push( + blocker('unsupported-runtime', `${path}.runtime`, 'Runtime must be supported.', name), + ); + } + const requestedClass = stringValue(rawAgent.class) || 'worker'; + const className = resolveClass( + requestedClass, + decision.classDisposition, + `${path}.class`, + name, + blockers, + ); + const requestedToolPolicy = + stringValue(rawAgent.tool_policy) || stringValue(rawAgent.toolPolicy); + const toolPolicy = resolveToolPolicy( + requestedToolPolicy, + decision.toolPolicyDisposition, + `${path}.tool_policy`, + name, + blockers, + ); + const observation = reviewedObservations[name]; + const desiredState = resolveLifecycle(observation, path, name, blockers); + if (desiredState && observation) { + if (desiredState === 'running' && !decision.enabled) { + blockers.push( + blocker( + 'disabled-running-observation', + `${path}.lifecycle`, + 'Observed-running agent cannot migrate as disabled.', + name, + ), + ); + } + observations.push({ agent: name, ...observation, preservedAs: desiredState }); + } + const persistentRaw = rawAgent.persistent_persona ?? rawAgent.persistentPersona; + let persistentPersona: boolean; + if (typeof persistentRaw === 'boolean') persistentPersona = persistentRaw; + else if (persistentRaw === undefined) persistentPersona = decision.persistentPersona ?? false; + else if (decision.persistentPersona !== undefined) + persistentPersona = decision.persistentPersona; + else { + blockers.push( + blocker( + 'persistent-persona-disposition-required', + `${path}.persistent_persona`, + 'String persistent_persona requires an explicit boolean disposition.', + name, + ), + ); + persistentPersona = false; + } + if ( + (rawAgent.kickstart_template !== undefined || rawAgent.kickstartTemplate !== undefined) && + decision.kickstartTemplate !== 'inventory-only' + ) { + blockers.push( + blocker( + 'kickstart-template-disposition-required', + `${path}.kickstart_template`, + 'kickstart_template has no v2 field and must be explicitly retained as inventory-only.', + name, + ), + ); + } + + if (!runtime || !className || !toolPolicy || !desiredState) continue; + candidateAgents.push({ + name, + alias: stringValue(rawAgent.alias) || name, + class: className, + runtime, + provider: decision.provider, + model: decision.model, + reasoning: decision.reasoning, + tool_policy: toolPolicy, + working_directory: + stringValue(rawAgent.working_directory) || + stringValue(rawAgent.workingDirectory) || + stringValue(defaults.working_directory) || + stringValue(defaults.workingDirectory) || + '~/src', + persistent_persona: persistentPersona, + reset_between_tasks: + booleanValue(rawAgent.reset_between_tasks) ?? + booleanValue(rawAgent.resetBetweenTasks) ?? + false, + lifecycle: { enabled: decision.enabled, desired_state: desiredState }, + launch: { yolo: decision.launchYolo }, + }); + } + + const localAgentNames = new Set( + rawAgents + .map((candidate) => record(candidate)) + .filter((candidate) => classifyAgentLocality(candidate, decisions.fleetHost) !== 'remote') + .map((candidate) => stringValue(candidate.name)) + .filter((name): name is string => name !== undefined), + ); + for (const decisionName of Object.keys(decisions.agents)) { + if (!localAgentNames.has(decisionName)) { + blockers.push( + blocker( + 'extra-agent-decision', + `decisions.agents.${decisionName}`, + 'Decision does not correspond to a local migration candidate.', + decisionName, + ), + ); + } + } + for (const observedName of Object.keys(reviewedObservations)) { + if (!localAgentNames.has(observedName)) { + blockers.push( + blocker( + 'extra-lifecycle-observation', + `observations.${observedName}`, + 'Observation does not correspond to a local migration candidate.', + observedName, + ), + ); + } + } + + if (!decisions.defaultRuntime) { + blockers.push( + blocker( + 'default-runtime-disposition-required', + 'defaults.runtime', + 'v1 has no default runtime; an explicit v2 default is required.', + ), + ); + } + if (socketName === undefined) { + blockers.push( + blocker( + 'named-socket-disposition-required', + 'tmux.socket_name', + 'A socket decision is required only when both supported v1 socket fields are absent.', + ), + ); + } + if (candidateAgents.length === 0) { + blockers.push( + blocker('no-local-candidates', 'agents', 'No local agents are eligible for v2 output.'), + ); + } + + let canonicalRoster: FleetRosterV2 | undefined; + let canonicalYaml: string | undefined; + const environment: EnvironmentMigrationPreview[] = []; + + if (blockers.length === 0 && socketName !== undefined && decisions.defaultRuntime) { + const defaultRuntime = decisions.defaultRuntime; + try { + canonicalRoster = parseRosterV2( + YAML.stringify({ + version: 2, + generation: decisions.generation, + transport: 'tmux', + tmux: { + socket_name: socketName, + holder_session: + stringValue(tmux.holder_session) || stringValue(tmux.holderSession) || '_holder', + }, + defaults: { + working_directory: + stringValue(defaults.working_directory) || + stringValue(defaults.workingDirectory) || + '~/src', + runtime: defaultRuntime, + }, + runtimes, + agents: candidateAgents, + }), + 'yaml', + ); + canonicalYaml = renderRosterV2Yaml(canonicalRoster); + await validateRosterV2Semantics(canonicalRoster, options.personaDirs); + if (options.environment) { + for (const agent of canonicalRoster.agents) { + try { + const prepared = await previewAgentEnvironmentProjection({ + ...options.environment, + agentName: agent.name, + generated: generatedValues(canonicalRoster, agent), + }); + environment.push({ + agent: agent.name, + generated: 'rebuild', + legacy: prepared.legacy, + relocatedKeys: prepared.relocatedKeys, + diagnostics: prepared.diagnostics, + }); + } catch (error: unknown) { + const diagnostic = safeEnvironmentDiagnostic(error); + blockers.push({ + code: 'environment-preflight-failed', + path: `agents.${agent.name}.environment`, + agent: agent.name, + detail: diagnostic, + }); + } + } + } + } catch { + blockers.push( + blocker( + 'v2-compilation-failed', + 'candidate', + 'Candidate failed canonical v2 compilation or semantic validation.', + ), + ); + canonicalRoster = undefined; + canonicalYaml = undefined; + } + } + + const status = blockers.length === 0 ? 'ready' : 'blocked'; + return { + status, + inventory, + inventoryOnlyAgents, + blockers, + ...(status === 'ready' && canonicalRoster && canonicalYaml + ? { canonicalRoster, canonicalYaml } + : {}), + environment, + evidence: { + source: { + ...(options.sourcePath ? { path: options.sourcePath } : {}), + sha256: sha256(options.source), + }, + ...(status === 'ready' && canonicalYaml + ? { candidate: { sha256: sha256(canonicalYaml), generation: decisions.generation } } + : {}), + observations, + excludedInventory: [ + ...(raw.connector === undefined ? [] : ['connector']), + ...inventoryOnlyAgents.map(({ name }): string => `agents.${name}`), + ], + recovery: { + executable: false, + canaryExecuted: false, + rollbackExecuted: false, + requiredArtifacts: [ + 'v1-roster-backup', + 'environment-backup', + 'observed-lifecycle-evidence', + 'candidate-v2-roster', + ], + gateOwner: 'FCM-M4-002', + }, + }, + }; +} + +/** + * Runs the existing executable M1 guard before emitting the exact M4 migration posture. + * New, removed, or invalid shipped artifacts therefore fail closed through one inventory owner. + */ +export async function validateShippedFleetMigrationDispositions( + options: ValidateShippedFleetMigrationDispositionsOptions, +): Promise { + await validateShippedFleetArtifactDispositions(options); + const v1Fixtures = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter( + ({ disposition }): boolean => disposition === 'v1-fixture', + ); + const expected = new Set(v1Fixtures.map(({ path }): string => path)); + for (const supplied of Object.keys(options.v1Previews)) { + if (!expected.has(supplied)) { + throw new Error('Migration evidence was supplied for an unknown v1 fixture.'); + } + } + for (const artifact of v1Fixtures) { + const evidence = options.v1Previews[artifact.path]; + if (!evidence) throw new Error(`Missing explicit migration evidence for ${artifact.path}.`); + const source = await readFile(join(options.frameworkFleet, artifact.path), 'utf8'); + const preview = await previewV1ToV2Migration({ + source, + sourcePath: artifact.path, + decisions: evidence.decisions, + observations: evidence.observations, + personaDirs: { + rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'), + overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'), + }, + }); + if (preview.status !== 'ready') { + throw new Error( + `Shipped v1 fixture migration preview is blocked: ${artifact.path} (${preview.blockers + .map(({ code, path }) => `${code}:${path}`) + .join(', ')}).`, + ); + } + } + return collectShippedFleetMigrationDispositions(); +} + +/** Adds M4 migration posture to the exact executable M1 artifact inventory. */ +export function collectShippedFleetMigrationDispositions(): readonly ShippedFleetMigrationDisposition[] { + return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map((artifact) => ({ + path: artifact.path, + currentDisposition: artifact.disposition, + migrationDisposition: + artifact.disposition === 'v1-fixture' + ? 'preview-with-explicit-class-and-lifecycle-evidence' + : artifact.disposition === 'canonical-profile' + ? 'retain-through-shared-persona-resolver' + : 'retain-generic-policy-with-approved-tool-policy-alias', + })); +} + +function parseV1Source(source: string): Record { + let parsed: unknown; + try { + parsed = source.trimStart().startsWith('{') ? JSON.parse(source) : YAML.parse(source); + } catch (error: unknown) { + throw new Error( + `V1 roster parse failed: ${error instanceof Error ? error.message : 'unknown parse error'}`, + ); + } + return record(parsed); +} + +type AgentLocality = 'local' | 'remote' | 'ambiguous'; + +function classifyAgentLocality( + agent: Record, + fleetHost: string | undefined, +): AgentLocality { + const host = stringValue(agent.host); + const ssh = stringValue(agent.ssh); + const hasHost = agent.host !== undefined; + const hasSsh = agent.ssh !== undefined; + if (!hasHost && !hasSsh) return 'local'; + if (!host || !fleetHost) return 'ambiguous'; + if (hasSsh) { + if (!ssh) return 'ambiguous'; + const separator = ssh.lastIndexOf('@'); + const sshHost = separator >= 0 ? ssh.slice(separator + 1) : undefined; + if (!sshHost || sshHost !== host) return 'ambiguous'; + } + return host === fleetHost ? 'local' : 'remote'; +} + +function inventorySource( + value: Record, + fleetHost: string | undefined, +): readonly MigrationInventoryEntry[] { + const paths: MigrationInventoryEntry[] = []; + walkInventory(value, '', paths); + const remoteAgentPrefixes = (Array.isArray(value.agents) ? value.agents : []) + .map((agent, index): string | undefined => + classifyAgentLocality(record(agent), fleetHost) === 'remote' ? `agents[${index}]` : undefined, + ) + .filter((prefix): prefix is string => prefix !== undefined); + return paths + .map( + (entry): MigrationInventoryEntry => + remoteAgentPrefixes.some( + (prefix) => entry.path === prefix || entry.path.startsWith(`${prefix}.`), + ) + ? { ...entry, disposition: 'inventory-only' } + : entry, + ) + .sort((left, right) => compareCodePoints(left.path, right.path)); +} + +function walkInventory(value: unknown, path: string, output: MigrationInventoryEntry[]): void { + if (Array.isArray(value)) { + value.forEach((entry, index) => walkInventory(entry, `${path}[${index}]`, output)); + return; + } + if (typeof value === 'object' && value !== null) { + for (const [key, entry] of Object.entries(value as Record)) { + const entryPath = path ? `${path}.${key}` : key; + output.push(inventoryEntry(entryPath)); + if (typeof entry === 'object' && entry !== null) walkInventory(entry, entryPath, output); + } + } +} + +function inventoryEntry(path: string): MigrationInventoryEntry { + const inventoryOnly = path === 'connector' || path.startsWith('connector.'); + const requiresDecision = + path.endsWith('.kickstart_template') || path.endsWith('.kickstartTemplate'); + return { + path, + disposition: inventoryOnly + ? 'inventory-only' + : requiresDecision + ? 'requires-decision' + : 'candidate', + }; +} + +function blockPresentEmptyV1Fields( + raw: Record, + blockers: MigrationBlocker[], +): void { + const tmux = record(raw.tmux); + blockPresentEmptyField(tmux, ['holder_session', 'holderSession'], 'tmux', blockers); + const defaults = record(raw.defaults); + blockPresentEmptyField(defaults, ['working_directory', 'workingDirectory'], 'defaults', blockers); + for (const [runtimeName, runtimeValue] of Object.entries(record(raw.runtimes))) { + blockPresentEmptyField( + record(runtimeValue), + ['reset_command', 'resetCommand'], + `runtimes.${runtimeName}`, + blockers, + ); + } + for (const [index, agentValue] of (Array.isArray(raw.agents) ? raw.agents : []).entries()) { + blockPresentEmptyField(record(agentValue), ['alias'], `agents[${index}]`, blockers); + blockPresentEmptyField( + record(agentValue), + ['working_directory', 'workingDirectory'], + `agents[${index}]`, + blockers, + ); + } +} + +function blockPresentEmptyField( + value: Record, + aliases: readonly string[], + path: string, + blockers: MigrationBlocker[], +): void { + for (const field of aliases) { + if (Object.hasOwn(value, field) && trimmedString(value[field]) === '') { + blockers.push( + blocker( + 'empty-v1-field-disposition-required', + `${path}.${field}`, + 'An explicit empty v1 value cannot be silently replaced by a migration default.', + ), + ); + } + } +} + +function validateV1FieldCatalog(raw: Record, blockers: MigrationBlocker[]): void { + rejectUnknownKeys(raw, ROOT_FIELDS, 'root', blockers); + requireObjectWhenPresent(raw.tmux, 'tmux', blockers); + requireObjectWhenPresent(raw.defaults, 'defaults', blockers); + requireObjectWhenPresent(raw.runtimes, 'runtimes', blockers); + const tmux = record(raw.tmux); + rejectUnknownKeys(tmux, TMUX_FIELDS, 'tmux', blockers); + rejectOptionalStringFields(tmux, [...TMUX_FIELDS], 'tmux', blockers); + rejectSynonymCollision(tmux, 'socket_name', 'socketName', 'tmux', blockers); + rejectSynonymCollision(tmux, 'holder_session', 'holderSession', 'tmux', blockers); + const defaults = record(raw.defaults); + rejectUnknownKeys(defaults, DEFAULT_FIELDS, 'defaults', blockers); + rejectOptionalStringFields(defaults, [...DEFAULT_FIELDS], 'defaults', blockers, true); + rejectSynonymCollision(defaults, 'working_directory', 'workingDirectory', 'defaults', blockers); + for (const [runtimeName, runtimeValue] of Object.entries(record(raw.runtimes))) { + requireObjectWhenPresent(runtimeValue, `runtimes.${runtimeName}`, blockers); + const runtime = record(runtimeValue); + rejectUnknownKeys(runtime, RUNTIME_FIELDS, `runtimes.${runtimeName}`, blockers); + rejectOptionalStringFields(runtime, [...RUNTIME_FIELDS], `runtimes.${runtimeName}`, blockers); + rejectSynonymCollision( + runtime, + 'reset_command', + 'resetCommand', + `runtimes.${runtimeName}`, + blockers, + ); + } + const names = new Set(); + for (const [index, agentValue] of (Array.isArray(raw.agents) ? raw.agents : []).entries()) { + requireObjectWhenPresent(agentValue, `agents[${index}]`, blockers); + const agent = record(agentValue); + const path = `agents[${index}]`; + rejectUnknownKeys(agent, AGENT_FIELDS, path, blockers); + rejectOptionalStringFields( + agent, + [ + 'name', + 'alias', + 'provider', + 'runtime', + 'class', + 'working_directory', + 'workingDirectory', + 'model_hint', + 'modelHint', + 'reasoning_level', + 'reasoningLevel', + 'tool_policy', + 'toolPolicy', + 'kickstart_template', + 'kickstartTemplate', + ], + path, + blockers, + ); + const name = stringValue(agent.name); + if (!name || !AGENT_NAME_PATTERN.test(name)) { + blockers.push( + blocker('invalid-v1-field-type', `${path}.name`, 'A valid agent name is required.'), + ); + } + if (stringValue(agent.runtime) === undefined) { + blockers.push( + blocker('invalid-v1-field-type', `${path}.runtime`, 'Agent runtime is required.'), + ); + } + const resetBetweenTasks = agent.reset_between_tasks ?? agent.resetBetweenTasks; + if (resetBetweenTasks !== undefined && typeof resetBetweenTasks !== 'boolean') { + blockers.push( + blocker( + 'invalid-v1-field-type', + `${path}.reset_between_tasks`, + 'reset_between_tasks must be boolean when present.', + ), + ); + } + const persistentPersona = agent.persistent_persona ?? agent.persistentPersona; + if ( + persistentPersona !== undefined && + typeof persistentPersona !== 'boolean' && + typeof persistentPersona !== 'string' + ) { + blockers.push( + blocker( + 'invalid-v1-field-type', + `${path}.persistent_persona`, + 'persistent_persona must be boolean or string when present.', + ), + ); + } + for (const key of REMOTE_AGENT_INVENTORY_FIELDS) { + if (agent[key] !== undefined && stringValue(agent[key]) === undefined) { + blockers.push( + blocker( + 'invalid-v1-field-type', + `${path}.${key}`, + 'Remote inventory field must be a non-empty string.', + ), + ); + } + } + for (const [snake, camel] of [ + ['working_directory', 'workingDirectory'], + ['model_hint', 'modelHint'], + ['reasoning_level', 'reasoningLevel'], + ['tool_policy', 'toolPolicy'], + ['persistent_persona', 'persistentPersona'], + ['reset_between_tasks', 'resetBetweenTasks'], + ['kickstart_template', 'kickstartTemplate'], + ] as const) { + rejectSynonymCollision(agent, snake, camel, path, blockers); + } + if (name && names.has(name)) { + blockers.push( + blocker('duplicate-agent-name', `${path}.name`, 'Agent name is duplicated.', name), + ); + } + if (name) names.add(name); + } + requireObjectWhenPresent(raw.connector, 'connector', blockers); + validateConnector(record(raw.connector), raw.connector !== undefined, blockers); +} + +function validateConnector( + connector: Record, + present: boolean, + blockers: MigrationBlocker[], +): void { + if (!present) return; + rejectUnknownKeys(connector, new Set(['kind', 'matrix', 'discord']), 'connector', blockers); + const kind = stringValue(connector.kind); + if (!kind || !['tmux', 'discord', 'matrix'].includes(kind)) { + blockers.push(blocker('invalid-connector', 'connector.kind', 'Unsupported connector kind.')); + } + if (kind === 'matrix' && connector.matrix === undefined) { + blockers.push( + blocker('invalid-connector', 'connector.matrix', 'Matrix connector data is required.'), + ); + } + if (kind === 'discord' && connector.discord === undefined) { + blockers.push( + blocker('invalid-connector', 'connector.discord', 'Discord connector data is required.'), + ); + } + if (connector.matrix !== undefined) { + requireObjectWhenPresent(connector.matrix, 'connector.matrix', blockers); + const matrix = record(connector.matrix); + rejectUnknownKeys( + matrix, + new Set(['homeserver_url', 'user_id', 'room_id']), + 'connector.matrix', + blockers, + ); + rejectMissingStringFields( + matrix, + ['homeserver_url', 'user_id', 'room_id'], + 'connector.matrix', + blockers, + ); + } + if (connector.discord !== undefined) { + requireObjectWhenPresent(connector.discord, 'connector.discord', blockers); + const discord = record(connector.discord); + rejectUnknownKeys(discord, new Set(['channel_id']), 'connector.discord', blockers); + rejectMissingStringFields(discord, ['channel_id'], 'connector.discord', blockers); + } +} + +function requireObjectWhenPresent( + value: unknown, + path: string, + blockers: MigrationBlocker[], +): void { + if ( + value !== undefined && + (typeof value !== 'object' || value === null || Array.isArray(value)) + ) { + blockers.push(blocker('invalid-v1-field-type', path, 'Field must be an object when present.')); + } +} + +function rejectOptionalStringFields( + value: Record, + fields: readonly string[], + path: string, + blockers: MigrationBlocker[], + allowYamlTilde = false, +): void { + for (const field of fields) { + const requested = value[field]; + if ( + requested !== undefined && + typeof requested !== 'string' && + !(allowYamlTilde && requested === null) + ) { + blockers.push( + blocker( + 'invalid-v1-field-type', + `${path}.${field}`, + 'Field must be a string when present.', + ), + ); + } + } +} + +function rejectMissingStringFields( + value: Record, + required: readonly string[], + path: string, + blockers: MigrationBlocker[], +): void { + for (const key of required) { + if (stringValue(value[key]) === undefined) { + blockers.push( + blocker( + 'invalid-v1-field-type', + `${path}.${key}`, + 'Required inventory field must be a non-empty string.', + ), + ); + } + } +} + +function rejectUnknownKeys( + value: Record, + allowed: ReadonlySet, + path: string, + blockers: MigrationBlocker[], +): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + blockers.push( + blocker( + 'unsupported-v1-field', + path === 'root' ? key : `${path}.${key}`, + 'Field is not declared by the shipped v1 contract.', + ), + ); + } + } +} + +function rejectSynonymCollision( + value: Record, + snake: string, + camel: string, + path: string, + blockers: MigrationBlocker[], +): void { + if (value[snake] !== undefined && value[camel] !== undefined) { + blockers.push( + blocker( + 'v1-synonym-collision', + `${path}.${snake}`, + `Both ${snake} and ${camel} are present; precedence is not inferred.`, + ), + ); + } +} + +function assertV1Root(raw: Record, blockers: MigrationBlocker[]): void { + if (raw.version !== 1) + blockers.push(blocker('invalid-v1-version', 'version', 'Version must be 1.')); + if (raw.transport !== 'tmux') { + blockers.push(blocker('invalid-v1-transport', 'transport', 'Transport must be tmux.')); + } + if (!Array.isArray(raw.agents) || raw.agents.length === 0) { + blockers.push(blocker('invalid-v1-agents', 'agents', 'At least one v1 agent is required.')); + } +} + +function normalizeRuntimes( + raw: Record, + blockers: MigrationBlocker[], +): Record { + const result: Record = Object.fromEntries( + Object.entries(V1_RUNTIME_RESETS).map(([name, resetCommand]) => [ + name, + { reset_command: resetCommand }, + ]), + ); + for (const [name, value] of Object.entries(raw)) { + if (!isRuntime(name)) { + blockers.push(blocker('unsupported-runtime', `runtimes.${name}`, 'Unsupported v2 runtime.')); + continue; + } + const runtime = record(value); + const resetCommand = + stringValue(runtime.reset_command) || + stringValue(runtime.resetCommand) || + V1_RUNTIME_RESETS[name]; + result[name] = { reset_command: resetCommand }; + } + return result; +} + +function resolveClass( + requested: string, + disposition: V1ClassDisposition | undefined, + path: string, + agent: string, + blockers: MigrationBlocker[], +): string | undefined { + const canonical = canonicalizeRoleClass(requested).canonicalClass; + if (canonical !== requested || AUTOMATIC_CLASSES.has(canonical)) { + if (disposition !== undefined) { + blockers.push( + blocker( + 'redundant-class-disposition', + path, + 'Automatically canonical classes must not carry a competing disposition.', + agent, + ), + ); + } + return canonical; + } + if (!disposition) { + blockers.push( + blocker( + 'class-disposition-required', + path, + 'Noncanonical class requires explicit preserve or replacement disposition.', + agent, + ), + ); + return undefined; + } + if (disposition.action === 'preserve') { + if (disposition.className !== undefined && disposition.className !== requested) { + blockers.push( + blocker( + 'invalid-class-disposition', + path, + 'Preserve disposition cannot change the requested class.', + agent, + ), + ); + return undefined; + } + return requested; + } + if (!disposition.className || disposition.className === requested) { + blockers.push( + blocker( + 'invalid-class-disposition', + path, + 'Replace disposition requires a different non-empty class.', + agent, + ), + ); + return undefined; + } + return canonicalizeRoleClass(disposition.className).canonicalClass; +} + +function resolveToolPolicy( + requested: string | undefined, + disposition: V1ClassDisposition | undefined, + path: string, + agent: string, + blockers: MigrationBlocker[], +): string | undefined { + if (requested === undefined) { + if (disposition?.action !== 'replace' || !disposition.className) { + blockers.push( + blocker( + 'tool-policy-disposition-required', + path, + 'Missing v1 tool_policy requires an explicit replacement; class is not inferred.', + agent, + ), + ); + return undefined; + } + return canonicalizeRoleClass(disposition.className).canonicalClass; + } + return resolveClass(requested, disposition, path, agent, blockers); +} + +function resolveLifecycle( + observation: V1MigrationObservation | undefined, + path: string, + agent: string, + blockers: MigrationBlocker[], +): RosterV2DesiredState | undefined { + if (!observation) { + blockers.push( + blocker( + 'missing-lifecycle-observation', + `${path}.lifecycle`, + 'Explicit systemd and tmux observation is required.', + agent, + ), + ); + return undefined; + } + if (observation.systemd === 'active' && observation.tmux === 'present') return 'running'; + if (observation.systemd === 'inactive' && observation.tmux === 'missing') return 'stopped'; + blockers.push( + blocker( + 'ambiguous-lifecycle-observation', + `${path}.lifecycle`, + 'Systemd and tmux evidence is unknown or contradictory.', + agent, + ), + ); + return undefined; +} + +function generatedValues( + roster: FleetRosterV2, + agent: FleetRosterV2['agents'][number], +): Readonly> { + return { + MOSAIC_AGENT_NAME: agent.name, + MOSAIC_AGENT_CLASS: agent.className, + MOSAIC_AGENT_RUNTIME: agent.runtime, + MOSAIC_AGENT_MODEL: agent.model, + MOSAIC_AGENT_REASONING: agent.reasoning, + MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy, + MOSAIC_AGENT_WORKDIR: expandHome(agent.workingDirectory), + MOSAIC_TMUX_SOCKET: roster.tmux.socketName, + }; +} + +function expandHome(path: string): string { + return path === '~' ? homedir() : path.startsWith('~/') ? join(homedir(), path.slice(2)) : path; +} + +function safeEnvironmentDiagnostic(error: unknown): string { + if ( + typeof error === 'object' && + error !== null && + 'diagnostic' in error && + typeof error.diagnostic === 'object' && + error.diagnostic !== null + ) { + const diagnostic = error.diagnostic as Partial; + return `code=${diagnostic.code ?? 'unknown'} key=${diagnostic.key ?? '(unknown)'} sha256=${diagnostic.sha256 ?? sha256('')}`; + } + return 'Environment projection preflight failed without publishable detail.'; +} + +function parseClassDisposition(value: unknown, path: string): V1ClassDisposition | undefined { + if (value === undefined) return undefined; + const disposition = requireRecord(value, path); + assertOnlyKeys(disposition, CLASS_DISPOSITION_FIELDS, path); + if (disposition.action !== 'preserve' && disposition.action !== 'replace') { + throw new Error(`${path}.action must be preserve or replace.`); + } + const className = optionalNonEmptyString(disposition.className, `${path}.className`); + if (disposition.action === 'replace' && className === undefined) { + throw new Error(`${path}.className is required for replacement.`); + } + return { action: disposition.action, ...(className ? { className } : {}) }; +} + +function requireRecord(value: unknown, path: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object.`); + } + return value as Record; +} + +function assertOnlyKeys( + value: Record, + allowed: ReadonlySet, + path: string, +): void { + const unknown = Object.keys(value).find((key) => !allowed.has(key)); + if (unknown !== undefined) throw new Error(`${path}.${unknown} is not supported.`); +} + +function requiredNonEmptyString(value: unknown, path: string): string { + const normalized = stringValue(value); + if (normalized === undefined) throw new Error(`${path} must be a non-empty string.`); + return normalized; +} + +function optionalNonEmptyString(value: unknown, path: string): string | undefined { + if (value === undefined) return undefined; + return requiredNonEmptyString(value, path); +} + +function blocker(code: string, path: string, detail: string, agent?: string): MigrationBlocker { + return { code, path, ...(agent ? { agent } : {}), detail }; +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function trimmedString(value: unknown): string | undefined { + return typeof value === 'string' ? value.trim() : undefined; +} + +function stringValue(value: unknown): string | undefined { + const trimmed = trimmedString(value); + return trimmed ? trimmed : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function isRuntime(value: string | undefined): value is RosterV2RuntimeName { + return value === 'claude' || value === 'codex' || value === 'opencode' || value === 'pi'; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +}