/* Site workspace resolver: resolves a site id to its runnable workspace across cache clones (../systems-cache//), materialized mission workpieces missions//workpiece/, and transitional apps/ directories. Do not define mission lifecycle or materialization semantics — that is RFC-0355/RFC-0356. Do not manage fleet registry state — that is system-config.yaml ownership. Do not handle deployment or propagation — that is RFC-0379. Dual representation is refused — a site in both apps/ and a workpiece is an error. RFC-0378: initial creation of the site workspace resolver seam. RFC-1025: add cache clone fallback (tryResolveCacheClone) as third resolution path. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. 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. RFC-1260: resolve cache clones via system-config.yaml — package.json gate left the RFC-1025 fallback dead for every real clone; executor-guard mutating commands against mirror targets */ import path from "node:path"; import { fileExists } from "@warpgogol/werkstatt-shared/node/fs"; import { discoverSystems, readSystemState, resolveCacheClonePath, } from "../sternsystem/registry-io.ts"; import type { DiscoveredSiteWorkspace } from "@warpgogol/werkstatt-shared/kernel"; import { createDefaultIO, type DirEntry, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; // @ai-invariant: The resolver must refuse dual representation — a site existing as both apps/ and a mission workpiece is an error, not a fallback. export type SiteWorkspaceSource = "apps" | "mission" | "cache-clone"; export interface SiteWorkspace extends DiscoveredSiteWorkspace { source: SiteWorkspaceSource; missionId: string | null; } const APP_CONFIG_FILENAMES = [ "kernel.config.ts", "kernel.config.mts", "kernel.config.js", "kernel.config.mjs", ] as const; function shouldIgnoreName(name: string): boolean { return name.startsWith("-") || name.startsWith("old-"); } async function readPackageName( packageJsonPath: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { try { const raw = await io.readFile(packageJsonPath); const data = JSON.parse(raw) as { name?: unknown }; return typeof data.name === "string" ? data.name : undefined; } catch { return undefined; } } async function resolveConfigPath(directory: string): Promise { const toolsDirectory = path.join(directory, "tools"); for (const filename of APP_CONFIG_FILENAMES) { const filePath = path.join(toolsDirectory, filename); if (await fileExists(filePath)) return filePath; } return null; } interface SystemInfo { id: string; currentMission?: string; } async function discoverSystemInfos(workspaceRoot: string): Promise { const { systems } = await discoverSystems(workspaceRoot); const infos: SystemInfo[] = []; for (const sys of systems) { let currentMission: string | undefined; try { const state = await readSystemState(workspaceRoot, sys.id); currentMission = state.currentMission ?? undefined; } catch { // State not available } infos.push({ id: sys.id, currentMission }); } return infos; } async function tryResolveMissionWorkpiece( workspaceRoot: string, info: SystemInfo, ): Promise { if (!info.currentMission) return null; const workpieceDir = path.join(workspaceRoot, "missions", info.currentMission, "workpiece"); if (!(await fileExists(path.join(workpieceDir, "package.json")))) return null; const configPath = await resolveConfigPath(workpieceDir); const packageName = await readPackageName(path.join(workpieceDir, "package.json")); return { name: info.id, source: "mission", directory: workpieceDir, toolsDirectory: path.join(workpieceDir, "tools"), missionId: info.currentMission, configPath: configPath ?? undefined, packageName, }; } async function tryResolveCacheClone( workspaceRoot: string, siteId: string, ): Promise { const cacheDir = resolveCacheClonePath(workspaceRoot, siteId); // Cache clones are identified by system-config.yaml (RFC-0790 — the same // marker discoverSystems uses), never by package.json: the generated // manifest is gitignored and exists only in materialized workpieces, so // requiring it left the RFC-1025 fallback unreachable for every real // clone. RFC-1260 read commands (content.get/query) resolve through here; // mutating commands are rejected at the executor level. if (!(await fileExists(path.join(cacheDir, "system-config.yaml")))) return null; const configPath = await resolveConfigPath(cacheDir); const packageName = await readPackageName(path.join(cacheDir, "package.json")); return { name: siteId, source: "cache-clone", directory: cacheDir, toolsDirectory: path.join(cacheDir, "tools"), missionId: null, configPath: configPath ?? undefined, packageName, }; } async function tryResolveAppsDirectory( workspaceRoot: string, siteId: string, ): Promise { const appDir = path.join(workspaceRoot, "apps", siteId); if (!(await fileExists(path.join(appDir, "package.json")))) return null; const configPath = await resolveConfigPath(appDir); const packageName = await readPackageName(path.join(appDir, "package.json")); return { name: siteId, source: "apps", directory: appDir, toolsDirectory: path.join(appDir, "tools"), missionId: null, configPath: configPath ?? undefined, packageName, }; } export async function resolveSiteWorkspace( workspaceRoot: string, siteId: string, ): Promise { const infos = await discoverSystemInfos(workspaceRoot); const info = infos.find((i) => i.id === siteId); const missionWorkspace = info ? await tryResolveMissionWorkpiece(workspaceRoot, info) : null; const cacheCloneWorkspace = await tryResolveCacheClone(workspaceRoot, siteId); const appsWorkspace = await tryResolveAppsDirectory(workspaceRoot, siteId); if (missionWorkspace && appsWorkspace) { if (info?.currentMission) { return missionWorkspace; } throw new Error( `dual-representation: site "${siteId}" exists both as apps/${siteId} and as mission workpiece missions/${missionWorkspace.missionId}/workpiece. RFC-0354 §6.4 forbids dual representation. Remove one before proceeding.`, ); } if (missionWorkspace) return missionWorkspace; if (cacheCloneWorkspace) return cacheCloneWorkspace; if (appsWorkspace) return appsWorkspace; const resolvable = await discoverSiteWorkspaces(workspaceRoot); const known = resolvable.map((ws) => ws.name).join(", "); throw new Error(`Unknown site id "${siteId}". Resolvable sites: ${known || "(none)"}`); } export async function discoverSiteWorkspaces( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const infos = await discoverSystemInfos(workspaceRoot); const results: SiteWorkspace[] = []; const seen = new Set(); // Mission workpieces from discovered systems for (const info of infos) { const ws = await tryResolveMissionWorkpiece(workspaceRoot, info); if (ws) { results.push(ws); seen.add(info.id); } } // Cache clone discovery (RFC-1025): sites with cache clones but no active mission for (const info of infos) { if (seen.has(info.id)) continue; const ws = await tryResolveCacheClone(workspaceRoot, info.id); if (ws) { results.push(ws); seen.add(info.id); } } // Transitional apps/ discovery const appsRoot = path.join(workspaceRoot, "apps"); let entries: DirEntry[]; try { entries = await io.readdir(appsRoot); } catch { entries = []; } for (const entry of entries) { if (!entry.isDirectory || shouldIgnoreName(entry.name)) continue; if (seen.has(entry.name)) { // A higher-precedence source already claimed this id // (resolveSiteWorkspace order: mission > cache-clone > apps/). The // cache clone wins silently — it is a read-only mirror, so there is no // write-target ambiguity. A mission workpiece coexisting with apps/ // remains a real dual-representation error (RFC-0354 §6.4). const claimed = results.find((ws) => ws.name === entry.name); if (claimed?.source === "cache-clone") continue; const sysInfo = infos.find((r) => r.id === entry.name); if (sysInfo?.currentMission) continue; throw new Error( `dual-representation: site "${entry.name}" exists both as apps/${entry.name} and as mission workpiece missions/${claimed?.missionId}/workpiece. RFC-0354 §6.4 forbids dual representation.`, ); } const ws = await tryResolveAppsDirectory(workspaceRoot, entry.name); if (ws) results.push(ws); } return results.sort((a, b) => a.name.localeCompare(b.name)); }