/** * Instance identity and the on-disk shape of an instance * (openspec/changes/submodules, D2 and D4). * * Two jobs, both small and both load-bearing. * * **Identity.** A parent addresses its instances by an opaque key it chose. * celilo addresses them by a `modules.id`, because an instance IS a module row, * and that is what lets every reader keyed on `moduleId` keep working. This * file is the single place those two names meet, so the mapping cannot drift. * * **Layout.** An instance directory is real and private, holding only * `generated/`, with the submodule's authored subtrees symlinked in. That keeps * `join(sourcePath, 'generated')` true (the invariant nine call sites already * depend on) while giving each instance somewhere of its own to be written to. * * The alternative, pointing `sourcePath` at the shared submodule directory, * looks cleaner and is not. Hooks run with the module directory as their root * and WRITE into it (`browser.ts` puts screenshots there, `module build` uses it * as cwd), so N instances would land on top of each other. Copying instead is * out on size: a module is about 20MB, nearly all of it the bundled hook closure * that celilo#173 makes load-bearing, against about 72KB of authored source. */ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { lstat, mkdir, readdir, readlink, rm, symlink } from 'node:fs/promises'; import { join, relative, resolve } from 'node:path'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { type InstanceState, moduleInstances } from '../db/schema'; import { SUBMODULES_DIR } from '../manifest/validate'; /** * Bits of hash in a derived instance id, as hex characters. * * 12 hex characters is 48 bits, so a birthday collision needs on the order of * 16 million instances of ONE submodule under ONE parent. A fleet runs tens. * Short enough to read in `celilo module list`, long enough that nobody has to * think about it. */ const INSTANCE_ID_HASH_CHARS = 12; /** Entries an instance never links, because it owns its own copy or none. */ const NOT_SYMLINKED = new Set(['generated', 'screenshots', 'cookies.json', 'checksums.json']); /** The one subdirectory of an instance that is real rather than a link. */ export const INSTANCE_GENERATED_DIR = 'generated'; /** * The `modules.id` an instance runs as. * * Derived rather than supplied, for two reasons. The parent's key is opaque and * may be anything (D2 forbids celilo interpreting it), while `modules.id` must * be kebab-case. And identity is the TRIPLE, so the parent and submodule have to * be inside the hash, or two parents using the same key would collide. * * Deterministic: the same triple always yields the same id, which is what makes * an instantiate safe to retry. */ export function deriveInstanceModuleId( parentId: string, submodule: string, instanceKey: string, ): string { // The separator matters. Hashing the concatenation alone would let // ("a", "b-c") and ("a-b", "c") collide, which is the kind of ambiguity that // never shows up until it does. const digest = createHash('sha256') .update(`${parentId} ${submodule} ${instanceKey}`) .digest('hex') .slice(0, INSTANCE_ID_HASH_CHARS); return `${parentId}-${submodule}-${digest}`; } /** Where a parent's shared submodule source lives inside its own install. */ export function submoduleSourcePath(parentSourcePath: string, submodule: string): string { return join(parentSourcePath, SUBMODULES_DIR, submodule); } /** * Which entries of a submodule's source get symlinked into an instance. * * Pure, so the decision is testable without a filesystem. `generated/` is * excluded because the instance owns its own. The derived hook outputs * (`screenshots/`, `cookies.json`) are excluded because linking them would put * every instance's writes back into one shared place, which is the failure this * layout exists to prevent. */ export function planInstanceLinks(submoduleEntries: string[]): string[] { return submoduleEntries.filter((entry) => !NOT_SYMLINKED.has(entry)).sort(); } /** * Create or repair an instance's symlink farm. * * Idempotent, and deliberately a full converge rather than a create. A submodule * that gains a top-level directory in a later version would otherwise leave * every existing instance stale, so this runs on instantiate AND on parent * update. A link pointing somewhere else is replaced rather than left, because a * repointed link is the one integrity property unique to instances. * * Returns the entries linked, so a caller can report what changed. */ export async function buildInstanceLinkFarm(opts: { instancePath: string; submodulePath: string; }): Promise { const { instancePath, submodulePath } = opts; if (!existsSync(submodulePath)) { throw new Error( `Cannot build an instance at ${instancePath}: its submodule source ${submodulePath} does not exist. The parent's install is incomplete.`, ); } await mkdir(instancePath, { recursive: true }); await mkdir(join(instancePath, INSTANCE_GENERATED_DIR), { recursive: true }); const wanted = planInstanceLinks(await readdir(submodulePath)); for (const entry of wanted) { const linkPath = join(instancePath, entry); // Relative, so the farm survives the whole data directory being moved or // restored somewhere else, which `celilo restore` does. const target = relative(instancePath, join(submodulePath, entry)); const existing = await readExistingLink(linkPath); if (existing === target) continue; if (existing !== null) await rm(linkPath, { recursive: true, force: true }); await symlink(target, linkPath); } // Links for entries the submodule no longer has. Left behind they dangle, and // a dangling link reads as a broken install rather than as a stale one. for (const entry of await readdir(instancePath)) { if (entry === INSTANCE_GENERATED_DIR || wanted.includes(entry)) continue; const stale = join(instancePath, entry); if ((await lstat(stale)).isSymbolicLink()) await rm(stale, { force: true }); } return wanted; } /** * What `linkPath` currently points at, or null if it is absent or not a link. * * A real file or directory where a link belongs returns null so the caller * replaces it. That is the right answer: an instance directory holds nothing of * its own except `generated/`, so anything else there is debris. */ async function readExistingLink(linkPath: string): Promise { if (!existsSync(linkPath)) { // existsSync FOLLOWS links, so a dangling link reports absent. lstat is what // distinguishes "nothing here" from "a link to nowhere", and only the second // needs removing before symlink() will succeed. try { await lstat(linkPath); } catch { return null; } await rm(linkPath, { force: true }); return null; } try { return (await lstat(linkPath)).isSymbolicLink() ? await readlink(linkPath) : null; } catch { return null; } } /** One link that does not point where it should. */ export interface LinkViolation { entry: string; expected: string; actual: string | null; } /** * Verify an instance's farm points where it claims. * * The integrity property unique to instances (D4). An instance carries no * authored bytes of its own, so `module verify` has nothing of its own to * checksum: its bytes are the submodule's, which are covered by the parent's * baseline. What IS worth checking, and what nothing else checks, is that the * links still resolve inside the declaring parent and have not been repointed at * somebody else's source. */ export async function verifyInstanceLinks(opts: { instancePath: string; submodulePath: string; }): Promise { const { instancePath, submodulePath } = opts; const violations: LinkViolation[] = []; for (const entry of planInstanceLinks(await readdir(submodulePath))) { const linkPath = join(instancePath, entry); const expected = resolve(submodulePath, entry); const actual = await readExistingLink(linkPath); const resolved = actual === null ? null : resolve(instancePath, actual); if (resolved !== expected) { violations.push({ entry, expected, actual: resolved }); } } return violations; } /** An instance row, joined to what the caller needs to reach its source. */ export interface InstanceRecord { moduleId: string; parentId: string; submodule: string; instanceKey: string; label: string | null; state: InstanceState; } /** * The instance row for `moduleId`, or null if it names an ordinary module. * * The one question every caller that must treat an instance differently has to * ask, so it lives here rather than being a join each of them writes. */ export function loadInstance(moduleId: string, db: DbClient): InstanceRecord | null { const row = db .select({ moduleId: moduleInstances.moduleId, parentId: moduleInstances.parentId, submodule: moduleInstances.submodule, instanceKey: moduleInstances.instanceKey, label: moduleInstances.label, state: moduleInstances.state, }) .from(moduleInstances) .where(eq(moduleInstances.moduleId, moduleId)) .get(); return row ?? null; } /** Every instance a parent owns, oldest first so listings are stable. */ export function loadInstancesForParent(parentId: string, db: DbClient): InstanceRecord[] { return db .select({ moduleId: moduleInstances.moduleId, parentId: moduleInstances.parentId, submodule: moduleInstances.submodule, instanceKey: moduleInstances.instanceKey, label: moduleInstances.label, state: moduleInstances.state, }) .from(moduleInstances) .where(eq(moduleInstances.parentId, parentId)) .orderBy(moduleInstances.createdAt) .all(); } /** * Every module id whose systems `moduleId` transitively owns: itself, plus each * of its instances. * * This is the allow-list behind D12 of `openspec/changes/hook-process-boundary` * ("a module may reach the systems it provisioned, or that its submodules * provisioned"). Feed it to `getModuleSystems` per id, or use it directly as an * `IN (...)` set. * * NOT RECURSIVE, and that is a property of the model rather than a shortcut. * Nesting is refused at parent import (`validateSubmoduleManifest`), so * ownership is exactly one level deep by construction: a parent owns instances, * and an instance owns nothing. So this is one indexed lookup on * `module_instances_parent_idx` rather than a walk, which is what makes it * cheap enough to run at hook-invocation time. * * An instance asking gets only itself, which is correct: an instance provisions * its own systems and owns nobody else's. */ export function ownedSystemModuleIds(moduleId: string, db: DbClient): string[] { const instances = db .select({ moduleId: moduleInstances.moduleId }) .from(moduleInstances) .where(eq(moduleInstances.parentId, moduleId)) .all(); return [moduleId, ...instances.map((row) => row.moduleId)]; }