diff --git a/internal/project-management/mdtodo.test.ts b/internal/project-management/mdtodo.test.ts index b9e09158..d984ced0 100644 --- a/internal/project-management/mdtodo.test.ts +++ b/internal/project-management/mdtodo.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { mutateMdtodoDocument, parseMdtodoDocument } from "./mdtodo.ts"; +import { mutateMdtodoDocument, parseMdtodoDocument, readMdtodoTaskDetail } from "./mdtodo.ts"; test("parseMdtodoDocument projects Rxx hierarchy without raw body leakage", () => { const document = parseMdtodoDocument(`# Demo @@ -34,6 +34,36 @@ Root notes with [context](https://example.test/context). expect(document.document.valuesRedacted).toBe(true); }); +test("readMdtodoTaskDetail exposes Rxx body and safe Markdown report links", () => { + const detail = readMdtodoTaskDetail(`# Demo + +## R1 Root task + +Task details + +- report: [R1.1](./20260110_LOG/R1.1_log_implementation.md) +- external: [context](https://example.test/context) + +### R1.1 Child + +Child details +`, { + sourceId: "demo", + relativePath: "20260609_feedback.md", + taskRef: "mdtodo:demo:file_plan:R1", + fileRef: "file_plan", + projectId: "project_test" + }); + + expect(detail?.body).toContain("Task details"); + expect(detail?.body).not.toContain("Child details"); + expect(detail?.links.find((link) => link.label === "R1.1")).toMatchObject({ + kind: "markdown-report", + relativePath: "20260110_LOG/R1.1_log_implementation.md" + }); + expect(detail?.links.find((link) => link.label === "context")).toMatchObject({ kind: "external", relativePath: null }); +}); + test("parseMdtodoDocument keeps legacy checkbox parsing explicit", () => { const document = parseMdtodoDocument(`# Demo diff --git a/internal/project-management/mdtodo.ts b/internal/project-management/mdtodo.ts index d7f22806..7471096a 100644 --- a/internal/project-management/mdtodo.ts +++ b/internal/project-management/mdtodo.ts @@ -1,23 +1,12 @@ /** - * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source. + * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source; + * draft-2026-06-26-p1-mdtodo-web-operable. * Projects MDTODO Markdown source-of-truth into stable Rxx task projections and mutation helpers. */ import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; -const excludedDirectories = new Set([ - ".git", - ".worktree", - ".state", - "node_modules", - "dist", - "build", - "coverage", - "tmp", - "vendor" -]); - const taskLinePattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u; const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu; const rxxIdPattern = /^R\d+(?:\.\d+)*$/iu; @@ -46,8 +35,7 @@ export async function discoverMdtodoDocuments(options = {}) { const sourceId = safeToken(options.sourceId, "default-mdtodo"); const projectId = safeToken(options.projectId, null); const maxFiles = positiveInteger(options.maxFiles, 300); - const files = []; - await walkMarkdown(root, root, files, maxFiles); + const files = await listDirectMarkdownFiles(root, maxFiles); const documents = []; for (const filePath of files) { @@ -98,6 +86,41 @@ export function parseMdtodoDocument(markdown, options = {}) { }; } +export function readMdtodoTaskDetail(markdown, options = {}) { + const sourceId = safeToken(options.sourceId, "default-mdtodo"); + const relativePath = normalizeRelativePath(options.relativePath || "MDTODO.md"); + const fileRef = safeToken(options.fileRef, documentFileRef(relativePath)); + const projectId = safeToken(options.projectId, projectIdForRelativePath(relativePath)); + const rxxId = normalizeOptionalRxxId(options.rxxId ?? taskRxxIdFromRef(options.taskRef)); + if (!rxxId) return null; + + const lines = String(markdown ?? "").split(/\r?\n/u); + const block = collectRxxBlocks(lines).find((candidate) => candidate.rxxId === rxxId); + if (!block) return null; + + const body = taskBodyFromLines(lines, block); + const bodyPreview = bodyPreviewFromLines(lines, block.lineIndex + 1, block.endLine); + const title = cleanTaskTitle(block.title || bodyPreview || block.rxxId); + return { + taskRef: taskRefForRxx(sourceId, fileRef, block.rxxId), + taskId: block.rxxId, + rxxId: block.rxxId, + projectId, + sourceId, + fileRef, + relativePath, + title, + status: block.status, + lineNumber: block.lineNumber, + headingLevel: block.hashes.length, + statusMarker: block.statusMarker, + body, + bodyPreview, + links: markdownLinksFromBody(body, relativePath), + valuesRedacted: false + }; +} + export function mutateMdtodoDocument(markdown, operation = {}, options = {}) { const originalMarkdown = String(markdown ?? ""); const previousFingerprint = sha256(originalMarkdown); @@ -425,24 +448,16 @@ function isMdtodoCandidate(filePath, markdown) { return basename.includes("todo") || basename.includes("mdtodo") || String(markdown ?? "").split(/\r?\n/u).some((line) => taskLinePattern.test(line) || rxxHeadingPattern.test(line)); } -async function walkMarkdown(root, current, files, maxFiles) { - if (files.length >= maxFiles) return; - let entries; +async function listDirectMarkdownFiles(root, maxFiles) { try { - entries = await fs.readdir(current, { withFileTypes: true }); + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md")) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, maxFiles) + .map((entry) => path.join(root, entry.name)); } catch { - return; - } - entries.sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - if (files.length >= maxFiles) return; - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - if (excludedDirectories.has(entry.name)) continue; - await walkMarkdown(root, absolute, files, maxFiles); - continue; - } - if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) files.push(absolute); + return []; } } @@ -493,6 +508,11 @@ function normalizeOptionalRxxId(value) { return rxxIdPattern.test(text) ? text : null; } +function taskRxxIdFromRef(taskRef) { + const parts = String(taskRef ?? "").split(":"); + return parts.length >= 4 ? parts.at(-1) : null; +} + function normalizeStatus(value) { const key = String(value ?? "open").trim().toLowerCase().replace(/[\s-]+/gu, "_"); return statusAliases.get(key) ?? null; @@ -519,6 +539,69 @@ function countMarkdownLinks(value) { return [...String(value ?? "").matchAll(markdownLinkPattern)].length; } +function markdownLinksFromBody(body, relativePath) { + const links = []; + const markdownPattern = /!?\[([^\]]*)\]\(([^)]+)\)/gu; + let ordinal = 0; + for (const match of String(body ?? "").matchAll(markdownPattern)) { + ordinal += 1; + const label = String(match[1] ?? "").trim(); + const href = cleanMarkdownHref(match[2]); + const target = markdownReportTarget(relativePath, href); + links.push({ + linkId: `lnk_${shortHash(`${relativePath}\n${href}\n${ordinal}`, 12)}`, + label: label || href, + href, + kind: target ? "markdown-report" : linkKind(href), + relativePath: target, + ordinal, + valuesRedacted: true + }); + } + + for (const match of String(body ?? "").matchAll(/https?:\/\/\S+/giu)) { + ordinal += 1; + const href = String(match[0] ?? "").replace(/[),.;]+$/u, ""); + links.push({ + linkId: `lnk_${shortHash(`${relativePath}\n${href}\n${ordinal}`, 12)}`, + label: href, + href, + kind: "external", + relativePath: null, + ordinal, + valuesRedacted: true + }); + } + + return links; +} + +function cleanMarkdownHref(value) { + const text = String(value ?? "").trim().replace(/^<|>$/gu, ""); + return text.split(/\s+/u)[0] ?? ""; +} + +function markdownReportTarget(relativePath, href) { + const raw = String(href ?? "").trim(); + if (!raw || raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/iu.test(raw) || raw.startsWith("/") || /^[A-Za-z]:\//u.test(raw)) return null; + const withoutFragment = raw.split("#")[0]?.trim() ?? ""; + if (!withoutFragment.toLowerCase().endsWith(".md")) return null; + const directory = path.posix.dirname(normalizeRelativePath(relativePath)); + const normalized = path.posix.normalize(path.posix.join(directory === "." ? "" : directory, withoutFragment)); + if (!normalized || normalized === "." || normalized.startsWith("../") || normalized === ".." || normalized.includes("\0")) return null; + return normalized; +} + +function linkKind(href) { + if (/^https?:\/\//iu.test(String(href ?? ""))) return "external"; + if (String(href ?? "").startsWith("#")) return "anchor"; + return "file"; +} + +function taskBodyFromLines(lines, block) { + return lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim(); +} + function bodyPreviewFromLines(lines, start, end) { for (const line of lines.slice(start, end)) { const text = String(line ?? "").trim(); diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index 9bc0f57e..d4c6cf89 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -108,12 +108,16 @@ test("project management app configures HWPOD source and writes MDTODO content", const hwpodRoot = join(root, "hwpod-workspace"); const mdtodoDir = join(hwpodRoot, "docs", "MDTODO"); const samplePath = join(mdtodoDir, "hwlab-v03-mdtodo-web-sample.md"); + const reportDir = join(mdtodoDir, "20260110_LOG"); + const reportPath = join(reportDir, "R1.1_log_implementation.md"); const store = createProjectManagementStore({ kind: "memory" }); try { await mkdir(defaultRoot, { recursive: true }); await mkdir(mdtodoDir, { recursive: true }); + await mkdir(reportDir, { recursive: true }); await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8"); - await writeFile(samplePath, "# Sample\n\n## R1 Root\n\n### R1.1 Child [in_progress]\n", "utf8"); + await writeFile(samplePath, "# Sample\n\n## R1 Root\n\nSee [R1.1](./20260110_LOG/R1.1_log_implementation.md).\n\n### R1.1 Child [in_progress]\n", "utf8"); + await writeFile(reportPath, "# R1.1 implementation report\n\nReport body.", "utf8"); const app = createProjectManagementApp({ env: {}, store, @@ -148,12 +152,20 @@ test("project management app configures HWPOD source and writes MDTODO content", expect(reindex.projection.taskCount).toBe(2); const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=hwpod-mdtodo"); + expect(files.files).toHaveLength(1); expect(files.files[0].relativePath).toBe("hwlab-v03-mdtodo-web-sample.md"); const fileRef = files.files[0].fileRef; const content = await json(app, `/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`); expect(content.file.content).toContain("## R1 Root"); + const rootTaskRef = `mdtodo:hwpod-mdtodo:${fileRef}:R1`; + const detail = await json(app, `/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(rootTaskRef)}`); + expect(detail.detail.body).toContain("See [R1.1]"); + expect(detail.detail.links[0]).toMatchObject({ kind: "markdown-report", relativePath: "20260110_LOG/R1.1_log_implementation.md" }); + const report = await json(app, `/v1/project-management/mdtodo/report-preview?taskRef=${encodeURIComponent(rootTaskRef)}&linkId=${encodeURIComponent(detail.detail.links[0].linkId)}`); + expect(report.report.content).toContain("Report body."); + const nextContent = "# Sample\n\n## R1 Edited root\n\n### R1.1 Child [completed]\n"; const write = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`, { method: "PATCH", @@ -170,7 +182,6 @@ test("project management app configures HWPOD source and writes MDTODO content", ["R1.1", "Child", "done"] ]); - const rootTaskRef = `mdtodo:hwpod-mdtodo:${fileRef}:R1`; const updateTask = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(rootTaskRef)}`, { method: "PATCH", body: { expectedFingerprint: written.file.fingerprint, title: "Root from task API", status: "in_progress", body: "Task API body." } diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts index da17c44a..106566d3 100644 --- a/internal/project-management/server.ts +++ b/internal/project-management/server.ts @@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; -import { mutateMdtodoDocument } from "./mdtodo.ts"; +import { mutateMdtodoDocument, readMdtodoTaskDetail } from "./mdtodo.ts"; import { createMdtodoSourceAdapter, normalizeProjectSourceInput, sourceProjection } from "./source-adapter.ts"; const contractVersion = "project-management-v1"; @@ -147,6 +147,12 @@ export function createProjectManagementApp(options = {}) { if (fileContent) { return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef); } + if (url.pathname === "/v1/project-management/mdtodo/task-detail") { + return await handleReadMdtodoTaskDetail(url, store, sourceAdapter); + } + if (url.pathname === "/v1/project-management/mdtodo/report-preview") { + return await handleReadMdtodoReportPreview(url, store, sourceAdapter); + } if (url.pathname === "/v1/project-management/mdtodo/tasks") { const taskFilters = { sourceId: queryToken(url, "sourceId"), @@ -377,6 +383,61 @@ async function handleReadMdtodoFile(url, store, sourceAdapter, fileRef) { return json(200, envelope({ file })); } +async function handleReadMdtodoTaskDetail(url, store, sourceAdapter) { + const taskRef = queryOpaque(url, "taskRef"); + if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required"); + const detail = await loadTaskDetail({ store, sourceAdapter, taskRef }); + if (detail.error) return detail.error; + return json(200, envelope({ + task: { ...detail.task, bodyPreview: detail.detail.bodyPreview, linkCount: detail.detail.links.length }, + detail: detail.detail, + file: fileSummary(detail.file) + })); +} + +async function handleReadMdtodoReportPreview(url, store, sourceAdapter) { + const taskRef = queryOpaque(url, "taskRef"); + const linkId = queryToken(url, "linkId"); + if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required"); + if (!linkId) return jsonError(400, "link_id_required", "linkId is required"); + const detail = await loadTaskDetail({ store, sourceAdapter, taskRef }); + if (detail.error) return detail.error; + const link = detail.detail.links.find((item) => item.linkId === linkId); + if (!link) return jsonError(404, "report_link_not_found", "MDTODO report link was not found"); + if (link.kind !== "markdown-report" || !link.relativePath) return jsonError(400, "unsupported_report_link", "Only safe relative Markdown report links can be previewed"); + const report = await sourceAdapter.readLinkedMarkdown(detail.source, detail.document.relativePath, link.href); + return json(200, envelope({ + task: detail.task, + link, + report: { + ...report, + linkId: link.linkId, + label: link.label, + href: link.href, + valuesRedacted: false + }, + file: fileSummary(detail.file) + })); +} + +async function loadTaskDetail({ store, sourceAdapter, taskRef }) { + const task = await getTaskByRef(store, taskRef); + if (!task) return { error: jsonError(404, "task_not_found", "MDTODO task was not found") }; + const { source, document, error } = await resolveMdtodoDocument(store, task.sourceId, task.fileRef); + if (error) return { error }; + const file = await sourceAdapter.readFile(source, document.relativePath); + const detail = readMdtodoTaskDetail(file.content, { + sourceId: source.sourceId, + fileRef: document.fileRef, + relativePath: document.relativePath, + projectId: document.projectId, + taskRef: task.taskRef, + rxxId: task.taskId + }); + if (!detail) return { error: jsonError(404, "task_detail_not_found", "MDTODO task detail was not found in source Markdown") }; + return { source, document, file, task, detail }; +} + async function handleWriteMdtodoFile(request, url, store, sourceAdapter, fileRef, actor) { const body = await readJsonObject(request, 1024 * 1024 + 4096); if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message); @@ -584,7 +645,7 @@ function matchTaskAction(pathname) { function safeOpaqueValue(value) { const text = String(value ?? "").trim(); - return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null; + return /^[A-Za-z0-9_.:/#_-]{1,360}$/u.test(text) ? text : null; } function safeWorkbenchUrl(value) { @@ -618,7 +679,7 @@ function queryToken(url, name) { function queryOpaque(url, name) { const value = url.searchParams.get(name); - return /^[A-Za-z0-9_.:/#-]{1,320}$/u.test(String(value ?? "")) ? value : null; + return /^[A-Za-z0-9_.:/#_-]{1,320}$/u.test(String(value ?? "")) ? value : null; } function querySearch(url, name) { diff --git a/internal/project-management/source-adapter.ts b/internal/project-management/source-adapter.ts index 89eb1d7e..c3139c0e 100644 --- a/internal/project-management/source-adapter.ts +++ b/internal/project-management/source-adapter.ts @@ -1,5 +1,6 @@ /** - * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source. + * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source; + * draft-2026-06-26-p1-mdtodo-web-operable. * Owns MDTODO Source normalization and bounded file access for local and HWPOD workspace sources. */ import { createHash, randomUUID } from "node:crypto"; @@ -40,6 +41,14 @@ export function createMdtodoSourceAdapter(options = {}) { return fileContentPayload(source, safePath, String(content ?? "")); }, + async readLinkedMarkdown(source, documentRelativePath, href) { + const safePath = normalizeLinkedMarkdownPath(documentRelativePath, href); + const content = source.sourceKind === "hwpod-workspace" + ? (await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.cat", { path: hwpodWorkspacePath(source, safePath), maxBytes: maxContentBytes })).content + : await readBoundedLocalMarkdown(source, safePath); + return linkedMarkdownPayload(source, safePath, String(content ?? "")); + }, + async writeFile(source, relativePath, content, expectedFingerprint) { const safePath = normalizeMdtodoRelativePath(relativePath); const nextContent = String(content ?? ""); @@ -145,22 +154,15 @@ async function discoverHwpodDocuments(hwpodNodeOpsHandler, source) { async function listHwpodMarkdownFiles(hwpodNodeOpsHandler, source) { const root = normalizeDirectoryRef(source.mdtodoRootRef ?? "."); const files = []; - async function walk(relativeDir) { - if (files.length >= source.maxFiles) return; - const workspacePath = relativeDir ? joinRelative(root, relativeDir, { allowDirectory: true }) : root; - const output = await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.ls", { path: workspacePath }); - const entries = Array.isArray(output.entries) ? output.entries : []; - entries.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""))); - for (const entry of entries) { - const name = String(entry.name ?? ""); - if (!name || isHiddenOrSecretSegment(name)) continue; - const child = relativeDir ? `${relativeDir}/${name}` : name; - if (entry.type === "dir") await walk(child); - else if (entry.type === "file" && name.toLowerCase().endsWith(".md")) files.push(normalizeMdtodoRelativePath(child)); - if (files.length >= source.maxFiles) return; - } + const output = await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.ls", { path: root }); + const entries = Array.isArray(output.entries) ? output.entries : []; + entries.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""))); + for (const entry of entries) { + const name = String(entry.name ?? ""); + if (!name || isHiddenOrSecretSegment(name)) continue; + if (entry.type === "file" && name.toLowerCase().endsWith(".md")) files.push(normalizeMdtodoRelativePath(name)); + if (files.length >= source.maxFiles) break; } - await walk(""); return files; } @@ -199,6 +201,20 @@ function fileContentPayload(source, relativePath, content) { }; } +function linkedMarkdownPayload(source, relativePath, content) { + const fingerprint = sha256(content); + return { + sourceId: source.sourceId, + relativePath, + content, + fingerprint, + revision: fingerprint, + byteCount: Buffer.byteLength(content, "utf8"), + render: "markdown", + valuesRedacted: false + }; +} + function localRootForSource(source) { return path.resolve(String(source.rootRef ?? process.cwd())); } @@ -210,6 +226,13 @@ function localFilePathForSource(source, relativePath) { return target; } +async function readBoundedLocalMarkdown(source, relativePath) { + const target = localFilePathForSource(source, relativePath); + const stats = await fs.stat(target); + if (stats.size > maxContentBytes) throw sourceError("mdtodo_content_too_large", "Markdown report content is too large", 413); + return fs.readFile(target, "utf8"); +} + function hwpodWorkspacePath(source, relativePath) { return joinRelative(normalizeDirectoryRef(source.mdtodoRootRef ?? "."), normalizeMdtodoRelativePath(relativePath)); } @@ -220,6 +243,15 @@ function normalizeMdtodoRelativePath(value) { return normalized; } +function normalizeLinkedMarkdownPath(documentRelativePath, href) { + const raw = String(href ?? "").trim(); + if (!raw || raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/iu.test(raw) || raw.startsWith("/") || /^[A-Za-z]:\//u.test(raw)) failPath(); + const withoutFragment = raw.split("#")[0]?.trim() ?? ""; + const directory = path.posix.dirname(normalizeMdtodoRelativePath(documentRelativePath)); + const normalized = path.posix.normalize(path.posix.join(directory === "." ? "" : directory, withoutFragment)); + return normalizeMdtodoRelativePath(normalized); +} + function normalizeDirectoryRef(value) { return normalizeRelativeSegments(value || ".", { allowDirectory: true }); } diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 655fdd04..4dd9c1bb 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -347,6 +347,8 @@ async function projectManagementPayload(request: IncomingMessage, response: Serv if (path === "/v1/project-management/projects") return json(response, 200, projectManagementEnvelope({ projects: [{ projectId: "project_hwlab_v03", name: "HWLAB MDTODO", sourceIds: [source.sourceId], sourceOfTruth: "markdown-files", status: "active" }] })); if (path === "/v1/project-management/mdtodo/sources") return json(response, 200, projectManagementEnvelope({ sources: [source] })); if (path === "/v1/project-management/mdtodo/files") return json(response, 200, projectManagementEnvelope({ files: files.filter((file) => !url.searchParams.get("sourceId") || file.sourceId === url.searchParams.get("sourceId")) })); + if (path === "/v1/project-management/mdtodo/task-detail") return projectManagementTaskDetail(response, url); + if (path === "/v1/project-management/mdtodo/report-preview") return projectManagementReportPreview(response, url); if (path === "/v1/project-management/mdtodo/tasks") return json(response, 200, projectManagementEnvelope({ tasks: tasks.filter((task) => (!url.searchParams.get("sourceId") || task.sourceId === url.searchParams.get("sourceId")) && (!url.searchParams.get("fileRef") || task.fileRef === url.searchParams.get("fileRef"))) })); if (path === "/v1/project-management/workbench-links") return json(response, 200, projectManagementEnvelope({ links: links.filter((link) => !url.searchParams.get("taskRef") || link.taskRef === url.searchParams.get("taskRef")) })); return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "not_found" }, valuesRedacted: true }); @@ -396,13 +398,80 @@ function projectManagementSeedTasks(links: JsonRecord[] = []): JsonRecord[] { const r111 = "mdtodo:hwlab-v03-mdtodo:file_plan:R1.1.1"; const r2 = "mdtodo:hwlab-v03-mdtodo:file_rollout:R2"; return [ - { taskRef: r1, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: linkCount(r1), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Root notes", updatedAt: "2026-06-25T09:00:00.000Z" }, - { taskRef: r11, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: r1, depth: 1, lineNumber: 8, ordinal: 2, linkCount: linkCount(r11), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Detail body", updatedAt: "2026-06-25T09:00:00.000Z" }, - { taskRef: r111, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1.1", title: "Source 顶部控制", status: "in_progress", parentTaskRef: r11, depth: 2, lineNumber: 12, ordinal: 3, linkCount: linkCount(r111), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Toolbar body", updatedAt: "2026-06-25T09:00:00.000Z" }, - { taskRef: r2, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "R2", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 4, linkCount: linkCount(r2), sourceFingerprint: "sha256:e2e-rollout", bodyPreview: "Rollout body", updatedAt: "2026-06-25T09:00:00.000Z" } + { taskRef: r1, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: linkCount(r1), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Root notes", body: "Root notes\n\n参考报告 [R1.1](./20260110_LOG/R1.1_log_implementation.md)", updatedAt: "2026-06-25T09:00:00.000Z" }, + { taskRef: r11, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: r1, depth: 1, lineNumber: 8, ordinal: 2, linkCount: linkCount(r11), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Detail body", body: "Detail body", updatedAt: "2026-06-25T09:00:00.000Z" }, + { taskRef: r111, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1.1", title: "Source 顶部控制", status: "in_progress", parentTaskRef: r11, depth: 2, lineNumber: 12, ordinal: 3, linkCount: linkCount(r111), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Toolbar body", body: "Toolbar body", updatedAt: "2026-06-25T09:00:00.000Z" }, + { taskRef: r2, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "R2", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 4, linkCount: linkCount(r2), sourceFingerprint: "sha256:e2e-rollout", bodyPreview: "Rollout body", body: "Rollout body", updatedAt: "2026-06-25T09:00:00.000Z" } ]; } +function projectManagementTaskDetail(response: ServerResponse, url: URL): void { + const taskRef = url.searchParams.get("taskRef"); + const task = state.projectTasks.find((item) => item.taskRef === taskRef); + if (!task) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "task_not_found" }, valuesRedacted: true }); + const body = String(task.body ?? task.bodyPreview ?? ""); + const links = projectManagementTaskLinks(task, body); + return json(response, 200, projectManagementEnvelope({ + task: { ...task, bodyPreview: body.slice(0, 240), linkCount: links.length }, + detail: { + taskRef: task.taskRef, + taskId: task.taskId, + rxxId: task.taskId, + projectId: task.projectId, + sourceId: task.sourceId, + fileRef: task.fileRef, + relativePath: projectFileForTask(task).relativePath, + title: task.title, + status: task.status, + body, + bodyPreview: body.slice(0, 240), + links, + valuesRedacted: false + }, + file: projectFileForTask(task) + })); +} + +function projectManagementReportPreview(response: ServerResponse, url: URL): void { + const taskRef = url.searchParams.get("taskRef"); + const linkId = url.searchParams.get("linkId"); + const task = state.projectTasks.find((item) => item.taskRef === taskRef); + if (!task) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "task_not_found" }, valuesRedacted: true }); + const link = projectManagementTaskLinks(task, String(task.body ?? task.bodyPreview ?? "")).find((item) => item.linkId === linkId); + if (!link) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "report_link_not_found" }, valuesRedacted: true }); + return json(response, 200, projectManagementEnvelope({ + task, + link, + report: { + sourceId: task.sourceId, + relativePath: link.relativePath, + content: "# R1.1 implementation report\n\nReport body from fake server.", + fingerprint: "sha256:e2e-report", + revision: "sha256:e2e-report", + byteCount: 56, + render: "markdown", + linkId: link.linkId, + label: link.label, + href: link.href, + valuesRedacted: false + }, + file: projectFileForTask(task) + })); +} + +function projectManagementTaskLinks(task: JsonRecord, body: string): JsonRecord[] { + if (String(task.taskId) !== "R1" || !body.includes("R1.1_log_implementation.md")) return []; + return [{ + linkId: "lnk_r11_report", + label: "R1.1", + href: "./20260110_LOG/R1.1_log_implementation.md", + kind: "markdown-report", + relativePath: "20260110_LOG/R1.1_log_implementation.md", + ordinal: 1, + valuesRedacted: true + }]; +} + function projectManagementUpdateTask(response: ServerResponse, taskRef: string, body: JsonRecord): void { const task = state.projectTasks.find((item) => item.taskRef === taskRef); if (!task) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "task_not_found" }, valuesRedacted: true }); @@ -410,7 +479,10 @@ function projectManagementUpdateTask(response: ServerResponse, taskRef: string, if (!projectManagementExpectedFingerprintOk(response, file, body)) return; if (typeof body.title === "string" && body.title.trim()) task.title = body.title.trim(); if (typeof body.status === "string" && body.status.trim()) task.status = body.status.trim(); - if (typeof body.body === "string") task.bodyPreview = body.body.slice(0, 240); + if (typeof body.body === "string") { + task.body = body.body; + task.bodyPreview = body.body.slice(0, 240); + } task.updatedAt = new Date().toISOString(); touchProjectFile(String(task.fileRef)); return json(response, 200, projectManagementEnvelope({ task, file: projectFileForTask(task), changedTaskId: task.taskId, affectedTaskIds: [task.taskId] })); @@ -429,7 +501,7 @@ function projectManagementCreateTask(response: ServerResponse, body: JsonRecord) const taskId = parent ? nextProjectChildTaskId(fileRef, String(parent.taskId)) : after ? nextProjectSiblingTaskId(fileRef, String(after.taskId)) : nextProjectRootTaskId(fileRef); const taskRef = `mdtodo:${sourceId}:${fileRef}:${taskId}`; const parentTaskRef = parent?.taskRef ?? after?.parentTaskRef ?? null; - const task = { taskRef, projectId: "project_hwlab_v03", sourceId, fileRef, taskId, title, status: String(body.status ?? "open"), parentTaskRef, depth: taskId.split(".").length - 1, lineNumber: 20 + state.projectTasks.length, ordinal: state.projectTasks.length + 1, linkCount: 0, sourceFingerprint: file.fingerprint, bodyPreview: typeof body.body === "string" ? body.body.slice(0, 240) : "", updatedAt: new Date().toISOString() }; + const task = { taskRef, projectId: "project_hwlab_v03", sourceId, fileRef, taskId, title, status: String(body.status ?? "open"), parentTaskRef, depth: taskId.split(".").length - 1, lineNumber: 20 + state.projectTasks.length, ordinal: state.projectTasks.length + 1, linkCount: 0, sourceFingerprint: file.fingerprint, body: typeof body.body === "string" ? body.body : "", bodyPreview: typeof body.body === "string" ? body.body.slice(0, 240) : "", updatedAt: new Date().toISOString() }; state.projectTasks.push(task); touchProjectFile(fileRef); return json(response, 201, projectManagementEnvelope({ task, file: projectFileForTask(task), changedTaskId: taskId, affectedTaskIds: [taskId] })); diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index e7e4add0..db821604 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. +// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-26-p1-mdtodo-web-operable. // Responsibility: Cloud Web API barrel, including project-management public API exports. export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client"; @@ -14,7 +14,7 @@ export { apiKeysAPI } from "./apiKeys"; export { usageAPI } from "./usage"; export { billingAPI } from "./billing"; export { systemAPI, type SkillUploadFileInput } from "./system"; -export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; +export { projectManagementAPI, type MdtodoFileRecord, type MdtodoReportPreviewRecord, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; import { fetchJson } from "./client"; import { accessAPI } from "./access"; diff --git a/web/hwlab-cloud-web/src/api/projectManagement.ts b/web/hwlab-cloud-web/src/api/projectManagement.ts index 32609565..d1227b05 100644 --- a/web/hwlab-cloud-web/src/api/projectManagement.ts +++ b/web/hwlab-cloud-web/src/api/projectManagement.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. +// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-26-p1-mdtodo-web-operable. // Responsibility: Cloud Web client for the public project-management same-origin API. import { fetchJson } from "./client"; @@ -117,6 +117,49 @@ export interface MdtodoTaskRecord { updatedAt?: string; } +export interface MdtodoTaskLinkRecord { + linkId: string; + label?: string; + href?: string; + kind?: string; + relativePath?: string | null; + ordinal?: number; + valuesRedacted?: boolean; +} + +export interface MdtodoTaskDetailRecord { + taskRef: string; + taskId?: string; + rxxId?: string; + projectId?: string; + sourceId?: string; + fileRef?: string; + relativePath?: string; + title?: string; + status?: string; + lineNumber?: number; + headingLevel?: number; + statusMarker?: string | null; + body?: string; + bodyPreview?: string; + links?: MdtodoTaskLinkRecord[]; + valuesRedacted?: boolean; +} + +export interface MdtodoReportPreviewRecord { + sourceId?: string; + relativePath?: string; + content?: string; + fingerprint?: string; + revision?: string; + byteCount?: number; + render?: string; + linkId?: string; + label?: string; + href?: string; + valuesRedacted?: boolean; +} + export interface ProjectWorkbenchLinkRecord { linkId: string; projectId?: string; @@ -143,6 +186,8 @@ export interface MdtodoTaskPage { hasMore?: boolean; } export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[]; page?: MdtodoTaskPage } +export interface MdtodoTaskDetailResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; detail?: MdtodoTaskDetailRecord; file?: MdtodoFileRecord } +export interface MdtodoReportPreviewResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; link?: MdtodoTaskLinkRecord; report?: MdtodoReportPreviewRecord; file?: MdtodoFileRecord } export interface MdtodoTaskMutationInput { sourceId?: string; fileRef?: string; @@ -190,6 +235,8 @@ export const projectManagementAPI = { reindexSource: (sourceId: string): Promise> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/reindex`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source reindex" }), files: (sourceId?: string | null): Promise> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }), tasks: (query: MdtodoTaskQuery = {}): Promise> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId, parentTaskRef: query.parentTaskRef, status: query.status, search: query.search, limit: query.limit, offset: query.offset })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }), + taskDetail: (taskRef: string): Promise> => fetchJson(`/v1/project-management/mdtodo/task-detail${queryString({ taskRef })}`, { timeoutMs: 12000, timeoutName: "project mdtodo task detail" }), + reportPreview: (taskRef: string, linkId: string): Promise> => fetchJson(`/v1/project-management/mdtodo/report-preview${queryString({ taskRef, linkId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo report preview" }), updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "PATCH", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task update" }), createTask: (input: MdtodoTaskMutationInput): Promise> => fetchJson("/v1/project-management/mdtodo/tasks", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task create" }), deleteTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "DELETE", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task delete" }), diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index 38e25123..06fdeecf 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -49,9 +49,12 @@ export interface WorkbenchLaunchContext { taskRef: string; sourceId?: string | null; fileRef?: string | null; + fileName?: string | null; taskId?: string | null; title?: string | null; status?: string | null; + bodyPreview?: string | null; + reportLinks?: Array<{ linkId?: string; label?: string; href?: string; relativePath?: string | null }>; } export interface WorkbenchLaunchRequest { diff --git a/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue b/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue index a38ffb57..1eb38bb1 100644 --- a/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue +++ b/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue @@ -1,10 +1,12 @@ - +