feat: make mdtodo web tasks operable

This commit is contained in:
root
2026-06-26 12:21:21 +00:00
parent 1d636ba0d2
commit 5b5f0fe2a3
11 changed files with 662 additions and 93 deletions
+31 -1
View File
@@ -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
+114 -31
View File
@@ -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();
+13 -2
View File
@@ -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." }
+64 -3
View File
@@ -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) {
+48 -16
View File
@@ -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 });
}
@@ -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] }));
+2 -2
View File
@@ -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";
@@ -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<ApiResult<ProjectSourceReindexResponse>> => 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<ApiResult<MdtodoFileListResponse>> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }),
tasks: (query: MdtodoTaskQuery = {}): Promise<ApiResult<MdtodoTaskListResponse>> => 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<ApiResult<MdtodoTaskDetailResponse>> => fetchJson(`/v1/project-management/mdtodo/task-detail${queryString({ taskRef })}`, { timeoutMs: 12000, timeoutName: "project mdtodo task detail" }),
reportPreview: (taskRef: string, linkId: string): Promise<ApiResult<MdtodoReportPreviewResponse>> => fetchJson(`/v1/project-management/mdtodo/report-preview${queryString({ taskRef, linkId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo report preview" }),
updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => 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<ApiResult<MdtodoTaskMutationResponse>> => 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<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "DELETE", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task delete" }),
+3
View File
@@ -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 {
@@ -1,10 +1,12 @@
<!-- 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: MDTODO project page consuming public ProjectTask DTOs and Workbench launch DTOs without Workbench or Markdown internals. -->
<script setup lang="ts">
import DOMPurify from "dompurify";
import { marked } from "marked";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
import { agentAPI, projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoReportPreviewRecord, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
@@ -13,7 +15,9 @@ import type { ApiError, ErrorDiagnostic } from "@/types";
const router = useRouter();
const loading = ref(false);
const taskLoading = ref(false);
const taskDetailLoading = ref(false);
const linkLoading = ref(false);
const reportLoading = ref(false);
const launchLoading = ref(false);
const taskMutationLoading = ref(false);
const sourceSaving = ref(false);
@@ -21,9 +25,12 @@ const sourceProbeLoading = ref(false);
const sourceReindexLoading = ref(false);
const showInfo = ref(false);
const showSourceConfig = ref(false);
const showReportFullscreen = ref(false);
const launchError = ref<string | null>(null);
const taskMutationError = ref<string | null>(null);
const taskMutationMessage = ref<string | null>(null);
const taskDetailError = ref<string | null>(null);
const reportError = ref<string | null>(null);
const sourceMessage = ref<string | null>(null);
const sourceError = ref<string | null>(null);
const error = ref<string | null>(null);
@@ -35,12 +42,17 @@ const files = ref<MdtodoFileRecord[]>([]);
const tasks = ref<MdtodoTaskRecord[]>([]);
const taskPage = ref<MdtodoTaskPage | null>(null);
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
const taskDetail = ref<MdtodoTaskDetailRecord | null>(null);
const reportPreview = ref<MdtodoReportPreviewRecord | null>(null);
const activeReportLink = ref<MdtodoTaskLinkRecord | null>(null);
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
const selectedTaskRef = ref<string | null>(null);
const taskSearch = ref("");
const taskStatusFilter = ref("all");
const collapsedTaskRefs = ref<Set<string>>(new Set());
const editingTitle = ref(false);
const editingBody = ref(false);
const editTitle = ref("");
const editStatus = ref("open");
const editBody = ref("");
@@ -105,6 +117,11 @@ const visibleTaskRows = computed(() => {
const workbenchLaunchEnabled = computed(() => navigation.value?.navigation?.capabilities?.workbenchLaunch === true);
const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLaunchEnabled.value || launchLoading.value);
const selectedSourceCanReindex = computed(() => Boolean(selectedSourceId.value && !taskLoading.value && !sourceReindexLoading.value));
const selectedTaskBody = computed(() => taskDetail.value?.body ?? selectedTask.value?.bodyPreview ?? "");
const selectedTaskLinks = computed(() => taskDetail.value?.links ?? []);
const selectedTaskBodyHtml = computed(() => markdownToHtml(selectedTaskBody.value));
const reportPreviewHtml = computed(() => markdownToHtml(reportPreview.value?.content ?? ""));
const selectedFileName = computed(() => selectedFile.value ? fileDisplayName(selectedFile.value) : selectedTask.value?.fileRef || "-");
onMounted(() => void loadPage());
@@ -114,11 +131,15 @@ watch(selectedSourceId, async (sourceId) => {
});
watch(selectedTaskRef, async (taskRef) => {
await loadLinks(taskRef);
await Promise.all([loadLinks(taskRef), loadTaskDetail(taskRef)]);
});
watch(selectedTask, (task) => resetTaskEditor(task), { immediate: true });
watch(taskDetail, (detail) => {
if (detail?.taskRef === selectedTaskRef.value && !editingBody.value) editBody.value = detail.body || "";
});
async function loadPage(): Promise<void> {
loading.value = true;
error.value = null;
@@ -168,6 +189,28 @@ async function loadLinks(taskRef: string | null): Promise<void> {
}
}
async function loadTaskDetail(taskRef: string | null): Promise<void> {
taskDetailError.value = null;
reportError.value = null;
reportPreview.value = null;
activeReportLink.value = null;
if (!taskRef) {
taskDetail.value = null;
return;
}
taskDetailLoading.value = true;
try {
const response = await projectManagementAPI.taskDetail(taskRef);
if (!response.ok) throw response;
taskDetail.value = response.data?.detail ?? null;
} catch (err) {
taskDetail.value = null;
taskDetailError.value = projectApiError(err).error;
} finally {
taskDetailLoading.value = false;
}
}
function onSourceSelect(event: Event): void {
selectedSourceId.value = (event.target as HTMLSelectElement).value || null;
}
@@ -288,12 +331,14 @@ async function saveTaskBasics(): Promise<void> {
return;
}
await runTaskMutation(() => projectManagementAPI.updateTask(task.taskRef, input), task.taskRef, "任务已保存");
if (!taskMutationError.value) editingTitle.value = false;
}
async function saveTaskBody(): Promise<void> {
const task = selectedTask.value;
if (!task) return;
await runTaskMutation(() => projectManagementAPI.updateTask(task.taskRef, { expectedFingerprint: taskFingerprint(), body: editBody.value }), task.taskRef, "正文已保存");
if (!taskMutationError.value) editingBody.value = false;
}
async function createTask(kind: "root" | "subtask" | "continue"): Promise<void> {
@@ -333,6 +378,45 @@ async function deleteSelectedTask(): Promise<void> {
deleteConfirm.value = false;
}
function beginTitleEdit(): void {
if (!selectedTask.value || taskMutationLoading.value) return;
editTitle.value = selectedTask.value.title || "";
editingTitle.value = true;
}
function cancelTitleEdit(): void {
editTitle.value = selectedTask.value?.title || "";
editingTitle.value = false;
}
function beginBodyEdit(): void {
if (!selectedTask.value || taskMutationLoading.value) return;
editBody.value = selectedTaskBody.value;
editingBody.value = true;
}
function cancelBodyEdit(): void {
editBody.value = selectedTaskBody.value;
editingBody.value = false;
}
async function openReportPreview(link: MdtodoTaskLinkRecord): Promise<void> {
const taskRef = selectedTaskRef.value;
if (!taskRef || !link.linkId || link.kind !== "markdown-report") return;
reportLoading.value = true;
reportError.value = null;
try {
const response = await projectManagementAPI.reportPreview(taskRef, link.linkId);
if (!response.ok) throw response;
reportPreview.value = response.data?.report ?? null;
activeReportLink.value = response.data?.link ?? link;
} catch (err) {
reportError.value = projectApiError(err).error;
} finally {
reportLoading.value = false;
}
}
async function runTaskMutation(action: () => ReturnType<typeof projectManagementAPI.updateTask>, preferredTaskRef: string | undefined, message: string): Promise<void> {
const sourceId = selectedSourceId.value;
if (!sourceId) return;
@@ -351,6 +435,7 @@ async function runTaskMutation(action: () => ReturnType<typeof projectManagement
const nextTaskRef = response.data?.task?.taskRef || preferredTaskRef;
await loadFilesAndTasks(sourceId);
if (nextTaskRef && tasks.value.some((task) => task.taskRef === nextTaskRef)) selectedTaskRef.value = nextTaskRef;
if (selectedTaskRef.value) await Promise.all([loadLinks(selectedTaskRef.value), loadTaskDetail(selectedTaskRef.value)]);
taskMutationMessage.value = message;
} catch (err) {
taskMutationError.value = projectApiError(err).error;
@@ -367,7 +452,7 @@ async function loadTaskWindow(sourceId: string, fileRef: string | null, preferre
const preferredTask = preferredTaskRef ? tasks.value.find((task) => task.taskRef === preferredTaskRef) : null;
selectedTaskRef.value = preferredTask?.taskRef ?? tasks.value[0]?.taskRef ?? null;
collapsedTaskRefs.value = new Set();
if (!selectedTaskRef.value) await loadLinks(null);
if (!selectedTaskRef.value) await Promise.all([loadLinks(null), loadTaskDetail(null)]);
}
function taskFingerprint(): string | undefined {
@@ -375,9 +460,11 @@ function taskFingerprint(): string | undefined {
}
function resetTaskEditor(task: MdtodoTaskRecord | null): void {
editingTitle.value = false;
editingBody.value = false;
editTitle.value = task?.title || "";
editStatus.value = task?.status || "open";
editBody.value = "";
editBody.value = taskDetail.value?.taskRef === task?.taskRef ? taskDetail.value?.body || "" : "";
deleteConfirm.value = false;
}
@@ -387,21 +474,39 @@ async function launchSelectedTask(): Promise<void> {
launchLoading.value = true;
launchError.value = null;
try {
const prompt = buildWorkbenchPrompt(task);
const launchContext = {
source: "mdtodo-page",
projectId: task.projectId,
taskRef: task.taskRef,
sourceId: task.sourceId,
fileRef: task.fileRef,
fileName: selectedFileName.value,
taskId: task.taskId,
title: task.title,
status: task.status,
bodyPreview: selectedTaskBody.value.slice(0, 800),
reportLinks: selectedTaskLinks.value.filter((link) => link.kind === "markdown-report").map((link) => ({ linkId: link.linkId, label: link.label, href: link.href, relativePath: link.relativePath }))
};
const response = await workbenchAPI.launch({
projectId: task.projectId,
taskRef: task.taskRef,
launchContext: {
source: "mdtodo-page",
projectId: task.projectId,
taskRef: task.taskRef,
sourceId: task.sourceId,
fileRef: task.fileRef,
taskId: task.taskId,
title: task.title,
status: task.status
}
providerProfile: "codex",
launchContext
});
if (!response.ok) throw response;
if (response.data?.sessionId) {
const chat = await agentAPI.sendAgentMessage({
message: prompt,
prompt,
sessionId: response.data.sessionId,
providerProfile: "codex",
projectId: task.projectId,
taskRef: task.taskRef,
launchContext
}, 120000);
if (!chat.ok) throw chat;
}
await loadLinks(task.taskRef);
const target = response.data?.workbenchUrl || (response.data?.sessionId ? `/workbench/sessions/${encodeURIComponent(response.data.sessionId)}` : null);
if (target) await router.push(target);
@@ -427,6 +532,38 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
return { error: err instanceof Error ? err.message : String(err || "MDTODO 加载失败"), apiError: null, diagnostic: null };
}
function buildWorkbenchPrompt(task: MdtodoTaskRecord): string {
const reportLines = selectedTaskLinks.value
.filter((link) => link.kind === "markdown-report")
.map((link) => `- ${link.label || link.href}: ${link.relativePath || link.href}`)
.join("\n");
return [
"请基于以下 MDTODO 任务开始工作,先读取相关文件和报告,再给出下一步执行结果。",
"",
`Task: ${task.taskId || task.taskRef} ${task.title || ""}`.trim(),
`TaskRef: ${task.taskRef}`,
`File: ${selectedFile.value?.relativePath || task.fileRef || "-"}`,
`Status: ${task.status || "open"}`,
"",
"任务正文:",
selectedTaskBody.value.trim() || "(无正文)",
"",
"相关报告链接:",
reportLines || "(无)"
].join("\n");
}
function markdownToHtml(value?: string | null): string {
const text = String(value ?? "").trim();
if (!text) return "";
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string);
}
function fileDisplayName(file: MdtodoFileRecord): string {
const path = file.relativePath || file.fileRef || "-";
return path.split("/").filter(Boolean).at(-1) || path;
}
function statusLabel(value?: string | null): string {
if (value === "done") return "完成";
if (value === "blocked") return "阻塞";
@@ -476,7 +613,7 @@ function displayDate(value?: string | null): string {
<label class="toolbar-field" data-testid="mdtodo-file-control">
<span>File</span>
<select data-testid="mdtodo-file-select" :value="selectedFileRef || ''" :disabled="taskLoading || !files.length" @change="onFileSelect">
<option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ file.title || file.relativePath || file.fileRef }}</option>
<option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ fileDisplayName(file) }}</option>
</select>
</label>
<div class="toolbar-actions">
@@ -523,24 +660,65 @@ function displayDate(value?: string | null): string {
<EmptyState v-if="!selectedTask" title="未选择任务" description="选择任务后显示 taskRef、状态、文件和 Workbench link 摘要。" />
<template v-else>
<dl class="task-detail-list">
<div><dt>Title</dt><dd>{{ selectedTask.title || selectedTask.taskId }}</dd></div>
<div>
<dt>Title</dt>
<dd v-if="editingTitle" class="inline-editor">
<input v-model="editTitle" data-testid="mdtodo-edit-title" :disabled="taskMutationLoading" @keyup.enter="saveTaskBasics" @keyup.esc="cancelTitleEdit" />
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存</button>
<button class="btn btn-secondary" type="button" :disabled="taskMutationLoading" @click="cancelTitleEdit">取消</button>
</dd>
<dd v-else class="editable-value" data-testid="mdtodo-title-read" @dblclick="beginTitleEdit">{{ selectedTask.title || selectedTask.taskId }}</dd>
</div>
<div><dt>TaskRef</dt><dd><code>{{ selectedTask.taskRef }}</code></dd></div>
<div><dt>File</dt><dd><code>{{ selectedFile?.relativePath || selectedTask.fileRef || '-' }}</code></dd></div>
<div><dt>File</dt><dd><code>{{ selectedFileName }}</code></dd></div>
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div>
<div>
<dt>Status</dt>
<dd class="status-inline">
<select v-model="editStatus" data-testid="mdtodo-edit-status" :disabled="taskMutationLoading">
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
</select>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-status-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存状态</button>
</dd>
</div>
</dl>
<section class="task-editor" data-testid="mdtodo-task-editor">
<div class="editor-grid">
<label><span>Title</span><input v-model="editTitle" data-testid="mdtodo-edit-title" :disabled="taskMutationLoading" /></label>
<label><span>Status</span><select v-model="editStatus" data-testid="mdtodo-edit-status" :disabled="taskMutationLoading">
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
</select></label>
</div>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存标题/状态</button>
<label class="editor-block"><span>Replace body</span><textarea v-model="editBody" data-testid="mdtodo-edit-body" :disabled="taskMutationLoading" rows="4" placeholder="输入新的正文块;留空并保存会清空当前任务正文。" /></label>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="taskMutationLoading" @click="saveTaskBody">保存正文</button>
<LoadingState v-if="taskDetailLoading" compact label="同步任务详情" />
<p v-if="taskDetailError" class="source-error" data-testid="mdtodo-task-detail-error">{{ taskDetailError }}</p>
<section class="task-body-section" data-testid="mdtodo-task-body">
<header><strong>正文</strong></header>
<div v-if="editingBody" class="inline-body-editor">
<textarea v-model="editBody" data-testid="mdtodo-edit-body" :disabled="taskMutationLoading" rows="8" />
<div class="editor-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="taskMutationLoading" @click="saveTaskBody">保存正文</button>
<button class="btn btn-secondary" type="button" :disabled="taskMutationLoading" @click="cancelBodyEdit">取消</button>
</div>
</div>
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" @dblclick="beginBodyEdit">
<div v-if="selectedTaskBodyHtml" v-html="selectedTaskBodyHtml" />
<p v-else class="empty-inline">暂无正文</p>
</article>
</section>
<section v-if="selectedTaskLinks.length || reportPreview || reportError" class="report-section" data-testid="mdtodo-report-section">
<header><strong>报告</strong><span v-if="reportLoading">加载中</span></header>
<div v-if="selectedTaskLinks.length" class="report-link-list">
<button v-for="link in selectedTaskLinks" :key="link.linkId" class="report-link-button" type="button" data-testid="mdtodo-report-link" :disabled="link.kind !== 'markdown-report' || reportLoading" @click="openReportPreview(link)">
<span>{{ link.label || link.href }}</span>
<small>{{ link.relativePath || link.kind }}</small>
</button>
</div>
<p v-if="reportError" class="source-error" data-testid="mdtodo-report-error">{{ reportError }}</p>
<article v-if="reportPreview" class="report-preview" data-testid="mdtodo-report-preview">
<header>
<strong>{{ activeReportLink?.label || reportPreview.relativePath || 'Report' }}</strong>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-report-fullscreen" @click="showReportFullscreen = true">全屏</button>
</header>
<div class="markdown-body" v-html="reportPreviewHtml" />
</article>
</section>
<div class="task-create-box">
<label><span>New task</span><input v-model="newTaskTitle" data-testid="mdtodo-new-title" :disabled="taskMutationLoading" placeholder="新任务标题" /></label>
<textarea v-model="newTaskBody" data-testid="mdtodo-new-body" :disabled="taskMutationLoading" rows="3" placeholder="新任务正文,可留空。" />
@@ -586,6 +764,13 @@ function displayDate(value?: string | null): string {
</section>
</div>
<div v-if="showReportFullscreen && reportPreview" class="modal-backdrop" data-testid="mdtodo-report-fullscreen-dialog" role="dialog" aria-modal="true" aria-label="报告预览" @click.self="showReportFullscreen = false">
<section class="mdtodo-dialog report-fullscreen-dialog">
<header><h2>{{ activeReportLink?.label || reportPreview.relativePath || 'Report' }}</h2><button class="icon-button" type="button" aria-label="关闭报告" @click="showReportFullscreen = false">×</button></header>
<article class="markdown-body report-fullscreen-body" v-html="reportPreviewHtml" />
</section>
</div>
<div v-if="showSourceConfig" class="modal-backdrop" data-testid="mdtodo-source-config-dialog" role="dialog" aria-modal="true" aria-label="配置 MDTODO Source" @click.self="showSourceConfig = false">
<section class="mdtodo-dialog source-config-dialog">
<header><h2>Source 配置</h2><button class="icon-button" type="button" aria-label="关闭配置" @click="showSourceConfig = false">×</button></header>
@@ -618,6 +803,7 @@ function displayDate(value?: string | null): string {
.toolbar-field select,
.task-tools input,
.task-tools select,
.status-inline select,
.source-form input { width: 100%; min-width: 0; height: 36px; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; padding: 0 10px; font: inherit; }
.toolbar-actions { display: flex; gap: 8px; justify-content: flex-end; }
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
@@ -655,6 +841,11 @@ function displayDate(value?: string | null): string {
.task-detail-list dd,
.summary-list dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #14211d; font-size: 13px; }
.task-detail-list code { color: #0f766e; font-size: 12px; }
.editable-value { cursor: text; border-radius: 6px; padding: 3px 0; }
.editable-value:hover { background: #f4fbf8; }
.inline-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; align-items: center; }
.inline-editor input { width: 100%; min-width: 0; border: 1px solid #cbd8d4; border-radius: 6px; color: #14211d; font: inherit; padding: 8px 9px; }
.status-inline { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.task-editor { display: grid; gap: 10px; border: 1px solid #d8e2df; border-radius: 8px; background: #fbfefd; padding: 12px; }
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(132px, 0.32fr); gap: 10px; }
.task-editor label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
@@ -663,6 +854,30 @@ function displayDate(value?: string | null): string {
.task-editor textarea { width: 100%; min-width: 0; border: 1px solid #cbd8d4; border-radius: 6px; background: #fff; color: #14211d; font: inherit; padding: 8px 9px; }
.task-editor textarea { min-height: 72px; resize: vertical; line-height: 1.4; }
.editor-block { display: grid; gap: 5px; }
.task-body-section,
.report-section { display: grid; gap: 8px; border-bottom: 1px solid #e2ece8; padding-bottom: 10px; }
.task-body-section header,
.report-section header,
.report-preview header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; color: #14211d; font-size: 13px; }
.inline-body-editor { display: grid; gap: 8px; }
.markdown-body { min-width: 0; color: #14211d; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.markdown-body :deep(p),
.markdown-body :deep(ul),
.markdown-body :deep(ol),
.markdown-body :deep(pre),
.markdown-body :deep(blockquote) { margin: 0 0 8px; }
.markdown-body :deep(pre) { overflow: auto; border-radius: 6px; background: #0f172a; color: #e5e7eb; padding: 10px; }
.markdown-body :deep(code) { border-radius: 4px; background: #eef6f2; padding: 1px 4px; color: #0f766e; }
.markdown-body :deep(a) { color: #0f766e; font-weight: 750; }
.task-body-rendered { min-height: 92px; max-height: 240px; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; padding: 10px; }
.empty-inline { margin: 0; color: #64706b; }
.report-link-list { display: grid; gap: 6px; }
.report-link-button { display: grid; width: 100%; min-width: 0; gap: 3px; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; color: #14211d; padding: 8px 10px; text-align: left; }
.report-link-button:disabled { opacity: 0.55; }
.report-link-button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 800; }
.report-link-button small { color: #64706b; overflow-wrap: anywhere; }
.report-preview { display: grid; gap: 8px; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; padding: 10px; }
.report-preview > .markdown-body { max-height: 300px; overflow: auto; }
.task-create-box { display: grid; gap: 8px; border-top: 1px solid #e2ece8; padding-top: 10px; }
.editor-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.danger-actions { border-top: 1px solid #e2ece8; padding-top: 10px; }
@@ -677,6 +892,8 @@ function displayDate(value?: string | null): string {
.mdtodo-dialog { display: grid; width: min(720px, calc(100vw - 32px)); gap: 14px; border: 1px solid #cbd8d2; border-radius: 8px; background: #fff; color: #14211d; padding: 16px; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); }
.mdtodo-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.mdtodo-dialog h2 { margin: 0; font-size: 18px; }
.report-fullscreen-dialog { width: min(1120px, calc(100vw - 32px)); min-height: min(780px, calc(100dvh - 80px)); align-content: start; }
.report-fullscreen-body { max-height: calc(100dvh - 170px); overflow: auto; border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px; }
.source-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.source-form label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
.source-form footer { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: 8px; }
@@ -692,6 +909,8 @@ function displayDate(value?: string | null): string {
@media (max-width: 640px) {
.source-form { grid-template-columns: 1fr; }
.editor-grid { grid-template-columns: 1fr; }
.inline-editor,
.status-inline { grid-template-columns: 1fr; }
.task-row { grid-template-columns: minmax(48px, auto) minmax(0, 1fr); }
.task-status { grid-column: 1 / -1; }
}
@@ -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: Fake-server browser regression for project root and MDTODO pages.
import { expect, saveScreenshot, test } from "../fixtures/test";
@@ -18,6 +18,7 @@ test.describe("project management pages", () => {
await expect(page.getByTestId("mdtodo-toolbar")).toBeVisible();
await expect(page.getByTestId("mdtodo-source-select")).toHaveValue("hwlab-v03-mdtodo");
await expect(page.getByTestId("mdtodo-file-select")).toHaveValue("file_plan");
await expect(page.getByTestId("mdtodo-file-select")).toContainText("hwlab-v03-mdtodo-web-sample.md");
await expect(page.locator(".mdtodo-summary-grid .metric-card")).toHaveCount(0);
await expect(page.locator(".mdtodo-source-panel")).toHaveCount(0);
await expect(page.locator(".mdtodo-file-panel")).toHaveCount(0);
@@ -26,6 +27,12 @@ test.describe("project management pages", () => {
await expect(page.locator('[data-task-id="R1.1"]')).toBeVisible();
await expect(page.locator('[data-task-id="R1.1.1"]')).toBeVisible();
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
await expect(page.getByTestId("mdtodo-body-rendered")).toContainText("Root notes");
await page.getByTestId("mdtodo-report-link").click();
await expect(page.getByTestId("mdtodo-report-preview")).toContainText("Report body from fake server");
await page.getByTestId("mdtodo-report-fullscreen").click();
await expect(page.getByTestId("mdtodo-report-fullscreen-dialog")).toContainText("R1.1 implementation report");
await page.getByLabel("关闭报告").click();
await page.getByTestId("mdtodo-info-open").click();
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Source");
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Files");
@@ -40,12 +47,14 @@ test.describe("project management pages", () => {
await page.getByTestId("mdtodo-source-reindex-dialog").click();
await expect(page.getByTestId("mdtodo-source-message")).toContainText("Reindex 2 files / 4 tasks");
await page.getByLabel("关闭配置").click();
await page.getByTestId("mdtodo-title-read").dblclick();
await page.getByTestId("mdtodo-edit-title").fill("实现项目根导航 - edited");
await page.getByTestId("mdtodo-edit-status").selectOption("in_progress");
await page.getByTestId("mdtodo-edit-save").click();
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("任务已保存");
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("实现项目根导航 - edited");
await expect(page.locator('[data-task-id="R1"]')).toContainText("进行中");
await page.getByTestId("mdtodo-body-rendered").dblclick();
await page.getByTestId("mdtodo-edit-body").fill("Body saved from Playwright.");
await page.getByTestId("mdtodo-edit-body-save").click();
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("正文已保存");
@@ -78,6 +87,8 @@ test.describe("project management pages", () => {
const sessionPayload = await sessionDetail.json();
expect(sessionPayload.session?.launchContext?.projectId ?? sessionPayload.session?.metadata?.launchContext?.projectId).toBe("project_hwlab_v03");
expect(sessionPayload.session?.launchContext?.taskRef ?? sessionPayload.session?.metadata?.launchContext?.taskRef).toBe("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
expect(sessionPayload.session?.messageCount ?? sessionPayload.session?.messages?.length ?? 0).toBeGreaterThan(0);
expect(sessionPayload.session?.firstUserMessagePreview ?? "").toContain("MDTODO");
const links = await page.request.get("/v1/project-management/workbench-links?taskRef=mdtodo:hwlab-v03-mdtodo:file_plan:R1");
expect(links.ok()).toBeTruthy();