/*
scope — ScopeManager implementation managing scoped component registries with lifecycle (RFC-1036).
- Does not implement distributed fleet-wide synchronization — per-fleet uses local registry with HTTP fetch extension point.
- Does not implement periodic sweep — sweep is a future extension for long-running modes.
- Does not implement session timeout — sessions are disposed explicitly or via context.
- RFC-1036: initial implementation of ScopeManager, ScopedRegistry, ScopeError.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
*/
import type {
ComponentId,
CapabilityId,
ComponentDeclaration,
ComponentScope,
ScopeContext,
ScopedRegistry,
ScopeManager,
} from "@warpgogol/werkstatt-shared/component";
import { SCOPE_ERROR_CODES } from "@warpgogol/werkstatt-shared/component";
const DEFAULT_REGISTRY_LIMIT = 64;
const SCOPE_RESOLUTION_ORDER: readonly ComponentScope[] = [
"per-command",
"per-mission",
"per-session",
"per-workshop",
"per-fleet",
];
const MANUAL_SCOPES: readonly ComponentScope[] = ["per-fleet", "per-session"];
export class ScopeError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(`${code}: ${message}`);
this.name = "ScopeError";
this.code = code;
}
}
function scopeContextKey(ctx: ScopeContext): string {
const parts: string[] = [ctx.scope];
if (ctx.missionId) parts.push(`mission:${ctx.missionId}`);
if (ctx.sessionId) parts.push(`session:${ctx.sessionId}`);
if (ctx.fleetId) parts.push(`fleet:${ctx.fleetId}`);
if (ctx.invocationId) parts.push(`invocation:${ctx.invocationId}`);
return parts.join("|");
}
interface RegistryEntry {
readonly manifest: ComponentDeclaration;
readonly registeredAt: number;
}
class ScopedRegistryImpl implements ScopedRegistry {
readonly context: ScopeContext;
private readonly components = new Map();
private readonly order: ComponentId[] = [];
private disposed = false;
constructor(context: ScopeContext) {
this.context = context;
}
register(manifest: ComponentDeclaration): void {
if (this.disposed) {
throw new ScopeError(
SCOPE_ERROR_CODES.SCOPE_01,
`registry for scope '${this.context.scope}' is disposed`,
);
}
if (!manifest.scope) {
throw new ScopeError(
SCOPE_ERROR_CODES.SCOPE_03,
"component manifest missing required scope field",
);
}
if (manifest.scope !== this.context.scope) {
throw new ScopeError(
SCOPE_ERROR_CODES.SCOPE_01,
`manifest declares scope '${manifest.scope}' but registry is '${this.context.scope}'`,
);
}
if (this.components.has(manifest.componentId)) {
throw new ScopeError(
SCOPE_ERROR_CODES.SCOPE_01,
`component '${manifest.componentId}' already registered in scope '${this.context.scope}'`,
);
}
this.components.set(manifest.componentId, {
manifest,
registeredAt: Date.now(),
});
this.order.push(manifest.componentId);
}
resolve(capabilityId: CapabilityId): ComponentDeclaration | null {
if (this.disposed) return null;
for (const entry of this.components.values()) {
for (const provide of entry.manifest.provides) {
if (provide.capability === capabilityId) {
return entry.manifest;
}
}
}
return null;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
// LIFO order — reverse of registration
for (let i = this.order.length - 1; i >= 0; i--) {
const id = this.order[i];
this.components.delete(id);
}
this.order.length = 0;
}
list(): ReadonlyArray {
return this.order.map((id) => this.components.get(id)!.manifest);
}
getScope(): ComponentScope {
return this.context.scope;
}
hasComponent(componentId: ComponentId): boolean {
return this.components.has(componentId);
}
getManifest(componentId: ComponentId): ComponentDeclaration | null {
const entry = this.components.get(componentId);
return entry ? entry.manifest : null;
}
get isDisposed(): boolean {
return this.disposed;
}
}
export interface ScopeManagerOptions {
readonly registryLimit?: number;
}
export function createScopeManager(options: ScopeManagerOptions = {}): ScopeManager {
const limit = options.registryLimit ?? DEFAULT_REGISTRY_LIMIT;
const registries = new Map();
function findRegistry(ctx: ScopeContext): ScopedRegistryImpl | undefined {
return registries.get(scopeContextKey(ctx));
}
function getOrCreateRegistry(ctx: ScopeContext): ScopedRegistryImpl {
const key = scopeContextKey(ctx);
const existing = registries.get(key);
if (existing && !existing.isDisposed) return existing;
if (registries.size >= limit) {
const allActive = Array.from(registries.values()).every((r) => !r.isDisposed);
if (allActive) {
throw new ScopeError(
SCOPE_ERROR_CODES.SCOPE_04,
`registry limit reached (${limit} active registries)`,
);
}
}
const registry = new ScopedRegistryImpl(ctx);
registries.set(key, registry);
return registry;
}
function resolveInScope(
capabilityId: CapabilityId,
ctx: ScopeContext,
): ComponentDeclaration | null {
const registry = findRegistry(ctx);
if (!registry || registry.isDisposed) return null;
return registry.resolve(capabilityId);
}
function buildResolutionContexts(ctx: ScopeContext): ScopeContext[] {
const contexts: ScopeContext[] = [];
const startIdx = SCOPE_RESOLUTION_ORDER.indexOf(ctx.scope);
for (let i = startIdx; i < SCOPE_RESOLUTION_ORDER.length; i++) {
const scope = SCOPE_RESOLUTION_ORDER[i]!;
if (scope === "per-command" && ctx.invocationId) {
contexts.push({ scope, invocationId: ctx.invocationId });
} else if (scope === "per-mission" && ctx.missionId) {
contexts.push({ scope, missionId: ctx.missionId });
} else if (scope === "per-session" && ctx.sessionId) {
contexts.push({ scope, sessionId: ctx.sessionId });
} else if (scope === "per-workshop") {
contexts.push({ scope });
} else if (scope === "per-fleet" && ctx.fleetId) {
contexts.push({ scope, fleetId: ctx.fleetId });
}
}
return contexts;
}
const manager: ScopeManager = {
getRegistry(ctx: ScopeContext): ScopedRegistry {
return getOrCreateRegistry(ctx);
},
disposeRegistry(ctx: ScopeContext): void {
const registry = findRegistry(ctx);
if (registry) {
registry.dispose();
registries.delete(scopeContextKey(ctx));
}
},
resolveAcrossScopes(
capabilityId: CapabilityId,
ctx: ScopeContext,
): ComponentDeclaration | null {
const contexts = buildResolutionContexts(ctx);
for (const c of contexts) {
const result = resolveInScope(capabilityId, c);
if (result) return result;
}
return null;
},
inspect(): ReadonlyArray<{
context: ScopeContext;
componentCount: number;
componentIds: ComponentId[];
}> {
const result: Array<{
context: ScopeContext;
componentCount: number;
componentIds: ComponentId[];
}> = [];
for (const registry of registries.values()) {
if (registry.isDisposed) continue;
result.push({
context: registry.context,
componentCount: registry.list().length,
componentIds: registry.list().map((m) => m.componentId),
});
}
return result;
},
adopt(componentId: ComponentId, targetContext: ScopeContext): void {
if (!MANUAL_SCOPES.includes(targetContext.scope)) {
throw new ScopeError(
SCOPE_ERROR_CODES.SCOPE_02,
`adopt is only for manual scopes (per-fleet, per-session), not '${targetContext.scope}'`,
);
}
let manifest: ComponentDeclaration | null = null;
for (const registry of registries.values()) {
if (registry.isDisposed) continue;
const found = registry.getManifest(componentId);
if (found) {
manifest = found;
break;
}
}
if (!manifest) {
throw new ScopeError(
SCOPE_ERROR_CODES.SCOPE_01,
`component '${componentId}' not found in any scope`,
);
}
if (manifest.scope !== targetContext.scope) {
throw new ScopeError(
SCOPE_ERROR_CODES.SCOPE_02,
`scope mismatch — manifest declares '${manifest.scope}' but adoption targets '${targetContext.scope}'`,
);
}
const targetRegistry = getOrCreateRegistry(targetContext);
targetRegistry.register(manifest);
},
getScope(componentId: ComponentId): ComponentScope | null {
for (const registry of registries.values()) {
if (registry.isDisposed) continue;
if (registry.hasComponent(componentId)) {
return registry.getScope();
}
}
return null;
},
};
return manager;
}
let defaultManager: ScopeManager | null = null;
export function getDefaultScopeManager(): ScopeManager {
if (!defaultManager) {
const limit = Number(process.env.WERKSTATT_SCOPE_REGISTRY_LIMIT) || DEFAULT_REGISTRY_LIMIT;
defaultManager = createScopeManager({ registryLimit: limit });
}
return defaultManager;
}
export function resetDefaultScopeManager(): void {
defaultManager = null;
}