/** * The dependency guard on `module remove`. * * Extracted from `cli/commands/module-remove.ts` so it can be unit-tested * without a database, and so the two behaviour changes it carries are visible * in one place (openspec/changes/module-pause-lifecycle, design D3): * * 1. A PAUSED module is not a dependent. Sound rather than a loosening: a * paused module cannot return to service without a redeploy, and that * redeploy re-resolves its capabilities against whatever providers exist * at the time. The invariant is "paused implies not currently bound, and * guaranteed to rebind before going live". * * 2. A dependent is one that declares the capability under `requires` OR * `optional`, which is what `services/update/dep-graph.ts` already means * by an edge. The guard used to consider `requires` alone, and the two * definitions disagreeing was silently harmful: `technitium` consumes * `dhcp_server` under `optional:`, so removing its provider orphaned its * DHCP-handed-out DNS without a word while correctly blocking on `caddy`. * The guard and the cascade MUST agree on the set, or `pause --cascade` * pauses a set the guard still rejects. * * Note (2) makes the guard STRICTER for optional consumers while (1) makes it * looser for paused ones. Both directions are intended. */ import type { ModuleManifest } from '../manifest/schema'; /** How a module came to depend on the capability — reported, not just counted. */ export type DependencyKind = 'requires' | 'optional'; export interface Blocker { moduleId: string; capability: string; kind: DependencyKind; } export interface DependentCandidate { id: string; manifest: ModuleManifest; paused: boolean; /** * Has this module actually been deployed? The spec says removal is refused * when another **installed** module depends on the capability, and an * imported-but-never-deployed module is not installed: it has never resolved * the capability, holds no state derived from it, and cannot be orphaned by * the provider going away. * * This is not a nicety — it is required for the guard and the cascade to * agree (design D3). `pause --cascade` skips undeployed members, because * pausing requires a settled deployed state and there is nothing to quiesce. * If the guard still counted them, the operator would pause the full cascade * and the removal would STILL refuse, naming a module that pause cannot act * on — a dead end with no way forward. */ deployed: boolean; } /** * Modules that block removal of a provider of `providedCapabilities`. * * Pure. Paused candidates are excluded; a candidate depending via both * `requires` and `optional` is reported once, as `requires` (the stronger * claim, and the one whose removal consequence is worse). */ export function findRemovalBlockers( providedCapabilities: Iterable, candidates: DependentCandidate[], ): Blocker[] { const provided = new Set(providedCapabilities); if (provided.size === 0) return []; const blockers: Blocker[] = []; for (const candidate of candidates) { if (candidate.paused) continue; if (!candidate.deployed) continue; const required = (candidate.manifest.requires?.capabilities ?? []).map((c) => c.name); const optional = (candidate.manifest.optional?.capabilities ?? []).map((c) => c.name); const hit = required.find((name) => provided.has(name)) ?? optional.find((name) => provided.has(name)); if (!hit) continue; blockers.push({ moduleId: candidate.id, capability: hit, kind: required.includes(hit) ? 'requires' : 'optional', }); } return blockers.sort((a, b) => a.moduleId.localeCompare(b.moduleId)); } /** * The refusal an operator reads. Names each blocker AND which declaration makes * it one, so a hard prerequisite is distinguishable from an optional consumer — * the operator needs that to judge what pausing it implies. */ export function describeRemovalRefusal(moduleId: string, blockers: Blocker[]): string { const lines = blockers.map((b) => ` • ${b.moduleId} — ${b.kind} '${b.capability}'`); return [ `Cannot remove '${moduleId}': the following installed modules depend on its capabilities:`, ...lines, '', 'Pause them first so they rebind on their next deploy:', ` celilo module pause --cascade ${moduleId}`, ].join('\n'); }