feat: add the minimal operational module lifecycle
Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
@@ -137,6 +137,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => plan()),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
runtimeSatisfies: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
@@ -204,6 +205,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"modules.discovery",
|
||||
"modules.dependencies",
|
||||
"modules.permissions",
|
||||
"modules.lifecycle",
|
||||
"schemas.discovery",
|
||||
"migrations.discovery",
|
||||
"database.transport",
|
||||
@@ -844,3 +846,173 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const databaseUrl = "postgresql://not-rendered.invalid/ekklesia";
|
||||
|
||||
describe("Ekklesia Doctor — module lifecycle", () => {
|
||||
/** A discovered module double with a real required-dependency edge, so blocking is exercised
|
||||
* through the same resolver production uses rather than a hand-written eligibility record. */
|
||||
function dependentModule(id: string, requires: string): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
manifest: {
|
||||
...base.manifest,
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: { [requires]: "^0.1.0" } },
|
||||
},
|
||||
} as unknown as ModuleManifest,
|
||||
};
|
||||
}
|
||||
|
||||
it("skips the check, rather than guessing a default, when DATABASE_URL is not configured", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
|
||||
expect(check(report, "modules.lifecycle").status).toBe("skipped");
|
||||
expect(check(report, "modules.lifecycle").details).toEqual(["DATABASE_URL is not configured."]);
|
||||
// Configuration lives in PostgreSQL; reporting "everything is enabled" without reading it would
|
||||
// be a guess an operator could act on.
|
||||
expect(services.readModuleLifecycle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the check when discovery itself failed", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => {
|
||||
throw new Error("broken inventory");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.lifecycle").status).toBe("skipped");
|
||||
expect(check(report, "modules.lifecycle").details).toEqual([
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes and lists each module when everything enabled can run", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([discoveredModule("storage"), discoveredModule("calendar", "0.2.0")]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
||||
expect(check(report, "modules.lifecycle").details).toEqual([
|
||||
"2 eligible · 0 disabled · 0 blocked",
|
||||
"storage 0.1.0 · enabled",
|
||||
"calendar 0.2.0 · enabled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still passes when a module is deliberately disabled — a decision is not a problem", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
readModuleLifecycle: vi.fn(async () =>
|
||||
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "calendar" }]) }),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
||||
expect(check(report, "modules.lifecycle").details).toEqual([
|
||||
"0 eligible · 1 disabled · 0 blocked",
|
||||
"calendar 0.1.0 · disabled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("distinguishes the platform default from a decision somebody made", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
// Nothing stored: the module is enabled by the platform default, and the detail says so.
|
||||
expect(check(report, "modules.lifecycle").details).toContain("calendar 0.1.0 · enabled");
|
||||
});
|
||||
|
||||
it("warns when an enabled module cannot run, naming the dependency an operator must act on", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([discoveredModule("storage"), dependentModule("announcements", "storage")]),
|
||||
),
|
||||
readModuleLifecycle: vi.fn(async () =>
|
||||
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "storage" }]) }),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const lifecycle = check(report, "modules.lifecycle");
|
||||
|
||||
// An enabled module that cannot run is the anomaly worth surfacing — and it is exactly the
|
||||
// question an operator would otherwise take to the logs.
|
||||
expect(lifecycle.status).toBe("warning");
|
||||
expect(lifecycle.summary).toBe("1 enabled module cannot run");
|
||||
expect(lifecycle.details).toEqual([
|
||||
"0 eligible · 1 disabled · 1 blocked",
|
||||
"storage 0.1.0 · disabled",
|
||||
"announcements 0.1.0 · blocked · required_dependency_disabled · storage",
|
||||
]);
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
});
|
||||
|
||||
it("fails safely, without a driver detail, when the configuration cannot be read", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
readModuleLifecycle: vi.fn(async () => {
|
||||
throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const lifecycle = check(report, "modules.lifecycle");
|
||||
|
||||
expect(lifecycle.status).toBe("failure");
|
||||
expect(lifecycle.details).toEqual([
|
||||
"The stored module enablement configuration could not be read safely.",
|
||||
]);
|
||||
expect(JSON.stringify(lifecycle)).not.toContain("hunter2");
|
||||
expect(JSON.stringify(lifecycle)).not.toContain("10.40.0.2");
|
||||
});
|
||||
|
||||
it("reads with finite timeouts and the same validated transport policy every executable uses", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(services.readModuleLifecycle).toHaveBeenCalledWith(
|
||||
databaseUrl,
|
||||
expect.objectContaining({
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
transport: expect.objectContaining({ mode: "verify-full" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still reports lifecycle when an unrelated check fails, and never blocks on one", async () => {
|
||||
const services = dependencies({
|
||||
composePermissions: vi.fn(() => {
|
||||
throw new Error("composition exploded");
|
||||
}),
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.permissions").status).toBe("failure");
|
||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user