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:
施工员2号
2026-10-26 20:51:03 +08:00
co-authored by multica-agent
parent 344fede198
commit 5163230433
5 changed files with 457 additions and 15 deletions
@@ -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
+40 -9
View File
@@ -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
View File
@@ -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);
});
+148
View File
@@ -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)