Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 25s
u1 argparse --reuse-from PREV_RUN_ID + post-merge fail-closed guard (rejects
layout/zone_geometry/zone_section/image override axes by name; only
--override-frame is preserved).
u2 src/phase_z2_reuse_snapshot.py — JSON-only Step 6 snapshot with mdx_sha256
integrity key and {value, source_path, upstream_step} provenance per axis
(pickle forbidden per Stage 2 guardrail).
u3 _write_reuse_snapshot at the Step 6 boundary; soft-fails to stderr without
aborting the seed run.
u4 prev_run_dir RO copy of step00/01/02/05/06 + _reuse_snapshot.json into
new run_dir, state rehydration, reuse marker, frame-override application on
restored units, Step 7+ resume.
u4b fail-closed for missing prev_run_dir / missing/corrupt/invalid snapshot /
mdx_sha256 mismatch / accidental new==prev write, with value+path+upstream
diagnostics per axis.
u5 reuse_from Optional[str] threaded through run_phase_z2_mvp1 signature and
CLI dispatch; default None preserves byte-identical pre-IMP-43 behavior.
u6 Front /api/run optional reuseFromRunId forwarding (vite.config.ts +
designAgentApi.ts + run_pipeline_reuse_from.test.ts).
u7a fast CI equivalence (1 mdx × 1 layout × 2 frames); step13 whitelist =
run_id/timestamps/prev_run_id only. u7b 3 layouts × 3 mdx × 32 frames
sweep gated by pytest.mark.sweep (registered in pyproject.toml; default CI
must use -m 'not sweep').
u8 scripts/measure_reuse_savings.py argv-driven A/B/C harness with frame
pin self-discovery + seed-time exclusion; status board §8 TBD anchor
(issue-body 50-70% / 10-20s→3-8s claim explicitly unverified, not mirrored).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
874 lines
32 KiB
TypeScript
874 lines
32 KiB
TypeScript
import { jsxLocPlugin } from "@builder.io/vite-plugin-jsx-loc";
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { defineConfig, type Plugin, type ViteDevServer } from "vite";
|
|
import { vitePluginManusRuntime } from "vite-plugin-manus-runtime";
|
|
|
|
// =============================================================================
|
|
// Manus Debug Collector - Vite Plugin
|
|
// Writes browser logs directly to files, trimmed when exceeding size limit
|
|
// =============================================================================
|
|
|
|
const PROJECT_ROOT = import.meta.dirname;
|
|
const LOG_DIR = path.join(PROJECT_ROOT, ".manus-logs");
|
|
const MAX_LOG_SIZE_BYTES = 1 * 1024 * 1024; // 1MB per log file
|
|
const TRIM_TARGET_BYTES = Math.floor(MAX_LOG_SIZE_BYTES * 0.6); // Trim to 60% to avoid constant re-trimming
|
|
|
|
type LogSource = "browserConsole" | "networkRequests" | "sessionReplay";
|
|
|
|
function ensureLogDir() {
|
|
if (!fs.existsSync(LOG_DIR)) {
|
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
function trimLogFile(logPath: string, maxSize: number) {
|
|
try {
|
|
if (!fs.existsSync(logPath) || fs.statSync(logPath).size <= maxSize) {
|
|
return;
|
|
}
|
|
|
|
const lines = fs.readFileSync(logPath, "utf-8").split("\n");
|
|
const keptLines: string[] = [];
|
|
let keptBytes = 0;
|
|
|
|
// Keep newest lines (from end) that fit within 60% of maxSize
|
|
const targetSize = TRIM_TARGET_BYTES;
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const lineBytes = Buffer.byteLength(`${lines[i]}\n`, "utf-8");
|
|
if (keptBytes + lineBytes > targetSize) break;
|
|
keptLines.unshift(lines[i]);
|
|
keptBytes += lineBytes;
|
|
}
|
|
|
|
fs.writeFileSync(logPath, keptLines.join("\n"), "utf-8");
|
|
} catch {
|
|
/* ignore trim errors */
|
|
}
|
|
}
|
|
|
|
function writeToLogFile(source: LogSource, entries: unknown[]) {
|
|
if (entries.length === 0) return;
|
|
|
|
ensureLogDir();
|
|
const logPath = path.join(LOG_DIR, `${source}.log`);
|
|
|
|
// Format entries with timestamps
|
|
const lines = entries.map((entry) => {
|
|
const ts = new Date().toISOString();
|
|
return `[${ts}] ${JSON.stringify(entry)}`;
|
|
});
|
|
|
|
// Append to log file
|
|
fs.appendFileSync(logPath, `${lines.join("\n")}\n`, "utf-8");
|
|
|
|
// Trim if exceeds max size
|
|
trimLogFile(logPath, MAX_LOG_SIZE_BYTES);
|
|
}
|
|
|
|
/**
|
|
* Vite plugin to collect browser debug logs
|
|
* - POST /__manus__/logs: Browser sends logs, written directly to files
|
|
* - Files: browserConsole.log, networkRequests.log, sessionReplay.log
|
|
* - Auto-trimmed when exceeding 1MB (keeps newest entries)
|
|
*/
|
|
function vitePluginManusDebugCollector(): Plugin {
|
|
return {
|
|
name: "manus-debug-collector",
|
|
|
|
transformIndexHtml(html) {
|
|
if (process.env.NODE_ENV === "production") {
|
|
return html;
|
|
}
|
|
return {
|
|
html,
|
|
tags: [
|
|
{
|
|
tag: "script",
|
|
attrs: {
|
|
src: "/__manus__/debug-collector.js",
|
|
defer: true,
|
|
},
|
|
injectTo: "head",
|
|
},
|
|
],
|
|
};
|
|
},
|
|
|
|
configureServer(server: ViteDevServer) {
|
|
// POST /__manus__/logs: Browser sends logs (written directly to files)
|
|
server.middlewares.use("/__manus__/logs", (req, res, next) => {
|
|
if (req.method !== "POST") {
|
|
return next();
|
|
}
|
|
|
|
const handlePayload = (payload: any) => {
|
|
// Write logs directly to files
|
|
if (payload.consoleLogs?.length > 0) {
|
|
writeToLogFile("browserConsole", payload.consoleLogs);
|
|
}
|
|
if (payload.networkRequests?.length > 0) {
|
|
writeToLogFile("networkRequests", payload.networkRequests);
|
|
}
|
|
if (payload.sessionEvents?.length > 0) {
|
|
writeToLogFile("sessionReplay", payload.sessionEvents);
|
|
}
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ success: true }));
|
|
};
|
|
|
|
const reqBody = (req as { body?: unknown }).body;
|
|
if (reqBody && typeof reqBody === "object") {
|
|
try {
|
|
handlePayload(reqBody);
|
|
} catch (e) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ success: false, error: String(e) }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
let body = "";
|
|
req.on("data", (chunk) => {
|
|
body += chunk.toString();
|
|
});
|
|
|
|
req.on("end", () => {
|
|
try {
|
|
const payload = JSON.parse(body);
|
|
handlePayload(payload);
|
|
} catch (e) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ success: false, error: String(e) }));
|
|
}
|
|
});
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function vitePluginStorageProxy(): Plugin {
|
|
return {
|
|
name: "manus-storage-proxy",
|
|
configureServer(server: ViteDevServer) {
|
|
server.middlewares.use("/manus-storage", async (req, res) => {
|
|
const key = req.url?.replace(/^\//, "");
|
|
if (!key) {
|
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
res.end("Missing storage key");
|
|
return;
|
|
}
|
|
|
|
const forgeBaseUrl = (process.env.BUILT_IN_FORGE_API_URL || "").replace(/\/+$/, "");
|
|
const forgeKey = process.env.BUILT_IN_FORGE_API_KEY;
|
|
|
|
if (!forgeBaseUrl || !forgeKey) {
|
|
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
res.end("Storage proxy not configured");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const forgeUrl = new URL("v1/storage/presign/get", forgeBaseUrl + "/");
|
|
forgeUrl.searchParams.set("path", key);
|
|
|
|
const forgeResp = await fetch(forgeUrl, {
|
|
headers: { Authorization: `Bearer ${forgeKey}` },
|
|
});
|
|
|
|
if (!forgeResp.ok) {
|
|
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
res.end("Storage backend error");
|
|
return;
|
|
}
|
|
|
|
const { url } = (await forgeResp.json()) as { url: string };
|
|
if (!url) {
|
|
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
res.end("Empty signed URL");
|
|
return;
|
|
}
|
|
|
|
res.writeHead(307, { Location: url, "Cache-Control": "no-store" });
|
|
res.end();
|
|
} catch {
|
|
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
res.end("Storage proxy error");
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store).
|
|
//
|
|
// On-disk layout: <DESIGN_AGENT_ROOT>/data/user_overrides/<key>.json. Mirrors
|
|
// the Python contract in src/user_overrides_io.py — same validate_key regex,
|
|
// same graceful-degrade (corrupt → {}) so backend pipeline entry fallback
|
|
// (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file.
|
|
//
|
|
// Helpers are named exports so vitest can drive handleGetUserOverrides /
|
|
// handlePutUserOverrides with mock req/res without booting a real dev
|
|
// server. vite still consumes the default `defineConfig` export below.
|
|
// =============================================================================
|
|
|
|
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
|
|
|
|
// The five in-scope axes — exact mirror of KNOWN_AXES in
|
|
// src/user_overrides_io.py. Any payload key outside this allowlist is
|
|
// silently dropped by the PUT handler (u4) so the on-disk schema cannot
|
|
// drift from the backend pipeline (u2) contract. Foreign top-level keys
|
|
// already on disk are preserved verbatim (see mergeUserOverrides).
|
|
// IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h}
|
|
// percent-of-slide coordinates).
|
|
export const KNOWN_USER_OVERRIDES_AXES = [
|
|
"layout",
|
|
"zone_geometries",
|
|
"zone_sections",
|
|
"frames",
|
|
"image_overrides",
|
|
] as const;
|
|
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
|
|
|
|
// 1MB cap on PUT bodies. Override files in practice are < 10KB (5 axes,
|
|
// each a small dict). The cap is a safety net against runaway client
|
|
// loops, not a real schema constraint.
|
|
const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000;
|
|
|
|
export function isValidUserOverridesKey(key: string): boolean {
|
|
if (!key) return false;
|
|
if (key.includes("..")) return false;
|
|
if (key.includes("/") || key.includes("\\")) return false;
|
|
return USER_OVERRIDES_KEY_RE.test(key);
|
|
}
|
|
|
|
export function userOverridesPath(root: string, key: string): string {
|
|
return path.join(root, "data", "user_overrides", `${key}.json`);
|
|
}
|
|
|
|
// Minimal req/res shapes — node IncomingMessage / ServerResponse have many
|
|
// fields the handler does not touch, so we accept a structural subset for
|
|
// testability.
|
|
type GetReqLike = { method?: string; url?: string };
|
|
type PutReqLike = {
|
|
method?: string;
|
|
url?: string;
|
|
on(event: "data" | "end" | "error", cb: (...args: any[]) => void): unknown;
|
|
};
|
|
type ResLike = {
|
|
writeHead: (status: number, headers?: Record<string, string>) => void;
|
|
end: (body?: string) => void;
|
|
};
|
|
|
|
// IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the
|
|
// handler took over the response, false when the caller should `next()`.
|
|
// Invariants:
|
|
// • method != GET → false (chain continues; u4 PUT may handle)
|
|
// • invalid key → 400 {"error":"invalid key"}
|
|
// • file missing → 200 {}
|
|
// • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load)
|
|
// • non-object JSON root → 200 {} (mirrors u1 load)
|
|
// • valid object JSON → 200 with parsed JSON body
|
|
export function handleGetUserOverrides(
|
|
req: GetReqLike,
|
|
res: ResLike,
|
|
root: string,
|
|
): boolean {
|
|
if (req.method !== "GET") return false;
|
|
|
|
const url = req.url || "";
|
|
const key = url.split("?")[0].replace(/^\//, "");
|
|
|
|
if (!isValidUserOverridesKey(key)) {
|
|
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify({ error: "invalid key" }));
|
|
return true;
|
|
}
|
|
|
|
const filePath = userOverridesPath(root, key);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end("{}");
|
|
return true;
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end("{}");
|
|
return true;
|
|
}
|
|
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end("{}");
|
|
return true;
|
|
}
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify(parsed));
|
|
return true;
|
|
}
|
|
|
|
// IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save():
|
|
// • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated.
|
|
// • Axes absent from `partial` are preserved verbatim from `existing`.
|
|
// • Foreign top-level keys in `existing` (future axes like zone_sizes)
|
|
// are preserved verbatim — allowlist guards what the PUT writes, NOT
|
|
// what the file already holds.
|
|
// • `partial[axis] = null` is the explicit clear sentinel (remove key).
|
|
// • Any non-axis keys in `partial` are silently dropped (allowlist).
|
|
export function mergeUserOverrides(
|
|
existing: Record<string, unknown>,
|
|
partial: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const merged: Record<string, unknown> = { ...existing };
|
|
for (const axis of KNOWN_USER_OVERRIDES_AXES) {
|
|
if (!(axis in partial)) continue;
|
|
const value = partial[axis];
|
|
if (value === null) {
|
|
delete merged[axis];
|
|
} else {
|
|
merged[axis] = value;
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
// IMP-52 u4 — atomic file write via tmp + rename. Mirrors the
|
|
// `_atomic_write_json` semantics in src/user_overrides_io.py so a
|
|
// crashed/interrupted PUT cannot leave a half-written .json on disk
|
|
// (the next GET / pipeline-entry read would otherwise return {} via
|
|
// graceful degrade, silently losing the user's prior overrides).
|
|
export function atomicWriteUserOverrides(
|
|
filePath: string,
|
|
data: Record<string, unknown>,
|
|
): void {
|
|
const dir = path.dirname(filePath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
const tmpName = path.join(
|
|
dir,
|
|
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
|
|
);
|
|
try {
|
|
fs.writeFileSync(
|
|
tmpName,
|
|
JSON.stringify(data, null, 2) + "\n",
|
|
"utf-8",
|
|
);
|
|
fs.renameSync(tmpName, filePath);
|
|
} catch (err) {
|
|
try {
|
|
fs.unlinkSync(tmpName);
|
|
} catch {
|
|
// best-effort cleanup; the rename source may not exist on early failure
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the
|
|
// handler took over the response, false when the caller should `next()`.
|
|
// Invariants:
|
|
// • method != PUT → false (chain continues; GET runs first)
|
|
// • invalid key → 400 {"error":"invalid key"}
|
|
// • body > 1MB → 413 {"error":"payload too large"}
|
|
// • invalid JSON → 400 {"error":"invalid JSON"}
|
|
// • non-object JSON root → 400 {"error":"body must be a JSON object"}
|
|
// • write failure → 500 {"error":"write failed: ..."}
|
|
// • success → 200 with merged JSON body
|
|
//
|
|
// Existing-file read uses the same graceful-degrade rules as GET (corrupt
|
|
// JSON / non-object root → treat as empty {}) so a PUT cannot fail solely
|
|
// because a prior file is unparseable — the new payload replaces it.
|
|
export function handlePutUserOverrides(
|
|
req: PutReqLike,
|
|
res: ResLike,
|
|
root: string,
|
|
): boolean {
|
|
if (req.method !== "PUT") return false;
|
|
|
|
const url = req.url || "";
|
|
const key = url.split("?")[0].replace(/^\//, "");
|
|
|
|
if (!isValidUserOverridesKey(key)) {
|
|
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify({ error: "invalid key" }));
|
|
return true;
|
|
}
|
|
|
|
let body = "";
|
|
let aborted = false;
|
|
|
|
req.on("data", (chunk: Buffer | string) => {
|
|
if (aborted) return;
|
|
body += typeof chunk === "string" ? chunk : chunk.toString();
|
|
if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) {
|
|
aborted = true;
|
|
res.writeHead(413, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ error: "payload too large" }));
|
|
}
|
|
});
|
|
|
|
req.on("end", () => {
|
|
if (aborted) return;
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = body.length > 0 ? JSON.parse(body) : {};
|
|
} catch {
|
|
res.writeHead(400, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ error: "invalid JSON" }));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
typeof parsed !== "object" ||
|
|
parsed === null ||
|
|
Array.isArray(parsed)
|
|
) {
|
|
res.writeHead(400, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ error: "body must be a JSON object" }));
|
|
return;
|
|
}
|
|
|
|
const partial = parsed as Record<string, unknown>;
|
|
const filePath = userOverridesPath(root, key);
|
|
|
|
// Load existing — corrupt / non-object → {} so the PUT still succeeds
|
|
// and recovers the file to a clean state. Mirrors u1 load() graceful
|
|
// degrade.
|
|
let existing: Record<string, unknown> = {};
|
|
if (fs.existsSync(filePath)) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
const ex = JSON.parse(raw);
|
|
if (
|
|
typeof ex === "object" &&
|
|
ex !== null &&
|
|
!Array.isArray(ex)
|
|
) {
|
|
existing = ex as Record<string, unknown>;
|
|
}
|
|
} catch {
|
|
// corrupt → treat as empty
|
|
}
|
|
}
|
|
|
|
const merged = mergeUserOverrides(existing, partial);
|
|
|
|
try {
|
|
atomicWriteUserOverrides(filePath, merged);
|
|
} catch (err) {
|
|
res.writeHead(500, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ error: `write failed: ${String(err)}` }));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify(merged));
|
|
});
|
|
|
|
req.on("error", () => {
|
|
if (aborted) return;
|
|
aborted = true;
|
|
res.writeHead(500, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ error: "request error" }));
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
|
|
//
|
|
// Endpoints (vite dev middleware) :
|
|
// POST /api/run multipart/JSON body {filename, content} → run_id
|
|
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
|
|
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
|
|
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
|
|
//
|
|
// 환경 변수 (선택) :
|
|
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
|
|
// =============================================================================
|
|
|
|
function vitePluginPhaseZApi(): Plugin {
|
|
const DESIGN_AGENT_ROOT =
|
|
process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent";
|
|
const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads");
|
|
const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs");
|
|
|
|
return {
|
|
name: "phase-z-api",
|
|
configureServer(server: ViteDevServer) {
|
|
// ── POST /api/run — MDX 업로드 → 파이프라인 실행 (sync) ──
|
|
server.middlewares.use("/api/run", (req, res, next) => {
|
|
if (req.method !== "POST") return next();
|
|
|
|
let body = "";
|
|
req.on("data", (chunk) => {
|
|
body += chunk.toString();
|
|
});
|
|
|
|
req.on("end", () => {
|
|
let payload: {
|
|
filename?: string;
|
|
content?: string;
|
|
overrides?: {
|
|
layout?: string;
|
|
frames?: Record<string, string>; // unit_id → template_id
|
|
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id → bbox (slide-body 내부 0~1)
|
|
// IMP-08 B-3 : zone_id -> list of canonical section_id assignments
|
|
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
|
|
zoneSections?: Record<string, string[]>;
|
|
};
|
|
// IMP-43 (#72) u6 — optional PREV_RUN_ID to reuse Step 0/1/2/5/6
|
|
// artifacts from a prior run and resume execution at Step 7.
|
|
// Lives at the payload root (NOT under `overrides`) because the
|
|
// backend u1 post-merge guard rejects most override axes when
|
|
// --reuse-from is supplied. Absent / empty = full pipeline
|
|
// (byte-identical to pre-u6 spawn).
|
|
reuseFromRunId?: string;
|
|
};
|
|
try {
|
|
payload = JSON.parse(body);
|
|
} catch (e) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({ success: false, error: `bad JSON: ${String(e)}` })
|
|
);
|
|
return;
|
|
}
|
|
|
|
const { filename, content, overrides, reuseFromRunId } = payload;
|
|
if (!filename || typeof content !== "string") {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
success: false,
|
|
error: "missing filename or content",
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Security: filename 정규화 (path 분리 / .. 차단)
|
|
const safeName = filename.replace(/[\\/]/g, "_").replace(/\.\./g, "_");
|
|
if (!safeName.match(/\.mdx?$/i)) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
success: false,
|
|
error: "filename must end with .mdx or .md",
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 1. MDX 저장
|
|
if (!fs.existsSync(UPLOADS_DIR)) {
|
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
}
|
|
const mdxPath = path.join(UPLOADS_DIR, safeName);
|
|
fs.writeFileSync(mdxPath, content, "utf-8");
|
|
|
|
// 2. run_id 생성 (basename + timestamp)
|
|
const baseName = safeName
|
|
.replace(/\.mdx?$/i, "")
|
|
.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
const ts = new Date()
|
|
.toISOString()
|
|
.replace(/[-:T]/g, "")
|
|
.replace(/\..+$/, "")
|
|
.slice(0, 14); // YYYYMMDDHHMMSS
|
|
const runId = `${baseName}_${ts}`;
|
|
|
|
// 3. python -m src.phase_z2_pipeline {mdx_path} {run_id} (cwd=DESIGN_AGENT_ROOT)
|
|
// shell: false → argv 그대로 전달 (파일명 공백/한글 안전).
|
|
// Windows 에서 .exe 확장자 자동 탐색을 위해 python.exe 명시.
|
|
// Step D : overrides 가 들어오면 --override-layout / --override-frame 인자로 forward.
|
|
const cliArgs = ["-m", "src.phase_z2_pipeline", mdxPath, runId];
|
|
if (overrides?.layout && typeof overrides.layout === "string") {
|
|
cliArgs.push("--override-layout", overrides.layout);
|
|
}
|
|
if (overrides?.frames && typeof overrides.frames === "object") {
|
|
for (const [unitId, templateId] of Object.entries(overrides.frames)) {
|
|
if (typeof templateId === "string" && templateId) {
|
|
cliArgs.push("--override-frame", `${unitId}=${templateId}`);
|
|
}
|
|
}
|
|
}
|
|
if (overrides?.zoneGeometries && typeof overrides.zoneGeometries === "object") {
|
|
for (const [zoneId, geom] of Object.entries(overrides.zoneGeometries)) {
|
|
if (geom && typeof geom === "object") {
|
|
const { x, y, w, h } = geom;
|
|
cliArgs.push(
|
|
"--override-zone-geometry",
|
|
`${zoneId}=${x},${y},${w},${h}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// IMP-08 B-3 — zoneSections override forward to CLI.
|
|
// Each entry becomes `--override-section-assignment ZONE=sid[,sid]`.
|
|
// Empty arrays and non-string sids are filtered out so the backend
|
|
// never receives bogus assignments from a partially-built UI state.
|
|
if (overrides?.zoneSections && typeof overrides.zoneSections === "object") {
|
|
for (const [zoneId, sids] of Object.entries(overrides.zoneSections)) {
|
|
if (!Array.isArray(sids)) continue;
|
|
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
|
|
if (cleaned.length === 0) continue;
|
|
cliArgs.push(
|
|
"--override-section-assignment",
|
|
`${zoneId}=${cleaned.join(",")}`
|
|
);
|
|
}
|
|
}
|
|
// IMP-43 (#72) u6 — --reuse-from <PREV_RUN_ID> forward. Backend
|
|
// (u1) parses this flag, validates the snapshot, copies Step
|
|
// 0/1/2/5/6 artifacts from data/runs/<PREV_RUN_ID>/phase_z2 into
|
|
// the new run_dir, and resumes execution at Step 7. The post-merge
|
|
// guard at the same site rejects --override-layout /
|
|
// --override-zone-geometry / --override-section-assignment /
|
|
// --override-image with axis-named fail-closed exit; only
|
|
// --override-frame (above) is preserved. Truthy check excludes
|
|
// empty string + undefined so an invalid argument never reaches
|
|
// argparse.
|
|
if (reuseFromRunId && typeof reuseFromRunId === "string") {
|
|
cliArgs.push("--reuse-from", reuseFromRunId);
|
|
}
|
|
console.log(
|
|
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
|
|
);
|
|
const pythonExe = process.platform === "win32" ? "python.exe" : "python";
|
|
// 2026-05-14 — env toggle forward (보고용 일회성).
|
|
// PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT : status 통과
|
|
// 2026-05-21 — IMP-38 retire PHASE_Z_MAX_RANK env (never read by backend).
|
|
// v4 fallback chain max_rank 는 templates/phase_z2/catalog/v4_fallback_policy.yaml 의
|
|
// 정식 정책 (dynamic_usable_count_based) 으로 결정 — backend src/phase_z2_pipeline.py
|
|
// 의 lookup_v4_match_with_fallback() 가 load_v4_fallback_policy() 로 적용.
|
|
const proc = spawn(pythonExe, cliArgs, {
|
|
cwd: DESIGN_AGENT_ROOT,
|
|
shell: false,
|
|
env: {
|
|
...process.env,
|
|
PHASE_Z_ALLOW_RESTRUCTURE: "1",
|
|
PHASE_Z_ALLOW_REJECT: "1",
|
|
},
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
proc.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
proc.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
proc.on("error", (err) => {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
success: false,
|
|
error: `spawn failed: ${err.message}`,
|
|
})
|
|
);
|
|
});
|
|
|
|
proc.on("exit", (code) => {
|
|
const outDir = path.join(RUNS_DIR, runId, "phase_z2");
|
|
const finalHtmlExists = fs.existsSync(path.join(outDir, "final.html"));
|
|
const previewExists = fs.existsSync(path.join(outDir, "preview.png"));
|
|
|
|
// 2026-05-14 — success 판정 보완.
|
|
// exit_code 0 → full PASS
|
|
// exit_code != 0 → status enum 차이 (visual_regression / partial_coverage 등)
|
|
// 다만 final.html 존재 시 frontend 는 표시 가능 (clipping warning 만).
|
|
// = (exit_code 0) OR (final.html 존재) 모두 success=true.
|
|
const success = code === 0 || finalHtmlExists;
|
|
|
|
res.writeHead(success ? 200 : 500, {
|
|
"Content-Type": "application/json",
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
success,
|
|
run_id: runId,
|
|
exit_code: code,
|
|
final_html_exists: finalHtmlExists,
|
|
preview_exists: previewExists,
|
|
stdout: stdout.slice(-3000),
|
|
stderr: stderr.slice(-3000),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── GET /api/sample-mdx → samples/mdx/{03|04|05} (데모) ──
|
|
// 페이지 로드 시 frontend 가 자동 fetch — 상대방에게 mdx 파일 공유 안 해도 되게.
|
|
// 2026-05-14 — query param `?mdx=04` / `?mdx=05` 추가 (default = 03).
|
|
// 04 / 05 도 frontend simulation 가능하도록 frame/layout override 적용 path 유지.
|
|
const SAMPLE_MDX_MAP: Record<string, string> = {
|
|
"03": "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx",
|
|
"04": "04. DX 지연 요인.mdx",
|
|
"05": "05. 설계 방식의 왜곡.mdx",
|
|
};
|
|
server.middlewares.use("/api/sample-mdx", (req, res, next) => {
|
|
if (req.method !== "GET") return next();
|
|
const url = new URL(req.url || "/", "http://x");
|
|
const which = url.searchParams.get("mdx") || "03";
|
|
const filename = SAMPLE_MDX_MAP[which] || SAMPLE_MDX_MAP["03"];
|
|
const samplePath = path.join(
|
|
DESIGN_AGENT_ROOT,
|
|
"samples",
|
|
"mdx",
|
|
filename
|
|
);
|
|
if (!fs.existsSync(samplePath)) {
|
|
res.writeHead(404);
|
|
res.end("sample mdx not found");
|
|
return;
|
|
}
|
|
res.setHeader("Content-Type", "text/markdown; charset=utf-8");
|
|
res.setHeader("X-Mdx-Filename", encodeURIComponent(filename));
|
|
fs.createReadStream(samplePath).pipe(res);
|
|
});
|
|
|
|
// ── GET /frame-preview/{frame_number} → data/figma_previews/{NN}.png ──
|
|
// V4 후보 카드 thumbnail. data/figma_previews/ 는 frame_number 1~32 모두 존재 (Figma original export).
|
|
// 우선 figma_to_html_agent/blocks/{frame_number}/preview.png 시도해서 더 detail 한 렌더 결과 있으면 사용 — 단 frame_id 와 frame_number 가 다른 식별자라 path 매핑이 없으므로 일단 figma_previews 만 사용.
|
|
server.middlewares.use("/frame-preview", (req, res, next) => {
|
|
if (req.method !== "GET") return next();
|
|
const url = (req.url || "").split("?")[0];
|
|
const arg = url.replace(/^\//, "").replace(/[^0-9]/g, "");
|
|
if (!arg) return next();
|
|
// 1-digit / 2-digit 모두 zero-pad 시도 (e.g., "13" → "13.png", "5" → "05.png")
|
|
const padded = arg.padStart(2, "0");
|
|
const previewPath = path.join(
|
|
DESIGN_AGENT_ROOT,
|
|
"data",
|
|
"figma_previews",
|
|
`${padded}.png`
|
|
);
|
|
if (!fs.existsSync(previewPath)) {
|
|
res.writeHead(404);
|
|
res.end("frame preview not found");
|
|
return;
|
|
}
|
|
res.setHeader("Content-Type", "image/png");
|
|
fs.createReadStream(previewPath).pipe(res);
|
|
});
|
|
|
|
// ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ──
|
|
// IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic
|
|
// lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides)
|
|
// so vitest can exercise them without booting vite. Both handlers
|
|
// return false when the HTTP method does not match, so they chain
|
|
// cleanly: GET first, then PUT, then next() for everything else
|
|
// (e.g., OPTIONS / preflight handled by upstream middleware).
|
|
server.middlewares.use("/api/user-overrides", (req, res, next) => {
|
|
if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
|
if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
|
next();
|
|
});
|
|
|
|
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
|
|
server.middlewares.use("/data/runs", (req, res, next) => {
|
|
if (req.method !== "GET") return next();
|
|
|
|
const url = req.url || "";
|
|
if (url.includes("..") || url.includes("\\")) {
|
|
res.writeHead(400);
|
|
res.end("Bad Request");
|
|
return;
|
|
}
|
|
|
|
const segments = url.split("?")[0].split("/").filter((s) => s.length > 0);
|
|
if (segments.length < 2) return next();
|
|
|
|
const [runId, ...rest] = segments;
|
|
const filePath = path.join(RUNS_DIR, runId, "phase_z2", ...rest);
|
|
|
|
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
|
// Fallback to next middleware (vite default static — client/public)
|
|
return next();
|
|
}
|
|
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mimeMap: Record<string, string> = {
|
|
".html": "text/html; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".svg": "image/svg+xml",
|
|
".md": "text/markdown; charset=utf-8",
|
|
".txt": "text/plain; charset=utf-8",
|
|
};
|
|
res.setHeader("Content-Type", mimeMap[ext] || "application/octet-stream");
|
|
fs.createReadStream(filePath).pipe(res);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector(), vitePluginStorageProxy(), vitePluginPhaseZApi()];
|
|
|
|
export default defineConfig({
|
|
plugins,
|
|
resolve: {
|
|
alias: {
|
|
"@": path.resolve(import.meta.dirname, "client", "src"),
|
|
"@shared": path.resolve(import.meta.dirname, "shared"),
|
|
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
|
|
},
|
|
},
|
|
envDir: path.resolve(import.meta.dirname),
|
|
root: path.resolve(import.meta.dirname, "client"),
|
|
build: {
|
|
outDir: path.resolve(import.meta.dirname, "dist/public"),
|
|
emptyOutDir: true,
|
|
},
|
|
server: {
|
|
port: 3000,
|
|
strictPort: false, // Will find next available port if 3000 is busy
|
|
host: true,
|
|
allowedHosts: [
|
|
".manuspre.computer",
|
|
".manus.computer",
|
|
".manus-asia.computer",
|
|
".manuscomputer.ai",
|
|
".manusvm.computer",
|
|
"localhost",
|
|
"127.0.0.1",
|
|
],
|
|
fs: {
|
|
strict: true,
|
|
deny: ["**/.*"],
|
|
},
|
|
},
|
|
});
|