feat(#80): IMP-52 user_overrides.json persistence (u1~u10 backend + frontend + tests)
4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames
survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence
on backend pipeline entry; 300ms-debounced PUT flushed before Generate.
u1 src/user_overrides_io.py — load/save/validate_key (MDX-stem regex), 4-axis schema,
miss={}, corrupt warning+{}, atomic tmp+rename, foreign-key preserve.
u2 src/phase_z2_pipeline.py — post-argparse fallback fills only missing axes.
u3 Front/vite.config.ts — GET /api/user-overrides/:key (200 {} on miss, 400 traversal).
u4 Front/vite.config.ts — PUT /api/user-overrides/:key, 4-axis allowlist, partial merge.
u5 Front/client/src/services/userOverridesApi.ts — typed get/save + flushUserOverrides
with 300ms debounce and mutated-axis partial payloads.
u6 Front/client/src/pages/Home.tsx + slidePlanUtils.ts — restore on MDX upload (non-frame
axes immediately, frames remapped post-loadRun unit_id → region.id).
u7 Home.tsx — persist on 4 mutation handlers (section drop, layout select, zone resize,
frame select); zone_sizes and Generate excluded.
u8 tests/test_user_overrides_io.py — round-trip, unknown-key passthrough, missing/corrupt,
invalid keys (26 tests).
u9 tests/test_user_overrides_pipeline_fallback.py — per-axis fill, CLI-wins, no-file noop,
corrupt warning+skip (16 tests).
u10 Home.tsx + user_overrides_write.test.ts — await flushUserOverrides() before runPipeline
in handleGenerate try-block head; source-pattern regression assertions (20 → 22 tests).
Backend pytest 42/42 green. Frontend vitest 113/113 green (endpoint 42 / restore 21 /
service 28 / write 22). HEAD baseline ee97f4f; no spillover to phase_z2 templates /
families / frames / pipeline orchestration outside the IMP-52 surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -204,12 +204,307 @@ function vitePluginStorageProxy(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 four 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).
|
||||
export const KNOWN_USER_OVERRIDES_AXES = [
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
] as const;
|
||||
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
|
||||
|
||||
// 1MB cap on PUT bodies. Override files in practice are < 10KB (4 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
|
||||
// or image_overrides) 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
|
||||
@@ -464,6 +759,19 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user