/*
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));
}