diff --git a/docs/development-plan.md b/docs/development-plan.md
index e993d34..e571ffc 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -261,6 +261,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
- **U-028 [Value Path]** `v_artifact_browser`
- 依赖:U-011
+ - Done:Artifact 列表+预览+血缘链可用(原文档缺 Done/覆盖行,2026-07-31 补上)
+ - **实现补充(2026-07-31)**:`c_artifact_get` 在机器可读的 `sdd-mvcp-contract.json` 里其实定义过(`GET /api/artifacts/:artifact_id`),只是 03-C 的正文一直没写出来;`c_artifact_list` 连 MVCP JSON 里都没有 operation 定义,纯粹是 `v_artifact_browser` 自己的过滤字段(`artifact_type_filter`/`task_id_filter`)倒逼出来的缺口,和 U-025 补 `c_task_list` 是同一类"读接口没人建"问题。新增 `src/artifact_query.py`:`list_artifacts()` 返回裸数组(跟 `list_tasks()` 保持同款风格,不跟 MVCP JSON 里 `c_task_list` 的 `{items,total}` 包一层——本地单项目 MVP 不需要分页,跟自己代码库已有约定保持一致比跟一份机器生成的镜像文件保持一致更重要);`get_artifact()` 顺带把血缘链(`lineage_chain()`,按 `depends_on_artifact_ids` 往回走,多个依赖时只走第一个——原型图和这条流水线的实际产出都只有线性 BRD→PRD→SDD 链,没有真正的多依赖分叉场景)和文件内容(`artifact` 表本身没有 content 列,内容活在磁盘上,走 `project.target_folder_path` + `artifact.file_path` 拼出绝对路径现读)一起返回,预览面板一次请求就够。前端 `v_artifact_browser` 的预览统一用 `
` 纯文本展示,不做 Markdown 渲染/代码高亮——02-V 原文想要那个效果,但这需要引入第一个前端第三方库(图表/高亮/markdown 解析器),和这个控制台"纯 vanilla JS、无构建步骤"的一贯做法(05 §5.7 精神延伸到前端)冲突,判断为有意简化而非漏做。顺手把 `frontend/index.html` 的常驻导航栏加上了"产物"链接(之前只有 Dashboard/Tasks/Settings 三个占位项)。真实服务器验证过三级血缘链(BRD→PRD→SDD)返回顺序正确。
+ - 覆盖:02-V `v_artifact_browser`;03-C `c_artifact_get`(正文订正补全)+ 新增 `c_artifact_list`
- **U-029 [Value Path]** `v_skill_library` + `v_skill_audit_log`
- 依赖:U-018
diff --git a/docs/sdd/03-C-控制与接口.md b/docs/sdd/03-C-控制与接口.md
index 5ddb687..ab822e3 100644
--- a/docs/sdd/03-C-控制与接口.md
+++ b/docs/sdd/03-C-控制与接口.md
@@ -171,6 +171,13 @@
- `side_effects`:写文件 + Git commit(commit message 包含 `task_id` 与 `artifact_type`)
- `errors`:`GIT_COMMIT_FAILED`(可重试)
+## 3.7.1 `c_artifact_get` / `c_artifact_list`(2026-07-31 订正:`c_artifact_get` 此前只存在于机器可读的 `sdd-mvcp-contract.json`,正文一直没写;`c_artifact_list` 连 MVCP JSON 里都没有,`v_artifact_browser` 自己的过滤字段倒逼补上)
+
+- `GET /api/artifacts/:artifact_id`(`c_artifact_get`)、`GET /api/artifacts?project_id=&task_id=&artifact_type=`(`c_artifact_list`,`project_id` 必填)
+- `output_contract`:`c_artifact_list` 返回裸数组;`c_artifact_get` 返回单条 `artifact` 全部字段 + `content`(现读磁盘文件,`artifact` 表本身无 content 列)+ `lineage`(按 `depends_on_artifact_ids` 回溯的血缘链,oldest-first,含自身)
+- `idempotent`:是
+- `errors`:`NOT_FOUND`(不可重试,仅 `c_artifact_get`);`INVALID_INPUT`(不可重试,仅 `c_artifact_list`):缺少 `project_id`
+
## 3.8 `c_skill_update`
- `POST /api/skills/:skill_id/update`
diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json
index 1d9491b..f4f1019 100644
--- a/docs/sdd/unit_matrix.json
+++ b/docs/sdd/unit_matrix.json
@@ -43,7 +43,7 @@
{"id": "U-025", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_dashboard + v_task_board + v_task_detail(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_dashboard", "v_task_board", "v_task_detail"], "controller": ["c_task_list", "c_task_get", "c_org_loop_route"], "process": [], "requirement": []}, "done": "三个页面可正常展示实时数据;补上此前无 Unit 认领的 c_task_list/c_task_get HTTP 路由,c_org_loop_route 首次接上 HTTP;v_graph_visualizer/v_log_viewer 子模块占位等 U-026/U-027"},
{"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Work Graph DAG 分层视图含 waiting_for_quota 信息态;Org Graph 层复用 v_task_detail 已有的 org-strip 面板,不重复渲染"},
{"id": "U-027", "phase": "PH3", "sub_phase": "PH3-S3", "name": "execution_log_file + 日志查看器(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": ["execution_log_file", "log_retention_config"], "view": ["v_log_viewer"], "controller": ["c_execution_log_get", "c_ws_log_tail"], "process": ["p_log_retention_cleanup"], "requirement": ["REQ-13"]}, "done": "REQ-13 happy/boundary/failure 通过;补上此前从未存在的 append_log 写路径;顺带修了 U-024 就存在的 server_close() 并发 segfault 隐患(conn_lock 未覆盖)"},
- {"id": "U-028", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_artifact_browser", "classification": "Value Path", "depends_on": ["U-011"], "coverage": {"model": [], "view": ["v_artifact_browser"], "controller": [], "process": [], "requirement": []}, "done": "Artifact 列表+预览+血缘链可用"},
+ {"id": "U-028", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_artifact_browser(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-011"], "coverage": {"model": [], "view": ["v_artifact_browser"], "controller": ["c_artifact_get", "c_artifact_list"], "process": [], "requirement": []}, "done": "Artifact 列表+预览+血缘链可用;预览为纯文本,不做 Markdown 渲染/代码高亮(避免引入前端第三方依赖)"},
{"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": [], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看"},
{"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效"},
diff --git a/frontend/app.js b/frontend/app.js
index a4e5359..9f580b6 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -77,6 +77,7 @@ function connectLogTailSocket(nodeExecId, { onChunk, onClose }) {
const routes = {
"#/": { render: renderDashboard, mount: mountDashboard },
"#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
+ "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser },
"#/settings": { render: () => "Settings placeholder -- real content is PH3 (U-030)
" },
"#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
"#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
@@ -968,3 +969,109 @@ function mountTaskDetail(taskId) {
closeAllLogViewers();
});
}
+
+// ---- v_artifact_browser (02-V §2.8): artifact list + read-only preview ----
+// Preview renders content in a plain block for every artifact_type --
+// 02-V asks for Markdown rendering / syntax highlighting, but pulling in a
+// markdown/highlighting library would be this frontend's first external
+// dependency (05 §5.7's "no build step" ethos extends to keeping the
+// console vanilla-JS-only); a monospace raw-text preview is a deliberate,
+// documented simplification, not a missed requirement.
+
+const ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark", "bug_record", "code_diff", "test_report"];
+
+function renderArtifactBrowser() {
+ if (!getCurrentProjectId()) return renderNoProjectSelected();
+ return `
+
+
Artifact 浏览器
+
+
+ 全部类型(artifact_type_filter)
+ ${ARTIFACT_TYPES.map((t) => '' + t + " ").join("")}
+
+
+ 全部任务(task_id_filter)
+
+
+
+
+ `;
+}
+
+function mountArtifactBrowser() {
+ const projectId = getCurrentProjectId();
+ if (!projectId) return;
+ const listEl = document.getElementById("ab_list");
+ const previewEl = document.getElementById("ab_preview");
+ const typeFilterEl = document.getElementById("ab_type_filter");
+ const taskFilterEl = document.getElementById("ab_task_filter");
+ let selectedArtifactId = null;
+
+ fetch("/api/tasks?project_id=" + encodeURIComponent(projectId))
+ .then((resp) => resp.json())
+ .then((tasks) => {
+ for (const task of tasks) {
+ const option = document.createElement("option");
+ option.value = task.task_id;
+ option.textContent = task.goal_text.slice(0, 40) + " (" + task.task_id.slice(0, 8) + ")";
+ taskFilterEl.appendChild(option);
+ }
+ });
+
+ async function showPreview(artifactId) {
+ selectedArtifactId = artifactId;
+ for (const li of listEl.querySelectorAll("li")) {
+ li.classList.toggle("active", li.dataset.artifactId === artifactId);
+ }
+ previewEl.innerHTML = '加载中…
';
+ const artifact = await fetch("/api/artifacts/" + encodeURIComponent(artifactId)).then((r) => r.json());
+ const lineageHtml = artifact.lineage.map((a, i) => {
+ const isCurrent = a.artifact_id === artifactId;
+ const label = escapeHtml(a.artifact_type) + (isCurrent ? "(当前)" : "");
+ const arrow = i < artifact.lineage.length - 1 ? " → " : "";
+ return '' + label + " " + arrow;
+ }).join("");
+ previewEl.innerHTML = (
+ '血缘链:' + lineageHtml + "
"
+ + '' + (artifact.content == null ? "(文件不可用)" : escapeHtml(artifact.content)) + " "
+ );
+ }
+
+ async function refreshList() {
+ const params = new URLSearchParams({ project_id: projectId });
+ if (typeFilterEl.value) params.set("artifact_type", typeFilterEl.value);
+ if (taskFilterEl.value) params.set("task_id", taskFilterEl.value);
+ const artifacts = await fetch("/api/artifacts?" + params.toString()).then((r) => r.json());
+ if (artifacts.length === 0) {
+ listEl.innerHTML = ' 暂无产物 ';
+ previewEl.innerHTML = '暂无产物
';
+ return;
+ }
+ listEl.innerHTML = artifacts.map((a) =>
+ '' + escapeHtml(a.artifact_type) + "
"
+ + escapeHtml(a.file_path.split("/").pop()) + " "
+ ).join("");
+ for (const li of listEl.querySelectorAll("li[data-artifact-id]")) {
+ li.addEventListener("click", () => showPreview(li.dataset.artifactId));
+ }
+ if (selectedArtifactId && artifacts.some((a) => a.artifact_id === selectedArtifactId)) {
+ showPreview(selectedArtifactId);
+ } else {
+ showPreview(artifacts[0].artifact_id);
+ }
+ }
+
+ typeFilterEl.addEventListener("change", refreshList);
+ taskFilterEl.addEventListener("change", refreshList);
+ refreshList();
+
+ const socket = connectEventSocket(projectId, null, {
+ onEvent: (event) => { if (event.event_type === "artifact_created") refreshList(); },
+ onStatusChange: () => {},
+ });
+ registerRouteCleanup(() => socket.close());
+}
diff --git a/frontend/index.html b/frontend/index.html
index b5f8f36..46ce3fd 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -9,6 +9,7 @@
Dashboard
Tasks
+ Artifacts
Settings
diff --git a/frontend/style.css b/frontend/style.css
index b057afb..4817f8d 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -126,3 +126,15 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; }
ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; }
ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; }
ul.simple-list li:last-child { border-bottom: none; }
+
+/* U-028: v_artifact_browser -- class names match docs/wireframes/v_artifact_browser.html */
+.artifact-browser-grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; }
+ul.artifact-list { list-style: none; margin: 0; padding: 0; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; height: fit-content; }
+ul.artifact-list li { padding: 10px 14px; border-bottom: 1px solid #e5e7eb; font-size: 13px; cursor: pointer; }
+ul.artifact-list li:last-child { border-bottom: none; }
+ul.artifact-list li:hover, ul.artifact-list li.active { background: #f9fafb; }
+ul.artifact-list .type { font-size: 11px; color: #6b7280; text-transform: uppercase; }
+.artifact-browser-grid .preview { border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px; background: #f9fafb; }
+.lineage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: #6b7280; margin-bottom: 14px; }
+.lineage span.node { border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 10px; background: #fff; }
+pre.code { background: #0b1120; color: #d1d5db; padding: 14px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre-wrap; }
diff --git a/src/artifact_query.py b/src/artifact_query.py
new file mode 100644
index 0000000..3d321a6
--- /dev/null
+++ b/src/artifact_query.py
@@ -0,0 +1,100 @@
+"""c_artifact_get (`GET /api/artifacts/:artifact_id`, named in
+docs/sdd/sdd-mvcp-contract.json but never documented in 03-C's prose, and
+never implemented) + c_artifact_list (no operation_id in either the prose
+doc or the MVCP JSON at all, but v_artifact_browser's own filter fields --
+`artifact_type_filter`/`task_id_filter`, 02-V §2.8 -- require one; same
+"unowned gap" precedent as c_task_list before U-025 built it). Returns a
+bare array from list_artifacts(), matching list_tasks()'s shape rather than
+the MVCP JSON's c_task_list `{items, total}` wrapper -- no pagination need
+for a local single-project MVP, and staying consistent with this
+codebase's own established list-endpoint shape matters more here than
+matching a machine-generated mirror of the SDD.
+"""
+
+import json
+import os
+
+
+class ArtifactNotFoundError(Exception):
+ """NOT_FOUND -- c_artifact_get on a nonexistent artifact_id."""
+
+
+def _row_to_dict(r):
+ return {
+ "artifact_id": r[0], "artifact_type": r[1], "task_id": r[2], "produced_by_run_id": r[3],
+ "file_path": r[4], "git_commit_sha": r[5],
+ "depends_on_artifact_ids": json.loads(r[6]) if r[6] else [],
+ "validation_status": r[7], "created_at": r[8],
+ }
+
+
+def list_artifacts(conn, project_id, task_id=None, artifact_type=None):
+ query = (
+ "SELECT a.artifact_id, a.artifact_type, a.task_id, a.produced_by_run_id, a.file_path, "
+ "a.git_commit_sha, a.depends_on_artifact_ids, a.validation_status, a.created_at "
+ "FROM artifact a JOIN task t ON t.task_id = a.task_id WHERE t.project_id = ?"
+ )
+ params = [project_id]
+ if task_id:
+ query += " AND a.task_id = ?"
+ params.append(task_id)
+ if artifact_type:
+ query += " AND a.artifact_type = ?"
+ params.append(artifact_type)
+ query += " ORDER BY a.created_at DESC"
+ rows = conn.execute(query, params).fetchall()
+ return [_row_to_dict(r) for r in rows]
+
+
+def get_artifact(conn, artifact_id):
+ """Returns the artifact row plus its file content (read live off disk --
+ `artifact` itself has no content column, see 01-M 1.5) and its lineage
+ chain, so v_artifact_browser's preview pane needs exactly one request."""
+ row = conn.execute(
+ "SELECT a.artifact_id, a.artifact_type, a.task_id, a.produced_by_run_id, a.file_path, "
+ "a.git_commit_sha, a.depends_on_artifact_ids, a.validation_status, a.created_at, p.target_folder_path "
+ "FROM artifact a JOIN task t ON t.task_id = a.task_id JOIN project p ON p.project_id = t.project_id "
+ "WHERE a.artifact_id = ?",
+ (artifact_id,),
+ ).fetchone()
+ if row is None:
+ raise ArtifactNotFoundError(artifact_id)
+
+ result = _row_to_dict(row[:9])
+ abs_path = os.path.join(row[9], row[4])
+ try:
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
+ result["content"] = f.read()
+ except OSError:
+ result["content"] = None
+ result["lineage"] = lineage_chain(conn, artifact_id)
+ return result
+
+
+def lineage_chain(conn, artifact_id, max_depth=50):
+ """02-V §2.8: click_row 展示 depends_on_artifact_ids 血缘链. Returns
+ oldest-first [{artifact_id, artifact_type}, ...] ending with
+ `artifact_id` itself. `depends_on_artifact_ids` is a list (an artifact
+ can depend on more than one predecessor), but every real usage in this
+ pipeline is a linear brd->prd->sdd chain and the wireframe itself only
+ ever shows a single line -- walks depends_on_artifact_ids[0] when more
+ than one dependency exists, a deliberate simplification for a case this
+ codebase doesn't otherwise produce. A cycle guard is defensive: this is
+ a read path and shouldn't assume c_artifact_write's own dependency
+ validation was never bypassed (e.g. by a direct DB edit)."""
+ chain = []
+ seen = set()
+ current_id = artifact_id
+ while current_id and current_id not in seen and len(chain) < max_depth:
+ seen.add(current_id)
+ row = conn.execute(
+ "SELECT artifact_id, artifact_type, depends_on_artifact_ids FROM artifact WHERE artifact_id = ?",
+ (current_id,),
+ ).fetchone()
+ if row is None:
+ break
+ chain.append({"artifact_id": row[0], "artifact_type": row[1]})
+ deps = json.loads(row[2]) if row[2] else []
+ current_id = deps[0] if deps else None
+ chain.reverse()
+ return chain
diff --git a/src/backend_server.py b/src/backend_server.py
index e8560dc..0623651 100644
--- a/src/backend_server.py
+++ b/src/backend_server.py
@@ -15,9 +15,11 @@ hand-rolled RFC 6455 WebSocket handshake + framing in src/ws_subscribe.py --
no `websockets` dependency) for real-time `execution_event` push,
`c_execution_log_get`/`c_ws_log_tail` (03-C §3.6.1/§3.6.2, U-027, the
latter reusing the same hand-rolled WS machinery) for raw execution log
-viewing, and serving the U-008 frontend shell as static files (no build
-step, 05 §5.7: whitelisted by exact path, not a generic directory walker,
-so there's no path-traversal surface to reason about).
+viewing, `c_artifact_list`/`c_artifact_get` (U-028, the former unnamed in
+either 03-C or the MVCP JSON contract) for `v_artifact_browser`, and
+serving the U-008 frontend shell as static files (no build step, 05 §5.7:
+whitelisted by exact path, not a generic directory walker, so there's no
+path-traversal surface to reason about).
"""
import json
@@ -31,8 +33,9 @@ import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from src import (
- budget_guardrail, bug_record, execution_log, org_loop_route, paths,
- project_scaffold, schema, task_create, task_query, ws_subscribe,
+ artifact_query, budget_guardrail, bug_record, execution_log,
+ org_loop_route, paths, project_scaffold, schema, task_create,
+ task_query, ws_subscribe,
)
_FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
@@ -118,6 +121,7 @@ _TASKS_PREFIX = "/api/tasks/"
_WORK_NODES_PREFIX = "/api/work-nodes/"
_WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/"
_LOG_SUFFIX = "/log"
+_ARTIFACTS_PREFIX = "/api/artifacts/"
class _Handler(BaseHTTPRequestHandler):
@@ -145,6 +149,14 @@ class _Handler(BaseHTTPRequestHandler):
node_exec_id = parsed.path[len(_WORK_NODES_PREFIX):-len(_LOG_SUFFIX)]
self._handle_execution_log_get(node_exec_id, parsed.query)
return
+ if parsed.path == "/api/artifacts":
+ self._handle_artifact_list(parsed.query)
+ return
+ if parsed.path.startswith(_ARTIFACTS_PREFIX):
+ artifact_id = parsed.path[len(_ARTIFACTS_PREFIX):]
+ if artifact_id and "/" not in artifact_id:
+ self._handle_artifact_get(artifact_id)
+ return
entry = _STATIC_FILES.get(self.path)
if entry is None:
@@ -300,6 +312,29 @@ class _Handler(BaseHTTPRequestHandler):
result = budget_guardrail.list_budget_configs(self.server.conn)
self._send_json(200, result)
+ def _handle_artifact_list(self, query_string):
+ params = urllib.parse.parse_qs(query_string)
+ project_id = params.get("project_id", [None])[0]
+ if not project_id:
+ self._send_json(400, {"error": "INVALID_INPUT", "detail": "project_id is required"})
+ return
+ with self.server.conn_lock:
+ result = artifact_query.list_artifacts(
+ self.server.conn, project_id,
+ task_id=params.get("task_id", [None])[0],
+ artifact_type=params.get("artifact_type", [None])[0],
+ )
+ self._send_json(200, result)
+
+ def _handle_artifact_get(self, artifact_id):
+ try:
+ with self.server.conn_lock:
+ result = artifact_query.get_artifact(self.server.conn, artifact_id)
+ except artifact_query.ArtifactNotFoundError:
+ self._send_json(404, {"error": "NOT_FOUND"})
+ else:
+ self._send_json(200, result)
+
def _handle_ws_subscribe(self, path, query_string):
"""c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id.
Rejects with a normal HTTP response (no 101) before ever touching
diff --git a/src/test_artifact_query.py b/src/test_artifact_query.py
new file mode 100644
index 0000000..9ae8c8b
--- /dev/null
+++ b/src/test_artifact_query.py
@@ -0,0 +1,159 @@
+"""U-028 tests: c_artifact_list / c_artifact_get / lineage_chain. Real
+sqlite + real tempfile-backed artifact content, no mocking needed.
+
+Run: python3 -m unittest src.test_artifact_query -v
+"""
+
+import json
+import os
+import tempfile
+import unittest
+
+from src import artifact_query, schema
+
+
+class ArtifactQueryTestBase(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.conn = schema.connect(":memory:")
+ schema.create_schema(self.conn)
+ self.conn.execute(
+ "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
+ "VALUES ('proj-1', 'P', ?, 1, 'active')",
+ (self.tmp.name,),
+ )
+ self.conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
+ "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
+ )
+ self.conn.execute(
+ "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
+ "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
+ )
+ self.conn.commit()
+
+ def tearDown(self):
+ self.conn.close()
+
+ def _insert_artifact(self, artifact_id, artifact_type, file_path, depends_on=None, content=None, task_id="task-1"):
+ if content is not None:
+ abs_path = os.path.join(self.tmp.name, file_path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+ with open(abs_path, "w") as f:
+ f.write(content)
+ self.conn.execute(
+ "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
+ "git_commit_sha, depends_on_artifact_ids) VALUES (?, ?, ?, 'run-1', ?, 'sha1', ?)",
+ (artifact_id, artifact_type, task_id, file_path, json.dumps(depends_on) if depends_on else None),
+ )
+ self.conn.commit()
+
+
+class ListArtifactsTest(ArtifactQueryTestBase):
+ def test_lists_artifacts_for_project(self):
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ self._insert_artifact("art-2", "sdd", "docs/sdd.md")
+ result = artifact_query.list_artifacts(self.conn, "proj-1")
+ self.assertEqual({a["artifact_id"] for a in result}, {"art-1", "art-2"})
+
+ def test_other_project_excluded(self):
+ self.conn.execute(
+ "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
+ "VALUES ('proj-2', 'P2', '/tmp/proj-2', 1, 'active')"
+ )
+ self.conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
+ "VALUES ('task-2', 'proj-2', 'g', 'goal', 'coding', 'coder')"
+ )
+ self.conn.commit()
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ self._insert_artifact("art-2", "brd", "docs/brd2.md", task_id="task-2")
+ result = artifact_query.list_artifacts(self.conn, "proj-1")
+ self.assertEqual([a["artifact_id"] for a in result], ["art-1"])
+
+ def test_task_id_filter(self):
+ self.conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
+ "VALUES ('task-2', 'proj-1', 'g', 'goal', 'coding', 'coder')"
+ )
+ self.conn.commit()
+ self._insert_artifact("art-1", "brd", "docs/brd.md", task_id="task-1")
+ self._insert_artifact("art-2", "brd", "docs/brd2.md", task_id="task-2")
+ result = artifact_query.list_artifacts(self.conn, "proj-1", task_id="task-2")
+ self.assertEqual([a["artifact_id"] for a in result], ["art-2"])
+
+ def test_artifact_type_filter(self):
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ self._insert_artifact("art-2", "sdd", "docs/sdd.md")
+ result = artifact_query.list_artifacts(self.conn, "proj-1", artifact_type="sdd")
+ self.assertEqual([a["artifact_id"] for a in result], ["art-2"])
+
+ def test_depends_on_artifact_ids_parsed_as_json(self):
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1"])
+ result = artifact_query.list_artifacts(self.conn, "proj-1", artifact_type="prd")
+ self.assertEqual(result[0]["depends_on_artifact_ids"], ["art-1"])
+
+ def test_no_deps_returns_empty_list_not_null(self):
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ result = artifact_query.list_artifacts(self.conn, "proj-1")
+ self.assertEqual(result[0]["depends_on_artifact_ids"], [])
+
+
+class GetArtifactTest(ArtifactQueryTestBase):
+ def test_nonexistent_raises(self):
+ with self.assertRaises(artifact_query.ArtifactNotFoundError):
+ artifact_query.get_artifact(self.conn, "no-such-artifact")
+
+ def test_returns_content_read_from_disk(self):
+ self._insert_artifact("art-1", "sdd", "docs/sdd.md", content="# hello\nworld")
+ result = artifact_query.get_artifact(self.conn, "art-1")
+ self.assertEqual(result["content"], "# hello\nworld")
+ self.assertEqual(result["artifact_type"], "sdd")
+
+ def test_missing_physical_file_returns_none_content_not_error(self):
+ self._insert_artifact("art-1", "sdd", "docs/does-not-exist.md")
+ result = artifact_query.get_artifact(self.conn, "art-1")
+ self.assertIsNone(result["content"])
+
+ def test_includes_lineage(self):
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1"])
+ result = artifact_query.get_artifact(self.conn, "art-2")
+ self.assertEqual(
+ result["lineage"],
+ [{"artifact_id": "art-1", "artifact_type": "brd"}, {"artifact_id": "art-2", "artifact_type": "prd"}],
+ )
+
+
+class LineageChainTest(ArtifactQueryTestBase):
+ def test_single_artifact_no_deps(self):
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ self.assertEqual(artifact_query.lineage_chain(self.conn, "art-1"), [{"artifact_id": "art-1", "artifact_type": "brd"}])
+
+ def test_three_level_chain_oldest_first(self):
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1"])
+ self._insert_artifact("art-3", "sdd", "docs/sdd.md", depends_on=["art-2"])
+ chain = artifact_query.lineage_chain(self.conn, "art-3")
+ self.assertEqual([c["artifact_id"] for c in chain], ["art-1", "art-2", "art-3"])
+
+ def test_multiple_deps_follows_first_only(self):
+ self._insert_artifact("art-1", "brd", "docs/brd.md")
+ self._insert_artifact("art-1b", "benchmark", "docs/bench.md")
+ self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1", "art-1b"])
+ chain = artifact_query.lineage_chain(self.conn, "art-2")
+ self.assertEqual([c["artifact_id"] for c in chain], ["art-1", "art-2"])
+
+ def test_cycle_does_not_infinite_loop(self):
+ # simulates externally-corrupted data (c_artifact_write itself
+ # never allows this) -- a read path shouldn't hang on it
+ self._insert_artifact("art-1", "brd", "docs/brd.md", depends_on=["art-2"])
+ self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1"])
+ chain = artifact_query.lineage_chain(self.conn, "art-1")
+ self.assertLessEqual(len(chain), 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/test_backend_server.py b/src/test_backend_server.py
index 22e4184..6eb2cba 100644
--- a/src/test_backend_server.py
+++ b/src/test_backend_server.py
@@ -813,6 +813,80 @@ class ExecutionLogRouteTest(unittest.TestCase):
self.assertEqual(opcode, ws_subscribe.OPCODE_CLOSE)
+class ArtifactRouteTest(unittest.TestCase):
+ """U-028: c_artifact_list (GET /api/artifacts) + c_artifact_get
+ (GET /api/artifacts/:artifact_id) for v_artifact_browser."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.server = backend_server.create_server(db_path=":memory:")
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+ conn = self.server.conn
+ conn.execute(
+ "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
+ "VALUES ('proj-1', 'P', ?, 1, 'active')",
+ (self.tmp.name,),
+ )
+ conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
+ "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
+ )
+ conn.execute(
+ "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
+ "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
+ )
+ with open(os.path.join(self.tmp.name, "sdd.md"), "w") as f:
+ f.write("# SDD content")
+ conn.execute(
+ "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
+ "git_commit_sha) VALUES ('art-1', 'sdd', 'task-1', 'run-1', 'sdd.md', 'sha1')"
+ )
+ conn.commit()
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=5)
+
+ def _get(self, path):
+ host, port = self.server.server_address
+ conn = http.client.HTTPConnection(host, port, timeout=5)
+ conn.request("GET", path)
+ resp = conn.getresponse()
+ body = json.loads(resp.read())
+ conn.close()
+ return resp.status, body
+
+ def test_list_requires_project_id(self):
+ status, body = self._get("/api/artifacts")
+ self.assertEqual(status, 400)
+ self.assertEqual(body["error"], "INVALID_INPUT")
+
+ def test_list_returns_artifacts_for_project(self):
+ status, body = self._get("/api/artifacts?project_id=proj-1")
+ self.assertEqual(status, 200)
+ self.assertEqual(len(body), 1)
+ self.assertEqual(body[0]["artifact_id"], "art-1")
+
+ def test_list_artifact_type_filter(self):
+ status, body = self._get("/api/artifacts?project_id=proj-1&artifact_type=brd")
+ self.assertEqual(status, 200)
+ self.assertEqual(body, [])
+
+ def test_get_returns_content_and_lineage(self):
+ status, body = self._get("/api/artifacts/art-1")
+ self.assertEqual(status, 200)
+ self.assertEqual(body["content"], "# SDD content")
+ self.assertEqual(body["lineage"], [{"artifact_id": "art-1", "artifact_type": "sdd"}])
+
+ def test_get_nonexistent_returns_404(self):
+ status, body = self._get("/api/artifacts/no-such-artifact")
+ self.assertEqual(status, 404)
+ self.assertEqual(body["error"], "NOT_FOUND")
+
+
class ZeroThirdPartyDependencyTest(unittest.TestCase):
"""05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
doesn't silently rot if someone adds an import later."""