feat(fleet): add generation-guarded agent CRUD (FCM-M2-002)
Some checks failed
ci/woodpecker/pr/ci Pipeline failed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-07-15 06:32:30 -05:00
parent 191efaefeb
commit 23d58cdb90
10 changed files with 1877 additions and 0 deletions

View File

@@ -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 <name>
mosaic fleet plan create --expected-generation <n> --agent '<json>'
mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'
mosaic fleet plan delete <name> --expected-generation <n>
```
`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 '<complete JSON agent payload>'
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 <name>` 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 `<mosaic-home>/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.

View File

@@ -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 `<mosaic-home>/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 <name>
mosaic fleet plan <create|update|delete> [name] --expected-generation <n> [--agent '<json>'] [--persisted-start]
mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]
mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]
mosaic fleet delete <name> --expected-generation <n> [--dry-run]
```
`get` returns the authoritative generation and the selected agent. `plan create` derives its name from `--agent`; `plan update <name>` and `plan delete <name>` 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 `<name>.env.generated` projection for the removed roster entry. Operator-owned `<name>.env.local`, legacy `<name>.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.

View File

@@ -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 <operation> [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.

View File

@@ -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<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fleetHome(): Promise<string> {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<unknown> => {
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<void> => {
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<void> => {
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<unknown> => {
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<void> => {
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<void> => {
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');
});
});

View File

@@ -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 <name>')
.description('Read one local roster-v2 agent as JSON')
.action(async (name: string): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
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 <operation> [name]')
.description('Plan a local roster-v2 mutation without writing roster or projections')
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
.option('--agent <json>', '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<void> => {
await writeJsonOutcome(async (): Promise<void> => {
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 <name>'
: `${operation}${operation === 'delete' ? ' <name>' : ''}`,
)
.description(`${operation} a local roster-v2 agent without runtime actions`)
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
.option('--agent <json>', '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<void> => {
await writeJsonOutcome(async (): Promise<void> => {
await executeCommand(agentCommand, deps, operation, opts, false);
});
});
return;
}
command.action(async (name: string, opts: MutationOptions): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
await executeCommand(agentCommand, deps, operation, opts, false, name);
});
});
}
async function executeCommand(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps,
operation: FleetAgentMutationOperation,
opts: MutationOptions,
forceDryRun: boolean,
name?: string,
): Promise<void> {
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<FleetAgentMutationResult, 'recovery'>,
): 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<FleetRosterV2> {
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 <mosaic-home>/fleet/roster.yaml path.',
);
}
return canonical;
}
function rejectUnknownKeys(
value: Record<string, unknown>,
allowedKeys: ReadonlySet<string>,
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, key: string): Record<string, unknown> {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
async function writeJsonOutcome(action: () => Promise<void>): Promise<void> {
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));
}

View File

@@ -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',
]);
});

View File

@@ -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;
}

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
});
});

View File

@@ -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<unknown>;
}
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<FleetAgentMutationResult> {
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<readonly PreparedAgentEnvironmentProjection[]> {
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<Record<string, string>> {
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<void> {
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<void>> {
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<void> => {
await unlink(lockPath).catch((): void => {});
};
}

View File

@@ -17,6 +17,12 @@ export interface AgentEnvironmentProjectionOptions {
readonly generated: Readonly<Record<string, string>>;
}
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<string> {
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,