Commit accfe51feafaa1c143dbf8ba4df3d653cf150c85

Authored by bboonnee
1 parent b1c19805

U-028: v_artifact_browser

c_artifact_get 在机器可读的 sdd-mvcp-contract.json 里其实定义过
(GET /api/artifacts/:artifact_id),只是 03-C 正文一直没写出来,已订正
补全。c_artifact_list 连 MVCP JSON 里都没有 operation 定义,纯粹是
v_artifact_browser 自己的过滤字段倒逼出来的缺口,跟 U-025 补 c_task_list
是同一类问题。

新增 src/artifact_query.py:list_artifacts() 返回裸数组(跟已有的
list_tasks() 保持同款风格);get_artifact() 把血缘链(按
depends_on_artifact_ids 往回走,只走第一个依赖——这条流水线的实际产出
只有线性 BRD→PRD→SDD 链)和文件内容(artifact 表本身没有 content 列,
走 project.target_folder_path + artifact.file_path 现读磁盘)一起返回,
预览面板一次请求就够。

前端预览统一用 <pre> 纯文本展示,不做 Markdown 渲染/代码高亮——02-V
原文想要那个效果,但需要引入第一个前端第三方库,跟这个控制台一贯的纯
vanilla JS、无构建步骤做法冲突,是有意简化。顺手给 index.html 常驻导航
栏加上了"产物"链接。

真实服务器验证过三级血缘链(BRD→PRD→SDD)返回顺序正确。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -261,6 +261,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
261 261  
262 262 - **U-028 [Value Path]** `v_artifact_browser`
263 263 - 依赖:U-011
  264 + - Done:Artifact 列表+预览+血缘链可用(原文档缺 Done/覆盖行,2026-07-31 补上)
  265 + - **实现补充(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` 的预览统一用 `<pre>` 纯文本展示,不做 Markdown 渲染/代码高亮——02-V 原文想要那个效果,但这需要引入第一个前端第三方库(图表/高亮/markdown 解析器),和这个控制台"纯 vanilla JS、无构建步骤"的一贯做法(05 §5.7 精神延伸到前端)冲突,判断为有意简化而非漏做。顺手把 `frontend/index.html` 的常驻导航栏加上了"产物"链接(之前只有 Dashboard/Tasks/Settings 三个占位项)。真实服务器验证过三级血缘链(BRD→PRD→SDD)返回顺序正确。
  266 + - 覆盖:02-V `v_artifact_browser`;03-C `c_artifact_get`(正文订正补全)+ 新增 `c_artifact_list`
264 267  
265 268 - **U-029 [Value Path]** `v_skill_library` + `v_skill_audit_log`
266 269 - 依赖:U-018
... ...
docs/sdd/03-C-控制与接口.md
... ... @@ -171,6 +171,13 @@
171 171 - `side_effects`:写文件 + Git commit(commit message 包含 `task_id` 与 `artifact_type`)
172 172 - `errors`:`GIT_COMMIT_FAILED`(可重试)
173 173  
  174 +## 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` 自己的过滤字段倒逼补上)
  175 +
  176 +- `GET /api/artifacts/:artifact_id`(`c_artifact_get`)、`GET /api/artifacts?project_id=&task_id=&artifact_type=`(`c_artifact_list`,`project_id` 必填)
  177 +- `output_contract`:`c_artifact_list` 返回裸数组;`c_artifact_get` 返回单条 `artifact` 全部字段 + `content`(现读磁盘文件,`artifact` 表本身无 content 列)+ `lineage`(按 `depends_on_artifact_ids` 回溯的血缘链,oldest-first,含自身)
  178 +- `idempotent`:是
  179 +- `errors`:`NOT_FOUND`(不可重试,仅 `c_artifact_get`);`INVALID_INPUT`(不可重试,仅 `c_artifact_list`):缺少 `project_id`
  180 +
174 181 ## 3.8 `c_skill_update`
175 182  
176 183 - `POST /api/skills/:skill_id/update`
... ...
docs/sdd/unit_matrix.json
... ... @@ -43,7 +43,7 @@
43 43 {"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"},
44 44 {"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 面板,不重复渲染"},
45 45 {"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 未覆盖)"},
46   - {"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 列表+预览+血缘链可用"},
  46 + {"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 渲染/代码高亮(避免引入前端第三方依赖)"},
47 47 {"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 库与变更审计日志可查看"},
48 48 {"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": "三个配置页可用且改动生效"},
49 49  
... ...
frontend/app.js
... ... @@ -77,6 +77,7 @@ function connectLogTailSocket(nodeExecId, { onChunk, onClose }) {
77 77 const routes = {
78 78 "#/": { render: renderDashboard, mount: mountDashboard },
79 79 "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
  80 + "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser },
80 81 "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" },
81 82 "#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
82 83 "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
... ... @@ -968,3 +969,109 @@ function mountTaskDetail(taskId) {
968 969 closeAllLogViewers();
969 970 });
970 971 }
  972 +
  973 +// ---- v_artifact_browser (02-V §2.8): artifact list + read-only preview ----
  974 +// Preview renders content in a plain <pre> block for every artifact_type --
  975 +// 02-V asks for Markdown rendering / syntax highlighting, but pulling in a
  976 +// markdown/highlighting library would be this frontend's first external
  977 +// dependency (05 §5.7's "no build step" ethos extends to keeping the
  978 +// console vanilla-JS-only); a monospace raw-text preview is a deliberate,
  979 +// documented simplification, not a missed requirement.
  980 +
  981 +const ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark", "bug_record", "code_diff", "test_report"];
  982 +
  983 +function renderArtifactBrowser() {
  984 + if (!getCurrentProjectId()) return renderNoProjectSelected();
  985 + return `
  986 + <div class="page page-wide">
  987 + <h1>Artifact 浏览器</h1>
  988 + <div class="filters">
  989 + <select id="ab_type_filter">
  990 + <option value="">全部类型(artifact_type_filter)</option>
  991 + ${ARTIFACT_TYPES.map((t) => '<option value="' + t + '">' + t + "</option>").join("")}
  992 + </select>
  993 + <select id="ab_task_filter">
  994 + <option value="">全部任务(task_id_filter)</option>
  995 + </select>
  996 + </div>
  997 + <div class="artifact-browser-grid">
  998 + <ul class="artifact-list" id="ab_list"></ul>
  999 + <div class="preview" id="ab_preview"><div class="hint">选择左侧一个 Artifact 查看预览</div></div>
  1000 + </div>
  1001 + </div>
  1002 + `;
  1003 +}
  1004 +
  1005 +function mountArtifactBrowser() {
  1006 + const projectId = getCurrentProjectId();
  1007 + if (!projectId) return;
  1008 + const listEl = document.getElementById("ab_list");
  1009 + const previewEl = document.getElementById("ab_preview");
  1010 + const typeFilterEl = document.getElementById("ab_type_filter");
  1011 + const taskFilterEl = document.getElementById("ab_task_filter");
  1012 + let selectedArtifactId = null;
  1013 +
  1014 + fetch("/api/tasks?project_id=" + encodeURIComponent(projectId))
  1015 + .then((resp) => resp.json())
  1016 + .then((tasks) => {
  1017 + for (const task of tasks) {
  1018 + const option = document.createElement("option");
  1019 + option.value = task.task_id;
  1020 + option.textContent = task.goal_text.slice(0, 40) + " (" + task.task_id.slice(0, 8) + ")";
  1021 + taskFilterEl.appendChild(option);
  1022 + }
  1023 + });
  1024 +
  1025 + async function showPreview(artifactId) {
  1026 + selectedArtifactId = artifactId;
  1027 + for (const li of listEl.querySelectorAll("li")) {
  1028 + li.classList.toggle("active", li.dataset.artifactId === artifactId);
  1029 + }
  1030 + previewEl.innerHTML = '<div class="hint">加载中…</div>';
  1031 + const artifact = await fetch("/api/artifacts/" + encodeURIComponent(artifactId)).then((r) => r.json());
  1032 + const lineageHtml = artifact.lineage.map((a, i) => {
  1033 + const isCurrent = a.artifact_id === artifactId;
  1034 + const label = escapeHtml(a.artifact_type) + (isCurrent ? "(当前)" : "");
  1035 + const arrow = i < artifact.lineage.length - 1 ? " → " : "";
  1036 + return '<span class="node">' + label + "</span>" + arrow;
  1037 + }).join("");
  1038 + previewEl.innerHTML = (
  1039 + '<div class="lineage">血缘链:' + lineageHtml + "</div>"
  1040 + + '<pre class="code">' + (artifact.content == null ? "(文件不可用)" : escapeHtml(artifact.content)) + "</pre>"
  1041 + );
  1042 + }
  1043 +
  1044 + async function refreshList() {
  1045 + const params = new URLSearchParams({ project_id: projectId });
  1046 + if (typeFilterEl.value) params.set("artifact_type", typeFilterEl.value);
  1047 + if (taskFilterEl.value) params.set("task_id", taskFilterEl.value);
  1048 + const artifacts = await fetch("/api/artifacts?" + params.toString()).then((r) => r.json());
  1049 + if (artifacts.length === 0) {
  1050 + listEl.innerHTML = '<li class="hint" style="cursor:default;">暂无产物</li>';
  1051 + previewEl.innerHTML = '<div class="hint">暂无产物</div>';
  1052 + return;
  1053 + }
  1054 + listEl.innerHTML = artifacts.map((a) =>
  1055 + '<li data-artifact-id="' + a.artifact_id + '"><div class="type">' + escapeHtml(a.artifact_type) + "</div>"
  1056 + + escapeHtml(a.file_path.split("/").pop()) + "</li>"
  1057 + ).join("");
  1058 + for (const li of listEl.querySelectorAll("li[data-artifact-id]")) {
  1059 + li.addEventListener("click", () => showPreview(li.dataset.artifactId));
  1060 + }
  1061 + if (selectedArtifactId && artifacts.some((a) => a.artifact_id === selectedArtifactId)) {
  1062 + showPreview(selectedArtifactId);
  1063 + } else {
  1064 + showPreview(artifacts[0].artifact_id);
  1065 + }
  1066 + }
  1067 +
  1068 + typeFilterEl.addEventListener("change", refreshList);
  1069 + taskFilterEl.addEventListener("change", refreshList);
  1070 + refreshList();
  1071 +
  1072 + const socket = connectEventSocket(projectId, null, {
  1073 + onEvent: (event) => { if (event.event_type === "artifact_created") refreshList(); },
  1074 + onStatusChange: () => {},
  1075 + });
  1076 + registerRouteCleanup(() => socket.close());
  1077 +}
... ...
frontend/index.html
... ... @@ -9,6 +9,7 @@
9 9 <nav id="nav">
10 10 <a href="#/">Dashboard</a>
11 11 <a href="#/tasks">Tasks</a>
  12 + <a href="#/artifacts">Artifacts</a>
12 13 <a href="#/settings">Settings</a>
13 14 </nav>
14 15 <main id="app"></main>
... ...
frontend/style.css
... ... @@ -126,3 +126,15 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; }
126 126 ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; }
127 127 ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; }
128 128 ul.simple-list li:last-child { border-bottom: none; }
  129 +
  130 +/* U-028: v_artifact_browser -- class names match docs/wireframes/v_artifact_browser.html */
  131 +.artifact-browser-grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; }
  132 +ul.artifact-list { list-style: none; margin: 0; padding: 0; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; height: fit-content; }
  133 +ul.artifact-list li { padding: 10px 14px; border-bottom: 1px solid #e5e7eb; font-size: 13px; cursor: pointer; }
  134 +ul.artifact-list li:last-child { border-bottom: none; }
  135 +ul.artifact-list li:hover, ul.artifact-list li.active { background: #f9fafb; }
  136 +ul.artifact-list .type { font-size: 11px; color: #6b7280; text-transform: uppercase; }
  137 +.artifact-browser-grid .preview { border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px; background: #f9fafb; }
  138 +.lineage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: #6b7280; margin-bottom: 14px; }
  139 +.lineage span.node { border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 10px; background: #fff; }
  140 +pre.code { background: #0b1120; color: #d1d5db; padding: 14px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre-wrap; }
... ...
src/artifact_query.py 0 → 100644
  1 +"""c_artifact_get (`GET /api/artifacts/:artifact_id`, named in
  2 +docs/sdd/sdd-mvcp-contract.json but never documented in 03-C's prose, and
  3 +never implemented) + c_artifact_list (no operation_id in either the prose
  4 +doc or the MVCP JSON at all, but v_artifact_browser's own filter fields --
  5 +`artifact_type_filter`/`task_id_filter`, 02-V §2.8 -- require one; same
  6 +"unowned gap" precedent as c_task_list before U-025 built it). Returns a
  7 +bare array from list_artifacts(), matching list_tasks()'s shape rather than
  8 +the MVCP JSON's c_task_list `{items, total}` wrapper -- no pagination need
  9 +for a local single-project MVP, and staying consistent with this
  10 +codebase's own established list-endpoint shape matters more here than
  11 +matching a machine-generated mirror of the SDD.
  12 +"""
  13 +
  14 +import json
  15 +import os
  16 +
  17 +
  18 +class ArtifactNotFoundError(Exception):
  19 + """NOT_FOUND -- c_artifact_get on a nonexistent artifact_id."""
  20 +
  21 +
  22 +def _row_to_dict(r):
  23 + return {
  24 + "artifact_id": r[0], "artifact_type": r[1], "task_id": r[2], "produced_by_run_id": r[3],
  25 + "file_path": r[4], "git_commit_sha": r[5],
  26 + "depends_on_artifact_ids": json.loads(r[6]) if r[6] else [],
  27 + "validation_status": r[7], "created_at": r[8],
  28 + }
  29 +
  30 +
  31 +def list_artifacts(conn, project_id, task_id=None, artifact_type=None):
  32 + query = (
  33 + "SELECT a.artifact_id, a.artifact_type, a.task_id, a.produced_by_run_id, a.file_path, "
  34 + "a.git_commit_sha, a.depends_on_artifact_ids, a.validation_status, a.created_at "
  35 + "FROM artifact a JOIN task t ON t.task_id = a.task_id WHERE t.project_id = ?"
  36 + )
  37 + params = [project_id]
  38 + if task_id:
  39 + query += " AND a.task_id = ?"
  40 + params.append(task_id)
  41 + if artifact_type:
  42 + query += " AND a.artifact_type = ?"
  43 + params.append(artifact_type)
  44 + query += " ORDER BY a.created_at DESC"
  45 + rows = conn.execute(query, params).fetchall()
  46 + return [_row_to_dict(r) for r in rows]
  47 +
  48 +
  49 +def get_artifact(conn, artifact_id):
  50 + """Returns the artifact row plus its file content (read live off disk --
  51 + `artifact` itself has no content column, see 01-M 1.5) and its lineage
  52 + chain, so v_artifact_browser's preview pane needs exactly one request."""
  53 + row = conn.execute(
  54 + "SELECT a.artifact_id, a.artifact_type, a.task_id, a.produced_by_run_id, a.file_path, "
  55 + "a.git_commit_sha, a.depends_on_artifact_ids, a.validation_status, a.created_at, p.target_folder_path "
  56 + "FROM artifact a JOIN task t ON t.task_id = a.task_id JOIN project p ON p.project_id = t.project_id "
  57 + "WHERE a.artifact_id = ?",
  58 + (artifact_id,),
  59 + ).fetchone()
  60 + if row is None:
  61 + raise ArtifactNotFoundError(artifact_id)
  62 +
  63 + result = _row_to_dict(row[:9])
  64 + abs_path = os.path.join(row[9], row[4])
  65 + try:
  66 + with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
  67 + result["content"] = f.read()
  68 + except OSError:
  69 + result["content"] = None
  70 + result["lineage"] = lineage_chain(conn, artifact_id)
  71 + return result
  72 +
  73 +
  74 +def lineage_chain(conn, artifact_id, max_depth=50):
  75 + """02-V §2.8: click_row 展示 depends_on_artifact_ids 血缘链. Returns
  76 + oldest-first [{artifact_id, artifact_type}, ...] ending with
  77 + `artifact_id` itself. `depends_on_artifact_ids` is a list (an artifact
  78 + can depend on more than one predecessor), but every real usage in this
  79 + pipeline is a linear brd->prd->sdd chain and the wireframe itself only
  80 + ever shows a single line -- walks depends_on_artifact_ids[0] when more
  81 + than one dependency exists, a deliberate simplification for a case this
  82 + codebase doesn't otherwise produce. A cycle guard is defensive: this is
  83 + a read path and shouldn't assume c_artifact_write's own dependency
  84 + validation was never bypassed (e.g. by a direct DB edit)."""
  85 + chain = []
  86 + seen = set()
  87 + current_id = artifact_id
  88 + while current_id and current_id not in seen and len(chain) < max_depth:
  89 + seen.add(current_id)
  90 + row = conn.execute(
  91 + "SELECT artifact_id, artifact_type, depends_on_artifact_ids FROM artifact WHERE artifact_id = ?",
  92 + (current_id,),
  93 + ).fetchone()
  94 + if row is None:
  95 + break
  96 + chain.append({"artifact_id": row[0], "artifact_type": row[1]})
  97 + deps = json.loads(row[2]) if row[2] else []
  98 + current_id = deps[0] if deps else None
  99 + chain.reverse()
  100 + return chain
... ...
src/backend_server.py
... ... @@ -15,9 +15,11 @@ hand-rolled RFC 6455 WebSocket handshake + framing in src/ws_subscribe.py --
15 15 no `websockets` dependency) for real-time `execution_event` push,
16 16 `c_execution_log_get`/`c_ws_log_tail` (03-C §3.6.1/§3.6.2, U-027, the
17 17 latter reusing the same hand-rolled WS machinery) for raw execution log
18   -viewing, and serving the U-008 frontend shell as static files (no build
19   -step, 05 §5.7: whitelisted by exact path, not a generic directory walker,
20   -so there's no path-traversal surface to reason about).
  18 +viewing, `c_artifact_list`/`c_artifact_get` (U-028, the former unnamed in
  19 +either 03-C or the MVCP JSON contract) for `v_artifact_browser`, and
  20 +serving the U-008 frontend shell as static files (no build step, 05 §5.7:
  21 +whitelisted by exact path, not a generic directory walker, so there's no
  22 +path-traversal surface to reason about).
21 23 """
22 24  
23 25 import json
... ... @@ -31,8 +33,9 @@ import urllib.parse
31 33 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
32 34  
33 35 from src import (
34   - budget_guardrail, bug_record, execution_log, org_loop_route, paths,
35   - project_scaffold, schema, task_create, task_query, ws_subscribe,
  36 + artifact_query, budget_guardrail, bug_record, execution_log,
  37 + org_loop_route, paths, project_scaffold, schema, task_create,
  38 + task_query, ws_subscribe,
36 39 )
37 40  
38 41 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
... ... @@ -118,6 +121,7 @@ _TASKS_PREFIX = &quot;/api/tasks/&quot;
118 121 _WORK_NODES_PREFIX = "/api/work-nodes/"
119 122 _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/"
120 123 _LOG_SUFFIX = "/log"
  124 +_ARTIFACTS_PREFIX = "/api/artifacts/"
121 125  
122 126  
123 127 class _Handler(BaseHTTPRequestHandler):
... ... @@ -145,6 +149,14 @@ class _Handler(BaseHTTPRequestHandler):
145 149 node_exec_id = parsed.path[len(_WORK_NODES_PREFIX):-len(_LOG_SUFFIX)]
146 150 self._handle_execution_log_get(node_exec_id, parsed.query)
147 151 return
  152 + if parsed.path == "/api/artifacts":
  153 + self._handle_artifact_list(parsed.query)
  154 + return
  155 + if parsed.path.startswith(_ARTIFACTS_PREFIX):
  156 + artifact_id = parsed.path[len(_ARTIFACTS_PREFIX):]
  157 + if artifact_id and "/" not in artifact_id:
  158 + self._handle_artifact_get(artifact_id)
  159 + return
148 160  
149 161 entry = _STATIC_FILES.get(self.path)
150 162 if entry is None:
... ... @@ -300,6 +312,29 @@ class _Handler(BaseHTTPRequestHandler):
300 312 result = budget_guardrail.list_budget_configs(self.server.conn)
301 313 self._send_json(200, result)
302 314  
  315 + def _handle_artifact_list(self, query_string):
  316 + params = urllib.parse.parse_qs(query_string)
  317 + project_id = params.get("project_id", [None])[0]
  318 + if not project_id:
  319 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "project_id is required"})
  320 + return
  321 + with self.server.conn_lock:
  322 + result = artifact_query.list_artifacts(
  323 + self.server.conn, project_id,
  324 + task_id=params.get("task_id", [None])[0],
  325 + artifact_type=params.get("artifact_type", [None])[0],
  326 + )
  327 + self._send_json(200, result)
  328 +
  329 + def _handle_artifact_get(self, artifact_id):
  330 + try:
  331 + with self.server.conn_lock:
  332 + result = artifact_query.get_artifact(self.server.conn, artifact_id)
  333 + except artifact_query.ArtifactNotFoundError:
  334 + self._send_json(404, {"error": "NOT_FOUND"})
  335 + else:
  336 + self._send_json(200, result)
  337 +
303 338 def _handle_ws_subscribe(self, path, query_string):
304 339 """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id.
305 340 Rejects with a normal HTTP response (no 101) before ever touching
... ...
src/test_artifact_query.py 0 → 100644
  1 +"""U-028 tests: c_artifact_list / c_artifact_get / lineage_chain. Real
  2 +sqlite + real tempfile-backed artifact content, no mocking needed.
  3 +
  4 +Run: python3 -m unittest src.test_artifact_query -v
  5 +"""
  6 +
  7 +import json
  8 +import os
  9 +import tempfile
  10 +import unittest
  11 +
  12 +from src import artifact_query, schema
  13 +
  14 +
  15 +class ArtifactQueryTestBase(unittest.TestCase):
  16 + def setUp(self):
  17 + self.tmp = tempfile.TemporaryDirectory()
  18 + self.addCleanup(self.tmp.cleanup)
  19 + self.conn = schema.connect(":memory:")
  20 + schema.create_schema(self.conn)
  21 + self.conn.execute(
  22 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  23 + "VALUES ('proj-1', 'P', ?, 1, 'active')",
  24 + (self.tmp.name,),
  25 + )
  26 + self.conn.execute(
  27 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  28 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
  29 + )
  30 + self.conn.execute(
  31 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  32 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
  33 + )
  34 + self.conn.commit()
  35 +
  36 + def tearDown(self):
  37 + self.conn.close()
  38 +
  39 + def _insert_artifact(self, artifact_id, artifact_type, file_path, depends_on=None, content=None, task_id="task-1"):
  40 + if content is not None:
  41 + abs_path = os.path.join(self.tmp.name, file_path)
  42 + os.makedirs(os.path.dirname(abs_path), exist_ok=True)
  43 + with open(abs_path, "w") as f:
  44 + f.write(content)
  45 + self.conn.execute(
  46 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
  47 + "git_commit_sha, depends_on_artifact_ids) VALUES (?, ?, ?, 'run-1', ?, 'sha1', ?)",
  48 + (artifact_id, artifact_type, task_id, file_path, json.dumps(depends_on) if depends_on else None),
  49 + )
  50 + self.conn.commit()
  51 +
  52 +
  53 +class ListArtifactsTest(ArtifactQueryTestBase):
  54 + def test_lists_artifacts_for_project(self):
  55 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  56 + self._insert_artifact("art-2", "sdd", "docs/sdd.md")
  57 + result = artifact_query.list_artifacts(self.conn, "proj-1")
  58 + self.assertEqual({a["artifact_id"] for a in result}, {"art-1", "art-2"})
  59 +
  60 + def test_other_project_excluded(self):
  61 + self.conn.execute(
  62 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  63 + "VALUES ('proj-2', 'P2', '/tmp/proj-2', 1, 'active')"
  64 + )
  65 + self.conn.execute(
  66 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  67 + "VALUES ('task-2', 'proj-2', 'g', 'goal', 'coding', 'coder')"
  68 + )
  69 + self.conn.commit()
  70 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  71 + self._insert_artifact("art-2", "brd", "docs/brd2.md", task_id="task-2")
  72 + result = artifact_query.list_artifacts(self.conn, "proj-1")
  73 + self.assertEqual([a["artifact_id"] for a in result], ["art-1"])
  74 +
  75 + def test_task_id_filter(self):
  76 + self.conn.execute(
  77 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  78 + "VALUES ('task-2', 'proj-1', 'g', 'goal', 'coding', 'coder')"
  79 + )
  80 + self.conn.commit()
  81 + self._insert_artifact("art-1", "brd", "docs/brd.md", task_id="task-1")
  82 + self._insert_artifact("art-2", "brd", "docs/brd2.md", task_id="task-2")
  83 + result = artifact_query.list_artifacts(self.conn, "proj-1", task_id="task-2")
  84 + self.assertEqual([a["artifact_id"] for a in result], ["art-2"])
  85 +
  86 + def test_artifact_type_filter(self):
  87 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  88 + self._insert_artifact("art-2", "sdd", "docs/sdd.md")
  89 + result = artifact_query.list_artifacts(self.conn, "proj-1", artifact_type="sdd")
  90 + self.assertEqual([a["artifact_id"] for a in result], ["art-2"])
  91 +
  92 + def test_depends_on_artifact_ids_parsed_as_json(self):
  93 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  94 + self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1"])
  95 + result = artifact_query.list_artifacts(self.conn, "proj-1", artifact_type="prd")
  96 + self.assertEqual(result[0]["depends_on_artifact_ids"], ["art-1"])
  97 +
  98 + def test_no_deps_returns_empty_list_not_null(self):
  99 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  100 + result = artifact_query.list_artifacts(self.conn, "proj-1")
  101 + self.assertEqual(result[0]["depends_on_artifact_ids"], [])
  102 +
  103 +
  104 +class GetArtifactTest(ArtifactQueryTestBase):
  105 + def test_nonexistent_raises(self):
  106 + with self.assertRaises(artifact_query.ArtifactNotFoundError):
  107 + artifact_query.get_artifact(self.conn, "no-such-artifact")
  108 +
  109 + def test_returns_content_read_from_disk(self):
  110 + self._insert_artifact("art-1", "sdd", "docs/sdd.md", content="# hello\nworld")
  111 + result = artifact_query.get_artifact(self.conn, "art-1")
  112 + self.assertEqual(result["content"], "# hello\nworld")
  113 + self.assertEqual(result["artifact_type"], "sdd")
  114 +
  115 + def test_missing_physical_file_returns_none_content_not_error(self):
  116 + self._insert_artifact("art-1", "sdd", "docs/does-not-exist.md")
  117 + result = artifact_query.get_artifact(self.conn, "art-1")
  118 + self.assertIsNone(result["content"])
  119 +
  120 + def test_includes_lineage(self):
  121 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  122 + self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1"])
  123 + result = artifact_query.get_artifact(self.conn, "art-2")
  124 + self.assertEqual(
  125 + result["lineage"],
  126 + [{"artifact_id": "art-1", "artifact_type": "brd"}, {"artifact_id": "art-2", "artifact_type": "prd"}],
  127 + )
  128 +
  129 +
  130 +class LineageChainTest(ArtifactQueryTestBase):
  131 + def test_single_artifact_no_deps(self):
  132 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  133 + self.assertEqual(artifact_query.lineage_chain(self.conn, "art-1"), [{"artifact_id": "art-1", "artifact_type": "brd"}])
  134 +
  135 + def test_three_level_chain_oldest_first(self):
  136 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  137 + self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1"])
  138 + self._insert_artifact("art-3", "sdd", "docs/sdd.md", depends_on=["art-2"])
  139 + chain = artifact_query.lineage_chain(self.conn, "art-3")
  140 + self.assertEqual([c["artifact_id"] for c in chain], ["art-1", "art-2", "art-3"])
  141 +
  142 + def test_multiple_deps_follows_first_only(self):
  143 + self._insert_artifact("art-1", "brd", "docs/brd.md")
  144 + self._insert_artifact("art-1b", "benchmark", "docs/bench.md")
  145 + self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1", "art-1b"])
  146 + chain = artifact_query.lineage_chain(self.conn, "art-2")
  147 + self.assertEqual([c["artifact_id"] for c in chain], ["art-1", "art-2"])
  148 +
  149 + def test_cycle_does_not_infinite_loop(self):
  150 + # simulates externally-corrupted data (c_artifact_write itself
  151 + # never allows this) -- a read path shouldn't hang on it
  152 + self._insert_artifact("art-1", "brd", "docs/brd.md", depends_on=["art-2"])
  153 + self._insert_artifact("art-2", "prd", "docs/prd.md", depends_on=["art-1"])
  154 + chain = artifact_query.lineage_chain(self.conn, "art-1")
  155 + self.assertLessEqual(len(chain), 2)
  156 +
  157 +
  158 +if __name__ == "__main__":
  159 + unittest.main()
... ...
src/test_backend_server.py
... ... @@ -813,6 +813,80 @@ class ExecutionLogRouteTest(unittest.TestCase):
813 813 self.assertEqual(opcode, ws_subscribe.OPCODE_CLOSE)
814 814  
815 815  
  816 +class ArtifactRouteTest(unittest.TestCase):
  817 + """U-028: c_artifact_list (GET /api/artifacts) + c_artifact_get
  818 + (GET /api/artifacts/:artifact_id) for v_artifact_browser."""
  819 +
  820 + def setUp(self):
  821 + self.tmp = tempfile.TemporaryDirectory()
  822 + self.addCleanup(self.tmp.cleanup)
  823 + self.server = backend_server.create_server(db_path=":memory:")
  824 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  825 + self.thread.start()
  826 + conn = self.server.conn
  827 + conn.execute(
  828 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  829 + "VALUES ('proj-1', 'P', ?, 1, 'active')",
  830 + (self.tmp.name,),
  831 + )
  832 + conn.execute(
  833 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  834 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
  835 + )
  836 + conn.execute(
  837 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  838 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
  839 + )
  840 + with open(os.path.join(self.tmp.name, "sdd.md"), "w") as f:
  841 + f.write("# SDD content")
  842 + conn.execute(
  843 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
  844 + "git_commit_sha) VALUES ('art-1', 'sdd', 'task-1', 'run-1', 'sdd.md', 'sha1')"
  845 + )
  846 + conn.commit()
  847 +
  848 + def tearDown(self):
  849 + self.server.shutdown()
  850 + self.server.server_close()
  851 + self.thread.join(timeout=5)
  852 +
  853 + def _get(self, path):
  854 + host, port = self.server.server_address
  855 + conn = http.client.HTTPConnection(host, port, timeout=5)
  856 + conn.request("GET", path)
  857 + resp = conn.getresponse()
  858 + body = json.loads(resp.read())
  859 + conn.close()
  860 + return resp.status, body
  861 +
  862 + def test_list_requires_project_id(self):
  863 + status, body = self._get("/api/artifacts")
  864 + self.assertEqual(status, 400)
  865 + self.assertEqual(body["error"], "INVALID_INPUT")
  866 +
  867 + def test_list_returns_artifacts_for_project(self):
  868 + status, body = self._get("/api/artifacts?project_id=proj-1")
  869 + self.assertEqual(status, 200)
  870 + self.assertEqual(len(body), 1)
  871 + self.assertEqual(body[0]["artifact_id"], "art-1")
  872 +
  873 + def test_list_artifact_type_filter(self):
  874 + status, body = self._get("/api/artifacts?project_id=proj-1&artifact_type=brd")
  875 + self.assertEqual(status, 200)
  876 + self.assertEqual(body, [])
  877 +
  878 + def test_get_returns_content_and_lineage(self):
  879 + status, body = self._get("/api/artifacts/art-1")
  880 + self.assertEqual(status, 200)
  881 + self.assertEqual(body["content"], "# SDD content")
  882 + self.assertEqual(body["lineage"], [{"artifact_id": "art-1", "artifact_type": "sdd"}])
  883 +
  884 + def test_get_nonexistent_returns_404(self):
  885 + status, body = self._get("/api/artifacts/no-such-artifact")
  886 + self.assertEqual(status, 404)
  887 + self.assertEqual(body["error"], "NOT_FOUND")
  888 +
  889 +
816 890 class ZeroThirdPartyDependencyTest(unittest.TestCase):
817 891 """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
818 892 doesn't silently rot if someone adds an import later."""
... ...