From 5928cc052e16085a4af7d52ec8f5d2da88280809 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 25 Sep 2026 15:59:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E9=81=93=E5=8F=AA=E9=85=8D=E4=B8=8D?= =?UTF-8?q?=E7=AE=A1=E7=9A=84=E6=8A=A4=E6=A0=8F=E5=BC=80=E5=A7=8B=E5=B9=B2?= =?UTF-8?q?=E6=B4=BB=EF=BC=9A=E8=BF=90=E8=A1=8C=E6=97=B6=E9=99=90=E3=80=81?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=B6=85=E6=97=B6=E3=80=81=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 显示成生效却没生效,比明说没有更危险——它让人以为有东西在兜底。 1. max_run_seconds 只存在于配置和设置接口里,没有任何代码执行它:一个挂住的 模型或工具能把运行和它占着的并发名额一直拖着。现在 runner 用 asyncio.timeout 强制执行,按"一段执行"计(等人工审批时 _drive 已结束,不算);超时判 failed、 说清是哪道上限,可以接着跑。别处抛的 TimeoutError 不冒充这道上限(看 Timeout.expired())。 2. 模型调用从来没设过超时:ModelSpec.timeout 从没被赋值,那条传参分支一次都没 执行过,所有调用走 SDK 默认的 10 分钟外加重试。新增 model_timeout_seconds (默认 300 秒)作为缺省,节点显式配的仍优先;已确认 ChatAnthropic / ChatOpenAI 都接这个参数。 3. manifest 哈希只写不验,而且是在 run.finished 落库之前算的——清单里恰恰少了 宣布"跑完了、成果是什么"的那条,改成果清单照样对得上。现在终态事件落库之后 再封存,并记下封到哪一条(manifest_seq,新列);新增 verify_manifest 与 GET /api/runs/{id}/verify,运行详情页加了核对按钮。封存之后追加的事件不算篡改。 老运行没有 manifest_seq,按当时的口径还原范围——开发库里 300 条带哈希的历史 运行全部核对通过(成功 / 失败 / 取消各类都有),没有一条误报。 设置页「运行环境」补上单次执行时限和模型调用超时两项,README 加了对应的常见问题。 顺带修了一个被 _seal 放大的老问题:_drive 的收尾被打断时(关停取消、库连接出错) 漏掉 _tasks.pop,留下的任务让下一次 shutdown 的 gather 在别的事件循环里抛 "future belongs to a different loop"——测试里表现为每八九轮一次、一出就是几十个 teardown error。pop 移进内层 finally,shutdown 只等未结束的任务并清空。修后全量 连跑 12 轮 12 轮全绿。 test_guardrails.py 8 例:超时中止(挂住的节点 1 秒后被停下)、缺省模型超时、封存覆盖 run.finished、改成果被戳穿、事后追加不误报、老运行按旧口径核、中断态未封存、接口 404。414 → 422 passed。 --- README.md | 7 +- backend/app/api/runs.py | 11 ++ backend/app/api/settings.py | 2 + backend/app/core/config.py | 5 + backend/app/db/base.py | 1 + backend/app/db/models.py | 3 + backend/app/engine/runner.py | 125 +++++++++++++++---- backend/app/providers/factory.py | 6 +- backend/tests/test_guardrails.py | 182 ++++++++++++++++++++++++++++ frontend/src/api/client.ts | 3 + frontend/src/pages/RunsPage.tsx | 16 ++- frontend/src/pages/SettingsPage.tsx | 2 + 12 files changed, 337 insertions(+), 26 deletions(-) create mode 100644 backend/tests/test_guardrails.py diff --git a/README.md b/README.md index 99caa4e4..e7ad3ab5 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,8 @@ flowchart LR 只是与上周不可比」。 每次运行的完整证据存于内容寻址的工件库(`data/artifacts/`,sha256 即地址,取回时复验哈希); -运行终态的事件清单哈希记录在 Run 上,事后修改流水将无法对齐。 +运行终态的事件清单哈希记录在 Run 上(封存到 `run.finished` 为止),事后修改流水将无法对齐。 +运行详情页一键核对,接口是 `GET /api/runs/{id}/verify`;封存之后追加的事件不在核对范围内。 --- @@ -416,6 +417,10 @@ agent 侧同样会收到实情,并被告知可直接查询 `information_schema **端口冲突。** 前端默认 5273、后端 8000,通过 `AGENTLAB_WEB_PORT` / `AGENTLAB_PORT` 修改。 +**运行被中止,提示超过时限。** 单次执行(两次人工介入之间的那一段)默认最多 600 秒, +单次模型调用默认 300 秒没有输出即超时。多半是模型或工具没有响应;确实需要更久,调大 +`AGENTLAB_MAX_RUN_SECONDS` / `AGENTLAB_MODEL_TIMEOUT_SECONDS`。设置页「运行环境」里能看到当前值。 + --- ## 开发与验证 diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index 94d9f56e..36280958 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -302,6 +302,17 @@ async def get_state(run_id: str) -> dict[str, Any]: } +@router.get("/{run_id}/verify") +async def verify_run(run_id: str) -> dict[str, Any]: + """核对事件流和封存时的清单哈希是否一致:事后被改过、删过、插过都会对不上。""" + from app.engine.runner import verify_manifest + + try: + return await verify_manifest(run_id) + except KeyError as e: + raise HTTPException(404, str(e)) from e + + @router.get("/{run_id}/history") async def get_history(run_id: str, limit: int = 50) -> list[dict[str, Any]]: """checkpoint 历史 —— 可以看到每一步之后的状态快照,用来做时间旅行式调试。""" diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 8db5fcae..0a724011 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -258,5 +258,7 @@ async def system_info() -> dict[str, Any]: "max_graph_steps": app_settings.max_graph_steps, "max_agent_steps": app_settings.max_agent_steps, "max_concurrent_runs": app_settings.max_concurrent_runs, + "max_run_seconds": app_settings.max_run_seconds, + "model_timeout_seconds": app_settings.model_timeout_seconds, }, } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 28bfd46c..f0af072f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -63,7 +63,12 @@ class Settings(BaseSettings): microvm_boot_timeout: int = 300 # --- 执行引擎护栏 --- + # 单次执行(两次人工介入之间的那一段)的墙钟上限,runner 里强制执行 max_run_seconds: int = 600 + # 单次模型调用的超时。流式调用时是"两段输出之间最长沉默多久",非流式是整次 + # 请求。不设的话走 SDK 默认的 10 分钟、外加重试——一个挂住的服务商能把一个 + # 节点拖上半小时 + model_timeout_seconds: int = 300 max_graph_steps: int = 200 # 节点上配多少步都过不去这个硬顶。默认值仍是节点上的 12(nodeDefs.ts), # 这里只决定"想调大的人最多能调到哪"——实测 87% 的 agent 节点 ≤10 步就收口, diff --git a/backend/app/db/base.py b/backend/app/db/base.py index fe02274c..a090f459 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -107,6 +107,7 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [ ("runs", "version", "INTEGER"), ("runs", "version_hash", "VARCHAR(64)"), ("runs", "manifest_hash", "VARCHAR(64)"), + ("runs", "manifest_seq", "INTEGER"), ("runs", "started_by", "VARCHAR(100)"), ("workflows", "status", "TEXT DEFAULT 'draft'"), ("workflows", "published_version", "INTEGER"), diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 290738fe..533c8786 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -176,6 +176,9 @@ class Run(Base, TimestampMixin): version_hash: Mapped[str | None] = mapped_column(String(64), default=None) # 结束时对全部事件计算的清单哈希,事后改动事件流会被它戳穿 manifest_hash: Mapped[str | None] = mapped_column(String(64), default=None) + # 清单封到了哪一条事件(含)。之后追加的事件(复核批注之类)不在封存范围内; + # 为空的是记录这一列之前封存的老运行,核对时按当时的口径算 + manifest_seq: Mapped[int | None] = mapped_column(Integer, default=None) started_by: Mapped[str | None] = mapped_column(String(100), default=None) events: Mapped[list["RunEvent"]] = relationship( diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index 5b60380c..a3eeb2bf 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -61,9 +61,13 @@ class RunManager: await session.commit() async def shutdown(self) -> None: - for task in list(self._tasks.values()): + # 只等还没结束的:结束了的没什么可等,而且它可能属于另一个事件循环, + # gather 会直接抛 "future belongs to a different loop" + pending = [t for t in self._tasks.values() if not t.done()] + for task in pending: task.cancel() - await asyncio.gather(*self._tasks.values(), return_exceptions=True) + await asyncio.gather(*pending, return_exceptions=True) + self._tasks.clear() if self._cm is not None: with contextlib.suppress(Exception): await self._cm.__aexit__(None, None, None) @@ -418,6 +422,7 @@ class RunManager: error: str | None = None final_state: dict[str, Any] = {} + deadline: asyncio.Timeout | None = None async with self._semaphore: try: async with SessionLocal() as session: @@ -449,14 +454,19 @@ class RunManager: } interrupted = False - async for mode, chunk in app.astream( - payload, config=config, stream_mode=["custom", "updates"] - ): - if mode == "custom": - await self._handle_custom(run_id, chunk) - elif mode == "updates": - if await self._handle_updates(run_id, chunk): - interrupted = True + # 单次执行的墙钟上限。以前 max_run_seconds 只存在于配置里,没有任何 + # 代码执行它——一个挂住的模型或工具能把运行和它占着的并发名额一直拖着。 + # 按"这一段执行"计:等人工审批时 _drive 已经结束,那段时间不算。 + deadline = asyncio.timeout(settings.max_run_seconds) + async with deadline: + async for mode, chunk in app.astream( + payload, config=config, stream_mode=["custom", "updates"] + ): + if mode == "custom": + await self._handle_custom(run_id, chunk) + elif mode == "updates": + if await self._handle_updates(run_id, chunk): + interrupted = True snapshot = await app.aget_state(config) final_state = dict(snapshot.values or {}) @@ -470,6 +480,17 @@ class RunManager: error = "用户取消" await self._emit(run_id, EventType.RUN_CANCELLED, data={}) raise + except TimeoutError as e: + status = "failed" + if deadline is not None and deadline.expired(): + error = ( + f"这次执行超过了 {settings.max_run_seconds} 秒的上限,已中止。" + "跑得慢的多半是模型或工具没有响应;确实需要更久,调大 AGENTLAB_MAX_RUN_SECONDS" + ) + else: # 别处抛的超时,不是这道上限——按普通失败说 + error = f"{type(e).__name__}: {e}" + logger.exception("run %s 失败", run_id) + await self._emit(run_id, EventType.RUN_FAILED, data={"error": error}) except Exception as e: # noqa: BLE001 status = "failed" # NodeError 的 message 本来就是写给人看的("人工驳回:…"), @@ -479,8 +500,13 @@ class RunManager: await self._emit(run_id, EventType.RUN_FAILED, data={"error": error}) finally: elapsed = int((time.perf_counter() - started) * 1000) - await self._finalize(run_id, status, error, final_state, elapsed) - self._tasks.pop(run_id, None) + try: + await self._finalize(run_id, status, error, final_state, elapsed) + finally: + # 收尾被打断(关停时的取消、库连接出错)也得把自己摘掉。以前写在 + # _finalize 后面,一被打断就漏掉,留下的任务让下一次 shutdown 的 + # gather 在别的事件循环里炸开 + self._tasks.pop(run_id, None) # 运行到终态就把沙箱会话收掉。thread_id 每个 run 都是新的, # 不收的话每跑一次带代码节点的图就多一台常驻 microVM(几百 MB), # 而且没有任何自动回收路径会碰它——闲置回收只在"下次有人执行 @@ -566,16 +592,6 @@ class RunManager: run.usage = usage if status != "interrupted": run.finished_at = datetime.now(timezone.utc) - # 终态时对全部已落库事件计算清单哈希:事后任何对事件流的 - # 增删改都会与这个值对不上。中断态不算——事件还会继续追加。 - from app.core.artifact_store import manifest_hash as _manifest - - rows = await session.execute( - select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) - .where(RunEvent.run_id == run_id) - .order_by(RunEvent.seq) - ) - run.manifest_hash = _manifest([tuple(r) for r in rows]) await session.commit() if status == "succeeded": @@ -584,9 +600,74 @@ class RunManager: EventType.RUN_FINISHED, data={"output": _safe(output), "usage": usage, "duration_ms": elapsed_ms}, ) + if status != "interrupted": + # 终态事件落库之后再封存。以前在 run.finished 之前算,清单里恰恰少了 + # 那条宣布"跑完了、成果是什么"的事件——改它的成果,清单照样对得上。 + # 中断态不封:事件还会继续追加 + await self._seal(run_id) if status in ("succeeded", "failed", "cancelled"): await bus.close(run_id) + async def _seal(self, run_id: str) -> None: + """对到此为止的全部事件算清单哈希,记下封到了哪一条。""" + from app.core.artifact_store import manifest_hash as _manifest + + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None: + return + rows = [tuple(r) for r in await session.execute( + select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) + .where(RunEvent.run_id == run_id) + .order_by(RunEvent.seq) + )] + run.manifest_hash = _manifest(rows) + run.manifest_seq = rows[-1][0] if rows else 0 + await session.commit() + + +_TERMINAL = (EventType.RUN_FINISHED, EventType.RUN_FAILED, EventType.RUN_CANCELLED) + + +async def verify_manifest(run_id: str) -> dict[str, Any]: + """重算封存范围内的清单哈希,和封存时记下的对一下。 + + 以前只写不验:README 说"事后修改流水将无法对齐",可仓库里没有一行代码去对。 + 封存范围之后追加的事件不参与核对。没有 manifest_seq 的是加这一列之前封存的 + 老运行——当时的哈希是在 run.finished 落库之前算的,按那时的口径还原范围。 + """ + from app.core.artifact_store import manifest_hash as _manifest + + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None: + raise KeyError(f"找不到运行 {run_id}") + if not run.manifest_hash: + return {"sealed": False, "ok": None, + "message": "这次运行还没有封存(没跑完,或者停在人工介入)"} + rows = [tuple(r) for r in await session.execute( + select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) + .where(RunEvent.run_id == run_id) + .order_by(RunEvent.seq) + )] + expected, sealed_at = run.manifest_hash, run.manifest_seq + + legacy = sealed_at is None + if legacy: + last = next((r for r in reversed(rows) if r[1] in _TERMINAL), None) + if last is None: + sealed_at = rows[-1][0] if rows else 0 + elif last[1] == EventType.RUN_FINISHED: + sealed_at = last[0] - 1 # 老口径:run.finished 本身不在清单里 + else: + sealed_at = last[0] # run.failed / run.cancelled 在封存前就落库了 + covered = [r for r in rows if r[0] <= sealed_at] + ok = _manifest(covered) == expected + return { + "sealed": True, "ok": ok, "events": len(covered), "sealed_at": sealed_at, "legacy": legacy, + "message": "事件流与封存时一致" if ok else "事件流和封存时对不上:封存之后有事件被改过、删过或插过", + } + def _safe(value: Any, limit: int = 4000) -> Any: """把任意对象裁剪成能安全放进事件负载的形状。""" diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 43d67e61..c3a3c168 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import settings from app.core.crypto import decrypt from app.db.models import Provider from app.providers import catalog @@ -106,8 +107,9 @@ def build_chat_model(provider: Provider, spec: ModelSpec) -> BaseChatModel: kwargs: dict[str, Any] = {"model": model} if spec.max_tokens: kwargs["max_tokens"] = spec.max_tokens - if spec.timeout: - kwargs["timeout"] = spec.timeout + # 以前只有节点显式配了才传,而没有任何地方给 spec.timeout 赋过值——所有调用 + # 都走 SDK 默认的 10 分钟 + kwargs["timeout"] = spec.timeout or settings.model_timeout_seconds # 采样参数:能力允许才传 if spec.temperature is not None and catalog.supports_sampling(model): diff --git a/backend/tests/test_guardrails.py b/backend/tests/test_guardrails.py new file mode 100644 index 00000000..310b0ca6 --- /dev/null +++ b/backend/tests/test_guardrails.py @@ -0,0 +1,182 @@ +"""三道"配了、显示了、却没人执行"的护栏。 + +- max_run_seconds:配置里有,没有任何代码执行它。一个挂住的模型或工具能把运行 + 和它占着的并发名额一直拖着。 +- 模型调用超时:ModelSpec.timeout 从没被赋过值,所有调用都走 SDK 默认的 10 分钟 + 外加重试。 +- manifest 哈希:只写不验,而且是在 run.finished 落库之前算的——清单里恰恰少了 + 那条宣布"跑完了、成果是什么"的事件。 + +显示成生效却没生效,比明说没有更危险:它让人以为有东西在兜底。 +""" +from __future__ import annotations + +import asyncio +import time + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select, update + +from app.core.artifact_store import manifest_hash +from app.core.config import settings +from app.core.crypto import encrypt +from app.db.base import SessionLocal +from app.db.models import Provider, Run, RunEvent +from app.engine import compiler +from app.engine.runner import run_manager, verify_manifest +from app.engine.schema import NodeType +from app.main import app +from app.providers.factory import ModelSpec, build_chat_model + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +SIMPLE = chain( + node("start", "input", fields=[{"name": "question"}]), + node("t", "transform", mode="template", template="好", assign_to="x"), + node("out", "output", fields=[{"name": "结果", "value": "{{ vars.x }}"}]), +) + + +async def _run(graph, *, timeout=20.0) -> Run: + run = await run_manager.start(graph=graph, input_payload={"question": "随便"}) + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row and row.status in ("succeeded", "failed", "cancelled", "interrupted"): + return row + await asyncio.sleep(0.05) + raise AssertionError("运行没有按时结束") + + +# -------------------------------------------------------------------------- +# max_run_seconds +# -------------------------------------------------------------------------- + + +async def test_a_run_past_its_time_limit_is_stopped(monkeypatch): + async def stuck(state, ctx): + await asyncio.sleep(30) # 一个不响应的模型或工具 + return {} + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, stuck) + monkeypatch.setattr(settings, "max_run_seconds", 1) + + started = time.perf_counter() + run = await _run(SIMPLE) + assert run.status == "failed" + assert "1 秒的上限" in (run.error or "") + assert time.perf_counter() - started < 10, "上限没起作用,等到了节点自己结束" + + +# -------------------------------------------------------------------------- +# 模型调用超时 +# -------------------------------------------------------------------------- + + +def test_models_get_a_timeout_even_when_the_node_sets_none(): + provider = Provider(name="t", kind="anthropic", api_key=encrypt("sk-test"), models=[], + default_model="claude-sonnet-5", enabled=True, extra={}) + model = build_chat_model(provider, ModelSpec()) + assert model.default_request_timeout == settings.model_timeout_seconds + # 节点显式配了的照旧优先 + assert build_chat_model(provider, ModelSpec(timeout=42)).default_request_timeout == 42 + + +# -------------------------------------------------------------------------- +# manifest +# -------------------------------------------------------------------------- + + +async def _events(run_id): + async with SessionLocal() as session: + return list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq) + )).scalars()) + + +async def test_the_seal_covers_run_finished_and_verifies(): + run = await _run(SIMPLE) + assert run.status == "succeeded", run.error + events = await _events(run.id) + finished = next(e for e in events if e.type == "run.finished") + assert run.manifest_seq == finished.seq, "清单没有封到 run.finished" + + report = await verify_manifest(run.id) + assert report["sealed"] and report["ok"], report + + +async def test_tampering_with_the_output_is_caught(): + """改成果——以前 run.finished 不在清单里,这一改清单照样对得上。""" + run = await _run(SIMPLE) + async with SessionLocal() as session: + await session.execute( + update(RunEvent) + .where(RunEvent.run_id == run.id, RunEvent.type == "run.finished") + .values(data={"output": {"结果": "被改过的结论"}}) + ) + await session.commit() + report = await verify_manifest(run.id) + assert report["ok"] is False + + +async def test_events_appended_after_the_seal_are_not_tampering(): + """封存之后追加的事件(复核批注之类)不在范围内,不该让核对失败。""" + run = await _run(SIMPLE) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + session.add(RunEvent(run_id=run.id, seq=row.manifest_seq + 5, type="log", ts=time.time(), + node_id=None, data={"message": "事后追加的批注"})) + await session.commit() + assert (await verify_manifest(run.id))["ok"] is True + + +async def test_runs_sealed_before_manifest_seq_existed_verify_by_the_old_cut(): + """老运行的哈希是在 run.finished 落库之前算的,没有 manifest_seq——按当时的口径核。""" + run = await _run(SIMPLE) + rows = [(e.seq, e.type, e.node_id, e.data) for e in await _events(run.id) + if e.type != "run.finished"] + async with SessionLocal() as session: + await session.execute(update(Run).where(Run.id == run.id) + .values(manifest_hash=manifest_hash(rows), manifest_seq=None)) + await session.commit() + report = await verify_manifest(run.id) + assert report["ok"] is True and report["legacy"] is True + + +async def test_a_run_waiting_for_a_human_is_not_sealed_yet(): + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("h", "human", mode="approve", title="批吗"), + node("out", "output", fields=[]), + ) + run = await _run(graph) + assert run.status == "interrupted" + report = await verify_manifest(run.id) + assert report["sealed"] is False and report["ok"] is None + + +async def test_verify_through_the_api(): + run = await _run(SIMPLE) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + ok = await client.get(f"/api/runs/{run.id}/verify") + assert ok.status_code == 200 and ok.json()["ok"] is True + assert (await client.get("/api/runs/没有这个/verify")).status_code == 404 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3fa45591..4e4ab4ee 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -129,6 +129,9 @@ export const api = { events: (id: string, after = 0) => get(`/runs/${id}/events?after=${after}`), state: (id: string) => get(`/runs/${id}/state`), history: (id: string) => get(`/runs/${id}/history`), + /** 核对事件流和封存时的清单哈希:事后被改过、删过、插过都会对不上 */ + verify: (id: string) => get<{ sealed: boolean; ok: boolean | null; message: string }>( + `/runs/${id}/verify`), remove: (id: string) => del(`/runs/${id}`), }, diff --git a/frontend/src/pages/RunsPage.tsx b/frontend/src/pages/RunsPage.tsx index b03e21bd..911bac1d 100644 --- a/frontend/src/pages/RunsPage.tsx +++ b/frontend/src/pages/RunsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { Code2, GitFork, History, RefreshCw, Trash2 } from 'lucide-react' +import { Code2, GitFork, History, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { Empty, Spinner, StatusDot, useToast } from '../components/ui' @@ -157,6 +157,20 @@ export function RunsPage() { + {selected.manifest_hash && ( + + )}