fix(HEL-872): library delete reports real kept reasons, not a merged vague one
List only excludes project-data refs, but delete also kept canvas-card files with the same 'referenced' reason the UI merged into 被项目使用或 无法删除 — every fresh playground output (materialized as a canvas card) looked undeletable from 素材库 with a wrong explanation. Backend: split the kept reason into referenced (project data) vs on_canvas (live canvas card); protections unchanged. Frontend: per-reason kept summary with counts and an actionable hint, error toast names failure and stays retryable, readable API error text. E2E: isolated real-page regression (cancel/single/batch/retry) + backend tests for the on_canvas reason. Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
@@ -16,8 +16,7 @@ from pydantic import BaseModel
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
from calliope.routers.canvas import (
|
||||
_path_is_referenced,
|
||||
_playground_file_roots,
|
||||
_path_on_canvas,
|
||||
_project_path_referenced,
|
||||
)
|
||||
from calliope.routers.playground import PLAYGROUND_STATUS
|
||||
@@ -112,8 +111,9 @@ async def list_library_media() -> list[dict[str, Any]]:
|
||||
|
||||
@router.delete("/media")
|
||||
async def delete_library_media(payload: LibraryDeleteRequest) -> dict[str, Any]:
|
||||
"""Bulk-delete unlinked library files. Referenced/project-owned/foreign
|
||||
paths are kept and reported per-path."""
|
||||
"""Bulk-delete unlinked library files. Kept reasons per path:
|
||||
referenced (project data), on_canvas (live canvas card),
|
||||
project_owned/outside_assets (not library-deletable), unlink_failed."""
|
||||
if not payload.paths:
|
||||
raise HTTPException(status_code=400, detail="paths must not be empty")
|
||||
if len(payload.paths) > 200:
|
||||
@@ -140,9 +140,12 @@ async def delete_library_media(payload: LibraryDeleteRequest) -> dict[str, Any]:
|
||||
if not under_root:
|
||||
results.append({"path": path, "status": "kept", "reason": "project_owned"})
|
||||
continue
|
||||
if _path_is_referenced(conn, path):
|
||||
if _project_path_referenced(conn, path):
|
||||
results.append({"path": path, "status": "kept", "reason": "referenced"})
|
||||
continue
|
||||
if _path_on_canvas(conn, path):
|
||||
results.append({"path": path, "status": "kept", "reason": "on_canvas"})
|
||||
continue
|
||||
try:
|
||||
target.unlink()
|
||||
deleted.append(path)
|
||||
|
||||
@@ -151,6 +151,76 @@ def test_delete_missing_file_counts_as_deleted(client):
|
||||
assert ghost in [Path(p) for p in r.json()["deleted"]]
|
||||
|
||||
|
||||
def test_canvas_referenced_file_listed_but_kept_with_on_canvas_reason(client):
|
||||
"""HEL-872: a live canvas card's file shows in the library list (list only
|
||||
excludes project-data refs) but must survive delete with its own reason,
|
||||
distinct from a project-data reference."""
|
||||
scratch = _scratch_project()
|
||||
out = _write_image(
|
||||
Path(config_module.settings.assets_dir) / str(scratch) / "image",
|
||||
"canvas-card-image.png",
|
||||
)
|
||||
conn = get_db(config_module.settings.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO canvas (id, title) VALUES (1, 'Sandbox')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO canvas_node (canvas_id, type, title, artifact_path, status)"
|
||||
" VALUES (1, 'image', 'card', ?, 'done')",
|
||||
(str(out),),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
listed = client.get("/api/library/media").json()
|
||||
assert str(out) in [i["path"] for i in listed], "canvas file is library media"
|
||||
|
||||
r = client.request("DELETE", "/api/library/media", json={"paths": [str(out)]})
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status"] == "kept"
|
||||
assert res["reason"] == "on_canvas"
|
||||
assert out.exists(), "canvas-referenced file must survive"
|
||||
|
||||
|
||||
def test_mixed_batch_reports_per_path_reasons(client):
|
||||
"""One deletable upload + one canvas-referenced scratch output in a single
|
||||
batch: the deletable file is really unlinked, the canvas one is kept."""
|
||||
scratch = _scratch_project()
|
||||
up = _uploads_dir()
|
||||
free = _write_image(up, "dddd1212-free.png")
|
||||
held = _write_image(
|
||||
Path(config_module.settings.assets_dir) / str(scratch) / "video",
|
||||
"canvas-card-video.mp4",
|
||||
)
|
||||
conn = get_db(config_module.settings.db_path)
|
||||
try:
|
||||
conn.execute("INSERT INTO canvas (id, title) VALUES (1, 'Sandbox')")
|
||||
conn.execute(
|
||||
"INSERT INTO canvas_node (canvas_id, type, title, artifact_path, status)"
|
||||
" VALUES (1, 'video', 'card', ?, 'done')",
|
||||
(str(held),),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.request(
|
||||
"DELETE", "/api/library/media", json={"paths": [str(free), str(held)]}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["deleted"] == [str(free)]
|
||||
assert not free.exists(), "unlinked upload is really deleted"
|
||||
results = {res["path"]: res for res in body["results"]}
|
||||
assert results[str(free)]["status"] == "deleted"
|
||||
assert results[str(held)]["status"] == "kept"
|
||||
assert results[str(held)]["reason"] == "on_canvas"
|
||||
assert held.exists()
|
||||
|
||||
|
||||
def test_delete_empty_paths_rejected(client):
|
||||
r = client.request("DELETE", "/api/library/media", json={"paths": []})
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { libraryApi, assetUrl, type LibraryMediaItem } from "$lib/api";
|
||||
import {
|
||||
libraryApi,
|
||||
assetUrl,
|
||||
apiErrorMessage,
|
||||
type LibraryMediaItem,
|
||||
} from "$lib/api";
|
||||
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
|
||||
import AttachToProject from "$lib/components/AttachToProject.svelte";
|
||||
import ImageLightbox from "$lib/components/ImageLightbox.svelte";
|
||||
@@ -146,6 +151,28 @@
|
||||
selectAll();
|
||||
}
|
||||
}
|
||||
// Kept reasons the server reports per path — each maps to one user-facing
|
||||
// explanation so a kept file says why it was kept (HEL-872).
|
||||
const KEPT_REASONS: Record<string, string> = {
|
||||
referenced: "已被项目使用",
|
||||
on_canvas: "仍在画布中使用",
|
||||
project_owned: "属于项目文件,不归素材库删除",
|
||||
outside_assets: "不在素材库存储范围内",
|
||||
unlink_failed: "文件被占用或无权限,删除失败",
|
||||
other: "无法删除",
|
||||
};
|
||||
function keptSummary(kept: { reason?: string }[]) {
|
||||
const byReason: Record<string, number> = {};
|
||||
for (const r of kept) {
|
||||
const key = (r.reason && KEPT_REASONS[r.reason]) ? r.reason : "other";
|
||||
byReason[key] = (byReason[key] ?? 0) + 1;
|
||||
}
|
||||
const parts = Object.entries(byReason).map(
|
||||
([reason, n]) => `${KEPT_REASONS[reason]} ${n}`,
|
||||
);
|
||||
const hint = byReason.on_canvas ? ";画布中的素材请先删除对应卡片" : "";
|
||||
return `保留 ${kept.length} 个:${parts.join(" · ")}${hint}`;
|
||||
}
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: async (paths: string[]) => {
|
||||
const results = [];
|
||||
@@ -155,17 +182,21 @@
|
||||
},
|
||||
onSuccess: (batch) => {
|
||||
const deleted = batch.reduce((n, r) => n + r.deleted.length, 0),
|
||||
kept = batch
|
||||
.flatMap((r) => r.results)
|
||||
.filter((r) => r.status === "kept").length;
|
||||
toast.info(
|
||||
`已删除 ${deleted} 个文件${kept ? ` · 保留 ${kept} 个:被项目使用或无法删除` : ""}`,
|
||||
);
|
||||
kept = batch.flatMap((r) => r.results).filter((r) => r.status === "kept");
|
||||
if (deleted > 0) {
|
||||
toast.success(
|
||||
`已删除 ${deleted} 个文件${kept.length ? ` · ${keptSummary(kept)}` : ""}`,
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
`未能删除 ${kept.length} 个文件 · ${keptSummary(kept)}`,
|
||||
);
|
||||
}
|
||||
selected.clear();
|
||||
deletePaths = [];
|
||||
void client.invalidateQueries({ queryKey: ["library-media"] });
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
onError: (e) => toast.error(`删除请求失败:${apiErrorMessage(e)}`),
|
||||
});
|
||||
function attached(path: string, title: string) {
|
||||
attachPaths = attachPaths.filter((p) => p !== path);
|
||||
@@ -470,7 +501,7 @@
|
||||
open={deletePaths.length > 0}
|
||||
title={"删除 " + deletePaths.length + " 个文件?"}
|
||||
onclose={() => (deletePaths = [])}
|
||||
><p>删除前服务器会再次检查,若文件此刻已被项目使用会自动保留。</p>
|
||||
><p>删除前服务器会再次检查,仍在画布或项目中使用的文件会自动保留并说明原因。</p>
|
||||
<div class="delete-preview">
|
||||
{#each items.filter((i) => deletePaths.includes(i.path)) as i (i.path)}<div>
|
||||
<SafeMedia
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// HEL-872 — real-page library delete regression (isolated API, real build).
|
||||
// Drives /library with a real browser: single delete, cancel, canvas-held
|
||||
// retention reason, mixed batch, HTTP-failure retry. Asserts disk + list.
|
||||
const { chromium } = require(
|
||||
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
|
||||
);
|
||||
const fs = require("node:fs"),
|
||||
assert = require("node:assert/strict");
|
||||
const OUT = "../evidence/hel872",
|
||||
WEB = "http://127.0.0.1:15272",
|
||||
API = "http://127.0.0.1:18272";
|
||||
const phase = process.env.HEL872_PHASE || "after";
|
||||
const report = { phase, checks: [], errors: [] };
|
||||
const ok = (name, pass) => {
|
||||
report.checks.push({ name, pass: !!pass });
|
||||
assert(pass, name);
|
||||
console.log("PASS", name);
|
||||
};
|
||||
const api = async (p, b, method) => {
|
||||
const r = await fetch(API + p, {
|
||||
method: method || (b ? "POST" : "GET"),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: b ? JSON.stringify(b) : undefined,
|
||||
});
|
||||
assert(r.ok, await r.clone().text());
|
||||
const t = await r.text();
|
||||
return t ? JSON.parse(t) : null;
|
||||
};
|
||||
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const lastToast = (p) =>
|
||||
p.evaluate(() => {
|
||||
const els = document.querySelectorAll(".toast, [class*='toast']");
|
||||
return els.length ? els[els.length - 1].textContent : "";
|
||||
});
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(`${OUT}/${phase}`, { recursive: true });
|
||||
const names = await api("/api/library/media");
|
||||
const byName = Object.fromEntries(names.map((i) => [i.name, i.path]));
|
||||
const free = byName["aaaa-free-upload.png"];
|
||||
const plain = byName["plain-scratch-out.png"];
|
||||
const held = byName["held-on-canvas.png"];
|
||||
ok("seed: three visible library items", !!free && !!plain && !!held);
|
||||
ok("seed: project-referenced file hidden from list", !names.some((i) => i.name === "hidden-project-ref.png"));
|
||||
|
||||
const browser = await chromium.launch({
|
||||
channel: "chromium", // full build — headless shell lacks host libs
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
recordVideo: { dir: `${OUT}/${phase}/video` },
|
||||
});
|
||||
const p = await ctx.newPage();
|
||||
p.on("pageerror", (e) => report.errors.push(e.stack));
|
||||
p.setDefaultTimeout(12000);
|
||||
await ctx.addInitScript(() => {
|
||||
localStorage.setItem("aigc-lang", "zh");
|
||||
localStorage.setItem("aigc-motion", "off");
|
||||
});
|
||||
await p.goto(WEB + "/library");
|
||||
await p.getByRole("button", { name: /查看 aaaa-free-upload/ }).waitFor();
|
||||
await pause(400);
|
||||
await p.screenshot({ path: `${OUT}/${phase}/01-initial.png` });
|
||||
|
||||
// ── cancel leaves everything untouched ─────────────────────────────
|
||||
await p.getByRole("button", { name: /查看 plain-scratch-out/ }).click();
|
||||
await p.getByRole("button", { name: "删除此文件" }).click();
|
||||
await p.getByRole("dialog").getByText("删除 1 个文件?").waitFor();
|
||||
await p.screenshot({ path: `${OUT}/${phase}/02-confirm-dialog.png` });
|
||||
await p.getByRole("dialog").getByRole("button", { name: "取消" }).click();
|
||||
await pause(400);
|
||||
ok("cancel: modal closed", !(await p.getByRole("dialog").count()));
|
||||
ok("cancel: file untouched on disk", fs.existsSync(plain));
|
||||
|
||||
if (phase === "before") {
|
||||
// ── defect demo: canvas-held file kept with merged vague reason ──
|
||||
await p.getByRole("button", { name: /查看 held-on-canvas/ }).click();
|
||||
await p.getByRole("button", { name: "删除此文件" }).click();
|
||||
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
|
||||
await pause(800);
|
||||
const toast = await lastToast(p);
|
||||
await p.screenshot({ path: `${OUT}/${phase}/03-before-held-toast.png` });
|
||||
ok("before: canvas-held delete reports 0 deleted", /已删除 0 个文件/.test(toast));
|
||||
ok("before: merged vague reason (defect)", /被项目使用或无法删除/.test(toast));
|
||||
ok("before: file survives (protection works)", fs.existsSync(held));
|
||||
} else {
|
||||
// ── deletable file: cancel already proven; now confirm ──────────
|
||||
await p.getByRole("button", { name: /查看 plain-scratch-out/ }).click();
|
||||
await p.getByRole("button", { name: "删除此文件" }).click();
|
||||
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
|
||||
await pause(900);
|
||||
const toast = await lastToast(p);
|
||||
await p.screenshot({ path: `${OUT}/${phase}/03-deleted-single.png` });
|
||||
ok("single delete: success toast", /已删除 1 个文件/.test(toast));
|
||||
ok("single delete: file really gone from disk", !fs.existsSync(plain));
|
||||
await p.getByRole("button", { name: /查看 aaaa-free-upload/ }).waitFor();
|
||||
ok(
|
||||
"single delete: gone from list after refresh",
|
||||
!(await p.getByRole("button", { name: /查看 plain-scratch-out/ }).count()),
|
||||
);
|
||||
await p.reload();
|
||||
await p.getByRole("button", { name: /查看 aaaa-free-upload/ }).waitFor();
|
||||
ok(
|
||||
"single delete: no resurrection after page reload",
|
||||
!(await p.getByRole("button", { name: /查看 plain-scratch-out/ }).count()),
|
||||
);
|
||||
|
||||
// ── canvas-held file: kept with the accurate reason ─────────────
|
||||
await p.getByRole("button", { name: /查看 held-on-canvas/ }).click();
|
||||
await p.getByRole("button", { name: "删除此文件" }).click();
|
||||
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
|
||||
await pause(900);
|
||||
const heldToast = await lastToast(p);
|
||||
await p.screenshot({ path: `${OUT}/${phase}/04-held-on-canvas.png` });
|
||||
ok("held: nothing deleted reported", /未能删除 1 个文件/.test(heldToast));
|
||||
ok("held: accurate on-canvas reason", /仍在画布中使用 1/.test(heldToast));
|
||||
ok("held: actionable hint", /请先删除对应卡片/.test(heldToast));
|
||||
ok("held: file survives on disk", fs.existsSync(held));
|
||||
await p.getByRole("button", { name: /查看 held-on-canvas/ }).waitFor();
|
||||
ok("held: still listed (kept, not hidden)", (await p.getByRole("button", { name: /查看 held-on-canvas/ }).count()) === 1);
|
||||
|
||||
// ── mixed batch: partial success with per-reason breakdown ──────
|
||||
await p.getByRole("button", { name: "选择", exact: true }).click();
|
||||
await p.getByRole("button", { name: /选择 aaaa-free-upload/, exact: true }).click();
|
||||
await p.getByRole("button", { name: /选择 held-on-canvas/, exact: true }).click();
|
||||
await p.getByRole("button", { name: "删除", exact: true }).click();
|
||||
await p.getByRole("dialog").getByText("删除 2 个文件?").waitFor();
|
||||
await p.screenshot({ path: `${OUT}/${phase}/05-batch-dialog.png` });
|
||||
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
|
||||
await pause(900);
|
||||
const batchToast = await lastToast(p);
|
||||
await p.screenshot({ path: `${OUT}/${phase}/06-batch-result.png` });
|
||||
ok("batch: one deleted one kept", /已删除 1 个文件/.test(batchToast) && /保留 1 个/.test(batchToast));
|
||||
ok("batch: kept reason named", /仍在画布中使用 1/.test(batchToast));
|
||||
ok("batch: free file gone from disk", !fs.existsSync(free));
|
||||
ok("batch: held file survives", fs.existsSync(held));
|
||||
ok(
|
||||
"batch: selection bar cleared after result",
|
||||
!(await p.getByText("已选 1").count()),
|
||||
);
|
||||
|
||||
// ── HTTP failure: readable error, modal open, retry succeeds ────
|
||||
await p.reload();
|
||||
await p.getByRole("button", { name: /查看 held-on-canvas/ }).waitFor();
|
||||
await p.getByRole("button", { name: /查看 aaaa-free-upload/ }).waitFor().catch(() => {});
|
||||
// seed one more deletable file through the API for the retry leg
|
||||
const seeded = await (async () => {
|
||||
const list = await api("/api/library/media");
|
||||
return list.find((i) => i.name === "retry-upload.png");
|
||||
})();
|
||||
ok("retry: seed file present", !!seeded);
|
||||
let blocked = 1;
|
||||
await p.route("**/api/library/media", async (route) => {
|
||||
if (route.request().method() === "DELETE" && blocked > 0) {
|
||||
blocked--;
|
||||
await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "隔离测试注入的暂时故障" }) });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await p.getByRole("button", { name: /查看 retry-upload/ }).click();
|
||||
await p.getByRole("button", { name: "删除此文件" }).click();
|
||||
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
|
||||
await pause(600);
|
||||
const errToast = await lastToast(p);
|
||||
await p.screenshot({ path: `${OUT}/${phase}/07-error-toast.png` });
|
||||
ok("failure: readable error toast", /删除请求失败/.test(errToast) && /隔离测试注入的暂时故障/.test(errToast));
|
||||
ok("failure: no false success", !/已删除/.test(errToast));
|
||||
ok("failure: file still on disk", fs.existsSync(seeded.path));
|
||||
ok("failure: modal stays open for retry", (await p.getByRole("dialog").count()) === 1);
|
||||
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
|
||||
await pause(900);
|
||||
const retryToast = await lastToast(p);
|
||||
await p.screenshot({ path: `${OUT}/${phase}/08-retry-success.png` });
|
||||
ok("retry: second confirm succeeds", /已删除 1 个文件/.test(retryToast));
|
||||
ok("retry: file really gone", !fs.existsSync(seeded.path));
|
||||
await p.unroute("**/api/library/media");
|
||||
}
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
fs.writeFileSync(`${OUT}/${phase}/report.json`, JSON.stringify(report, null, 2));
|
||||
console.log(`HEL-872 ${phase}: ${report.checks.length} checks passed`);
|
||||
})().catch((e) => {
|
||||
report.errors.push(String(e));
|
||||
fs.writeFileSync(`${OUT}/${phase}/report.json`, JSON.stringify(report, null, 2));
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
"""HEL-872 — own a loopback-only API + static web for one foreground UI check.
|
||||
|
||||
Seeds an isolated library (plain upload, plain scratch output, canvas-held
|
||||
scratch output, project-referenced upload) and drives the real /library page
|
||||
via e2e/hel872.cjs. HEL872_PHASE=before|after picks which web build to serve;
|
||||
both phases run against the SAME backend code (base backend for before, fixed
|
||||
backend for after — the runner is executed from the tree being tested).
|
||||
"""
|
||||
import json, os, signal, socket, sqlite3, subprocess, sys, tempfile, time, urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
os.chdir(root)
|
||||
phase = os.environ.get("HEL872_PHASE", "after")
|
||||
build_dir = os.environ.get("HEL872_BUILD", f"calliope-web/build{'-base' if phase == 'before' else ''}")
|
||||
out = root.parent / "evidence/hel872"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
API_PORT, WEB_PORT = 18272, 15272
|
||||
for port in (API_PORT, WEB_PORT):
|
||||
with socket.socket() as s:
|
||||
assert s.connect_ex(("127.0.0.1", port)) != 0, f"Port occupied: {port}"
|
||||
|
||||
PNG = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||||
"0000000d4944415478da636460f85f0f0002870180eb47ba920000000049454e44"
|
||||
"ae426082"
|
||||
) # canonical 1x1 RGBA PNG fixture
|
||||
|
||||
processes, handles, cleanup = [], [], []
|
||||
with tempfile.TemporaryDirectory(prefix="hel872-", dir=out) as tmp:
|
||||
tmp = Path(tmp)
|
||||
env = {**os.environ, "PYTHONPATH": str(root / "calliope-backend/src"),
|
||||
"HEL872_DATA": str(tmp), "HEL872_BUILD": build_dir,
|
||||
"LD_LIBRARY_PATH": str(out / "browser-libs"),
|
||||
"PLAYWRIGHT_MODULE_PATH": "/home/xiaomo/.npm-global/lib/node_modules/playwright"}
|
||||
api_code = f"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from calliope import config
|
||||
root = Path({str(tmp)!r})
|
||||
config.CONFIG_FILE = root / 'settings.json'
|
||||
config.settings = config.Settings(
|
||||
data_dir=root / 'data', assets_dir=root / 'data/assets',
|
||||
llm_base_url='http://127.0.0.1:1/v1', llm_model='isolated',
|
||||
comfyui_base_url='http://127.0.0.1:1', dry_run=False)
|
||||
config.settings.ensure_storage_dirs()
|
||||
config.settings.ensure_llm_profiles()
|
||||
from calliope.main import create_app
|
||||
import uvicorn
|
||||
uvicorn.run(create_app(), host='127.0.0.1', port={API_PORT})
|
||||
"""
|
||||
web_code = """
|
||||
const http=require('node:http'),fs=require('node:fs'),path=require('node:path');
|
||||
const build=path.resolve(process.env.HEL872_BUILD);
|
||||
http.createServer((req,res)=>{
|
||||
if(req.url.startsWith('/api/')){
|
||||
const q=http.request('http://127.0.0.1:%d'+req.url,{method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});
|
||||
q.on('error',()=>{res.statusCode=502;res.end()});res.on('close',()=>q.destroy());req.pipe(q);return}
|
||||
let file=path.join(build,new URL(req.url,'http://localhost').pathname);
|
||||
if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html');
|
||||
res.setHeader('Content-Type',({'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.jpg':'image/jpeg','.woff2':'font/woff2','.svg':'image/svg+xml'})[path.extname(file)]||'application/octet-stream');
|
||||
fs.createReadStream(file).pipe(res);
|
||||
}).listen(%d,'127.0.0.1');
|
||||
""" % (API_PORT, WEB_PORT)
|
||||
|
||||
def seed():
|
||||
base = f"http://127.0.0.1:{API_PORT}"
|
||||
def post(p, body):
|
||||
req = urllib.request.Request(base + p, data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
return json.load(urllib.request.urlopen(req, timeout=10))
|
||||
db = sqlite3.connect(tmp / "data" / "calliope.db")
|
||||
db.execute("INSERT INTO projects (title, idea, status) VALUES ('Playground Scratch', NULL, 'system')")
|
||||
db.commit()
|
||||
pid = db.execute("SELECT id FROM projects WHERE status='system'").fetchone()[0]
|
||||
up = tmp / "data" / "assets" / "uploads"
|
||||
scratch_img = tmp / "data" / "assets" / str(pid) / "image"
|
||||
up.mkdir(parents=True, exist_ok=True)
|
||||
scratch_img.mkdir(parents=True, exist_ok=True)
|
||||
(up / "aaaa-free-upload.png").write_bytes(PNG)
|
||||
(up / "retry-upload.png").write_bytes(PNG)
|
||||
(up / "hidden-project-ref.png").write_bytes(PNG)
|
||||
(scratch_img / "plain-scratch-out.png").write_bytes(PNG)
|
||||
held = scratch_img / "held-on-canvas.png"
|
||||
held.write_bytes(PNG)
|
||||
db.execute("INSERT INTO canvas (id, title) VALUES (1, '隔离沙盒画布')")
|
||||
db.execute(
|
||||
"INSERT INTO canvas_node (canvas_id, type, title, artifact_path, status)"
|
||||
" VALUES (1, 'image', '画布卡片', ?, 'done')", (str(held),))
|
||||
db.commit()
|
||||
proj = post("/api/projects", {"title": "隔离引用项目", "idea": "hel872"})
|
||||
db.execute(
|
||||
"INSERT INTO characters (project_id, name, appearance, portrait_path)"
|
||||
" VALUES (?, '隔离角色', 'x', ?)", (proj["id"], str(up / "hidden-project-ref.png")))
|
||||
db.commit()
|
||||
db.close()
|
||||
listed = json.load(urllib.request.urlopen(base + "/api/library/media", timeout=10))
|
||||
print("SEED listed:", [i["name"] for i in listed], flush=True)
|
||||
|
||||
try:
|
||||
for name, cmd in [("api", [sys.executable, "-c", api_code]), ("web", ["node", "-e", web_code])]:
|
||||
h = (out / f"{phase}-{name}.log").open("w")
|
||||
handles.append(h)
|
||||
processes.append(subprocess.Popen(cmd, env=env, stdout=h, stderr=subprocess.STDOUT))
|
||||
for url in (f"http://127.0.0.1:{API_PORT}/api/health", f"http://127.0.0.1:{WEB_PORT}"):
|
||||
for attempt in range(150):
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=1)
|
||||
break
|
||||
except Exception:
|
||||
if any(p.poll() is not None for p in processes):
|
||||
raise RuntimeError(f"Service failed: {name}")
|
||||
if attempt == 149:
|
||||
raise
|
||||
time.sleep(0.1)
|
||||
seed()
|
||||
# Minimal API-level repro, logged raw for the evidence bundle:
|
||||
# list shows the canvas-held file, delete keeps it with the reason.
|
||||
base = f"http://127.0.0.1:{API_PORT}"
|
||||
listed = json.load(urllib.request.urlopen(base + "/api/library/media", timeout=10))
|
||||
held_path = next(i["path"] for i in listed if i["name"] == "held-on-canvas.png")
|
||||
req = urllib.request.Request(
|
||||
base + "/api/library/media", method="DELETE",
|
||||
data=json.dumps({"paths": [held_path]}).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
body = json.load(urllib.request.urlopen(req, timeout=10))
|
||||
(out / f"{phase}-api-repro.json").write_text(json.dumps(
|
||||
{"listed_held": True, "delete_response": body}, ensure_ascii=False, indent=2))
|
||||
print("API REPRO:", json.dumps(body, ensure_ascii=False), flush=True)
|
||||
subprocess.run(["node", "e2e/hel872.cjs"], env=env, check=True)
|
||||
finally:
|
||||
daemon = json.loads(subprocess.check_output(["multica", "daemon", "status", "--output", "json"]))["pid"]
|
||||
for p in processes:
|
||||
assert p.pid != daemon, "refusing to kill the multica daemon"
|
||||
if p.poll() is None:
|
||||
p.send_signal(signal.SIGTERM)
|
||||
for p in processes:
|
||||
try:
|
||||
p.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
p.wait()
|
||||
cleanup.append({"pid": p.pid, "exit": p.returncode, "daemon_pid": daemon})
|
||||
for h in handles:
|
||||
h.close()
|
||||
(out / f"{phase}-cleanup.json").write_text(json.dumps(
|
||||
{"children": cleanup, "isolated_data_removed": not Path(tmp).exists()}, indent=2))
|
||||
print("CLEANUP", cleanup, "isolated data removed:", not Path(tmp).exists(), flush=True)
|
||||
Reference in New Issue
Block a user