diff --git a/api/ops/orchestrator/core.py b/api/ops/orchestrator/core.py index adce1c8e..803c9ea9 100644 --- a/api/ops/orchestrator/core.py +++ b/api/ops/orchestrator/core.py @@ -15,6 +15,7 @@ from api.ops.llm.types import LlmUsage from api.ops.queries import OpsQueries from api.ops.review.rules import review_result +from api.ops.store.artifacts import save_artifact_with_failure_event from api.ops.store.runs import OpsRunStore, append_event from api.ops.tracing import trace_span, traceable, update_current_span_metadata @@ -358,6 +359,19 @@ def run_deep( "verdict": final_verdict, }, ) + save_artifact_with_failure_event( + run_id, + "deep.final_answer", + { + "answer": answer, + "agent": agent_name, + "issue_number": analyst_result.get("issue_number"), + "verdict": final_verdict, + "intent": intent or "issue_contribution", + "route": "deep", + }, + store=store, + ) store.append_event(run_id, "orchestrator", "run.end", node_id="deep.end") # B5: run 级 metrics_json 汇总 metrics_json = _build_metrics_json( diff --git a/api/ops/react_loop.py b/api/ops/react_loop.py index bda78bcb..71ab695c 100644 --- a/api/ops/react_loop.py +++ b/api/ops/react_loop.py @@ -14,6 +14,7 @@ from api.ops.queries import OpsQueries from api.ops.react_tools import _build_v0_registry, _truncate_summary from api.ops.review.rules import review_result +from api.ops.store.artifacts import save_artifact_with_failure_event from api.ops.store.runs import OpsRunStore, append_event from api.ops.tracing import trace_span, traceable, update_current_span_metadata @@ -315,6 +316,18 @@ def run_react_fallback( status=final_verdict, final_answer={"answer": answer, "verdict": final_verdict}, ) + save_artifact_with_failure_event( + run_id, + "react.final_answer", + { + "answer": answer, + "verdict": final_verdict, + "intent": "fallback", + "route": "react", + "steps_taken": step, + }, + store=store, + ) store.append_event(run_id, "orchestrator", "run.end", node_id="react.end") # Run metrics diff --git a/api/ops/store/artifacts.py b/api/ops/store/artifacts.py new file mode 100644 index 00000000..03d24068 --- /dev/null +++ b/api/ops/store/artifacts.py @@ -0,0 +1,70 @@ +"""Ops Chat run artifacts 仓储层(P1-1)。""" + +from __future__ import annotations + +from typing import Any + + +class ArtifactStoreError(RuntimeError): + """Artifact 写入失败时的自说明异常。""" + + +def save_artifact( + run_id: str, + kind: str, + payload: dict[str, Any], + store: Any | None = None, + max_retries: int = 3, +) -> dict[str, Any]: + """幂等写入 ops_run_artifacts;按 (run_id, kind) 去重。 + + - 未提供 store 时,使用全局 supabase_client() 构造 OpsRunStore。 + - 重试 max_retries + 1 次后仍失败则抛出 ArtifactStoreError。 + """ + from api.ops.store.runs import OpsRunStore + from api.rag_env import supabase_client + + target = store if store is not None else OpsRunStore(supabase_client()) + normalized = dict(payload) + last_exc: Exception | None = None + + for _attempt in range(max_retries + 1): + try: + return target.save_artifact(run_id, kind, normalized) + except Exception as exc: + last_exc = exc + + raise ArtifactStoreError( + f"Failed to save artifact run_id={run_id} kind={kind} " + f"after {max_retries + 1} attempts: {last_exc}" + ) + + +def save_artifact_with_failure_event( + run_id: str, + kind: str, + payload: dict[str, Any], + store: Any | None = None, + max_retries: int = 3, +) -> dict[str, Any] | None: + """保存 artifact;写入失败时记录 `artifact.write_failed` 事件并吞掉异常。 + + 返回值:成功返回写入行;失败返回 None。 + """ + from api.ops.events_schema import SCHEMA_VERSION + from api.ops.store.runs import append_event + + try: + return save_artifact(run_id, kind, payload, store=store, max_retries=max_retries) + except ArtifactStoreError as exc: + append_event( + run_id, + "artifact.write_failed", + { + "kind": kind, + "error": str(exc), + "schema_version": SCHEMA_VERSION, + }, + store=store, + ) + return None diff --git a/api/ops/store/runs.py b/api/ops/store/runs.py index 8db81deb..b1fc321d 100644 --- a/api/ops/store/runs.py +++ b/api/ops/store/runs.py @@ -216,6 +216,38 @@ def _once() -> dict[str, Any]: return supabase_execute_with_retry(_once) + def save_artifact( + self, run_id: str, kind: str, payload: dict[str, Any] + ) -> dict[str, Any]: + """幂等写入 ops_run_artifacts;由 (run_id, kind) 唯一去重。""" + row = {"run_id": run_id, "kind": kind, "payload": payload} + + def _once() -> dict[str, Any]: + res = ( + self.client.table("ops_run_artifacts") + .upsert(row, on_conflict="run_id,kind") + .execute() + ) + data = res.data if isinstance(res.data, list) else [] + if data and isinstance(data[0], dict): + return data[0] + raise RuntimeError("ops_run_artifacts upsert did not return row") + + return supabase_execute_with_retry(_once) + + def list_artifacts(self, run_id: str) -> list[dict[str, Any]]: + def _once() -> list[dict[str, Any]]: + res = ( + self.client.table("ops_run_artifacts") + .select("*") + .eq("run_id", run_id) + .order("created_at", desc=True) + .execute() + ) + return res.data if isinstance(res.data, list) else [] + + return supabase_execute_with_retry(_once) + def append_event( run_id: str, diff --git a/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_30_ops_chat_session_sink_p0_p1_P1-1.md b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_30_ops_chat_session_sink_p0_p1_P1-1.md new file mode 100644 index 00000000..6aa41b3a --- /dev/null +++ b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_30_ops_chat_session_sink_p0_p1_P1-1.md @@ -0,0 +1,79 @@ +# 30 执行编码帽 · Invoke 快照 + +| 字段 | 内容 | +| --- | --- | +| **timestamp** | 20260709_1622 | +| **hat** | 30-execute-code | +| **task_slug** | ops-chat-session-sink-p0-p1 | +| **subtask** | P1-1 Artifacts → Supabase | +| **git_branch** | task/ops-chat-session-sink-p0-p1 | +| **worktree_root** | ai-ink-brain-api-python/ | +| **verify_command** | `pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops` | +| **task_path** | `docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` | +| **audit_review_path** | `ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R2_20260708.md` | +| **plan_path** | `docs/harness/guides/PLAN_ops_chat_session_sink_p0_p1_v1_zh.md` | + +## 用户消息快照 + +```text +你正在扮演工作区 Harness「30-execute-code · 执行编码帽」,严格遵循 docs/harness/prompts/30-execute-code.md。 + +**输入(已替换占位符)** +- 主 task 路径(相对 Projects/):`docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` +- 逻辑子仓(相对 Projects/):`ai-ink-brain-api-python` +- Worktree 研发目录(所有 git/pytest/ruff 默认 cwd):`ai-ink-brain-api-python` +- 当前分支:`task/ops-chat-session-sink-p0-p1`(已基于 main fast-forward) +- 合并前须跑通的验证命令: + ```bash + pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops + ``` +- 关联任务审核书面结论路径:`ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R2_20260708.md` +- 关联 PLAN / 总规:`docs/harness/guides/PLAN_ops_chat_session_sink_p0_p1_v1_zh.md` + +**本棒目标:P1-1 Artifacts → Supabase** + +P1-1 具体要求(来自 PLAN §2、§3.1 D6、task §失败路径、§实现备忘): +- 新建 Supabase 表 `ops_run_artifacts`:至少含 `run_id` (uuid, FK ops_runs)、`kind` (text)、`payload` (jsonb)、`created_at`;与现有 `ops_run_events` 风格保持一致。 +- 新增 `supabase_sql/` migration 文件(可回滚),按本仓 migration 命名与编号惯例落盘。 +- 新增 `api/ops/store/artifacts.py`:实现 `save_artifact(run_id, kind, payload)`,幂等写入 `ops_run_artifacts`;失败时抛出自说明异常。 +- 在 `api/ops/orchestrator/core.py` 的 deep 路径与 `api/ops/react_loop.py` 的 ReAct 路径中,于关键结果产出处调用 `save_artifact`,将 deep/ReAct 结果写入 DB artifact(kind 建议如 `deep.final_answer`、`react.final_answer`,或按 task 失败路径约定)。 +- 与 P0-2 events 集成:artifact 写失败时,须记录 `ops_run_events` 事件 `kind=artifact.write_failed`,并仍返回答案(见 task §失败路径)。 + +**范围限制** +- 只做 P1-1;不改 P1-2 checkpoint、P1-3 clarify、P1-4 LLM router +- 不改 `harness_runtime` 生产图 +- 不改 Agently lab +- 不改前端代码 + +**test_strategy: required** +- 先写/调整可失败的自动化测试,再改实现 +- 新增 `tests/ops/test_artifacts.py` 覆盖: + - `save_artifact` 成功写入并可按 run_id 读取 + - schema 字段(run_id / kind / payload / created_at)存在 + - 写失败时生成 `artifact.write_failed` event(可 mock Supabase 失败) + - deep / ReAct 路径在运行时调用 save_artifact(可 mock 或集成) +- 最终验证命令必须绿 + +**失败路径硬性检查** +- task §失败路径已列 `Supabase artifact 写失败`:行为 = 记录 event `artifact.write_failed` + 仍返回答案;可观测 = `ops_run_events` 行 `kind=artifact.write_failed`;可重试 = 幂等重试 N 次后仍失败则记录;验证命令 = `pytest tests/ops/test_artifacts.py -k write_failed` + +**你必须完成** +0. **Invoke 快照(开帽起点)**:将本用户消息全文落盘到 `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_YYYYMMDD_HHMM_30_ops_chat_session_sink_p0_p1_P1-1.md`(含元数据表 + 快照 fenced code)。同一会话内追问 **不** 再新增快照文件。 +0b. **人工闸**:扫描 task / 关联 reviews 的 human_gate。若任一对本帽(30)为 pending → 仅输出须人改的 gate_id 与路径,拒开工;禁止代填 approved。 +1. 通读 task 全文:头部 gates_before_code、audit_profile、orchestration、chain_prompt、test_strategy / test_strategy_note、failure_paths、验收标准、必读列表、非范围。 +2. 阅读 PLAN §2、§3.1 与关联 SNAPSHOT/gap matrix。 +3. 先读现有代码:`api/ops/store/runs.py`、`api/ops/events_schema.py`、`api/ops/orchestrator/core.py`、`api/ops/react_loop.py`、`api/ops/review/rules.py`。 +4. 先写失败可复现的测试(`tests/ops/test_artifacts.py`),再实现 migration、`api/ops/store/artifacts.py` 与写入点。 +5. 在 deep/ReAct 路径中合适位置调用 `save_artifact`,并保证失败时记录 `artifact.write_failed` event。 +6. 执行验证命令,保留可核对输出要点;修复直至通过。 +7. 按 40-self-check.md 将结论与命令摘要回填至 task 正文「### 自检结论(执行者)」小节(在 P1-1 区域新增;不要覆盖 P0 已有结论)。 +8. 对话回复:生成可以完整复制的 Prompt,用于直接交给下一棒 40 自检执行。 +9. **自动 commit**:在输出下一棒 Prompt 且本轮代码/测试/task 自检回填已落盘后,按 HANDOFF_AUTO_COMMIT.md 在 ai-ink-brain-api-python/ commit(仅本轮路径;禁止 git add -A;对话报 short-hash)。 +10. **禁止**自行 push;由 Lead 合并。 + +**输出要求** +- 若拒开工:仅 Markdown 阻塞清单 +- 若执行:diff 摘要、验证命令输出、commit short-hash、下一棒 40 Prompt + +**Judgment(本帽 · 对话末尾必填)**:experience_capture / gate/risk / hat_self +``` diff --git a/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_40_ops_chat_session_sink_p0_p1_P1-1.md b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_40_ops_chat_session_sink_p0_p1_P1-1.md new file mode 100644 index 00000000..8cbc768e --- /dev/null +++ b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_40_ops_chat_session_sink_p0_p1_P1-1.md @@ -0,0 +1,53 @@ +# 40 执行者自检帽 · Invoke 快照 + +| 字段 | 内容 | +| --- | --- | +| **timestamp** | 20260709_1622 | +| **hat** | 40-self-check | +| **task_slug** | ops-chat-session-sink-p0-p1 | +| **subtask** | P1-1 Artifacts → Supabase | +| **git_branch** | task/ops-chat-session-sink-p0-p1 | +| **worktree_root** | ai-ink-brain-api-python/ | +| **verify_command** | `pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops` | +| **task_path** | `docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` | +| **30_invoke_path** | `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_30_ops_chat_session_sink_p0_p1_P1-1.md` | +| **30_commit** | 待 30 自动 commit 后回填 | + +## 用户消息快照(30 → 40 交接 Prompt) + +```text +你正在扮演工作区 Harness「40-self-check · 执行者自检帽」,严格遵循 docs/harness/prompts/40-self-check.md。 + +**输入(已替换占位符)** +- 主 task 路径(相对 Projects/):`docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` +- 逻辑子仓(相对 Projects/):`ai-ink-brain-api-python` +- Worktree 研发目录(所有 git/pytest/ruff 默认 cwd):`ai-ink-brain-api-python` +- 本棒校验范围:**P1-1 Artifacts → Supabase** +- 30 执行者已声明的验证命令: + ```bash + pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops + ``` +- 30 invoke 快照:`ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_30_ops_chat_session_sink_p0_p1_P1-1.md` + +**你必须完成** +1. 独立阅读 30 invoke 快照与 task 正文「### 自检结论(执行者)· P1-1」小节。 +2. 独立阅读本轮 P1-1 改动代码: + - `supabase/sql/ops_desk_p1_artifacts.sql` + - `supabase/sql/ops_desk_p1_artifacts_rollback.sql` + - `api/ops/store/artifacts.py` + - `api/ops/store/runs.py` + - `api/ops/orchestrator/core.py` + - `api/ops/react_loop.py` + - `tests/ops/test_artifacts.py` +3. 在 `ai-ink-brain-api-python/` 内完整执行 30 声明的验证命令;保留命令输出要点。 +4. 执行 `git diff origin/main...HEAD --stat`(在 ai-ink-brain-api-python 内)核对全量变更路径,确认未扩 scope 到 P1-2/3/4、Session 生产图、Agently lab、前端。 +5. 按 40-self-check.md 将复核结论回填至 task 正文「### 自检结论(40 复核)· P1-1」小节(不要覆盖 P0 或 30 已有结论)。 +6. 与 30 结论逐项差异核对;若不一致,列出差异项并判断阻塞性。 +7. 生成可以完整复制的 Prompt,用于直接交给下一棒 50 独立复检执行。 +8. 在输出下一棒 Prompt 且 task 40 自检回填已落盘后,按 HANDOFF_AUTO_COMMIT.md 在 ai-ink-brain-api-python/ commit(仅本轮路径;禁止 git add -A;对话报 short-hash)。 +9. 禁止自行 push;由 Lead 合并。 + +**输出要求** +- 复核方法、命令输出、与 30 结论差异核对表、验收项复核表、阻塞项清单、合并建议、下一棒 50 Prompt。 +- Judgment(本帽 · 对话末尾必填):experience_capture / gate/risk / hat_self +``` diff --git a/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1650_50_ops_chat_session_sink_p0_p1_P1-1.md b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1650_50_ops_chat_session_sink_p0_p1_P1-1.md new file mode 100644 index 00000000..06928702 --- /dev/null +++ b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1650_50_ops_chat_session_sink_p0_p1_P1-1.md @@ -0,0 +1,59 @@ +# 50 独立复检 + 全局验收帽 · Invoke 快照 + +| 字段 | 内容 | +| --- | --- | +| **timestamp** | 20260709_1650 | +| **hat** | 50-reinspect | +| **task_slug** | ops-chat-session-sink-p0-p1 | +| **subtask** | P1-1 Artifacts → Supabase | +| **git_branch** | task/ops-chat-session-sink-p0-p1 | +| **worktree_root** | ai-ink-brain-api-python/ | +| **verify_command** | `pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops` | +| **task_path** | `docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` | +| **30_invoke_path** | `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_30_ops_chat_session_sink_p0_p1_P1-1.md` | +| **30_commit** | `api-python@13eb0524` | +| **40_commit** | `Projects@59e4aae` · `api-python@197ce5b7` | +| **audit_review_path** | `ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R2_20260708.md` | + +## 用户消息快照(40 → 50 交接 Prompt) + +```text +你正在扮演工作区 Harness「50-reinspect · 独立复检 + 全局验收帽」,严格遵循 docs/harness/prompts/50-reinspect.md。 + +**输入(已替换占位符)** +- 主 task 路径(相对 Projects/):`docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` +- 逻辑子仓(相对 Projects/):`ai-ink-brain-api-python` +- Worktree 研发目录(所有 git/pytest/ruff 默认 cwd):`ai-ink-brain-api-python` +- 本棒校验范围:**P1-1 Artifacts → Supabase** +- 30 执行者已声明的验证命令: + ```bash + pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops + ``` +- 30 invoke 快照:`ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1622_30_ops_chat_session_sink_p0_p1_P1-1.md` +- 30 commit:`api-python@13eb0524` +- 40 commit:`Projects@59e4aae` · `api-python@197ce5b7` + +**你必须完成** +1. 独立阅读 task 正文「### 自检结论(执行者)· P1-1」与「### 自检结论(40 复核)· P1-1」小节。 +2. 独立阅读本轮 P1-1 改动代码: + - `supabase/sql/ops_desk_p1_artifacts.sql` + - `supabase/sql/ops_desk_p1_artifacts_rollback.sql` + - `api/ops/store/artifacts.py` + - `api/ops/store/runs.py` + - `api/ops/orchestrator/core.py` + - `api/ops/react_loop.py` + - `tests/ops/test_artifacts.py` +3. 在 `ai-ink-brain-api-python/` 内完整执行 30 声明的验证命令;保留命令输出要点。 +4. 额外执行 task §失败路径所列的 `pytest tests/ops/test_artifacts.py -k write_failed -q`。 +5. 执行 `git diff origin/main...HEAD --stat`(在 ai-ink-brain-api-python 内)核对全量变更路径,确认未扩 scope 到 P1-2/3/4、Session 生产图、Agently lab、前端。 +6. 与 30 commit `api-python@13eb0524`、40 commit `Projects@59e4aae` / `api-python@197ce5b7`、R2 任务审核书面结论逐条核对。 +7. 将 50 复检书面结论落盘至 `ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P1-1.md`。 +8. 按 50-reinspect.md 将全局验收结论回填至 task 正文「### 自检结论(50 复检)· P1-1」小节(不要覆盖 P0 或 30/40 已有结论)。 +9. 输出:复核方法、命令输出、与 30/40 结论差异核对表、验收项复核表、阻塞项清单、合并建议、Judgment。 +10. 在输出结论且 task 50 复检回填已落盘后,按 HANDOFF_AUTO_COMMIT.md 在 `ai-ink-brain-api-python/` 与 `Projects/` 分仓 commit(仅本轮路径;禁止 git add -A;对话报 short-hash)。 +11. 禁止自行 push;由 Lead 合并。 + +**输出要求** +- 复核方法、命令输出、与 30/40 结论差异核对表、验收项复核表、阻塞项清单、合并建议。 +- Judgment(本帽 · 对话末尾必填):experience_capture / gate/risk / hat_self +``` diff --git a/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P1-1.md b/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P1-1.md new file mode 100644 index 00000000..434f4026 --- /dev/null +++ b/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P1-1.md @@ -0,0 +1,168 @@ +# 50 独立复检 + 全局验收报告 · P1-1 Artifacts → Supabase + +| 项 | 内容 | +| --- | --- | +| **task** | `ops-chat-session-sink-p0-p1` | +| **subtask** | P1-1 Artifacts → Supabase | +| **reviewer** | 50-reinspect | +| **date** | 2026-07-09 | +| **30_commit** | `api-python@13eb0524` | +| **40_commit** | `Projects@59e4aae` · `api-python@197ce5b7` | +| **scope** | `supabase/sql/ops_desk_p1_artifacts.sql` · `supabase/sql/ops_desk_p1_artifacts_rollback.sql` · `api/ops/store/artifacts.py` · `api/ops/store/runs.py` · `api/ops/orchestrator/core.py` · `api/ops/react_loop.py` · `tests/ops/test_artifacts.py` | + +--- + +## 复核方法 + +- 独立阅读 task 正文「### 自检结论(执行者)· P1-1」与「### 自检结论(40 复核)· P1-1」小节。 +- 独立阅读本轮 P1-1 改动代码: + - `supabase/sql/ops_desk_p1_artifacts.sql` + - `supabase/sql/ops_desk_p1_artifacts_rollback.sql` + - `api/ops/store/artifacts.py` + - `api/ops/store/runs.py` + - `api/ops/orchestrator/core.py` + - `api/ops/react_loop.py` + - `tests/ops/test_artifacts.py` +- 在 `ai-ink-brain-api-python/` 内完整执行 30 声明的验证命令: + ```bash + pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops + ``` +- 额外执行 task §失败路径所列的 `pytest tests/ops/test_artifacts.py -k write_failed -q`。 +- 执行 `git diff origin/main...HEAD --stat`(在 `ai-ink-brain-api-python` 内)核对全量变更路径,确认未扩 scope 到 P1-2/3/4、Session 生产图、Agently lab、前端。 +- 与 30 commit `api-python@13eb0524`、40 commit `Projects@59e4aae` / `api-python@197ce5b7`、R2 任务审核书面结论逐条核对。 + +--- + +## 命令输出 + +### 最终验证命令 + +```text +pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q +..........................s............................................. [ 23%] +........................................................................ [ 47%] +........................................................................ [ 71%] +.................ss......................sssssss........................ [ 95%] +.............. [100%] +=============================== warnings summary =============================== +../../../miniconda3/lib/python3.13/site-packages/fastapi/testclient.py:1 + /Users/cyning/miniconda3/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +=========================== short test summary info ============================ +SKIPPED [1] tests/ops/test_events_schema.py:181: 需要真实 Supabase 连接;本地/CI 环境缺失时跳过 +SKIPPED [2] tests/ops_desk/test_run_schema_p1.py:102: public 中表已存在,跳过写测试以避免破坏数据 +SKIPPED [7] tests/ops_desk/test_schema_p0.py:102: public 中表已存在,跳过写测试以避免破坏数据 +292 passed, 10 skipped, 1 warning in 70.97s (0:01:10) + +ruff check api/ops +All checks passed! +``` + +- pytest 退出码:`0`。 +- ruff 退出码:`0`。 +- 10 skipped 中:1 个为 `tests/ops/test_events_schema.py::test_append_event_integration_with_real_store`(显式 skip,需真实 Supabase 连接);其余 9 个为 `tests/ops_desk/test_run_schema_p1.py` / `tests/ops_desk/test_schema_p0.py` 中环境感知跳过(表已存在),与 P1-1 改动无关。 + +### 失败路径额外验证 + +```text +pytest tests/ops/test_artifacts.py -k write_failed -q +... [100%] +3 passed, 7 deselected in 0.61s +``` + +- pytest 退出码:`0`。 + +--- + +## 全量变更路径核对 + +```text +git diff origin/main...HEAD --stat + api/ops/orchestrator/core.py | 14 + + api/ops/react_loop.py | 13 + + api/ops/store/artifacts.py | 70 ++++ + api/ops/store/runs.py | 32 + + ...709_1622_30_ops_chat_session_sink_p0_p1_P1-1.md | 79 +++++ + ...709_1622_40_ops_chat_session_sink_p0_p1_P1-1.md | 53 +++ + ...709_1650_50_ops_chat_session_sink_p0_p1_P1-1.md | 59 ++++ + supabase/sql/ops_desk_p1_artifacts.sql | 14 + + supabase/sql/ops_desk_p1_artifacts_rollback.sql | 2 + + tests/ops/test_artifacts.py | 382 +++++++++++++++++++++ + 10 files changed, 718 insertions(+) +``` + +- 变更仅涉及 P1-1 Artifacts 相关实现、测试、SQL migration/rollback 与 invoke 文档。 +- 未涉及 P1-2 checkpoint、P1-3 clarify、P1-4 LLM router、Session 生产图、Agently lab、前端。 + +--- + +## 与 30 / 40 结论差异核对 + +| 30 / 40 声称项 | 50 独立复核 | 结果 | +| --- | --- | --- | +| 新建 Supabase 表 `ops_run_artifacts` 含 `run_id`/`kind`/`payload`/`created_at`、FK `ops_runs`、唯一键 `(run_id, kind)`、可回滚 | `supabase/sql/ops_desk_p1_artifacts.sql:4-14` 创建表;`run_id uuid` FK `ops_runs(id)`;`UNIQUE (run_id, kind)`;`ops_desk_p1_artifacts_rollback.sql:2` 含 `DROP TABLE IF EXISTS` | 一致 | +| 新增 `api/ops/store/artifacts.py`:`save_artifact` 幂等 upsert / 重试 / `ArtifactStoreError` | `artifacts.py:8` 定义 `ArtifactStoreError`;`artifacts.py:12-40` 实现 `save_artifact`;默认 `max_retries=3`,重试 `max_retries + 1` 次后抛异常 | 一致 | +| `save_artifact_with_failure_event` 失败时记录 `artifact.write_failed` event | `artifacts.py:43-70` 捕获 `ArtifactStoreError` 后调用 `append_event(kind="artifact.write_failed")` 并返回 `None` | 一致 | +| `OpsRunStore.save_artifact` / `list_artifacts` | `store/runs.py:219-236` 实现 `save_artifact`(upsert on_conflict `run_id,kind`);`store/runs.py:238-249` 实现 `list_artifacts` | 一致 | +| deep 路径写入 artifact | `orchestrator/core.py:362-373` 调用 `save_artifact_with_failure_event(kind="deep.final_answer", ...)` | 一致 | +| ReAct 路径写入 artifact | `react_loop.py:319-329` 调用 `save_artifact_with_failure_event(kind="react.final_answer", ...)` | 一致 | +| artifact 写失败仍返回答案 | `save_artifact_with_failure_event` 吞异常返回 `None`;deep/ReAct 继续执行到返回 `answer` | 一致 | +| `tests/ops/test_artifacts.py` 10 测例覆盖 | 文件存在;pytest 输出 `292 passed` 包含该文件;覆盖 save/list/schema/幂等/失败 event/deep/ReAct/migration | 一致 | +| 失败路径验证命令 `pytest tests/ops/test_artifacts.py -k write_failed -q` | 本轮独立跑通 `3 passed` | 一致 | +| 最终验证命令绿 | 本轮独立跑通 `292 passed, 10 skipped` + ruff 全绿 | 一致 | +| 未扩 scope | 全量 diff 仅 P1-1 artifact 相关文件 + invoke;未涉及 P1-2/3/4、Session 生产图、Agently lab、前端 | 一致 | + +**差异项**:无。 + +--- + +## 验收项复核表 + +| 验收项 | 状态 | 证据 | 备注 | +| --- | --- | --- | --- | +| 新建 Supabase 表 `ops_run_artifacts` 与 `ops_run_events` 风格一致 | pass | `supabase/sql/ops_desk_p1_artifacts.sql:4-14`;含 `run_id` uuid FK、`kind` text、`payload` jsonb、`created_at` timestamptz、`UNIQUE (run_id, kind)`、索引 | — | +| migration 可回滚 | pass | `supabase/sql/ops_desk_p1_artifacts_rollback.sql:2` 含 `DROP TABLE IF EXISTS public.ops_run_artifacts` | — | +| `api/ops/store/artifacts.py` 实现 `save_artifact(run_id, kind, payload)` | pass | `artifacts.py:12-40`;未提供 store 时用全局 `supabase_client()` 构造 `OpsRunStore`;重试 `max_retries + 1` 次后抛 `ArtifactStoreError` | — | +| `save_artifact` 幂等写入 | pass | `store/runs.py:225-228` 使用 `upsert(row, on_conflict="run_id,kind")`;`test_save_artifact_idempotent_upsert` 通过 | — | +| `save_artifact_with_failure_event` 失败时记录 `artifact.write_failed` event | pass | `artifacts.py:57-69` 捕获异常后调用 `append_event`;`test_save_artifact_failure_records_write_failed_event` 与 deep/ReAct 失败测例通过 | — | +| `OpsRunStore.save_artifact` / `list_artifacts` | pass | `store/runs.py:219-249` | — | +| deep 路径在最终答案后调用 `save_artifact_with_failure_event` | pass | `orchestrator/core.py:362-373`;`test_run_deep_saves_final_answer_artifact` 通过 | — | +| ReAct 路径在最终答案后调用 `save_artifact_with_failure_event` | pass | `react_loop.py:319-329`;`test_run_react_saves_final_answer_artifact` 通过 | — | +| artifact 写失败仍返回答案 | pass | `save_artifact_with_failure_event` 吞异常;`test_run_deep_artifact_failure_records_write_failed_event` / `test_run_react_artifact_failure_records_write_failed_event` 断言 `result["answer"]` 存在 | — | +| `tests/ops/test_artifacts.py` 覆盖 schema/幂等/失败 event/deep/ReAct/migration | pass | 10 测例全绿 | — | +| task §失败路径验证命令绿 | pass | `pytest tests/ops/test_artifacts.py -k write_failed -q` → `3 passed` | — | +| 最终验证命令绿 | pass | pytest `292 passed, 10 skipped` + ruff `All checks passed!`;退出码均为 `0` | — | +| 未静默扩大 scope | pass | 全量 diff 仅 P1-1 相关文件 + invoke;未涉及 P1-2/3/4、Session 生产图、Agently lab、前端 | — | +| P0-1 ~ P0-4 已有实现未被破坏 | pass | `api/ops/review/rules.py`、`api/ops/events_schema.py` 未改动;deep/ReAct 仍从共享模块导入 `review_result` 并继续通过 `append_event` 写入 handoff/review 事件 | — | + +--- + +## 阻塞项清单 + +无。 + +--- + +## 是否建议合并 + +**建议合并**。P1-1 Artifacts → Supabase 实现、测试、30 执行、40 自检、50 独立复检均通过,无 scope creep,人工闸 `HG-TASK-DRAFT` / `HG-AUDIT-R1` 已 approved。 + +--- + +## 执行路线与 Commit 回溯 + +| 阶段 | 帽子 | 关键动作 | 落盘工件 | 对应 commit | +|------|------|----------|----------|-------------| +| P1-1 | 30 execute | `ops_run_artifacts` 表 + `save_artifact` + deep/ReAct 写入 + 测试 | `supabase/sql/ops_desk_p1_artifacts.sql`, `api/ops/store/artifacts.py`, `api/ops/store/runs.py`, `api/ops/orchestrator/core.py`, `api/ops/react_loop.py`, `tests/ops/test_artifacts.py` | api-python@13eb0524 | +| P1-1 | 40 self-check | 复核 P1-1 验收 | task 内 P1-1 30/40 自检结论 | Projects@59e4aae · api-python@197ce5b7 | +| P1-1 | 50 reinspect R1 | 独立复检 + 全局验收 | `ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P1-1.md` | 待本审查落盘后 commit | + +--- + +## Judgment(50) + +- **experience_capture**: `required` — P1-1 artifact 写入与失败事件模式可复用到 P1-2 checkpoint 及其他运行时契约。 +- **gate/risk**: 无 — `HG-TASK-DRAFT` / `HG-AUDIT-R1` 均为 `approved`;50 未遇 pending 人工闸。 +- **hat_self**: `pass` — 独立复检与 30/40 结论一致,验证命令绿,输出 pass/fail 表、阻塞项清单、合并建议与执行路线回溯。 diff --git a/supabase/sql/ops_desk_p1_artifacts.sql b/supabase/sql/ops_desk_p1_artifacts.sql new file mode 100644 index 00000000..c7390ce5 --- /dev/null +++ b/supabase/sql/ops_desk_p1_artifacts.sql @@ -0,0 +1,14 @@ +-- Ops Desk P1-1: Run Artifacts(deep / ReAct 结果落库) +-- 与 ops_run_events 风格保持一致:run_id 外键、jsonb payload、默认 now() + +CREATE TABLE IF NOT EXISTS public.ops_run_artifacts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + run_id uuid NOT NULL REFERENCES public.ops_runs(id) ON DELETE CASCADE, + kind text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (run_id, kind) +); + +CREATE INDEX IF NOT EXISTS idx_ops_run_artifacts_run_kind ON public.ops_run_artifacts(run_id, kind); +CREATE INDEX IF NOT EXISTS idx_ops_run_artifacts_created ON public.ops_run_artifacts(created_at); diff --git a/supabase/sql/ops_desk_p1_artifacts_rollback.sql b/supabase/sql/ops_desk_p1_artifacts_rollback.sql new file mode 100644 index 00000000..5aff2588 --- /dev/null +++ b/supabase/sql/ops_desk_p1_artifacts_rollback.sql @@ -0,0 +1,2 @@ +-- Ops Desk P1-1 Artifacts Rollback +DROP TABLE IF EXISTS public.ops_run_artifacts; diff --git a/tests/ops/test_artifacts.py b/tests/ops/test_artifacts.py new file mode 100644 index 00000000..448d7017 --- /dev/null +++ b/tests/ops/test_artifacts.py @@ -0,0 +1,382 @@ +"""P1-1: ops_run_artifacts 与 save_artifact 单测。""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from api.ops.react_loop import run_react_fallback +from tests.ops_desk._llm_mocks import patch_ops_llm_imports + + +class FakeArtifactStore: + """内存版 Artifact 存储,支持 save/list/events。""" + + def __init__(self) -> None: + self.artifacts: dict[tuple[str, str], dict[str, Any]] = {} + self.events: dict[str, list[dict[str, Any]]] = {} + self.runs: dict[str, dict[str, Any]] = {} + self._fail_artifacts = False + self._artifact_counter = 0 + + def save_artifact( + self, run_id: str, kind: str, payload: dict[str, Any], max_retries: int = 3 + ) -> dict[str, Any]: + if self._fail_artifacts: + raise RuntimeError("supabase connection refused") + self._artifact_counter += 1 + row = { + "id": f"art-{self._artifact_counter}", + "run_id": run_id, + "kind": kind, + "payload": dict(payload), + "created_at": "2026-07-09T16:22:00Z", + } + self.artifacts[(run_id, kind)] = row + return row + + def list_artifacts(self, run_id: str) -> list[dict[str, Any]]: + return [a for a in self.artifacts.values() if a["run_id"] == run_id] + + def append_event( + self, + run_id: str, + agent_role: str, + event_type: str, + payload: dict[str, Any] | None = None, + node_id: str | None = None, + seq: int | None = None, + ) -> dict[str, Any]: + evt: dict[str, Any] = { + "run_id": run_id, + "agent_role": agent_role, + "event_type": event_type, + "payload": payload or {}, + "node_id": node_id, + } + self.events.setdefault(run_id, []).append(evt) + return evt + + def update_run(self, run_id: str, **fields: Any) -> None: + self.runs.setdefault(run_id, {}).update(fields) + + def update_run_metrics_json(self, run_id: str, metrics_json: dict[str, Any]) -> None: + self.update_run(run_id, metrics_json=metrics_json) + + +class FakeQueries: + def __init__(self) -> None: + self.issues = { + 545: { + "number": 545, + "title": "Deep demo issue", + "state": "open", + "labels": ["bug"], + "html_url": "https://github.com/MoonshotAI/kimi-code/issues/545", + } + } + self.pulls: dict[int, dict[str, Any]] = {} + + def fetch_issue_by_number(self, number: int) -> dict[str, Any] | None: + return self.issues.get(number) + + def fetch_pull_by_number(self, number: int) -> dict[str, Any] | None: + return self.pulls.get(number) + + +class FakeReactQueries(FakeQueries): + def fetch_issues( + self, + days: int = 30, + state: str | None = None, + label: str | None = None, + module: str | None = None, + age: str | None = None, + limit: int = 20, + offset: int = 0, + ) -> tuple[list[dict[str, Any]], int]: + rows = list(self.issues.values()) + if state: + rows = [r for r in rows if r.get("state") == state] + return rows, len(rows) + + def fetch_pulls( + self, + days: int = 30, + state: str | None = None, + ci: str | None = None, + author: str | None = None, + limit: int = 20, + offset: int = 0, + ) -> tuple[list[dict[str, Any]], int]: + return [], 0 + + def cycle_time_metric(self, days: int = 30) -> dict[str, Any]: + return {"metric": "cycle-time", "summary": {"avg_hours": 48.0}} + + def review_time_metric(self, days: int = 30) -> dict[str, Any]: + return {"metric": "review-time", "summary": {"avg_hours": 12.0}} + + def issue_throughput_metric(self, days: int = 30) -> dict[str, Any]: + return {"metric": "issue-throughput", "summary": {"total": 2}} + + def sync_status(self) -> dict[str, Any]: + return {"status": "ok", "cursor": "c1", "as_of": "2026-06-25T00:00:00Z"} + + +@pytest.fixture +def store() -> FakeArtifactStore: + return FakeArtifactStore() + + +# --------------------------------------------------------------------------- +# save_artifact 单元行为 +# --------------------------------------------------------------------------- + + +def test_save_artifact_success_and_read_by_run_id(store: FakeArtifactStore) -> None: + from api.ops.store.artifacts import save_artifact + + row = save_artifact("run-1", "deep.final_answer", {"answer": "hello"}, store=store) + + assert row["run_id"] == "run-1" + assert row["kind"] == "deep.final_answer" + assert row["payload"] == {"answer": "hello"} + assert "created_at" in row + assert "id" in row + + artifacts = store.list_artifacts("run-1") + assert len(artifacts) == 1 + assert artifacts[0]["payload"]["answer"] == "hello" + + +def test_save_artifact_schema_fields(store: FakeArtifactStore) -> None: + from api.ops.store.artifacts import save_artifact + + row = save_artifact("run-2", "kind.x", {"x": 1}, store=store) + assert set(row.keys()) >= {"id", "run_id", "kind", "payload", "created_at"} + + +def test_save_artifact_idempotent_upsert(store: FakeArtifactStore) -> None: + from api.ops.store.artifacts import save_artifact + + save_artifact("run-3", "deep.final_answer", {"answer": "first"}, store=store) + save_artifact("run-3", "deep.final_answer", {"answer": "second"}, store=store) + + artifacts = store.list_artifacts("run-3") + assert len(artifacts) == 1 + assert artifacts[0]["payload"]["answer"] == "second" + + +def test_save_artifact_raises_after_retries(store: FakeArtifactStore) -> None: + from api.ops.store.artifacts import ArtifactStoreError, save_artifact + + store._fail_artifacts = True + with pytest.raises(ArtifactStoreError): + save_artifact("run-fail", "deep.final_answer", {"answer": "x"}, store=store, max_retries=2) + + +# --------------------------------------------------------------------------- +# 失败路径:artifact 写失败记录 event +# --------------------------------------------------------------------------- + + +def test_save_artifact_failure_records_write_failed_event(store: FakeArtifactStore) -> None: + from api.ops.store.artifacts import save_artifact_with_failure_event + + store._fail_artifacts = True + result = save_artifact_with_failure_event( + "run-fail", "deep.final_answer", {"answer": "x"}, store=store + ) + + assert result is None + events = store.events["run-fail"] + fail_events = [e for e in events if e["event_type"] == "artifact.write_failed"] + assert len(fail_events) == 1 + assert fail_events[0]["payload"]["kind"] == "deep.final_answer" + assert "error" in fail_events[0]["payload"] + + +# --------------------------------------------------------------------------- +# deep / ReAct 路径调用 save_artifact +# --------------------------------------------------------------------------- + + +def _fake_deep_llm_result() -> Any: + from api.ops.llm.types import LlmCompletionResult, LlmUsage + + return LlmCompletionResult( + content='{"reasoning":"分析完成","suggestion":"建议参与","confidence":0.85,"citations":[{"number":545,"url":"https://github.com/MoonshotAI/kimi-code/issues/545"}]}', + usage=LlmUsage( + provider="siliconflow", + model="Qwen/Qwen2.5-72B-Instruct", + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + latency_ms=100, + step="analyze", + ), + ) + + +def _fake_synth_result(content: str = "综合建议。") -> Any: + from api.ops.llm.types import LlmCompletionResult, LlmUsage + + return LlmCompletionResult( + content=content, + usage=LlmUsage( + provider="siliconflow", + model="Qwen/Qwen2.5-72B-Instruct", + prompt_tokens=8, + completion_tokens=4, + total_tokens=12, + latency_ms=80, + step="synthesize", + ), + ) + + +def test_run_deep_saves_final_answer_artifact( + monkeypatch: pytest.MonkeyPatch, store: FakeArtifactStore +) -> None: + from api.ops.orchestrator.core import run_deep + + queries = FakeQueries() + + patch_ops_llm_imports( + monkeypatch, + chat_completion=lambda *args, **kwargs: _fake_deep_llm_result(), + synthesize_answer=lambda *args, **kwargs: _fake_synth_result("综合建议:#545 值得参与。"), + synthesize=lambda *args, **kwargs: ("综合建议:#545 值得参与。", None), + ) + + result = run_deep("run-deep", "#545 适合我吗", {"issue_number": 545}, store, queries) + + assert result["answer"] + artifacts = store.list_artifacts("run-deep") + assert any(a["kind"] == "deep.final_answer" for a in artifacts) + art = next(a for a in artifacts if a["kind"] == "deep.final_answer") + assert art["payload"]["answer"] == result["answer"] + assert art["payload"]["agent"] == "issue_analyst" + + +def test_run_deep_artifact_failure_records_write_failed_event( + monkeypatch: pytest.MonkeyPatch, store: FakeArtifactStore +) -> None: + from api.ops.orchestrator.core import run_deep + + store._fail_artifacts = True + queries = FakeQueries() + + patch_ops_llm_imports( + monkeypatch, + chat_completion=lambda *args, **kwargs: _fake_deep_llm_result(), + synthesize_answer=lambda *args, **kwargs: _fake_synth_result("综合建议:#545 值得参与。"), + synthesize=lambda *args, **kwargs: ("综合建议:#545 值得参与。", None), + ) + + result = run_deep("run-deep-fail", "#545 适合我吗", {"issue_number": 545}, store, queries) + + assert result["answer"] + events = store.events["run-deep-fail"] + fail_events = [e for e in events if e["event_type"] == "artifact.write_failed"] + assert len(fail_events) == 1 + assert fail_events[0]["payload"]["kind"] == "deep.final_answer" + + +def test_run_react_saves_final_answer_artifact( + monkeypatch: pytest.MonkeyPatch, store: FakeArtifactStore +) -> None: + from api.ops.llm.types import LlmCompletionResult, LlmUsage + + queries = FakeReactQueries() + + def final_answer_chat(*args: Any, **kwargs: Any) -> Any: + return LlmCompletionResult( + content='{"thought":"直接回答","final_answer":"这是一个直接答案。"}', + usage=LlmUsage( + provider="siliconflow", + model="Qwen/Qwen2.5-72B-Instruct", + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + latency_ms=100, + step="react", + ), + ) + + patch_ops_llm_imports( + monkeypatch, + chat_completion=final_answer_chat, + synthesize_answer=lambda *args, **kwargs: _fake_synth_result("综合建议。"), + synthesize=lambda *args, **kwargs: ("综合建议。", None), + ) + + result = run_react_fallback("run-react", "hello", store, queries, max_steps=2) + + assert result["answer"] + artifacts = store.list_artifacts("run-react") + assert any(a["kind"] == "react.final_answer" for a in artifacts) + art = next(a for a in artifacts if a["kind"] == "react.final_answer") + assert art["payload"]["answer"] == result["answer"] + + +def test_run_react_artifact_failure_records_write_failed_event( + monkeypatch: pytest.MonkeyPatch, store: FakeArtifactStore +) -> None: + from api.ops.llm.types import LlmCompletionResult, LlmUsage + + store._fail_artifacts = True + queries = FakeReactQueries() + + def final_answer_chat(*args: Any, **kwargs: Any) -> Any: + return LlmCompletionResult( + content='{"thought":"直接回答","final_answer":"这是一个直接答案。"}', + usage=LlmUsage( + provider="siliconflow", + model="Qwen/Qwen2.5-72B-Instruct", + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + latency_ms=100, + step="react", + ), + ) + + patch_ops_llm_imports( + monkeypatch, + chat_completion=final_answer_chat, + synthesize_answer=lambda *args, **kwargs: _fake_synth_result("综合建议。"), + synthesize=lambda *args, **kwargs: ("综合建议。", None), + ) + + result = run_react_fallback("run-react-fail", "hello", store, queries, max_steps=2) + + assert result["answer"] + events = store.events["run-react-fail"] + fail_events = [e for e in events if e["event_type"] == "artifact.write_failed"] + assert len(fail_events) == 1 + assert fail_events[0]["payload"]["kind"] == "react.final_answer" + + +# --------------------------------------------------------------------------- +# Migration SQL 字段覆盖 +# --------------------------------------------------------------------------- + + +def test_migration_sql_contains_required_fields() -> None: + from pathlib import Path + + sql_path = Path(__file__).parents[2] / "supabase" / "sql" / "ops_desk_p1_artifacts.sql" + sql = sql_path.read_text(encoding="utf-8") + assert "CREATE TABLE IF NOT EXISTS public.ops_run_artifacts" in sql + assert "run_id uuid" in sql + assert "kind text" in sql + assert "payload jsonb" in sql + assert "created_at timestamptz" in sql + assert "REFERENCES public.ops_runs(id)" in sql + + rollback_path = Path(__file__).parents[2] / "supabase" / "sql" / "ops_desk_p1_artifacts_rollback.sql" + rollback = rollback_path.read_text(encoding="utf-8") + assert "DROP TABLE IF EXISTS public.ops_run_artifacts" in rollback