221 lines
7.4 KiB
TypeScript
221 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|