122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
import { readdir } from 'node:fs/promises';
|
|
import { basename, join } from 'node:path';
|
|
import { loadFleetRoster } from '../commands/fleet.js';
|
|
import { loadProfiles } from '../commands/fleet-profiles.js';
|
|
import {
|
|
provisionInteractionService,
|
|
readInteractionServiceProfile,
|
|
} from './interaction-service-profile.js';
|
|
|
|
export type FleetArtifactDisposition =
|
|
| 'v1-fixture'
|
|
| 'canonical-profile'
|
|
| 'canonical-service-policy';
|
|
|
|
export interface ShippedFleetArtifactDisposition {
|
|
readonly path: string;
|
|
readonly disposition: FleetArtifactDisposition;
|
|
}
|
|
|
|
export interface ValidateShippedFleetArtifactDispositionsOptions {
|
|
readonly frameworkFleet: string;
|
|
readonly rolesDir?: string;
|
|
readonly overrideDir?: string;
|
|
}
|
|
|
|
/**
|
|
* The M0 inventory in executable form. Every shipped fleet YAML asset is either
|
|
* a deliberately retained v1 fixture or validated through its canonical loader.
|
|
*/
|
|
export const SHIPPED_FLEET_ARTIFACT_DISPOSITIONS: readonly ShippedFleetArtifactDisposition[] = [
|
|
{ path: 'examples/coding.yaml', disposition: 'v1-fixture' },
|
|
{ path: 'examples/general.yaml', disposition: 'v1-fixture' },
|
|
{ path: 'examples/hybrid.yaml', disposition: 'v1-fixture' },
|
|
{ path: 'examples/local-canary.yaml', disposition: 'v1-fixture' },
|
|
{ path: 'examples/minimal.yaml', disposition: 'v1-fixture' },
|
|
{ path: 'examples/operator-interaction.yaml', disposition: 'v1-fixture' },
|
|
{ path: 'examples/research.yaml', disposition: 'v1-fixture' },
|
|
{ path: 'profiles/business.yaml', disposition: 'canonical-profile' },
|
|
{ path: 'profiles/marketing.yaml', disposition: 'canonical-profile' },
|
|
{ path: 'profiles/personal-assistant.yaml', disposition: 'canonical-profile' },
|
|
{ path: 'profiles/research.yaml', disposition: 'canonical-profile' },
|
|
{ path: 'profiles/software-delivery.yaml', disposition: 'canonical-profile' },
|
|
{ path: 'services/operator-interaction.yaml', disposition: 'canonical-service-policy' },
|
|
];
|
|
|
|
/**
|
|
* Fail closed when a fleet YAML asset is added or removed without a disposition.
|
|
* This keeps legacy v1 compatibility explicit instead of silently accepting new
|
|
* unresolved classes outside the shared resolver.
|
|
*/
|
|
export async function validateShippedFleetArtifactDispositions(
|
|
options: ValidateShippedFleetArtifactDispositionsOptions,
|
|
): Promise<readonly ShippedFleetArtifactDisposition[]> {
|
|
await assertEveryShippedArtifactIsDeclared(options.frameworkFleet);
|
|
|
|
const examples = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
|
({ disposition }): boolean => disposition === 'v1-fixture',
|
|
);
|
|
for (const artifact of examples) {
|
|
const roster = await loadFleetRoster(join(options.frameworkFleet, artifact.path));
|
|
if (roster.version !== 1) {
|
|
throw new Error(`v1 fixture ${artifact.path} must declare version: 1`);
|
|
}
|
|
}
|
|
|
|
const profilesDir = join(options.frameworkFleet, 'profiles');
|
|
const profiles = await loadProfiles({
|
|
profilesDir,
|
|
rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'),
|
|
overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'),
|
|
});
|
|
const declaredProfiles = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
|
({ disposition }): boolean => disposition === 'canonical-profile',
|
|
).map(({ path }): string => basename(path, '.yaml'));
|
|
const resolvedProfiles = new Set(profiles.map(({ id }): string => id));
|
|
for (const profileId of declaredProfiles) {
|
|
if (!resolvedProfiles.has(profileId)) {
|
|
throw new Error(`declared canonical profile ${profileId} did not resolve`);
|
|
}
|
|
}
|
|
|
|
const servicePolicies = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
|
({ disposition }): boolean => disposition === 'canonical-service-policy',
|
|
);
|
|
for (const artifact of servicePolicies) {
|
|
const serviceProfile = await readInteractionServiceProfile(
|
|
join(options.frameworkFleet, artifact.path),
|
|
);
|
|
provisionInteractionService(serviceProfile, { agentName: 'interaction-example' });
|
|
}
|
|
|
|
return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS;
|
|
}
|
|
|
|
async function assertEveryShippedArtifactIsDeclared(frameworkFleet: string): Promise<void> {
|
|
const declared = new Set(SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }): string => path));
|
|
const shipped = await listShippedFleetArtifactPaths(frameworkFleet);
|
|
|
|
for (const path of shipped) {
|
|
if (!declared.has(path)) {
|
|
throw new Error(`undeclared shipped fleet artifact: ${path}`);
|
|
}
|
|
}
|
|
for (const path of declared) {
|
|
if (!shipped.has(path)) {
|
|
throw new Error(`declared shipped fleet artifact is missing: ${path}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function listShippedFleetArtifactPaths(frameworkFleet: string): Promise<Set<string>> {
|
|
const directories = ['examples', 'profiles', 'services'];
|
|
const paths = new Set<string>();
|
|
for (const directory of directories) {
|
|
const files = await readdir(join(frameworkFleet, directory));
|
|
for (const file of files) {
|
|
if (file.endsWith('.yaml') || file.endsWith('.yml')) paths.add(`${directory}/${file}`);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|