feat(fleet): add shared role semantics (#768)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #768.
This commit is contained in:
2026-07-15 00:53:47 +00:00
parent eb4e14ae5c
commit a5e8e55401
19 changed files with 1650 additions and 177 deletions

View File

@@ -1,13 +1,18 @@
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
authorityForCanonicalClass,
canonicalizeRoleClass,
extractClassesFromDir,
extractClassesFromDirSync,
listPersonaClasses,
personaStatus,
resolvePersona,
resolvePersonaFrom,
resolvePersonaSync,
} from './fleet-personas.js';
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
@@ -54,13 +59,156 @@ afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
describe('FCM class canonicalization and authority', () => {
it.each([
['implementer', 'code'],
['reviewer', 'review'],
['operator-interaction', 'interaction'],
])('canonicalizes the approved alias %s to %s', (requested: string, canonical: string) => {
expect(canonicalizeRoleClass(requested)).toEqual({
requestedClass: requested,
canonicalClass: canonical,
});
});
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron', 'constructor'])(
'does not infer an alias for %s',
(requested: string) => {
expect(canonicalizeRoleClass(requested)).toEqual({
requestedClass: requested,
canonicalClass: requested,
});
},
);
it('resolves inherited-property class names without granting protected authority', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'constructor.md'),
overridePersona('constructor', 'custom'),
'utf8',
);
const resolved = await resolvePersona('constructor', { rolesDir, overrideDir });
expect(resolved).toMatchObject({
requestedClass: 'constructor',
canonicalClass: 'constructor',
klass: 'constructor',
layer: 'override',
});
expect(authorityForCanonicalClass('constructor')).toMatchObject({
mayMerge: false,
mayIssueValidationCertificate: false,
mayOrchestrate: false,
});
});
it('canonicalizes before override lookup and lets the canonical override win', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'implementer.md'),
overridePersona('implementer', 'legacy'),
'utf8',
);
await writeFile(
join(overrideDir, 'code.md'),
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
'utf8',
);
const resolved = await resolvePersona('implementer', { rolesDir, overrideDir });
expect(resolved).toMatchObject({
requestedClass: 'implementer',
canonicalClass: 'code',
klass: 'code',
layer: 'override',
});
expect(resolved?.content).toContain('CANONICAL-OVERRIDE');
expect(resolved?.content).not.toContain('legacy');
});
it('keeps sync and async resolution equivalent for aliases and canonical overrides', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'code.md'),
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
'utf8',
);
const asyncResolution = await resolvePersona('implementer', { rolesDir, overrideDir });
const syncResolution = resolvePersonaSync('implementer', { rolesDir, overrideDir });
expect(syncResolution).toEqual(asyncResolution);
});
it('derives immutable protected authority only from the canonical class', () => {
expect(authorityForCanonicalClass('merge-gate')).toMatchObject({ mayMerge: true });
for (const klass of [
'validator',
'orchestrator',
'team-leader',
'interaction',
'code',
'custom-role',
]) {
expect(authorityForCanonicalClass(klass).mayMerge).toBe(false);
}
expect(authorityForCanonicalClass('validator')).toMatchObject({
mayIssueValidationCertificate: true,
mayMerge: false,
});
expect(authorityForCanonicalClass('orchestrator')).toMatchObject({
mayOrchestrate: true,
mayIssueLeases: true,
mayMerge: false,
});
expect(authorityForCanonicalClass('team-leader')).toMatchObject({
leasedCapacityOnly: true,
mayMutateRoster: false,
mayAccessCredentials: false,
mayMerge: false,
});
expect(authorityForCanonicalClass('interaction')).toMatchObject({
requestStatusOnly: true,
mayOrchestrate: false,
mayMerge: false,
});
expect(Object.isFrozen(authorityForCanonicalClass('merge-gate'))).toBe(true);
});
});
describe('required FCM role library', () => {
it.each([
'code',
'review',
'validator',
'orchestrator',
'team-leader',
'enhancer',
'interaction',
'merge-gate',
])('resolves %s through the real framework role library', async (klass: string) => {
const resolved = await resolvePersona(klass, {
rolesDir: realRolesDir,
overrideDir: join(tmp, 'none'),
});
expect(resolved).not.toBeNull();
expect(resolved?.canonicalClass).toBe(klass);
expect(resolved?.content.trim()).not.toBe('');
});
});
describe('extractClassesFromDir (shared extraction)', () => {
it('records class + domain from inline markers and degrades on missing dir', async () => {
it('records class + domain and distinguishes scanned from missing directories', async () => {
const base = await extractClassesFromDir(rolesDir);
expect(base.scanState).toBe('scanned');
expect(base.classes.has('ceo')).toBe(true);
expect(base.byClass.get('ceo')?.domain).toBe('executive');
const missing = await extractClassesFromDir(join(tmp, 'nope'));
const missingDir = join(tmp, 'nope');
const missing = await extractClassesFromDir(missingDir);
expect(missing.scanState).toBe('missing');
expect(missing.classes.size).toBe(0);
expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing');
});
});
@@ -81,6 +229,287 @@ describe('resolvePersona — override wins', () => {
expect(resolved?.content).toContain('BASELINE');
});
it('does not let a LIBRARY-only row shadow a readable baseline persona', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'LIBRARY.md'),
'| Persona | Purpose |\n| --- | --- |\n| code | Index only |\n',
'utf8',
);
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
expect(resolved?.layer).toBe('baseline');
expect(resolved?.content).toContain('BASELINE');
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
expect(
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
?.status,
).toBe('baseline');
});
it('does not let an incidental later class marker shadow a readable baseline persona', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'mascot.md'),
'# mascot\n\n(`class: mascot`)\n\nSee also (`class: code`).\n',
'utf8',
);
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
expect(resolved?.layer).toBe('baseline');
expect(resolved?.content).toContain('BASELINE');
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('mascot')).toBe(true);
expect(
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
?.status,
).toBe('baseline');
});
it('does not advertise an incidental-only class through listing or status APIs', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'mascot.md'),
'# mascot\n\n(`class: mascot`)\n\nSee also (`class: phantom`).\n',
'utf8',
);
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('phantom')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'phantom'),
).toBe(false);
});
it('rejects a canonical filename whose explicit marker names another class', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(join(overrideDir, 'merge-gate.md'), overridePersona('mascot', 'fun'), 'utf8');
await writeFile(
join(rolesDir, 'merge-gate.md'),
baselinePersona('merge-gate', 'governance'),
'utf8',
);
expect(await resolvePersona('merge-gate', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('merge-gate', { rolesDir, overrideDir })).toBeNull();
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('merge-gate')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'merge-gate'),
).toBe(false);
});
it('fails closed if a marker-defined override becomes unreadable after extraction', async () => {
await mkdir(overrideDir, { recursive: true });
const overrideFile = join(overrideDir, 'engineering.md');
await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8');
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
await rm(overrideFile);
await mkdir(overrideFile);
expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull();
});
it('fails closed if a marker-defined override changes identity after extraction', async () => {
await mkdir(overrideDir, { recursive: true });
const overrideFile = join(overrideDir, 'engineering.md');
await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8');
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull();
const classes = await listPersonaClasses({ rolesDir, overrideDir });
expect(classes.has('code')).toBe(true);
expect(classes.has('mascot')).toBe(true);
const status = new Map(
(await personaStatus({ rolesDir, overrideDir })).map((entry) => [entry.klass, entry]),
);
expect(status.get('code')?.status).toBe('baseline');
expect(status.get('mascot')?.status).toBe('custom');
});
it('fails closed if a markerless cached override gains a conflicting marker', async () => {
await mkdir(overrideDir, { recursive: true });
const overrideFile = join(overrideDir, 'merge-gate.md');
await writeFile(overrideFile, '# markerless merge gate\n', 'utf8');
await writeFile(
join(rolesDir, 'merge-gate.md'),
baselinePersona('merge-gate', 'governance'),
'utf8',
);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
expect(
await resolvePersonaFrom('merge-gate', { rolesDir, overrideDir, base, over }),
).toBeNull();
});
it('fails closed asynchronously when the override directory cannot be scanned', async () => {
await writeFile(overrideDir, 'not a directory', 'utf8');
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
});
it('fails closed synchronously when the override directory cannot be scanned', async () => {
await writeFile(overrideDir, 'not a directory', 'utf8');
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
});
it('fails closed asynchronously and synchronously for a dangling override-directory symlink', async () => {
await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir');
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
});
it('fails closed asynchronously and synchronously when an override ancestor is a dangling symlink', async () => {
const danglingAncestor = join(tmp, 'dangling-ancestor');
await symlink(join(tmp, 'missing-ancestor-target'), danglingAncestor, 'dir');
overrideDir = join(danglingAncestor, 'nested', 'roles.local');
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('fails closed when dot-dot traversal crosses a dangling override ancestor', async () => {
const danglingAncestor = join(tmp, 'dangling-dotdot-ancestor');
await symlink(join(tmp, 'missing-dotdot-target'), danglingAncestor, 'dir');
overrideDir = `${danglingAncestor}/../roles.local`;
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('fails closed when relative dot-dot traversal crosses a dangling override ancestor', async () => {
const danglingAncestor = join(tmp, 'relative-dangling-ancestor');
await symlink(join(tmp, 'missing-relative-target'), danglingAncestor, 'dir');
overrideDir = `${relative(process.cwd(), danglingAncestor)}/../roles.local`;
expect(overrideDir.startsWith('/')).toBe(false);
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('revalidates a cached missing override before baseline fallback', async () => {
const cachedOverrideDir = join(tmp, 'mutable-ancestor', 'roles.local');
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(cachedOverrideDir),
]);
expect(over.scanState).toBe('missing');
await symlink(join(tmp, 'missing-mutable-target'), join(tmp, 'mutable-ancestor'), 'dir');
expect(
await resolvePersonaFrom('code', {
rolesDir,
overrideDir: cachedOverrideDir,
base,
over,
}),
).toBeNull();
});
it('revalidates a cached scanned override before baseline fallback', async () => {
await mkdir(overrideDir, { recursive: true });
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
expect(over.scanState).toBe('scanned');
expect(over.classes.size).toBe(0);
await writeFile(join(overrideDir, 'engineering.md'), overridePersona('code', 'engineering'));
const resolved = await resolvePersonaFrom('code', {
rolesDir,
overrideDir,
base,
over,
});
expect(resolved?.layer).toBe('override');
expect(resolved?.file).toBe(join(overrideDir, 'engineering.md'));
});
it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => {
const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir });
const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir });
expect(asyncResolution?.layer).toBe('baseline');
expect(syncResolution?.layer).toBe('baseline');
});
it('fails closed asynchronously when the canonical override exists but is unreadable', async () => {
await mkdir(overrideDir, { recursive: true });
await mkdir(join(overrideDir, 'code.md'));
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
});
it('does not advertise a shadowed baseline whose canonical override is unreadable', async () => {
await mkdir(overrideDir, { recursive: true });
await mkdir(join(overrideDir, 'code.md'));
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'code'),
).toBe(false);
});
it('does not advertise baseline personas when the override directory is unscannable', async () => {
await writeFile(overrideDir, 'not a directory', 'utf8');
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('does not advertise baseline personas through a dangling override-directory symlink', async () => {
await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir');
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('preserves baseline listings when the override directory is missing', async () => {
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
expect(
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
?.status,
).toBe('baseline');
});
it('does not advertise an unreadable custom override as a valid persona class or status', async () => {
await mkdir(overrideDir, { recursive: true });
await mkdir(join(overrideDir, 'ghost.md'));
const extracted = await extractClassesFromDir(overrideDir);
expect(extracted.fileStems.has('ghost')).toBe(true);
expect(extracted.classes.has('ghost')).toBe(false);
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('ghost')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'ghost'),
).toBe(false);
});
it('fails closed synchronously when the canonical override exists but is unreadable', async () => {
await mkdir(overrideDir, { recursive: true });
await mkdir(join(overrideDir, 'code.md'));
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
});
it('returns null for an unknown class', async () => {
expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull();
});