feat: add module runtime lifecycle
CI / verify (push) Successful in 2m14s
CI / infrastructure-smoke (push) Failing after 1m36s

This commit is contained in:
ekklesia
2026-07-12 22:49:51 -05:00
parent 57552a21a4
commit 7b448d8706
28 changed files with 1382 additions and 203 deletions
+38 -8
View File
@@ -3,6 +3,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import {
isVersionCompatible,
type ModuleCapabilityDeclaration,
type ModuleManifest,
safeParseModuleManifest,
semanticVersionSchema,
@@ -12,6 +13,7 @@ export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
export type ModuleDiscoveryIssueCode =
| "DISCOVERY_CONFIGURATION_INVALID"
| "MODULE_CAPABILITY_INCOMPATIBLE"
| "MODULE_CAPABILITY_MISSING"
| "MODULE_DEPENDENCY_CYCLE"
| "MODULE_DEPENDENCY_INCOMPATIBLE"
@@ -83,6 +85,11 @@ interface ModuleCandidate {
packageName: string;
}
interface CapabilityProvider {
candidate: ModuleCandidate;
declaration: ModuleCapabilityDeclaration;
}
export class ModuleDiscoveryError extends Error {
readonly issues: readonly ModuleDiscoveryIssue[];
@@ -90,6 +97,7 @@ export class ModuleDiscoveryError extends Error {
const sortedIssues = [...issues].sort(compareIssues);
super(`Module discovery failed with ${sortedIssues.length} issue(s).`);
this.name = "ModuleDiscoveryError";
this.stack = `${this.name}: ${this.message}`;
this.issues = freezeRecursively(sortedIssues);
}
}
@@ -229,6 +237,19 @@ async function resolveEntrypoints(
if (relativeEntrypoint === undefined) {
continue;
}
if (
entrypointName === "server" &&
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
) {
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.",
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "entrypoints.server",
});
continue;
}
const configuredPath = path.resolve(moduleDirectory, relativeEntrypoint);
if (!pathIsInside(moduleDirectory, configuredPath)) {
issues.push({
@@ -590,7 +611,7 @@ export async function discoverModules(
.map((candidate) => [candidate.manifest.id, candidate] as const),
);
const capabilityProviders = new Map<string, ModuleCandidate>();
const capabilityProviders = new Map<string, CapabilityProvider>();
for (const candidate of modulesById.values()) {
if (!isVersionCompatible(options.platformVersion, candidate.manifest.compatibility.platform)) {
issues.push({
@@ -613,8 +634,8 @@ export async function discoverModules(
});
}
for (const capability of candidate.manifest.capabilities) {
capabilityProviders.set(capability, candidate);
for (const declaration of candidate.manifest.capabilities) {
capabilityProviders.set(declaration.id, { candidate, declaration });
}
}
@@ -657,9 +678,9 @@ export async function discoverModules(
}
for (const dependencyKind of ["required", "optional"] as const) {
for (const [index, capability] of candidate.manifest.dependencies.capabilities[
dependencyKind
].entries()) {
for (const [capability, versionRange] of Object.entries(
candidate.manifest.dependencies.capabilities[dependencyKind],
)) {
const provider = capabilityProviders.get(capability);
if (!provider) {
if (dependencyKind === "required") {
@@ -668,12 +689,21 @@ export async function discoverModules(
message: `Required capability ${capability} is not installed.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: `dependencies.capabilities.${dependencyKind}.${index}`,
path: `dependencies.capabilities.${dependencyKind}.${capability}`,
});
}
continue;
}
addGraphEdge(provider.manifest.id, candidate.manifest.id, dependents, indegrees);
if (!isVersionCompatible(provider.declaration.version, versionRange)) {
issues.push({
code: "MODULE_CAPABILITY_INCOMPATIBLE",
message: `Capability ${capability} ${provider.declaration.version} does not satisfy ${versionRange}.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: `dependencies.capabilities.${dependencyKind}.${capability}`,
});
}
addGraphEdge(provider.candidate.manifest.id, candidate.manifest.id, dependents, indegrees);
}
}
}