Implement deterministic website build engine

This commit is contained in:
2026-07-23 13:36:17 -07:00
parent 3af326a1fa
commit a27ec7bf0c
8 changed files with 847 additions and 128 deletions
+3
View File
@@ -6,3 +6,6 @@ coverage/
*.log
.env*
!.env.example
# Project-local SSH credentials (never commit private keys)
.ssh/
+49 -13
View File
@@ -1,20 +1,56 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Labyricorn Website Engine Control Plane
# Run and deploy your AI Studio app
A React control plane and Express backend for discovering Git-hosted Markdown, validating canonical site configuration, and producing deterministic static website releases.
This contains everything you need to run your app locally.
## Engine capabilities
View your app in AI Studio: https://ai.studio/apps/941c018f-56b5-4bf6-afbf-444c3f0a351f
The build engine now:
## Run Locally
- validates routes, route collisions, content state, raw-HTML policy, and media references;
- renders published content and navigation indexes into static HTML;
- escapes source HTML and renders a safe CommonMark-style subset;
- emits deterministic `build-manifest.json` and `checksums.json` files;
- calculates an artifact checksum from generated file checksums;
- writes releases atomically beneath the configured build root;
- activates releases through an atomic `current` symlink/junction;
- survives process restarts without reusing existing build numbers; and
- serves the active generated release through the live-preview endpoint.
**Prerequisites:** Node.js
## Development
Requires Node.js 22 or later.
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
```bash
npm install
npm test
npm run lint
npm run dev
```
Create a production bundle with:
```bash
npm run build
npm start
```
The control plane listens on port `3000`.
## Build artifacts
By default, releases are written to the platform temporary directory under `labyricorn-builds/`. Set `LABYRICORN_BUILD_ROOT` to use a persistent release directory in production.
Each successful release contains:
- generated route directories with `index.html`;
- `404.html`;
- `build-manifest.json`; and
- `checksums.json`.
Validation failures produce a failed build record but never publish a partial release directory.
## Current boundaries
- The built-in renderer is used until repository-provided theme templates are implemented.
- Git source synchronization supports HTTP(S), SSH-style Git URLs, and local paths; other source types remain unavailable.
- Remote rsync deployment is still represented by the existing control-plane simulation and must not be treated as a completed production deploy path.
+2 -1
View File
@@ -9,7 +9,8 @@
"start": "node dist/server.cjs",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
"lint": "tsc --noEmit",
"test": "node --import tsx --test src/backend/buildEngine.test.ts"
},
"dependencies": {
"@google/genai": "^2.4.0",
+8 -32
View File
@@ -1,7 +1,6 @@
import express from "express";
import path from "path";
import fs from "fs";
import crypto from "crypto";
import { createServer as createViteServer } from "vite";
import { store } from "./src/backend/store";
import { CredentialRef } from "./src/types";
@@ -561,41 +560,18 @@ async function startServer() {
store.builds.find((b) => b.isActiveLocal) || store.builds[0];
let pageRoute = (req.query.route as string) || "/";
const rootDir = "/tmp/labyricorn-builds/current";
const rootDir = store.getCurrentBuildDirectory();
if (fs.existsSync(rootDir)) {
if (pageRoute === "/") {
// Try to list all generated pages to simulate an index
let links = "";
try {
const items = store.contentItems;
links = items
.map(
(item) =>
`<li><a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'${item.route}'}, '*')">${item.title}</a></li>`,
)
.join("");
} catch (e) {}
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Labyricorn Projection</title>
<style>body { font-family: system-ui; padding: 2rem; background: #fff; color: #111; }</style>
</head>
<body>
<h1>Local Canonical Site</h1>
<p>Generated Projection from Repository Artifacts</p>
<ul>${links}</ul>
</body>
</html>
`);
const routeWithoutQuery = pageRoute.split(/[?#]/, 1)[0].replace(/\\/g, "/");
const relativeRoute = routeWithoutQuery.replace(/^\/+/, "");
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
const targetPath = path.resolve(rootDir, relativeRoute, "index.html");
if (!targetPath.startsWith(rootPath)) {
return res.status(400).json({ error: "Invalid preview route" });
}
const targetPath = path.join(rootDir, pageRoute, "index.html");
if (fs.existsSync(targetPath)) {
return res.send(fs.readFileSync(targetPath, "utf-8"));
return res.type("html").send(fs.readFileSync(targetPath, "utf-8"));
}
}
+181
View File
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { BuildEngine, renderMarkdown } from "./buildEngine";
import { BuildEngineInput } from "./buildEngine";
import { ContentItem, SiteConfig, ThemeConfig } from "../types";
const siteConfig: SiteConfig = {
protocol: "labyricorn-site/v1",
site: {
id: "test-site",
title: "Test Site",
baseUrl: "https://example.test",
language: "en-US",
},
navigation: [
{ id: "home", label: "Home", route: "/", iconName: "Home", contentModel: "page" },
{ id: "articles", label: "Articles", route: "/articles/", iconName: "FileText", contentModel: "article" },
],
contentModels: [],
sourcesFile: "./sources.yml",
styleInstancesPath: "./styles",
pagesPath: "./pages",
navigationFile: "./navigation.yml",
pushIntegrationsPath: "./push",
theme: { source: "site-definition", path: "/.theme" },
markdown: {
dialect: "commonmark",
rawHtmlPolicy: "disabled",
rawHtmlEnabled: false,
extensions: {
tables: true,
taskLists: true,
footnotes: true,
definitionLists: true,
headingAnchors: true,
fencedCode: true,
syntaxHighlighting: true,
callouts: true,
youtube: true,
wikipediaLinks: true,
},
},
hosting: {
engine: "nginx",
production: { enabled: true, hostname: "example.test", listen: 80 },
staging: { enabled: true, hostname: "preview.example.test", listen: 8080 },
releases: { retainCount: 5 },
},
buildPolicy: {
staging: { enabled: true, requireApproval: true },
localActivation: { automatic: false },
push: { automatic: false },
},
rawYaml: "",
};
const themeConfig: ThemeConfig = {
id: "test-theme",
name: "Test Theme",
version: "1.0.0",
path: "/.theme",
templates: {},
styles: [],
scripts: [],
supportsPackages: [],
isValidated: true,
validationErrors: [],
};
const content = (overrides: Partial<ContentItem> = {}): ContentItem => ({
id: "article-one",
title: "Article One",
slug: "article-one",
published: "2026-01-01T00:00:00.000Z",
status: "published",
artifactType: "article",
summary: "A deterministic article.",
tags: ["test"],
aliases: [],
sourceRepo: "content",
path: "articles/article-one.md",
contentMarkdown: "# Hello\n\nThis is **safe** markdown.",
mediaReferences: [],
youtubeDirectives: [],
wikipediaLinks: [],
validationStatus: "valid",
validationMessages: [],
route: "/articles/article-one/",
styleInstanceId: "default",
...overrides,
});
const input = (buildId: string, items: ContentItem[]): BuildEngineInput => ({
buildId,
siteConfig,
contentItems: items,
mediaAssets: [],
sourceCommits: { content: "abc123" },
themeConfig,
});
test("build output is deterministic for equivalent inputs", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
try {
const engine = new BuildEngine(root);
const firstItem = content();
const secondItem = content({
id: "article-two",
title: "Article Two",
slug: "article-two",
path: "articles/article-two.md",
route: "/articles/article-two/",
});
const first = engine.build(input("build-one", [firstItem, secondItem]));
const second = engine.build(input("build-two", [secondItem, firstItem]));
assert.equal(first.success, true);
assert.equal(second.success, true);
assert.equal(first.artifactChecksum, second.artifactChecksum);
assert.deepEqual(first.generatedFiles, second.generatedFiles);
assert.ok(fs.existsSync(path.join(first.outputDirectory, "build-manifest.json")));
assert.ok(fs.existsSync(path.join(first.outputDirectory, "checksums.json")));
assert.match(
fs.readFileSync(path.join(first.outputDirectory, "articles/article-one/index.html"), "utf8"),
/<h1>Hello<\/h1>/,
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("route collisions fail validation without writing a release", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
try {
const engine = new BuildEngine(root);
const result = engine.build(
input("collision", [content(), content({ id: "article-two", route: "/articles/article-one/" })]),
);
assert.equal(result.success, false);
assert.equal(result.validationReport.summary.passed, false);
assert.deepEqual(result.validationReport.routeCollisions, ["/articles/article-one/"]);
assert.equal(fs.existsSync(result.outputDirectory), false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("raw HTML is rejected when the site policy disables it", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
try {
const engine = new BuildEngine(root);
const result = engine.build(input("raw-html", [content({ contentMarkdown: "<script>alert(1)</script>" })]));
assert.equal(result.success, false);
assert.equal(result.validationReport.htmlPolicyViolations.length, 1);
assert.equal(fs.existsSync(result.outputDirectory), false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("raw HTML inside fenced code remains a valid code example", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
try {
const engine = new BuildEngine(root);
const result = engine.build(
input("html-code-example", [content({ contentMarkdown: "```html\n<div>example</div>\n```" })]),
);
assert.equal(result.success, true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("markdown renderer escapes source HTML", () => {
assert.equal(renderMarkdown("Text <img src=x>"), "<p>Text &lt;img src=x&gt;</p>");
});
+472
View File
@@ -0,0 +1,472 @@
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import {
ContentItem,
MediaAsset,
SiteConfig,
ThemeConfig,
ValidationReport,
} from "../types";
export const BUILDER_VERSION = "1.0.0-labyricorn";
export interface BuildEngineInput {
buildId: string;
siteConfig: SiteConfig;
contentItems: ContentItem[];
mediaAssets: MediaAsset[];
sourceCommits: Record<string, string>;
themeConfig: ThemeConfig;
}
export interface BuildEngineResult {
success: boolean;
outputDirectory: string;
generatedRoutesCount: number;
artifactChecksum: string;
artifactSizeBytes: number;
validationReport: ValidationReport;
generatedFiles: string[];
}
const emptyReport = (): ValidationReport => ({
errors: [],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
});
const escapeHtml = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const stableValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(stableValue);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, child]) => [key, stableValue(child)]),
);
}
return value;
};
const stableJson = (value: unknown): string =>
`${JSON.stringify(stableValue(value), null, 2)}\n`;
const sha256 = (value: string | Buffer): string =>
crypto.createHash("sha256").update(value).digest("hex");
const normalizeRoute = (route: string): string => {
const trimmed = route.trim();
if (!trimmed || trimmed.includes("\0") || /[?#]/.test(trimmed)) {
throw new Error(`Invalid route '${route}'`);
}
const segments = trimmed
.replace(/\\/g, "/")
.split("/")
.filter(Boolean);
if (segments.some((segment) => segment === "." || segment === "..")) {
throw new Error(`Route traversal is not allowed: '${route}'`);
}
return segments.length === 0 ? "/" : `/${segments.join("/")}/`;
};
const routeFile = (route: string): string => {
const normalized = normalizeRoute(route);
return normalized === "/"
? "index.html"
: `${normalized.slice(1)}index.html`;
};
const renderInlineMarkdown = (value: string): string => {
const codeTokens: string[] = [];
let rendered = value.replace(/`([^`]+)`/g, (_match, code: string) => {
const token = `\u0000CODE${codeTokens.length}\u0000`;
codeTokens.push(`<code>${escapeHtml(code)}</code>`);
return token;
});
rendered = escapeHtml(rendered)
.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
'<a href="$2" rel="noopener noreferrer">$1</a>',
)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) =>
codeTokens[Number(index)] ?? "",
);
};
export const renderMarkdown = (markdown: string): string => {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: string[] = [];
let paragraph: string[] = [];
let list: string[] = [];
let code: string[] | null = null;
let codeLanguage = "";
const flushParagraph = () => {
if (paragraph.length > 0) {
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
paragraph = [];
}
};
const flushList = () => {
if (list.length > 0) {
blocks.push(`<ul>${list.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`);
list = [];
}
};
for (const line of lines) {
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
if (fence) {
if (code === null) {
flushParagraph();
flushList();
code = [];
codeLanguage = fence[1];
} else {
const languageClass = codeLanguage
? ` class="language-${escapeHtml(codeLanguage)}"`
: "";
blocks.push(`<pre><code${languageClass}>${escapeHtml(code.join("\n"))}</code></pre>`);
code = null;
codeLanguage = "";
}
continue;
}
if (code !== null) {
code.push(line);
continue;
}
if (!line.trim()) {
flushParagraph();
flushList();
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length;
blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const listItem = line.match(/^[-*]\s+(.+)$/);
if (listItem) {
flushParagraph();
list.push(listItem[1]);
continue;
}
if (line.startsWith("> ")) {
flushParagraph();
flushList();
blocks.push(`<blockquote>${renderInlineMarkdown(line.slice(2))}</blockquote>`);
continue;
}
paragraph.push(line.trim());
}
if (code !== null) {
blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
}
flushParagraph();
flushList();
return blocks.join("\n");
};
export class BuildEngine {
readonly outputRoot: string;
constructor(outputRoot = process.env.LABYRICORN_BUILD_ROOT || path.join(os.tmpdir(), "labyricorn-builds")) {
this.outputRoot = path.resolve(outputRoot);
}
buildDirectory(buildId: string): string {
if (!/^[A-Za-z0-9._-]+$/.test(buildId)) {
throw new Error(`Invalid build ID '${buildId}'`);
}
return path.join(this.outputRoot, buildId);
}
currentDirectory(): string {
return path.join(this.outputRoot, "current");
}
nextBuildNumber(): number {
if (!fs.existsSync(this.outputRoot)) return 1;
const highestExisting = fs
.readdirSync(this.outputRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map((entry) => entry.name.match(/^build-(\d+)$/)?.[1])
.filter((value): value is string => Boolean(value))
.map(Number)
.reduce((highest, value) => Math.max(highest, value), 0);
return highestExisting + 1;
}
validate(input: BuildEngineInput): ValidationReport {
const report = emptyReport();
const claimedRoutes = new Map<string, string>();
const claim = (route: string, owner: string) => {
try {
const normalized = normalizeRoute(route);
const existing = claimedRoutes.get(normalized);
if (existing && existing !== owner) {
report.routeCollisions.push(normalized);
report.errors.push({
code: "E_ROUTE_COLLISION",
message: `Route '${normalized}' is claimed by both ${existing} and ${owner}.`,
category: "routing",
});
} else {
claimedRoutes.set(normalized, owner);
}
} catch (error) {
report.errors.push({
code: "E_ROUTE_INVALID",
message: error instanceof Error ? error.message : String(error),
category: "routing",
});
}
};
claim("/", "site-home");
for (const navigation of input.siteConfig.navigation) {
if (navigation.route !== "/") claim(navigation.route, `navigation:${navigation.id}`);
}
const knownMedia = new Set(input.mediaAssets.map((asset) => asset.publicNamespacePath));
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
claim(item.route, `content:${item.id}`);
if (item.validationStatus === "error") {
report.errors.push({
code: "E_CONTENT_INVALID",
message: `Content item '${item.id}' failed source validation.`,
file: item.path,
category: "content",
});
}
const markdownWithoutCode = item.contentMarkdown
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]*`/g, "");
if (!input.siteConfig.markdown.rawHtmlEnabled && /<\/?[A-Za-z][^>]*>/.test(markdownWithoutCode)) {
report.htmlPolicyViolations.push(item.path);
report.errors.push({
code: "E_RAW_HTML_DISABLED",
message: `Raw HTML is disabled but was found in '${item.path}'.`,
file: item.path,
category: "markdown",
});
}
for (const reference of item.mediaReferences) {
if (!/^https?:\/\//.test(reference) && !knownMedia.has(reference)) {
report.missingMedia.push(reference);
}
}
}
for (const missing of [...new Set(report.missingMedia)].sort()) {
report.errors.push({
code: "E_MEDIA_MISSING",
message: `Referenced media '${missing}' was not discovered.`,
category: "media",
});
}
for (const asset of input.mediaAssets.filter((candidate) => !candidate.isValidated)) {
report.warnings.push({
code: "W_MEDIA_UNVALIDATED",
message: `Media asset '${asset.originalPath}' has not been validated.`,
file: asset.originalPath,
category: "media",
});
}
report.routeCollisions = [...new Set(report.routeCollisions)].sort();
report.missingMedia = [...new Set(report.missingMedia)].sort();
report.htmlPolicyViolations = [...new Set(report.htmlPolicyViolations)].sort();
report.summary = {
totalErrors: report.errors.length,
totalWarnings: report.warnings.length,
passed: report.errors.length === 0,
};
return report;
}
build(input: BuildEngineInput): BuildEngineResult {
const validationReport = this.validate(input);
const outputDirectory = this.buildDirectory(input.buildId);
if (!validationReport.summary.passed) {
return {
success: false,
outputDirectory,
generatedRoutesCount: 0,
artifactChecksum: "",
artifactSizeBytes: 0,
validationReport,
generatedFiles: [],
};
}
fs.mkdirSync(this.outputRoot, { recursive: true });
const temporaryDirectory = path.join(
this.outputRoot,
`.${input.buildId}.tmp-${process.pid}-${crypto.randomUUID()}`,
);
fs.mkdirSync(temporaryDirectory, { recursive: true });
try {
const publishedItems = input.contentItems
.filter((item) => item.status === "published")
.sort((left, right) => normalizeRoute(left.route).localeCompare(normalizeRoute(right.route)) || left.id.localeCompare(right.id));
const generatedRoutes = new Set<string>();
const generatedFiles: string[] = [];
const navigation = input.siteConfig.navigation
.map((entry) => `<a href="${escapeHtml(normalizeRoute(entry.route))}">${escapeHtml(entry.label)}</a>`)
.join("");
const layout = (title: string, body: string) => `<!doctype html>
<html lang="${escapeHtml(input.siteConfig.site.language)}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} | ${escapeHtml(input.siteConfig.site.title)}</title>
<style>:root{color-scheme:light dark;font-family:Inter,system-ui,sans-serif}body{max-width:72rem;margin:auto;padding:2rem;line-height:1.65}nav{display:flex;gap:1rem;flex-wrap:wrap;border-bottom:1px solid #7775;padding-bottom:1rem;margin-bottom:2rem}a{color:#b38a2e}main{max-width:52rem}article+article{border-top:1px solid #7775;margin-top:1.5rem;padding-top:1.5rem}pre{overflow:auto;padding:1rem;background:#111;color:#eee}code{font-family:ui-monospace,monospace}blockquote{border-left:.25rem solid #b38a2e;padding-left:1rem;margin-left:0}</style>
</head>
<body><nav>${navigation}</nav><main>${body}</main></body>
</html>
`;
const writeRoute = (route: string, html: string) => {
const normalized = normalizeRoute(route);
const relativeFile = routeFile(normalized);
const absoluteFile = path.join(temporaryDirectory, relativeFile);
fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
fs.writeFileSync(absoluteFile, html, "utf8");
generatedRoutes.add(normalized);
generatedFiles.push(relativeFile.replace(/\\/g, "/"));
};
const homeItems = publishedItems
.map((item) => `<article><h2><a href="${escapeHtml(normalizeRoute(item.route))}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.summary)}</p></article>`)
.join("");
writeRoute("/", layout(input.siteConfig.site.title, `<h1>${escapeHtml(input.siteConfig.site.title)}</h1>${homeItems || "<p>No published content.</p>"}`));
for (const entry of input.siteConfig.navigation.filter((candidate) => candidate.route !== "/")) {
const items = publishedItems
.filter((item) => item.artifactType === entry.contentModel)
.map((item) => `<article><h2><a href="${escapeHtml(normalizeRoute(item.route))}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.summary)}</p></article>`)
.join("");
writeRoute(entry.route, layout(entry.label, `<h1>${escapeHtml(entry.label)}</h1>${items || "<p>No published content.</p>"}`));
}
for (const item of publishedItems) {
const embeds = item.youtubeDirectives
.filter((directive) => /^[A-Za-z0-9_-]{6,20}$/.test(directive.videoId))
.map((directive) => `<section><h2>${escapeHtml(directive.title)}</h2><iframe loading="lazy" src="https://www.youtube-nocookie.com/embed/${directive.videoId}" title="${escapeHtml(directive.title)}" allowfullscreen></iframe></section>`)
.join("");
writeRoute(
item.route,
layout(item.title, `<article><h1>${escapeHtml(item.title)}</h1><p><em>${escapeHtml(item.summary)}</em></p>${renderMarkdown(item.contentMarkdown)}${embeds}</article>`),
);
}
const errorPage = layout("Not Found", "<h1>404</h1><p>The requested page was not found.</p>");
fs.writeFileSync(path.join(temporaryDirectory, "404.html"), errorPage, "utf8");
generatedFiles.push("404.html");
const manifest = {
protocol: input.siteConfig.protocol,
builderVersion: BUILDER_VERSION,
site: {
id: input.siteConfig.site.id,
baseUrl: input.siteConfig.site.baseUrl,
language: input.siteConfig.site.language,
},
sourceCommits: input.sourceCommits,
theme: { id: input.themeConfig.id, version: input.themeConfig.version },
routes: [...generatedRoutes].sort(),
media: input.mediaAssets
.map((asset) => ({ path: asset.publicNamespacePath, sha256: null, sizeBytes: asset.sizeBytes }))
.sort((left, right) => left.path.localeCompare(right.path)),
};
fs.writeFileSync(path.join(temporaryDirectory, "build-manifest.json"), stableJson(manifest), "utf8");
generatedFiles.push("build-manifest.json");
const checksums = Object.fromEntries(
generatedFiles
.sort()
.map((relativeFile) => [relativeFile, sha256(fs.readFileSync(path.join(temporaryDirectory, relativeFile)))]),
);
const checksumsDocument = stableJson({ algorithm: "sha256", files: checksums });
fs.writeFileSync(path.join(temporaryDirectory, "checksums.json"), checksumsDocument, "utf8");
generatedFiles.push("checksums.json");
const artifactChecksum = `sha256-${sha256(checksumsDocument)}`;
const artifactSizeBytes = generatedFiles.reduce(
(total, relativeFile) => total + fs.statSync(path.join(temporaryDirectory, relativeFile)).size,
0,
);
if (fs.existsSync(outputDirectory)) {
throw new Error(`Build output already exists: ${outputDirectory}`);
}
fs.renameSync(temporaryDirectory, outputDirectory);
return {
success: true,
outputDirectory,
generatedRoutesCount: generatedRoutes.size,
artifactChecksum,
artifactSizeBytes,
validationReport,
generatedFiles: generatedFiles.sort(),
};
} catch (error) {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
throw error;
}
}
activate(buildId: string): string {
const buildDirectory = this.buildDirectory(buildId);
if (!fs.existsSync(path.join(buildDirectory, "build-manifest.json"))) {
throw new Error(`Build '${buildId}' is missing a build manifest.`);
}
fs.mkdirSync(this.outputRoot, { recursive: true });
const current = this.currentDirectory();
const next = path.join(this.outputRoot, `.current-${process.pid}-${crypto.randomUUID()}`);
fs.symlinkSync(buildDirectory, next, process.platform === "win32" ? "junction" : "dir");
try {
if (fs.existsSync(current) || fs.lstatSync(current, { throwIfNoEntry: false })) {
fs.rmSync(current, { recursive: true, force: true });
}
fs.renameSync(next, current);
} catch (error) {
fs.rmSync(next, { recursive: true, force: true });
throw error;
}
return current;
}
}
+101 -75
View File
@@ -19,7 +19,7 @@ import {
import YAML from "yaml";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { BuildEngine, BUILDER_VERSION } from "./buildEngine";
export class LabyricornStore {
siteConfig: SiteConfig;
@@ -34,6 +34,7 @@ export class LabyricornStore {
pushTargets: PushTarget[];
credentials: CredentialRef[];
auditLogs: AuditLogEntry[];
private readonly buildEngine = new BuildEngine();
constructor() {
this.gitSources = [];
@@ -193,7 +194,11 @@ export class LabyricornStore {
}
runBuild(): BuildRecord {
const nextBuildNum = this.builds.length + 1;
const buildStartedAt = Date.now();
const nextBuildNum = Math.max(
this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1,
this.buildEngine.nextBuildNumber(),
);
const buildId = `build-${String(nextBuildNum).padStart(6, "0")}`;
const timestamp = new Date().toISOString();
@@ -262,38 +267,46 @@ export class LabyricornStore {
step: "building",
});
// Actual build simulation logic into /tmp
const buildDir = `/tmp/labyricorn-builds/${buildId}`;
let artifactSizeBytes = 0;
let checksum = "sha256-";
let validationReport: ValidationReport = {
errors: [],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
};
let engineResult: ReturnType<BuildEngine["build"]>;
try {
if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
// Generate some actual files based on content items
this.contentItems.forEach((item) => {
const routePath = path.join(buildDir, item.route);
if (!fs.existsSync(routePath))
fs.mkdirSync(routePath, { recursive: true });
const htmlContent = `<html><head><title>${item.title}</title></head><body><h1>${item.title}</h1><p>${item.summary}</p></body></html>`;
fs.writeFileSync(path.join(routePath, "index.html"), htmlContent);
artifactSizeBytes += htmlContent.length;
engineResult = this.buildEngine.build({
buildId,
siteConfig: this.siteConfig,
contentItems: this.contentItems,
mediaAssets: this.mediaAssets,
sourceCommits,
themeConfig: this.themeConfig,
});
const checksumContent = this.contentItems.map((i) => i.id).join("-");
checksum = `sha256-${crypto.createHash("sha256").update(checksumContent).digest("hex")}`;
validationReport = engineResult.validationReport;
if (!engineResult.success) {
const validationError = new Error(
`Validation failed with ${validationReport.summary.totalErrors} error(s).`,
);
(validationError as Error & { validationReport: ValidationReport }).validationReport =
validationReport;
throw validationError;
}
logs.push({
timestamp: new Date().toISOString(),
level: "success",
message: `Static site build complete! Output written to ${buildDir}`,
message: `Static site build complete. Output written to ${engineResult.outputDirectory}`,
step: "built",
});
logs.push({
timestamp: new Date().toISOString(),
level: "success",
message: `Generated build-manifest.json & checksums.json (${checksum.substring(0, 16)}...)`,
message: `Generated build-manifest.json and checksums.json (${engineResult.artifactChecksum.substring(0, 20)}...)`,
step: "built",
});
} catch (e: any) {
@@ -303,21 +316,8 @@ export class LabyricornStore {
message: `Build failed: ${e.message}`,
step: "building",
});
const failedBuild: BuildRecord = {
id: buildId,
buildNumber: nextBuildNum,
timestamp,
durationMs: 450,
status: "failed",
siteDefCommit: "local",
sourceCommits,
themeVersion: this.themeConfig.version,
builderVersion: "1.0.0-labyricorn",
generatedRoutesCount: 0,
mediaCount: 0,
artifactChecksum: "",
artifactSizeBytes: 0,
validationReport: {
const failureReport: ValidationReport =
e.validationReport || {
errors: [
{ code: "E_BUILD_FAIL", message: e.message, category: "build" },
],
@@ -327,7 +327,22 @@ export class LabyricornStore {
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 1, totalWarnings: 0, passed: false },
},
};
const failedBuild: BuildRecord = {
id: buildId,
buildNumber: nextBuildNum,
timestamp,
durationMs: Date.now() - buildStartedAt,
status: "failed",
siteDefCommit: "local",
sourceCommits,
themeVersion: this.themeConfig.version,
builderVersion: BUILDER_VERSION,
generatedRoutesCount: 0,
mediaCount: 0,
artifactChecksum: "",
artifactSizeBytes: 0,
validationReport: failureReport,
logs,
stages: [
{ name: "Triggered", status: "succeeded", durationMs: 10 },
@@ -341,12 +356,20 @@ export class LabyricornStore {
status: "succeeded",
durationMs: 450,
},
{ name: "Metadata validation", status: "succeeded", durationMs: 120 },
{ name: "Artifact discovery", status: "succeeded", durationMs: 310 },
{
name: "Metadata validation",
status: failureReport.summary.passed ? "succeeded" : "failed",
durationMs: 120,
},
{
name: "Artifact discovery",
status: failureReport.summary.passed ? "succeeded" : "skipped",
durationMs: failureReport.summary.passed ? 310 : undefined,
},
{
name: "Website projection build",
status: "failed",
durationMs: 150,
status: failureReport.summary.passed ? "failed" : "skipped",
durationMs: failureReport.summary.passed ? 150 : undefined,
},
{ name: "Local staging", status: "skipped" },
],
@@ -361,25 +384,17 @@ export class LabyricornStore {
id: buildId,
buildNumber: nextBuildNum,
timestamp,
durationMs: 980,
durationMs: Date.now() - buildStartedAt,
status: "built",
siteDefCommit: "local",
sourceCommits,
themeVersion: this.themeConfig.version,
builderVersion: "1.0.0-labyricorn",
generatedRoutesCount: this.contentItems.length,
builderVersion: BUILDER_VERSION,
generatedRoutesCount: engineResult.generatedRoutesCount,
mediaCount: this.mediaAssets.length,
artifactChecksum: checksum,
artifactSizeBytes,
validationReport: {
errors: [],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
},
artifactChecksum: engineResult.artifactChecksum,
artifactSizeBytes: engineResult.artifactSizeBytes,
validationReport,
logs,
stages: [
{ name: "Triggered", status: "succeeded", durationMs: 10 },
@@ -402,6 +417,9 @@ export class LabyricornStore {
isActiveLocal: false,
};
// Register the build before policy hooks so automatic activation can resolve it.
this.builds.unshift(newBuild);
// Auto staging if policy requires
if (this.siteConfig.buildPolicy.staging.enabled) {
newBuild.status = "staged";
@@ -420,11 +438,10 @@ export class LabyricornStore {
this.activateReleaseLocally(buildId);
}
this.builds.unshift(newBuild);
this.addAudit(
`Triggered Build #${nextBuildNum}`,
"build",
`Successfully built static release ${buildId} (${checksum.substring(0, 12)})`,
`Successfully built static release ${buildId} (${engineResult.artifactChecksum.substring(0, 20)})`,
);
return newBuild;
}
@@ -439,10 +456,24 @@ export class LabyricornStore {
if (build.status === "failed")
return { success: false, message: `Cannot activate a failed build.` };
// Mark previous active as false
for (const b of this.builds) {
b.isActiveLocal = false;
if (b.status === "active-local") b.status = "built";
try {
this.buildEngine.activate(buildId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.nginxStatus.lastHealthCheckPassed = false;
this.addAudit(
"Activate Local Release Failed",
"nginx",
message,
"failure",
);
return { success: false, message };
}
// Update in-memory state only after the release pointer changed successfully.
for (const previousBuild of this.builds) {
previousBuild.isActiveLocal = false;
if (previousBuild.status === "active-local") previousBuild.status = "built";
}
build.isActiveLocal = true;
@@ -451,19 +482,6 @@ export class LabyricornStore {
this.nginxStatus.lastReloadTime = new Date().toISOString();
this.nginxStatus.lastHealthCheckPassed = true;
try {
if (fs.existsSync("/tmp/labyricorn-builds/current")) {
fs.unlinkSync("/tmp/labyricorn-builds/current");
}
fs.symlinkSync(
`/tmp/labyricorn-builds/${buildId}`,
"/tmp/labyricorn-builds/current",
"dir",
);
} catch (e) {
// Ignore symlink errors in test env
}
this.addAudit(
"Activate Local Release",
"nginx",
@@ -475,6 +493,14 @@ export class LabyricornStore {
};
}
getBuildRoot(): string {
return this.buildEngine.outputRoot;
}
getCurrentBuildDirectory(): string {
return this.buildEngine.currentDirectory();
}
pushToTarget(
targetId: string,
buildId: string,
+31 -7
View File
@@ -320,19 +320,43 @@ export const BuildEngineTab: React.FC<BuildEngineTabProps> = ({
{/* TAB CONTENT: VALIDATION */}
{activeTab === "validation" && (
<div className="space-y-3 text-xs">
<div className="p-4 bg-emerald-950/40 border border-emerald-500/20 rounded-sm text-emerald-300 space-y-1">
<div
className={`p-4 rounded-sm space-y-1 border ${
selectedBuild.validationReport.summary.passed
? "bg-emerald-950/40 border-emerald-500/20 text-emerald-300"
: "bg-rose-950/40 border-rose-500/20 text-rose-300"
}`}
>
<div className="flex items-center space-x-2 font-bold">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
{selectedBuild.validationReport.summary.passed ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
) : (
<XCircle className="w-4 h-4 text-rose-400" />
)}
<span>
Validation Passed: 0 Fatal Errors, 0 Route Collisions
Validation {selectedBuild.validationReport.summary.passed ? "Passed" : "Failed"}: {selectedBuild.validationReport.summary.totalErrors} Errors, {selectedBuild.validationReport.summary.totalWarnings} Warnings
</span>
</div>
<p className="text-emerald-400 text-[11px]">
All required metadata, Markdown dialect constraints,
YouTube embed IDs, and media namespace routes passed
verification.
<p className="text-[11px] opacity-80">
{selectedBuild.validationReport.routeCollisions.length} route collisions, {selectedBuild.validationReport.missingMedia.length} missing media references, and {selectedBuild.validationReport.htmlPolicyViolations.length} HTML policy violations.
</p>
</div>
{selectedBuild.validationReport.errors.map((error, index) => (
<div key={`error-${index}`} className="p-3 bg-rose-950/20 border border-rose-500/20 rounded-sm">
<div className="font-mono font-bold text-rose-400">{error.code}</div>
<div className="text-neutral-300 mt-1">{error.message}</div>
{error.file && <div className="font-mono text-neutral-500 mt-1">{error.file}</div>}
</div>
))}
{selectedBuild.validationReport.warnings.map((warning, index) => (
<div key={`warning-${index}`} className="p-3 bg-amber-950/20 border border-amber-500/20 rounded-sm">
<div className="font-mono font-bold text-amber-400">{warning.code}</div>
<div className="text-neutral-300 mt-1">{warning.message}</div>
{warning.file && <div className="font-mono text-neutral-500 mt-1">{warning.file}</div>}
</div>
))}
</div>
)}
</div>