feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
u1: text_overrides axis in user_overrides_io u2: structure_overrides axis in user_overrides_io u3: vite allowlist for new endpoints u4: text_override_resolver u5: Step 12 text_overrides apply in phase_z2_pipeline u6: structure_override_resolver u7: text_path_stamper u8: SlideCanvas text-edit capture u9: SlideCanvas structure-edit overlay u10: userOverridesApi service extension u11: designAgent types extension u12: slidePlanUtils restore u13: user_overrides endpoint tests u14: user_overrides restore tests u15: pipeline fallback tests u16: edit-mode state + gating tests u17: slide_base print mode CSS u18: /api/connect endpoint (vite) u19: /api/export endpoint (vite) Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in 9439575; this commit lands u1-u19 that were authored but not committed before #90 was externally closed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -219,25 +219,36 @@ function vitePluginStorageProxy(): Plugin {
|
||||
|
||||
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
|
||||
|
||||
// The six in-scope axes — mirror of KNOWN_AXES in
|
||||
// src/user_overrides_io.py minus `slide_css` (known gap, IMP-45 #74 —
|
||||
// the Python side persists it for backend consumption; the Vite PUT does
|
||||
// not write it because the frontend never mutates the slide-level CSS
|
||||
// override). 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).
|
||||
// The nine in-scope axes — full mirror of KNOWN_AXES in
|
||||
// src/user_overrides_io.py. Order matches the Python tuple verbatim so
|
||||
// a side-by-side audit reads as a no-op. 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).
|
||||
// IMP-55 (#93) u1: added `manual_section_assignment` (bool intent marker
|
||||
// — drag-drop sets true, layout apply/cancel sets false).
|
||||
// IMP-56 (#90) u3: allowlist sync — closes the prior `slide_css` gap
|
||||
// (IMP-45 #74; the Step-22 slide CSS edit path will write it from the
|
||||
// frontend) and pre-wires `text_overrides` (IMP-56 #90 u1, keyed by
|
||||
// {zone_id: {text_path: value}}) + `structure_overrides` (IMP-56 #90 u2,
|
||||
// keyed by {zone_id: {slot_order, hidden_slots}} — scope LOCKED to slot
|
||||
// reorder + hide; frame swap stays on the existing `frames` axis to
|
||||
// preserve Phase Z's no-AI-HTML-structure invariant) so the Step-22
|
||||
// capture path (u10~u17) can PUT either axis without a follow-on
|
||||
// allowlist edit.
|
||||
export const KNOWN_USER_OVERRIDES_AXES = [
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
] as const;
|
||||
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
|
||||
|
||||
@@ -506,6 +517,211 @@ export function handlePutUserOverrides(
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-56 (#90) u18 — POST /api/connect : cel astro dev mirror copy.
|
||||
//
|
||||
// Body: {"run_id": "<id>", "slug": "<mdx-stem>"}.
|
||||
// • Copies <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html →
|
||||
// <CEL_PROJECT_ROOT>/public/slides/<slug>.html (overwrite).
|
||||
// • If <run_dir>/phase_z2/assets/ exists, mirrors its contents into
|
||||
// <CEL_PROJECT_ROOT>/public/slides/assets/ (overwrite copy, recursive).
|
||||
// • run_id and slug are validated through the existing
|
||||
// isValidUserOverridesKey gate so path-traversal payloads are rejected.
|
||||
// =============================================================================
|
||||
|
||||
export function mirrorDirRecursive(srcDir: string, dstDir: string): number {
|
||||
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0;
|
||||
if (!fs.existsSync(dstDir)) fs.mkdirSync(dstDir, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
const dstPath = path.join(dstDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += mirrorDirRecursive(srcPath, dstPath);
|
||||
} else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, dstPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function handleConnectMirror(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
designAgentRoot: string,
|
||||
celRoot: string,
|
||||
): boolean {
|
||||
if (req.method !== "POST") return false;
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
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 { run_id, slug } = parsed as { run_id?: unknown; slug?: unknown };
|
||||
if (typeof run_id !== "string" || typeof slug !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "missing run_id or slug" }));
|
||||
return;
|
||||
}
|
||||
if (!isValidUserOverridesKey(run_id) || !isValidUserOverridesKey(slug)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid run_id or slug" }));
|
||||
return;
|
||||
}
|
||||
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
|
||||
const srcHtml = path.join(runDir, "final.html");
|
||||
if (!fs.existsSync(srcHtml)) {
|
||||
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "final.html not found" }));
|
||||
return;
|
||||
}
|
||||
const dstSlidesDir = path.join(celRoot, "public", "slides");
|
||||
if (!fs.existsSync(dstSlidesDir)) fs.mkdirSync(dstSlidesDir, { recursive: true });
|
||||
const dstHtml = path.join(dstSlidesDir, `${slug}.html`);
|
||||
try {
|
||||
fs.copyFileSync(srcHtml, dstHtml);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: `copy failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
const assetsCopied = mirrorDirRecursive(
|
||||
path.join(runDir, "assets"),
|
||||
path.join(dstSlidesDir, "assets"),
|
||||
);
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ success: true, run_id, slug, html_target: dstHtml, assets_copied: assetsCopied }));
|
||||
});
|
||||
req.on("error", () => {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-56 (#90) u19 — POST /api/export : standalone HTML download.
|
||||
//
|
||||
// Body: {"run_id": "<id>"}.
|
||||
// • Reads <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html.
|
||||
// • Inlines every `url(assets/<frame>/<file>)` reference (the only
|
||||
// external dep emitted by the Phase Z2 render path — verified by grep
|
||||
// against templates/phase_z2/slide_base.html and a representative run)
|
||||
// as a base64 data URL so the emitted HTML is portable (file:// open
|
||||
// or any external host, no co-located assets/ dir required). Mirrors
|
||||
// u18 validation: isValidUserOverridesKey gate for path-traversal
|
||||
// rejection; final.html missing → 404.
|
||||
// • Response: 200 text/html with Content-Disposition: attachment so the
|
||||
// browser triggers a download with `<run_id>.html` filename. Raw HTML
|
||||
// body (NOT JSON-wrapped) — the BottomActions wiring (u20) will pipe
|
||||
// the response body straight into a Blob → a[download] click chain
|
||||
// mirroring the existing serializeSlidePlan JSON download flow.
|
||||
// =============================================================================
|
||||
|
||||
export function inlineAssetsAsDataUrls(html: string, assetsRoot: string): string {
|
||||
// Match `url(assets/<rel-path>)` (with optional single/double quotes,
|
||||
// optional surrounding whitespace). The Phase Z2 render path emits
|
||||
// `url(assets/<frame>/<file>.png)` verbatim into inline `style="..."`
|
||||
// custom-property declarations (see slide_base.html `--card-frame-bg`
|
||||
// etc.) — there is no `<link rel="stylesheet">` or `<img src>` external
|
||||
// ref to handle. Keeping the matcher narrow avoids accidentally
|
||||
// rewriting `data:` / `http(s):` / sibling-path URLs that the render
|
||||
// path does not produce.
|
||||
const URL_RE = /url\(\s*(['"]?)assets\/([^)'"]+)\1\s*\)/g;
|
||||
return html.replace(URL_RE, (match, _quote: string, rel: string) => {
|
||||
const filePath = path.join(assetsRoot, rel);
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return match;
|
||||
const ext = path.extname(filePath).toLowerCase().slice(1);
|
||||
const mime =
|
||||
ext === "png" ? "image/png" :
|
||||
ext === "jpg" || ext === "jpeg" ? "image/jpeg" :
|
||||
ext === "svg" ? "image/svg+xml" :
|
||||
ext === "webp" ? "image/webp" :
|
||||
ext === "gif" ? "image/gif" :
|
||||
"application/octet-stream";
|
||||
const buf = fs.readFileSync(filePath);
|
||||
return `url("data:${mime};base64,${buf.toString("base64")}")`;
|
||||
});
|
||||
}
|
||||
|
||||
export function handleExportStandalone(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
designAgentRoot: string,
|
||||
): boolean {
|
||||
if (req.method !== "POST") return false;
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
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 { run_id } = parsed as { run_id?: unknown };
|
||||
if (typeof run_id !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "missing run_id" }));
|
||||
return;
|
||||
}
|
||||
if (!isValidUserOverridesKey(run_id)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid run_id" }));
|
||||
return;
|
||||
}
|
||||
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
|
||||
const srcHtml = path.join(runDir, "final.html");
|
||||
if (!fs.existsSync(srcHtml)) {
|
||||
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "final.html not found" }));
|
||||
return;
|
||||
}
|
||||
let html: string;
|
||||
try {
|
||||
html = fs.readFileSync(srcHtml, "utf-8");
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: `read failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
const inlined = inlineAssetsAsDataUrls(html, path.join(runDir, "assets"));
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${run_id}.html"`,
|
||||
});
|
||||
res.end(inlined);
|
||||
});
|
||||
req.on("error", () => {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
|
||||
//
|
||||
@@ -514,14 +730,19 @@ export function handlePutUserOverrides(
|
||||
// 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)
|
||||
// POST /api/connect → cel mirror (IMP-56 #90 u18)
|
||||
// POST /api/export → standalone HTML download (IMP-56 #90 u19)
|
||||
//
|
||||
// 환경 변수 (선택) :
|
||||
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
|
||||
// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel
|
||||
// =============================================================================
|
||||
|
||||
function vitePluginPhaseZApi(): Plugin {
|
||||
const DESIGN_AGENT_ROOT =
|
||||
process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent";
|
||||
const CEL_PROJECT_ROOT =
|
||||
process.env.CEL_PROJECT_ROOT || "D:\\ad-hoc\\cel";
|
||||
const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads");
|
||||
const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs");
|
||||
|
||||
@@ -801,6 +1022,27 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
next();
|
||||
});
|
||||
|
||||
// ── POST /api/connect → cel astro public/slides mirror ──
|
||||
// IMP-56 (#90) u18 — see handleConnectMirror docblock for body shape +
|
||||
// copy semantics. Logic lives in the pure helper so vitest can drive
|
||||
// it without booting vite.
|
||||
server.middlewares.use("/api/connect", (req, res, next) => {
|
||||
if (handleConnectMirror(req, res, DESIGN_AGENT_ROOT, CEL_PROJECT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── POST /api/export → standalone HTML download ──
|
||||
// IMP-56 (#90) u19 — see handleExportStandalone docblock for body
|
||||
// shape + inline-asset semantics. Logic lives in the pure helper
|
||||
// (handleExportStandalone + inlineAssetsAsDataUrls) so vitest can
|
||||
// drive it without booting vite. The response is raw text/html
|
||||
// (Content-Disposition: attachment); the u20 BottomActions wiring
|
||||
// will turn the response body into a Blob → a[download] click.
|
||||
server.middlewares.use("/api/export", (req, res, next) => {
|
||||
if (handleExportStandalone(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();
|
||||
|
||||
Reference in New Issue
Block a user