diff --git a/docs/fleet/how-to/create-update-delete-agent.md b/docs/fleet/how-to/create-update-delete-agent.md new file mode 100644 index 0000000..37d5f44 --- /dev/null +++ b/docs/fleet/how-to/create-update-delete-agent.md @@ -0,0 +1,74 @@ +# Create, Inspect, Update, and Delete a Local Fleet Agent + +Use the local roster-v2 control plane only. These commands change desired state and derived environment projections; they never start, stop, reconcile, inspect, or otherwise act on systemd, tmux, sessions, or runtimes. + +## Read and plan first + +```sh +mosaic fleet get +mosaic fleet plan create --expected-generation --agent '' +mosaic fleet plan update --expected-generation --agent '' +mosaic fleet plan delete --expected-generation +``` + +`plan create` takes the name from `--agent`. `plan update` and `plan delete` require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result. + +Every successful command prints JSON. `get` returns `{ "generation", "agent" }`; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`. + +## Create safely + +```sh +mosaic fleet create --expected-generation 7 --agent '{ + "name":"coder0", + "alias":"Coder 0", + "className":"code", + "runtime":"pi", + "provider":"openai", + "model":"gpt-5.6-sol", + "reasoning":"high", + "toolPolicy":"code", + "workingDirectory":"/srv/mosaic", + "persistentPersona":false, + "resetBetweenTasks":true, + "launch":{"yolo":true} +}' +``` + +Create defaults to `enabled: true` and `desired_state: stopped`. It does not start a process. Add `--persisted-start` only to persist `desired_state: running`; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value. + +## Update and delete safely + +```sh +mosaic fleet update coder0 --expected-generation 8 --agent '' +mosaic fleet delete coder0 --expected-generation 9 +``` + +Updates require a complete agent JSON payload and preserve the stable name. Delete removes only the exact roster-owned `coder0.env.generated` projection. It retains `coder0.env.local`, legacy `coder0.env`, `coder0.env.quarantine`, and every unrelated projection. A delete dry-run leaves all of those files byte-identical. + +## Handle generation conflicts + +Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON `error.code: "stale-generation"` with a non-zero exit. Reload with `mosaic fleet get ` or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock. + +## Interpret partial failures + +The roster is authoritative and is written before derived projections. A late projection I/O failure returns non-zero with redacted, actionable JSON: + +```json +{ + "applied": false, + "authoritativeRoster": "committed", + "projections": "incomplete", + "recovery": { + "code": "projection-apply-failed", + "action": "regenerate-projections-from-roster" + } +} +``` + +This is not a rollback and not a no-op: reload the roster because its generation and membership were committed, regenerate projections from that roster, then plan a new mutation. Recovery output never contains environment values, credentials, or command text. + +## Exit and boundary behavior + +Handled validation errors and partial projection failures exit non-zero. `plan`/`--dry-run` and normal mutation JSON make the state explicit; scripts should use both the exit code and `authoritativeRoster`/`projections`, not `applied` alone. + +The commands operate only on `/fleet/roster.yaml`, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations. diff --git a/docs/fleet/reference/agent-mutations.md b/docs/fleet/reference/agent-mutations.md new file mode 100644 index 0000000..86faaae --- /dev/null +++ b/docs/fleet/reference/agent-mutations.md @@ -0,0 +1,43 @@ +# Local Fleet Agent Mutations + +FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats. + +## CLI contract + +The commands operate only on the canonical `/fleet/roster.yaml` v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and `launch: { "yolo": boolean }`. + +```sh +mosaic fleet get +mosaic fleet plan [name] --expected-generation [--agent ''] [--persisted-start] +mosaic fleet create --expected-generation --agent '' [--dry-run] [--persisted-start] +mosaic fleet update --expected-generation --agent '' [--dry-run] +mosaic fleet delete --expected-generation [--dry-run] +``` + +`get` returns the authoritative generation and the selected agent. `plan create` derives its name from `--agent`; `plan update ` and `plan delete ` require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records `desired_state: running`, but does not start a process. Without it, create records `enabled: true` and `desired_state: stopped`. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code. + +## Generation, validation, and idempotency + +Each create, update, or delete request includes `expectedGeneration`. A request whose expected value differs from the authoritative roster generation fails with `stale-generation`; reload and retry with a newly computed plan. A private mutation lock rejects concurrent writers with `concurrent-mutation`. + +`planFleetAgentMutation` is deterministic and side-effect free. `executeFleetAgentMutation` validates the complete proposed roster through the existing structural and shared persona resolver, prepares generated/local/quarantine projections, and writes the roster authority atomically before applying derived projections. Equivalent create retries and delete requests for an already-absent agent are idempotent no-ops. + +Delete removes only the exact `.env.generated` projection for the removed roster entry. Operator-owned `.env.local`, legacy `.env`, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation. + +## Result and recovery + +Mutation results are JSON-safe objects with `applied`, `authoritativeRoster`, `projections`, `plan`, and—only if a derived projection write fails after the authoritative roster write—a recovery object. `applied` is true only when every roster and derived-projection write completed. The explicit state fields prevent a partial result from being mistaken for a rollback or a no-op: + +```json +{ + "applied": false, + "authoritativeRoster": "committed", + "projections": "incomplete", + "recovery": { + "code": "projection-apply-failed", + "action": "regenerate-projections-from-roster" + } +} +``` + +Dry-runs and idempotent no-ops report `authoritativeRoster: "unchanged"` and `projections: "not-applied"`; a complete mutation reports `"committed"` and `"complete"`. Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation. diff --git a/docs/scratchpads/758-fcm-m2-002-fleet-agent-crud.md b/docs/scratchpads/758-fcm-m2-002-fleet-agent-crud.md new file mode 100644 index 0000000..02201c9 --- /dev/null +++ b/docs/scratchpads/758-fcm-m2-002-fleet-agent-crud.md @@ -0,0 +1,46 @@ +# FCM-M2-002 — Generation-Guarded Fleet Agent CRUD + +- **Task / issue:** FCM-M2-002 / #758 +- **Branch / base:** `feat/758-fleet-agent-crud` from `origin/main` `191efaefeb5c0c6bb218c1292d12ce8e73ace12b` +- **Budget:** 30K card allocation; no deployment or live-fleet actions. + +## Objective + +Provide local roster-owned create, get, update, and delete mutations with a generation precondition, deterministic dry-run plan, complete-state structural/semantic/projection validation before writes, atomic roster persistence, and redacted recovery output on a late projection failure. + +## Scope and exclusions + +- Roster v2 is the sole desired-state authority. Reuse `parseRosterV2`, `renderRosterV2Yaml`, `validateRosterV2Semantics`, and the generated-environment boundary. +- Fresh create defaults to `enabled: true` and `desired_state: stopped`; this card never starts a runtime. +- Excluded: reconcile/apply; lifecycle/session/systemd/tmux actions; migration/canary; remote/connector/gateway mutation; arbitrary commands/channels/secrets; generated files as authority; `docs/TASKS.md` and orchestration ledger changes. + +## Red-first plan + +1. Add failing tests for dry-run non-mutation, stale generation, concurrent writer locking, stopped default create, equivalent idempotency, complete proposed-state semantic/boundary validation, atomic roster write, and injected late projection failure with redacted recovery details. +2. Implement only a roster-v2 CRUD service and file adapter; no legacy `fleet add/remove` behavior expansion. +3. Add operator/reference docs for JSON outcomes, generation retries, recovery, and no-runtime-action boundary. + +## Progress + +- Preflight: clean exact base and no duplicate PR confirmed. +- Intake read: FCM PRD/AC-FCM-03, task row, launch and generated-env boundaries, roster v2/resolver contracts, legacy fleet command behavior, delivery/QA/TypeScript/security/documentation guidance. +- TDD: RED observed for the missing CRUD module. GREEN: focused suite passes 7 tests covering stopped-default create, stale generation, idempotency, dry-run non-mutation, concurrent lock denial, exact stale/absent generated-projection delete cleanup, and redacted late-projection recovery. +- REVIEW-1 remediation: RED observed for absent CLI create/get/update/delete/plan wiring. Added roster-v2-only JSON commands, including safe `get`, read-only planning/dry-run, explicit persisted-start recording (never runtime start), stable handled error codes, and focused CLI coverage. +- REVIEW-2 remediation: RED observed for missing direct fleet-control-plane registration and ambiguous partial late-I/O result. The public surface is now direct `mosaic fleet {create,get,update,delete,plan}` (not root gateway `mosaic agent`); a late filesystem projection failure returns non-zero redacted JSON with `authoritativeRoster: committed` and `projections: incomplete`, proving no rollback/no-op claim. +- REVIEW-3 remediation: RED observed for unnamed update/delete plans and deleted-agent quarantine conflict. `plan [name]` now requires target names only for update/delete; delete validates/removes only exact generated state while retaining local/legacy/quarantine/unrelated files. Actual CLI/filesystem tests cover create/update/delete plans, plan validation/non-mutation, retained artifacts/dry-run bytes, unsafe permission/symlink rejection, and post-roster delete recovery. Added the required operator how-to. +- REVIEW-4 remediation: RED observed that actual Commander create accepted and silently dropped disallowed `command`, `channel`, and `secretRef` keys. The `--agent` object and nested `launch` now use strict own-property allowlists; non-plain/prototype-sensitive shapes and unknown keys fail `invalid-request` before resolver, roster, or projection mutation. Actual Commander plan/create/update tests cover top-level command/channel/secretRef/constructor/prototype/`__proto__` shapes and nested launch unknown fields, byte-identical retained artifacts, non-zero exit, and diagnostics that never echo rejected values. Docs state the same boundary. + +## Risks / assumptions + +- **ASSUMPTION:** M2 mutations operate exclusively on the existing v2 roster contract because generation/lifecycle fields are v2-only; legacy v1 add/remove commands remain unchanged compatibility paths. +- A multi-file roster/projection write cannot be one filesystem rename. The roster is authoritative; a post-roster projection failure returns a redacted recovery plan naming only safe paths/actions, never environment values. + +## Verification evidence + +- `pnpm --filter @mosaicstack/mosaic test -- fleet-agent-crud-command.spec.ts fleet-agent-crud.spec.ts generated-env-boundary.spec.ts` — PASS (48 tests after REVIEW-4 remediation). +- `pnpm --filter @mosaicstack/mosaic test` — PASS (54 files, 794 tests after REVIEW-4 remediation). +- `pnpm --filter @mosaicstack/mosaic lint` — PASS. +- `pnpm --filter @mosaicstack/mosaic typecheck` — PASS. +- `pnpm format:check` and `git diff --check` — PASS. +- Root `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `git diff --check` — PASS after REVIEW-4 remediation; full package test is 54 files / 794 tests. +- REVIEW-4 remediation focused checks are green; fresh full-delta independent review is required before author-green. No commit, push, or PR opened. diff --git a/packages/mosaic/src/commands/fleet-agent-crud-command.spec.ts b/packages/mosaic/src/commands/fleet-agent-crud-command.spec.ts new file mode 100644 index 0000000..463fb36 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-agent-crud-command.spec.ts @@ -0,0 +1,668 @@ +import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, 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 { fleetAgentMutationExitCode } from './fleet-agent-crud-command.js'; +import { applyPreparedAgentEnvironmentProjection } from '../fleet/generated-env-boundary.js'; +import { registerFleetCommand, type FleetCommandDeps } from './fleet.js'; + +const roster = ` +version: 2 +generation: 7 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: /srv/mosaic + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: orchestrator + alias: Orchestrator + class: orchestrator + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: high + tool_policy: orchestrator + working_directory: /srv/mosaic + persistent_persona: true + reset_between_tasks: false + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true +`; + +const agent = { + name: 'coder0', + alias: 'Coder 0', + className: 'code', + runtime: 'pi', + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + toolPolicy: 'code', + workingDirectory: '/srv/mosaic', + persistentPersona: false, + resetBetweenTasks: true, + launch: { yolo: true }, +}; + +let cleanup: string | undefined; + +afterEach(async (): Promise => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (cleanup) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +async function fleetHome(): Promise { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-command-')); + const fleetDir = join(cleanup, 'fleet'); + const agentsDir = join(fleetDir, 'agents'); + const rolesDir = join(fleetDir, 'roles'); + await mkdir(agentsDir, { recursive: true, mode: 0o700 }); + await mkdir(rolesDir, { recursive: true, mode: 0o700 }); + await chmod(cleanup, 0o700); + await chmod(fleetDir, 0o700); + await chmod(agentsDir, 0o700); + await chmod(rolesDir, 0o700); + await writeFile(join(fleetDir, 'roster.yaml'), roster, { mode: 0o600 }); + for (const className of ['orchestrator', 'code']) { + await writeFile( + join(rolesDir, `${className}.md`), + `# ${className}\n\n(\`class: ${className}\`)\n`, + { + mode: 0o600, + }, + ); + } + return cleanup; +} + +function program(mosaicHome: string, deps: FleetCommandDeps = {}): Command { + const result = new Command(); + result.exitOverride(); + registerFleetCommand(result, { ...deps, mosaicHome }); + return result; +} + +function output(): { lines: string[] } { + const lines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((value: string): void => { + lines.push(value); + }); + return { lines }; +} + +describe('mosaic fleet local roster mutations', (): void => { + it('returns non-zero when a projection failure leaves recovery work after roster persistence', (): void => { + expect( + fleetAgentMutationExitCode({ + recovery: { + code: 'projection-apply-failed', + rosterPath: '/private/fleet/roster.yaml', + action: 'regenerate-projections-from-roster', + }, + }), + ).toBe(1); + }); + + it('returns zero for a mutation result without recovery work', (): void => { + expect(fleetAgentMutationExitCode({})).toBe(0); + }); + + it('registers programmatic local create, get, update, delete, and plan commands', async (): Promise => { + const home = await fleetHome(); + const root = program(home); + const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet'); + expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual( + expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']), + ); + }); + + it('runs the JSON mutation commands through the direct mosaic fleet control plane', async (): Promise => { + const home = await fleetHome(); + const root = new Command(); + root.exitOverride(); + registerFleetCommand(root, { mosaicHome: home }); + const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet'); + expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual( + expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']), + ); + }); + + it('gets one v2 roster agent as stable JSON without mutating the roster', async (): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const captured = output(); + + const root = new Command(); + root.exitOverride(); + registerFleetCommand(root, { mosaicHome: home }); + await root.parseAsync(['node', 'mosaic', 'fleet', 'get', 'orchestrator']); + + expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({ + generation: 7, + agent: { name: 'orchestrator', lifecycle: { desiredState: 'stopped' } }, + }); + expect(await readFile(rosterPath, 'utf8')).toBe(roster); + }); + + it('returns a JSON error and non-zero exit for a missing local agent', async (): Promise => { + const home = await fleetHome(); + const captured = output(); + + await program(home).parseAsync(['node', 'mosaic', 'fleet', 'get', 'missing']); + + expect(JSON.parse(captured.lines.join('\n'))).toEqual({ error: { code: 'agent-not-found' } }); + expect(process.exitCode).toBe(1); + }); + + it('prints a deterministic create plan without changing roster or generated projections', async (): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const captured = output(); + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'plan', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + + expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({ + applied: false, + plan: { + operation: 'create', + currentGeneration: 7, + nextGeneration: 8, + agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } }, + }, + }); + expect(await readFile(rosterPath, 'utf8')).toBe(roster); + await expect( + readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'), + ).rejects.toThrow(); + }); + + it('plans create, update, and delete with operation-specific target names without mutation', async (): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const captured = output(); + const updatedCoder = { ...agent, alias: 'Coder Updated' }; + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'plan', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + const beforePlans = await readFile(rosterPath, 'utf8'); + const beforeOrchestratorProjection = await readFile( + join(home, 'fleet', 'agents', 'orchestrator.env.generated'), + 'utf8', + ); + captured.lines.length = 0; + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'plan', + 'update', + 'coder0', + '--expected-generation', + '8', + '--agent', + JSON.stringify(updatedCoder), + ]); + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'plan', + 'delete', + 'coder0', + '--expected-generation', + '8', + ]); + + const plans = captured.lines.map((line: string): unknown => JSON.parse(line)); + expect(plans).toMatchObject([ + { plan: { operation: 'update', agent: { name: 'coder0' } } }, + { plan: { operation: 'delete', agent: { name: 'coder0' } } }, + ]); + expect(await readFile(rosterPath, 'utf8')).toBe(beforePlans); + expect( + await readFile(join(home, 'fleet', 'agents', 'orchestrator.env.generated'), 'utf8'), + ).toBe(beforeOrchestratorProjection); + }); + + it.each([ + ['plan create', ['fleet', 'plan', 'create'], 'top-level'], + ['create', ['fleet', 'create'], 'top-level'], + ['update', ['fleet', 'update', 'orchestrator'], 'top-level'], + ['plan create', ['fleet', 'plan', 'create'], 'launch'], + ['create', ['fleet', 'create'], 'launch'], + ['update', ['fleet', 'update', 'orchestrator'], 'launch'], + ])( + 'rejects unknown and prototype-sensitive agent fields without mutation', + async (_operation: string, command: string[], shape: string): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const agentsDir = join(home, 'fleet', 'agents'); + const captured = output(); + const artifactContents = new Map([ + ['orchestrator.env.generated', 'generated-before\n'], + ['orchestrator.env.local', 'local-before\n'], + ['orchestrator.env', 'legacy-before\n'], + ['orchestrator.env.quarantine', 'quarantine-before\n'], + ]); + for (const [filename, content] of artifactContents) { + await writeFile(join(agentsDir, filename), content, { mode: 0o600 }); + } + const rejectedValue = 'must-not-appear-in-diagnostics'; + const unsafeAgent = { + ...agent, + name: 'orchestrator', + launch: shape === 'launch' ? { yolo: true, command: rejectedValue } : { yolo: true }, + ...(shape === 'top-level' + ? { + command: rejectedValue, + channel: rejectedValue, + secretRef: rejectedValue, + constructor: rejectedValue, + prototype: rejectedValue, + } + : {}), + }; + const payload = + shape === 'top-level' + ? JSON.stringify(unsafeAgent).replace(/}$/, ',"__proto__":{"unsupported":true}}') + : JSON.stringify(unsafeAgent); + + await program(home).parseAsync([ + 'node', + 'mosaic', + ...command, + '--expected-generation', + '7', + '--agent', + payload, + ]); + + const diagnostic = captured.lines.pop() ?? ''; + expect(JSON.parse(diagnostic)).toEqual({ error: { code: 'invalid-request' } }); + expect(diagnostic).not.toContain(rejectedValue); + expect(process.exitCode).toBe(1); + expect(await readFile(rosterPath, 'utf8')).toBe(roster); + for (const [filename, content] of artifactContents) { + expect(await readFile(join(agentsDir, filename), 'utf8')).toBe(content); + } + }, + ); + + it('rejects missing update/delete plan names and an extra create plan name', async (): Promise => { + const home = await fleetHome(); + const captured = output(); + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'plan', + 'delete', + '--expected-generation', + '7', + ]); + expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } }); + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'plan', + 'create', + 'coder0', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } }); + }); + + it('plans persisted start as desired state without starting a runtime', async (): Promise => { + const home = await fleetHome(); + const captured = output(); + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'plan', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + '--persisted-start', + ]); + + expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({ + applied: false, + plan: { agent: { lifecycle: { desiredState: 'running' } } }, + }); + }); + + it('returns non-zero JSON when a filesystem projection fails after roster persistence', async (): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const agentsDir = join(home, 'fleet', 'agents'); + const captured = output(); + + try { + await program(home, { + projectionApplier: async (prepared): Promise => { + await chmod(agentsDir, 0o500); + return applyPreparedAgentEnvironmentProjection(prepared); + }, + }).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + } finally { + await chmod(agentsDir, 0o700); + } + + expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({ + applied: false, + authoritativeRoster: 'committed', + projections: 'incomplete', + recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' }, + }); + expect(process.exitCode).toBe(1); + expect(await readFile(rosterPath, 'utf8')).toContain('generation: 8'); + expect(await readFile(rosterPath, 'utf8')).toContain('name: coder0'); + await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow(); + }); + + it('deletes only the target generated projection while retaining legacy and quarantine artifacts', async (): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const agentsDir = join(home, 'fleet', 'agents'); + const captured = output(); + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', { + mode: 0o600, + }); + await writeFile(join(agentsDir, 'coder0.env'), 'MOSAIC_AGENT_COMMAND=legacy-command\n', { + mode: 0o600, + }); + await writeFile(join(agentsDir, 'coder0.env.quarantine'), 'quarantine-before\n', { + mode: 0o600, + }); + await writeFile(join(agentsDir, 'unrelated.env.generated'), 'unrelated-before\n', { + mode: 0o600, + }); + const beforeDryRun = await Promise.all([ + readFile(rosterPath, 'utf8'), + readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'), + readFile(join(agentsDir, 'coder0.env.local'), 'utf8'), + readFile(join(agentsDir, 'coder0.env'), 'utf8'), + readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'), + readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'), + ]); + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'delete', + 'coder0', + '--expected-generation', + '8', + '--dry-run', + ]); + expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ + applied: false, + authoritativeRoster: 'unchanged', + projections: 'not-applied', + }); + expect( + await Promise.all([ + readFile(rosterPath, 'utf8'), + readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'), + readFile(join(agentsDir, 'coder0.env.local'), 'utf8'), + readFile(join(agentsDir, 'coder0.env'), 'utf8'), + readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'), + readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'), + ]), + ).toEqual(beforeDryRun); + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'delete', + 'coder0', + '--expected-generation', + '8', + ]); + + expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ + applied: true, + authoritativeRoster: 'committed', + projections: 'complete', + }); + await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow(); + expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(beforeDryRun[2]); + expect(await readFile(join(agentsDir, 'coder0.env'), 'utf8')).toBe(beforeDryRun[3]); + expect(await readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8')).toBe(beforeDryRun[4]); + expect(await readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8')).toBe( + beforeDryRun[5], + ); + expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9'); + }); + + it('reports truthful partial recovery when a delete projection fails after roster persistence', async (): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const agentsDir = join(home, 'fleet', 'agents'); + const captured = output(); + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + + try { + captured.lines.length = 0; + await program(home, { + projectionApplier: async (prepared): Promise => { + await chmod(agentsDir, 0o500); + return applyPreparedAgentEnvironmentProjection(prepared); + }, + }).parseAsync(['node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8']); + } finally { + await chmod(agentsDir, 0o700); + } + + expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ + applied: false, + authoritativeRoster: 'committed', + projections: 'incomplete', + recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' }, + }); + expect(process.exitCode).toBe(1); + expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9'); + expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0'); + expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain( + 'MOSAIC_AGENT_NAME=coder0', + ); + }); + + it.each(['unsafe directory permissions', 'symlinked directory'] as const)( + 'rejects delete before roster mutation for %s', + async (hazard: 'unsafe directory permissions' | 'symlinked directory'): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const agentsDir = join(home, 'fleet', 'agents'); + const captured = output(); + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + const beforeRoster = await readFile(rosterPath, 'utf8'); + let generatedPath = join(agentsDir, 'coder0.env.generated'); + + if (hazard === 'unsafe directory permissions') { + await chmod(agentsDir, 0o777); + } else { + const targetDir = join(home, 'fleet', 'agents-target'); + await rename(agentsDir, targetDir); + await symlink(targetDir, agentsDir, 'dir'); + generatedPath = join(targetDir, 'coder0.env.generated'); + } + + try { + captured.lines.length = 0; + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'delete', + 'coder0', + '--expected-generation', + '8', + ]); + } finally { + if (hazard === 'unsafe directory permissions') await chmod(agentsDir, 0o700); + } + + expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ + error: { code: 'mutation-failed' }, + }); + expect(await readFile(rosterPath, 'utf8')).toBe(beforeRoster); + expect(await readFile(generatedPath, 'utf8')).toContain('MOSAIC_AGENT_NAME=coder0'); + }, + ); + + it('creates, updates, and deletes through the v2 roster without runtime actions', async (): Promise => { + const home = await fleetHome(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const agentsDir = join(home, 'fleet', 'agents'); + const captured = output(); + const updated = { ...agent, alias: 'Coder Zero' }; + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'create', + '--expected-generation', + '7', + '--agent', + JSON.stringify(agent), + ]); + expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true }); + expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain( + 'MOSAIC_AGENT_NAME=coder0', + ); + + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'update', + 'coder0', + '--expected-generation', + '8', + '--agent', + JSON.stringify(updated), + ]); + expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ + applied: true, + plan: { nextGeneration: 9, agent: { alias: 'Coder Zero' } }, + }); + + await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', { + mode: 0o600, + }); + await program(home).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'delete', + 'coder0', + '--expected-generation', + '9', + ]); + + expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ + applied: true, + plan: { operation: 'delete', nextGeneration: 10, agent: { name: 'coder0' } }, + }); + await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow(); + expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe( + 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', + ); + expect(await readFile(rosterPath, 'utf8')).toContain('generation: 10'); + expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0'); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-agent-crud-command.ts b/packages/mosaic/src/commands/fleet-agent-crud-command.ts new file mode 100644 index 0000000..b2aac15 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-agent-crud-command.ts @@ -0,0 +1,392 @@ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import type { Command } from 'commander'; +import { + executeFleetAgentMutation, + FleetAgentMutationError, + type FleetAgentMutationAgent, + type FleetAgentMutationOperation, + type FleetAgentMutationOptions, + type FleetAgentMutationRequest, + type FleetAgentMutationResult, +} from '../fleet/fleet-agent-crud.js'; +import { + parseRosterV2, + ROSTER_V2_REASONING_LEVELS, + ROSTER_V2_SUPPORTED_RUNTIMES, + type FleetRosterV2, + type RosterV2ReasoningLevel, + type RosterV2RuntimeName, +} from '../fleet/roster-v2.js'; + +export interface FleetAgentCrudCommandDeps { + readonly mosaicHome?: string; + /** Test seam for deterministic post-roster filesystem projection failures. */ + readonly projectionApplier?: FleetAgentMutationOptions['projectionApplier']; +} + +interface MutationOptions { + readonly expectedGeneration: string; + readonly agent?: string; + readonly dryRun?: boolean; + readonly persistedStart?: boolean; +} + +const AGENT_REQUEST_KEYS = new Set([ + 'name', + 'alias', + 'className', + 'runtime', + 'provider', + 'model', + 'reasoning', + 'toolPolicy', + 'workingDirectory', + 'persistentPersona', + 'resetBetweenTasks', + 'launch', +]); +const AGENT_LAUNCH_REQUEST_KEYS = new Set(['yolo']); + +/** Registers only roster-v2 desired-state mutations; it never invokes runtime actions. */ +export function registerFleetAgentCrudCommands( + agentCommand: Command, + deps: FleetAgentCrudCommandDeps = {}, +): void { + agentCommand + .command('get ') + .description('Read one local roster-v2 agent as JSON') + .action(async (name: string): Promise => { + await writeJsonOutcome(async (): Promise => { + const roster = await loadRoster(agentCommand, deps); + const agent = roster.agents.find((candidate): boolean => candidate.name === name); + if (!agent) + throw new FleetAgentMutationError('agent-not-found', `Agent "${name}" is not in roster.`); + printJson({ generation: roster.generation, agent }); + }); + }); + + registerMutationCommand(agentCommand, deps, 'create'); + registerMutationCommand(agentCommand, deps, 'update'); + registerMutationCommand(agentCommand, deps, 'delete'); + + agentCommand + .command('plan [name]') + .description('Plan a local roster-v2 mutation without writing roster or projections') + .requiredOption('--expected-generation ', 'Authoritative roster generation') + .option('--agent ', 'JSON agent payload for create or update') + .option( + '--persisted-start', + 'Plan create with desired_state: running without starting a runtime', + ) + .action( + async (operation: string, name: string | undefined, opts: MutationOptions): Promise => { + await writeJsonOutcome(async (): Promise => { + const parsedOperation = parseOperation(operation); + await executeCommand( + agentCommand, + deps, + parsedOperation, + opts, + true, + parsePlanTargetName(parsedOperation, name), + ); + }); + }, + ); +} + +function registerMutationCommand( + agentCommand: Command, + deps: FleetAgentCrudCommandDeps, + operation: FleetAgentMutationOperation, +): void { + const command = agentCommand + .command( + operation === 'update' + ? 'update ' + : `${operation}${operation === 'delete' ? ' ' : ''}`, + ) + .description(`${operation} a local roster-v2 agent without runtime actions`) + .requiredOption('--expected-generation ', 'Authoritative roster generation') + .option('--agent ', 'JSON agent payload for create or update') + .option('--dry-run', 'Validate and plan without writing roster or projections'); + + if (operation === 'create') { + command.option( + '--persisted-start', + 'Persist desired_state: running without starting a runtime', + ); + command.action(async (opts: MutationOptions): Promise => { + await writeJsonOutcome(async (): Promise => { + await executeCommand(agentCommand, deps, operation, opts, false); + }); + }); + return; + } + + command.action(async (name: string, opts: MutationOptions): Promise => { + await writeJsonOutcome(async (): Promise => { + await executeCommand(agentCommand, deps, operation, opts, false, name); + }); + }); +} + +async function executeCommand( + agentCommand: Command, + deps: FleetAgentCrudCommandDeps, + operation: FleetAgentMutationOperation, + opts: MutationOptions, + forceDryRun: boolean, + name?: string, +): Promise { + const mosaicHome = resolveMosaicHome(agentCommand, deps); + const rosterPath = resolveRosterPath(agentCommand, mosaicHome); + const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'); + const request = buildRequest(operation, opts, name); + const result = await executeFleetAgentMutation({ + roster, + request, + mosaicHome, + rosterPath, + agentEnvDir: join(mosaicHome, 'fleet', 'agents'), + rolesDir: join(mosaicHome, 'fleet', 'roles'), + overrideDir: join(mosaicHome, 'fleet', 'roles.local'), + dryRun: forceDryRun || opts.dryRun === true, + ...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }), + }); + printJson(result); + process.exitCode = fleetAgentMutationExitCode(result); +} + +/** A persisted roster with failed derived projections is a non-zero CLI outcome. */ +export function fleetAgentMutationExitCode( + result: Pick, +): 0 | 1 { + return result.recovery === undefined ? 0 : 1; +} + +function parsePlanTargetName( + operation: FleetAgentMutationOperation, + name: string | undefined, +): string | undefined { + if (operation === 'create') { + if (name !== undefined) { + throw new FleetAgentMutationError( + 'invalid-request', + 'Create plan does not accept a target name.', + ); + } + return undefined; + } + if (name === undefined) { + throw new FleetAgentMutationError( + 'invalid-request', + `${operation} plan requires a target agent name.`, + ); + } + return name; +} + +function buildRequest( + operation: FleetAgentMutationOperation, + opts: MutationOptions, + name?: string, +): FleetAgentMutationRequest { + const expectedGeneration = parseExpectedGeneration(opts.expectedGeneration); + const agent = opts.agent === undefined ? undefined : parseAgent(opts.agent); + if ((operation === 'create' || operation === 'update') && !agent) { + throw new FleetAgentMutationError('invalid-request', `${operation} requires --agent JSON.`); + } + return { + operation, + expectedGeneration, + ...(name === undefined ? {} : { name }), + ...(agent === undefined ? {} : { agent }), + ...(operation === 'create' && opts.persistedStart === true ? { persistedStart: true } : {}), + }; +} + +function parseExpectedGeneration(value: string): number { + const generation = Number(value); + if (!Number.isSafeInteger(generation) || generation < 1) { + throw new FleetAgentMutationError( + 'invalid-request', + '--expected-generation must be a positive safe integer.', + ); + } + return generation; +} + +function parseOperation(value: string): FleetAgentMutationOperation { + if (value === 'create' || value === 'update' || value === 'delete') return value; + throw new FleetAgentMutationError( + 'invalid-request', + 'plan operation must be create, update, or delete.', + ); +} + +function parseAgent(source: string): FleetAgentMutationAgent { + let value: unknown; + try { + value = JSON.parse(source); + } catch { + throw new FleetAgentMutationError('invalid-request', '--agent must be valid JSON.'); + } + if (!isRecord(value)) { + throw new FleetAgentMutationError('invalid-request', '--agent must be a JSON object.'); + } + rejectUnknownKeys(value, AGENT_REQUEST_KEYS, '--agent'); + + const runtime = requiredRuntime(value, 'runtime'); + const reasoning = requiredReasoning(value, 'reasoning'); + const launch = requiredRecord(value, 'launch'); + rejectUnknownKeys(launch, AGENT_LAUNCH_REQUEST_KEYS, '--agent.launch'); + return { + name: requiredString(value, 'name'), + alias: requiredString(value, 'alias'), + className: requiredString(value, 'className'), + runtime, + provider: requiredString(value, 'provider'), + model: requiredString(value, 'model'), + reasoning, + toolPolicy: requiredString(value, 'toolPolicy'), + workingDirectory: requiredString(value, 'workingDirectory'), + persistentPersona: requiredBoolean(value, 'persistentPersona'), + resetBetweenTasks: requiredBoolean(value, 'resetBetweenTasks'), + launch: { yolo: requiredBoolean(launch, 'yolo') }, + }; +} + +async function loadRoster( + agentCommand: Command, + deps: FleetAgentCrudCommandDeps, +): Promise { + const mosaicHome = resolveMosaicHome(agentCommand, deps); + const rosterPath = resolveRosterPath(agentCommand, mosaicHome); + return parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'); +} + +function resolveMosaicHome(agentCommand: Command, deps: FleetAgentCrudCommandDeps): string { + const opts = agentCommand.optsWithGlobals<{ mosaicHome?: string }>(); + return opts.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic'); +} + +function resolveRosterPath(agentCommand: Command, mosaicHome: string): string { + const opts = agentCommand.optsWithGlobals<{ roster?: string }>(); + const canonical = join(mosaicHome, 'fleet', 'roster.yaml'); + if (opts.roster !== undefined && resolve(opts.roster) !== resolve(canonical)) { + throw new FleetAgentMutationError( + 'invalid-request', + 'Roster-v2 mutations require the canonical /fleet/roster.yaml path.', + ); + } + return canonical; +} + +function rejectUnknownKeys( + value: Record, + allowedKeys: ReadonlySet, + field: string, +): void { + const hasUnsupportedOwnProperty = Object.getOwnPropertyNames(value).some( + (key: string): boolean => !allowedKeys.has(key), + ); + if (hasUnsupportedOwnProperty || Object.getOwnPropertySymbols(value).length > 0) { + throw new FleetAgentMutationError('invalid-request', `${field} contains unsupported keys.`); + } +} + +function requiredString(value: Record, key: string): string { + const candidate = requiredOwnValue(value, key); + if (typeof candidate !== 'string' || candidate.length === 0) { + throw new FleetAgentMutationError( + 'invalid-request', + `--agent.${key} must be a non-empty string.`, + ); + } + return candidate; +} + +function requiredBoolean(value: Record, key: string): boolean { + const candidate = requiredOwnValue(value, key); + if (typeof candidate !== 'boolean') { + throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be a boolean.`); + } + return candidate; +} + +function requiredRecord(value: Record, key: string): Record { + const candidate = requiredOwnValue(value, key); + if (!isRecord(candidate)) { + throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be an object.`); + } + return candidate; +} + +function requiredRuntime(value: Record, key: string): RosterV2RuntimeName { + const candidate = requiredString(value, key); + if (!isRuntime(candidate)) { + throw new FleetAgentMutationError( + 'invalid-request', + `--agent.${key} is not a supported runtime.`, + ); + } + return candidate; +} + +function requiredReasoning(value: Record, key: string): RosterV2ReasoningLevel { + const candidate = requiredString(value, key); + if (!isReasoning(candidate)) { + throw new FleetAgentMutationError( + 'invalid-request', + `--agent.${key} is not a supported reasoning level.`, + ); + } + return candidate; +} + +function isRuntime(value: string): value is RosterV2RuntimeName { + return ROSTER_V2_SUPPORTED_RUNTIMES.some( + (runtime: RosterV2RuntimeName): boolean => runtime === value, + ); +} + +function isReasoning(value: string): value is RosterV2ReasoningLevel { + return ROSTER_V2_REASONING_LEVELS.some( + (reasoning: RosterV2ReasoningLevel): boolean => reasoning === value, + ); +} + +function requiredOwnValue(value: Record, key: string): unknown { + if (!Object.hasOwn(value, key)) { + throw new FleetAgentMutationError('invalid-request', `--agent.${key} is required.`); + } + return value[key]; +} + +function isRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +async function writeJsonOutcome(action: () => Promise): Promise { + try { + await action(); + } catch (error: unknown) { + process.exitCode = 1; + printJson({ + error: { + code: error instanceof FleetAgentMutationError ? error.code : 'mutation-failed', + }, + }); + } +} + +function printJson(value: object): void { + console.log(JSON.stringify(value)); +} diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index c216c1a..eb45726 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -82,10 +82,14 @@ describe('registerFleetCommand', () => { expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ 'add', 'backlog', + 'create', + 'delete', + 'get', 'init', 'install', 'install-systemd', 'persona', + 'plan', 'profile', 'provision', 'ps', @@ -94,6 +98,7 @@ describe('registerFleetCommand', () => { 'start', 'status', 'stop', + 'update', 'verify', ]); }); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 2f6178d..6c35f05 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -18,6 +18,10 @@ import { spawn } from 'node:child_process'; import * as readline from 'node:readline'; import type { Command } from 'commander'; import YAML from 'yaml'; +import { + registerFleetAgentCrudCommands, + type FleetAgentCrudCommandDeps, +} from './fleet-agent-crud-command.js'; import { resolveCommsBlock } from '../fleet/comms-onboarding.js'; import { applyPreparedAgentEnvironmentProjection, @@ -73,6 +77,7 @@ export interface FleetCommandDeps { * Tests stub this to simulate interactive or non-interactive environments. */ isStdinTTY?: boolean; + projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier']; } interface RawFleetRoster { @@ -2074,6 +2079,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = // profile. DRY-RUN by default; --write persists under the same --mosaic-home. registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); + // 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); + return cmd; } diff --git a/packages/mosaic/src/fleet/fleet-agent-crud.spec.ts b/packages/mosaic/src/fleet/fleet-agent-crud.spec.ts new file mode 100644 index 0000000..c475455 --- /dev/null +++ b/packages/mosaic/src/fleet/fleet-agent-crud.spec.ts @@ -0,0 +1,220 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + FleetAgentMutationError, + executeFleetAgentMutation, + planFleetAgentMutation, + type FleetAgentMutationRequest, +} from './fleet-agent-crud.js'; +import { parseRosterV2, renderRosterV2Yaml } from './roster-v2.js'; + +const roster = parseRosterV2(` +version: 2 +generation: 7 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: /srv/mosaic + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: orchestrator + alias: Orchestrator + class: orchestrator + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: high + tool_policy: orchestrator + working_directory: /srv/mosaic + persistent_persona: true + reset_between_tasks: false + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true +`); + +function createRequest(): FleetAgentMutationRequest { + return { + operation: 'create', + expectedGeneration: 7, + agent: { + name: 'coder0', + alias: 'Coder 0', + className: 'code', + runtime: 'pi', + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + toolPolicy: 'code', + workingDirectory: '/srv/mosaic', + persistentPersona: false, + resetBetweenTasks: true, + launch: { yolo: true }, + }, + }; +} + +let cleanup: string | undefined; +afterEach(async (): Promise => { + if (cleanup) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-')); + const rolesDir = join(cleanup, 'roles'); + const overrideDir = join(cleanup, 'roles.local'); + await mkdir(rolesDir, { recursive: true }); + await mkdir(overrideDir, { recursive: true }); + for (const klass of ['orchestrator', 'code']) { + await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`); + } + return { rolesDir, overrideDir }; +} + +describe('fleet agent CRUD plan', (): void => { + it('creates stopped-by-default desired state and a deterministic generation plan', (): void => { + const plan = planFleetAgentMutation(roster, createRequest()); + + expect(plan).toMatchObject({ + operation: 'create', + currentGeneration: 7, + nextGeneration: 8, + changed: true, + agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } }, + }); + }); + + it('rejects a stale generation before proposing effects', (): void => { + expect((): void => { + planFleetAgentMutation(roster, { ...createRequest(), expectedGeneration: 6 }); + }).toThrow(FleetAgentMutationError); + }); + + it('treats an equivalent retry as an idempotent no-op', (): void => { + const created = planFleetAgentMutation(roster, createRequest()).roster; + const retried = planFleetAgentMutation(created, { + ...createRequest(), + expectedGeneration: created.generation, + }); + + expect(retried).toMatchObject({ changed: false, currentGeneration: 8, nextGeneration: 8 }); + }); +}); + +describe('fleet agent CRUD execution', (): void => { + it('keeps roster and projections unchanged for dry-run', async (): Promise => { + const dirs = await semanticDirs(); + const home = join(cleanup!, 'mosaic'); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 }); + await writeFile(rosterPath, 'before\n', { mode: 0o600 }); + + const result = await executeFleetAgentMutation({ + roster, + request: createRequest(), + mosaicHome: home, + rosterPath, + agentEnvDir: join(home, 'fleet', 'agents'), + rolesDir: dirs.rolesDir, + overrideDir: dirs.overrideDir, + dryRun: true, + }); + + expect(result.applied).toBe(false); + expect(await readFile(rosterPath, 'utf8')).toBe('before\n'); + await expect( + readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'), + ).rejects.toThrow(); + }); + + it('fails closed when another writer owns the mutation lock', async (): Promise => { + const dirs = await semanticDirs(); + const home = join(cleanup!, 'mosaic'); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 }); + await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 }); + await writeFile(`${rosterPath}.mutation.lock`, 'owned\n', { mode: 0o600 }); + + await expect( + executeFleetAgentMutation({ + roster, + request: createRequest(), + mosaicHome: home, + rosterPath, + agentEnvDir: join(home, 'fleet', 'agents'), + rolesDir: dirs.rolesDir, + overrideDir: dirs.overrideDir, + }), + ).rejects.toMatchObject({ code: 'concurrent-mutation' }); + expect(await readFile(rosterPath, 'utf8')).toBe(renderRosterV2Yaml(roster)); + }); + + it('deletes only the removed agent generated projection when it is stale or absent', async (): Promise => { + const dirs = await semanticDirs(); + const home = join(cleanup!, 'mosaic'); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const agentEnvDir = join(home, 'fleet', 'agents'); + const created = planFleetAgentMutation(roster, createRequest()).roster; + await mkdir(agentEnvDir, { recursive: true, mode: 0o700 }); + await writeFile(rosterPath, renderRosterV2Yaml(created), { mode: 0o600 }); + await writeFile(join(agentEnvDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', { + mode: 0o600, + }); + + const result = await executeFleetAgentMutation({ + roster: created, + request: { operation: 'delete', expectedGeneration: 8, name: 'coder0' }, + mosaicHome: home, + rosterPath, + agentEnvDir, + rolesDir: dirs.rolesDir, + overrideDir: dirs.overrideDir, + }); + + expect(result).toMatchObject({ applied: true, plan: { nextGeneration: 9 } }); + await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow(); + expect(await readFile(join(agentEnvDir, 'coder0.env.local'), 'utf8')).toBe( + 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', + ); + }); + + it('returns redacted projection recovery after the authoritative roster write', async (): Promise => { + const dirs = await semanticDirs(); + const home = join(cleanup!, 'mosaic'); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 }); + await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 }); + + const result = await executeFleetAgentMutation({ + roster, + request: createRequest(), + mosaicHome: home, + rosterPath, + agentEnvDir: join(home, 'fleet', 'agents'), + rolesDir: dirs.rolesDir, + overrideDir: dirs.overrideDir, + projectionApplier: async (): Promise => { + throw new Error('MOSAIC_AGENT_COMMAND=must-not-leak'); + }, + }); + + expect(result).toMatchObject({ + applied: false, + authoritativeRoster: 'committed', + projections: 'incomplete', + recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' }, + }); + expect(JSON.stringify(result)).not.toContain('must-not-leak'); + expect(parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml').generation).toBe(8); + }); +}); diff --git a/packages/mosaic/src/fleet/fleet-agent-crud.ts b/packages/mosaic/src/fleet/fleet-agent-crud.ts new file mode 100644 index 0000000..3acfadf --- /dev/null +++ b/packages/mosaic/src/fleet/fleet-agent-crud.ts @@ -0,0 +1,397 @@ +import { open, readFile, unlink } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + applyPreparedAgentEnvironmentProjection, + prepareAgentGeneratedProjectionDeletion, + prepareAgentEnvironmentProjection, + type PreparedAgentEnvironmentProjection, + writeManagedFleetRoster, +} from './generated-env-boundary.js'; +import { + parseRosterV2, + renderRosterV2Yaml, + validateRosterV2Semantics, + type FleetRosterV2, + type FleetRosterV2Agent, + type FleetRosterV2Launch, + type FleetRosterV2Lifecycle, + type RosterV2ReasoningLevel, + type RosterV2RuntimeName, +} from './roster-v2.js'; + +export type FleetAgentMutationOperation = 'create' | 'update' | 'delete'; + +export interface FleetAgentMutationAgent { + readonly name: string; + readonly alias: string; + readonly className: string; + readonly runtime: RosterV2RuntimeName; + readonly provider: string; + readonly model: string; + readonly reasoning: RosterV2ReasoningLevel; + readonly toolPolicy: string; + readonly workingDirectory: string; + readonly persistentPersona: boolean; + readonly resetBetweenTasks: boolean; + readonly launch: FleetRosterV2Launch; +} + +export interface FleetAgentMutationRequest { + readonly operation: FleetAgentMutationOperation; + readonly expectedGeneration: number; + readonly name?: string; + readonly agent?: FleetAgentMutationAgent; + readonly persistedStart?: boolean; +} + +export interface FleetAgentMutationPlan { + readonly operation: FleetAgentMutationOperation; + readonly currentGeneration: number; + readonly nextGeneration: number; + readonly changed: boolean; + readonly roster: FleetRosterV2; + readonly agent?: FleetRosterV2Agent; +} + +export type FleetAgentMutationAuthoritativeRosterState = 'unchanged' | 'committed'; +export type FleetAgentMutationProjectionState = 'not-applied' | 'complete' | 'incomplete'; + +export interface FleetAgentMutationResult { + /** True only when every requested roster and projection write completed. */ + readonly applied: boolean; + /** Whether the authoritative roster changed on disk. */ + readonly authoritativeRoster: FleetAgentMutationAuthoritativeRosterState; + /** Whether derived projections were skipped, complete, or require recovery. */ + readonly projections: FleetAgentMutationProjectionState; + readonly plan: FleetAgentMutationPlan; + readonly recovery?: FleetAgentMutationRecovery; +} + +export interface FleetAgentMutationRecovery { + readonly code: 'projection-apply-failed'; + readonly rosterPath: string; + readonly action: 'regenerate-projections-from-roster'; +} + +export interface FleetAgentMutationOptions { + readonly roster: FleetRosterV2; + readonly request: FleetAgentMutationRequest; + readonly mosaicHome: string; + readonly rosterPath: string; + readonly agentEnvDir: string; + readonly rolesDir: string; + readonly overrideDir: string; + readonly dryRun?: boolean; + readonly projectionApplier?: (prepared: PreparedAgentEnvironmentProjection) => Promise; +} + +export class FleetAgentMutationError extends Error { + constructor( + readonly code: + | 'stale-generation' + | 'agent-conflict' + | 'agent-not-found' + | 'invalid-request' + | 'concurrent-mutation' + | 'projection-apply-failed', + message: string, + ) { + super(message); + this.name = FleetAgentMutationError.name; + } +} + +/** Plans a generation-guarded desired-state mutation without writing any file. */ +export function planFleetAgentMutation( + roster: FleetRosterV2, + request: FleetAgentMutationRequest, +): FleetAgentMutationPlan { + if (request.expectedGeneration !== roster.generation) { + throw new FleetAgentMutationError( + 'stale-generation', + `Expected roster generation ${request.expectedGeneration}; current generation is ${roster.generation}.`, + ); + } + + if (request.operation === 'create') return planCreate(roster, request); + if (request.operation === 'update') return planUpdate(roster, request); + return planDelete(roster, request); +} + +/** + * Validates the complete proposed roster and its deterministic projections before + * changing the roster authority. The only post-roster write is a derived projection. + */ +export async function executeFleetAgentMutation( + options: FleetAgentMutationOptions, +): Promise { + const plan = planFleetAgentMutation(options.roster, options.request); + await validateRosterV2Semantics(plan.roster, { + rolesDir: options.rolesDir, + overrideDir: options.overrideDir, + }); + const prepared = await prepareProjections(plan.roster, options.mosaicHome, options.agentEnvDir); + if (plan.operation === 'delete' && plan.agent) { + // Delete validates only the exact generated projection. Local overrides, + // legacy input, and quarantine records are retained operator state. + await prepareAgentGeneratedProjectionDeletion({ + mosaicHome: options.mosaicHome, + agentEnvDir: options.agentEnvDir, + agentName: plan.agent.name, + }); + } + + if (options.dryRun === true || !plan.changed) { + return { applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', plan }; + } + + const lockPath = `${options.rosterPath}.mutation.lock`; + const release = await acquireMutationLock(lockPath); + try { + const persisted = parseRosterV2(await readFile(options.rosterPath, 'utf8'), 'yaml'); + if (persisted.generation !== options.roster.generation) { + throw new FleetAgentMutationError( + 'stale-generation', + `Expected roster generation ${options.roster.generation}; current generation is ${persisted.generation}.`, + ); + } + + await writeManagedFleetRoster( + options.mosaicHome, + options.rosterPath, + renderRosterV2Yaml(plan.roster), + ); + const apply = options.projectionApplier ?? applyPreparedAgentEnvironmentProjection; + try { + for (const projection of prepared) await apply(projection); + if (plan.operation === 'delete' && plan.agent) { + await prepareAgentGeneratedProjectionDeletion({ + mosaicHome: options.mosaicHome, + agentEnvDir: options.agentEnvDir, + agentName: plan.agent.name, + }); + await removeGeneratedProjection(options.agentEnvDir, plan.agent.name); + } + } catch { + throw new FleetAgentMutationError( + 'projection-apply-failed', + `Projection application failed after roster write; regenerate projections from ${options.rosterPath}.`, + ); + } + return { applied: true, authoritativeRoster: 'committed', projections: 'complete', plan }; + } catch (error: unknown) { + if (error instanceof FleetAgentMutationError && error.code === 'projection-apply-failed') { + return { + applied: false, + authoritativeRoster: 'committed', + projections: 'incomplete', + plan, + recovery: { + code: 'projection-apply-failed', + rosterPath: options.rosterPath, + action: 'regenerate-projections-from-roster', + }, + }; + } + throw error; + } finally { + await release(); + } +} + +function planCreate( + roster: FleetRosterV2, + request: FleetAgentMutationRequest, +): FleetAgentMutationPlan { + if (!request.agent) + throw new FleetAgentMutationError('invalid-request', 'Create requires an agent.'); + const existing = roster.agents.find( + (agent: FleetRosterV2Agent): boolean => agent.name === request.agent?.name, + ); + const created = toRosterAgent(request.agent, request.persistedStart === true); + if (existing) { + if (sameAgent(existing, created)) return unchangedPlan(roster, request.operation, existing); + throw new FleetAgentMutationError('agent-conflict', `Agent "${created.name}" already exists.`); + } + return changedPlan(roster, request.operation, [...roster.agents, created], created); +} + +function planUpdate( + roster: FleetRosterV2, + request: FleetAgentMutationRequest, +): FleetAgentMutationPlan { + if (!request.name || !request.agent) { + throw new FleetAgentMutationError('invalid-request', 'Update requires name and agent.'); + } + const existing = roster.agents.find( + (agent: FleetRosterV2Agent): boolean => agent.name === request.name, + ); + if (!existing) + throw new FleetAgentMutationError( + 'agent-not-found', + `Agent "${request.name}" is not in roster.`, + ); + if (request.agent.name !== request.name) { + throw new FleetAgentMutationError( + 'invalid-request', + 'Agent names are immutable during update.', + ); + } + const updated = { + ...toRosterAgent(request.agent, existing.lifecycle.desiredState === 'running'), + lifecycle: existing.lifecycle, + }; + if (sameAgent(existing, updated)) return unchangedPlan(roster, request.operation, existing); + return changedPlan( + roster, + request.operation, + roster.agents.map( + (agent: FleetRosterV2Agent): FleetRosterV2Agent => + agent.name === request.name ? updated : agent, + ), + updated, + ); +} + +function planDelete( + roster: FleetRosterV2, + request: FleetAgentMutationRequest, +): FleetAgentMutationPlan { + if (!request.name) throw new FleetAgentMutationError('invalid-request', 'Delete requires name.'); + const existing = roster.agents.find( + (agent: FleetRosterV2Agent): boolean => agent.name === request.name, + ); + if (!existing) return unchangedPlan(roster, request.operation); + return changedPlan( + roster, + request.operation, + roster.agents.filter((agent: FleetRosterV2Agent): boolean => agent.name !== request.name), + existing, + ); +} + +function toRosterAgent( + input: FleetAgentMutationAgent, + persistedStart: boolean, +): FleetRosterV2Agent { + const lifecycle: FleetRosterV2Lifecycle = { + enabled: true, + desiredState: persistedStart ? 'running' : 'stopped', + }; + return { ...input, lifecycle }; +} + +function changedPlan( + roster: FleetRosterV2, + operation: FleetAgentMutationOperation, + agents: readonly FleetRosterV2Agent[], + agent?: FleetRosterV2Agent, +): FleetAgentMutationPlan { + const proposed = { ...roster, generation: roster.generation + 1, agents }; + const normalized = parseRosterV2(renderRosterV2Yaml(proposed), 'yaml'); + return { + operation, + currentGeneration: roster.generation, + nextGeneration: normalized.generation, + changed: true, + roster: normalized, + ...(agent ? { agent } : {}), + }; +} + +function unchangedPlan( + roster: FleetRosterV2, + operation: FleetAgentMutationOperation, + agent?: FleetRosterV2Agent, +): FleetAgentMutationPlan { + return { + operation, + currentGeneration: roster.generation, + nextGeneration: roster.generation, + changed: false, + roster, + ...(agent ? { agent } : {}), + }; +} + +function sameAgent(left: FleetRosterV2Agent, right: FleetRosterV2Agent): boolean { + return ( + left.name === right.name && + left.alias === right.alias && + left.className === right.className && + left.runtime === right.runtime && + left.provider === right.provider && + left.model === right.model && + left.reasoning === right.reasoning && + left.toolPolicy === right.toolPolicy && + left.workingDirectory === right.workingDirectory && + left.persistentPersona === right.persistentPersona && + left.resetBetweenTasks === right.resetBetweenTasks && + left.launch.yolo === right.launch.yolo && + left.lifecycle.enabled === right.lifecycle.enabled && + left.lifecycle.desiredState === right.lifecycle.desiredState + ); +} + +async function prepareProjections( + roster: FleetRosterV2, + mosaicHome: string, + agentEnvDir: string, +): Promise { + const projections: PreparedAgentEnvironmentProjection[] = []; + for (const agent of roster.agents) { + projections.push( + await prepareAgentEnvironmentProjection({ + mosaicHome, + agentEnvDir, + agentName: agent.name, + generated: generatedValues(roster, agent), + }), + ); + } + return projections; +} + +function generatedValues( + roster: FleetRosterV2, + agent: FleetRosterV2Agent, +): 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: agent.workingDirectory, + MOSAIC_TMUX_SOCKET: roster.tmux.socketName, + }; +} + +async function removeGeneratedProjection(agentEnvDir: string, agentName: string): Promise { + try { + await unlink(join(agentEnvDir, `${agentName}.env.generated`)); + } catch (error: unknown) { + if (isMissingFile(error)) return; + throw error; + } +} + +function isMissingFile(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; +} + +async function acquireMutationLock(lockPath: string): Promise<() => Promise> { + try { + const handle = await open(lockPath, 'wx', 0o600); + await handle.close(); + } catch { + throw new FleetAgentMutationError( + 'concurrent-mutation', + `Another roster mutation is in progress for ${dirname(lockPath)}.`, + ); + } + return async (): Promise => { + await unlink(lockPath).catch((): void => {}); + }; +} diff --git a/packages/mosaic/src/fleet/generated-env-boundary.ts b/packages/mosaic/src/fleet/generated-env-boundary.ts index aebd028..200cebe 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.ts @@ -17,6 +17,12 @@ export interface AgentEnvironmentProjectionOptions { readonly generated: Readonly>; } +export interface AgentGeneratedProjectionDeletionOptions { + readonly mosaicHome: string; + readonly agentEnvDir: string; + readonly agentName: string; +} + export interface AgentEnvironmentProjectionResult { readonly generatedPath: string; readonly localPath: string; @@ -185,6 +191,23 @@ export async function prepareAgentEnvironmentProjection( }; } +/** + * Validates only the exact generated projection eligible for a roster delete. + * Local overrides, legacy input, and quarantine records are operator-retained and + * intentionally excluded from delete validation and cleanup. + */ +export async function prepareAgentGeneratedProjectionDeletion( + options: AgentGeneratedProjectionDeletionOptions, +): Promise { + if (!AGENT_NAME.test(options.agentName)) { + throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName); + } + await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir); + const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`); + await assertPrivateRegularFileIfPresent(generatedPath); + return generatedPath; +} + /** Applies a previously prepared deterministic projection. */ export async function applyPreparedAgentEnvironmentProjection( prepared: PreparedAgentEnvironmentProjection,