diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index cf41d6de..a157c40e 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -55,7 +55,7 @@ test("project management app exposes MDTODO read model and Workbench link writes const taskLinks = await json(app, `/v1/project-management/workbench-links?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`); expect(taskLinks.links[0].sessionId).toBe("ses_project_test"); - const rejected = await app.fetch(new Request("http://service/v1/project-management/mdtodo/tasks", { method: "POST" })); + const rejected = await app.fetch(new Request("http://service/v1/project-management/mdtodo/tasks", { method: "PUT" })); expect(rejected.status).toBe(405); await app.close(); @@ -132,6 +132,43 @@ 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." } + })); + expect(updateTask.status).toBe(200); + const updatedTask = await updateTask.json(); + expect(updatedTask.task.title).toBe("Root from task API"); + expect(updatedTask.task.status).toBe("in_progress"); + expect(await readFile(samplePath, "utf8")).toContain("## R1 Root from task API [in_progress]\n\nTask API body."); + + const addSubtask = await app.fetch(jsonRequest("/v1/project-management/mdtodo/tasks", { + method: "POST", + body: { parentTaskRef: rootTaskRef, expectedFingerprint: updatedTask.file.fingerprint, title: "Added child", body: "Child body." } + })); + expect(addSubtask.status).toBe(201); + const added = await addSubtask.json(); + expect(added.changedTaskId).toBe("R1.2"); + expect(added.task.taskRef).toBe(`mdtodo:hwpod-mdtodo:${fileRef}:R1.2`); + + const continueTask = await app.fetch(jsonRequest("/v1/project-management/mdtodo/tasks", { + method: "POST", + body: { afterTaskRef: `mdtodo:hwpod-mdtodo:${fileRef}:R1.1`, expectedFingerprint: added.file.fingerprint, title: "Continued child" } + })); + expect(continueTask.status).toBe(201); + const continued = await continueTask.json(); + expect(continued.changedTaskId).toBe("R1.3"); + + const deleteTask = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(added.task.taskRef)}`, { + method: "DELETE", + body: { expectedFingerprint: continued.file.fingerprint } + })); + expect(deleteTask.status).toBe(200); + const deleted = await deleteTask.json(); + expect(deleted.affectedTaskIds).toEqual(["R1.2"]); + expect(await readFile(samplePath, "utf8")).not.toContain("R1.2 Added child"); + const conflict = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`, { method: "PATCH", body: { sourceId: "hwpod-mdtodo", expectedFingerprint: content.file.fingerprint, content: nextContent } diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts index 4af64f7b..f07fea25 100644 --- a/internal/project-management/server.ts +++ b/internal/project-management/server.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import path from "node:path"; +import { mutateMdtodoDocument } from "./mdtodo.ts"; import { createMdtodoSourceAdapter, normalizeProjectSourceInput, sourceProjection } from "./source-adapter.ts"; const contractVersion = "project-management-v1"; @@ -98,6 +99,19 @@ export function createProjectManagementApp(options = {}) { await initialize(); return await handleWriteMdtodoFile(request, url, store, sourceAdapter, fileContent.fileRef, actorFromRequest(request)); } + const taskAction = matchTaskAction(url.pathname); + if (url.pathname === "/v1/project-management/mdtodo/tasks" && request.method === "POST") { + await initialize(); + return await handleCreateMdtodoTask(request, store, sourceAdapter, actorFromRequest(request)); + } + if (taskAction && request.method === "PATCH") { + await initialize(); + return await handlePatchMdtodoTask(request, store, sourceAdapter, taskAction.taskRef, actorFromRequest(request)); + } + if (taskAction && request.method === "DELETE") { + await initialize(); + return await handleDeleteMdtodoTask(request, store, sourceAdapter, taskAction.taskRef, actorFromRequest(request)); + } if (request.method !== "GET" && request.method !== "HEAD") return jsonError(405, "method_not_allowed", "Project management API only accepts read requests plus Workbench link writes"); await initialize(); @@ -305,6 +319,97 @@ async function handleWriteMdtodoFile(request, url, store, sourceAdapter, fileRef return json(200, envelope({ file, projection })); } +async function handlePatchMdtodoTask(request, store, sourceAdapter, taskRef, actor) { + const body = await readJsonObject(request, 256 * 1024); + if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message); + const task = await getTaskByRef(store, taskRef); + if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found"); + const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision); + if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes"); + const operations = []; + if (typeof body.value.title === "string") operations.push({ type: "updateTitle", rxxId: task.taskId, title: body.value.title }); + if (typeof body.value.status === "string") operations.push({ type: "updateStatus", rxxId: task.taskId, status: body.value.status }); + if (typeof body.value.body === "string") operations.push({ type: "updateBody", rxxId: task.taskId, body: body.value.body }); + if (operations.length === 0) return jsonError(400, "mutation_required", "At least one MDTODO task field is required"); + return mutateTaskDocument({ store, sourceAdapter, sourceId: task.sourceId, fileRef: task.fileRef, expectedFingerprint, operations, auditAction: "mdtodo.task.update", targetTaskRef: taskRef, actor, status: 200 }); +} + +async function handleCreateMdtodoTask(request, store, sourceAdapter, actor) { + const body = await readJsonObject(request, 256 * 1024); + if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message); + const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision); + if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes"); + const title = shortText(body.value.title, 500); + if (!title) return jsonError(400, "title_required", "title is required for MDTODO task creation"); + const parentTask = body.value.parentTaskRef ? await getTaskByRef(store, safeOpaqueValue(body.value.parentTaskRef)) : null; + const afterTask = body.value.afterTaskRef ? await getTaskByRef(store, safeOpaqueValue(body.value.afterTaskRef)) : null; + if (body.value.parentTaskRef && !parentTask) return jsonError(404, "parent_task_not_found", "Parent MDTODO task was not found"); + if (body.value.afterTaskRef && !afterTask) return jsonError(404, "after_task_not_found", "Continuation MDTODO task was not found"); + const sourceId = parentTask?.sourceId ?? afterTask?.sourceId ?? safeToken(body.value.sourceId, null); + const fileRef = parentTask?.fileRef ?? afterTask?.fileRef ?? safeToken(body.value.fileRef, null); + if (!sourceId || !fileRef) return jsonError(400, "task_target_required", "sourceId and fileRef are required for root MDTODO task creation"); + const operation = { + type: "addTask", + title, + status: body.value.status ?? "open", + body: typeof body.value.body === "string" ? body.value.body : "", + parentRxxId: parentTask?.taskId ?? null, + afterRxxId: afterTask?.taskId ?? null + }; + return mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, expectedFingerprint, operations: [operation], auditAction: "mdtodo.task.create", targetTaskRef: parentTask?.taskRef ?? afterTask?.taskRef ?? `${sourceId}:${fileRef}`, actor, status: 201 }); +} + +async function handleDeleteMdtodoTask(request, store, sourceAdapter, taskRef, actor) { + const body = await readJsonObject(request, 32768); + if (!body.ok) return jsonError(400, body.code, body.message); + const task = await getTaskByRef(store, taskRef); + if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found"); + const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision); + if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes"); + return mutateTaskDocument({ store, sourceAdapter, sourceId: task.sourceId, fileRef: task.fileRef, expectedFingerprint, operations: [{ type: "deleteTask", rxxId: task.taskId }], auditAction: "mdtodo.task.delete", targetTaskRef: taskRef, actor, status: 200 }); +} + +async function mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, expectedFingerprint, operations, auditAction, targetTaskRef, actor, status }) { + const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef); + if (error) return error; + const current = await sourceAdapter.readFile(source, document.relativePath); + let markdown = current.content; + let mutation = null; + for (const operation of operations) { + mutation = mutateMdtodoDocument(markdown, { ...operation, expectedFingerprint: mutation ? null : expectedFingerprint }, { sourceId: source.sourceId, relativePath: document.relativePath, projectId: document.projectId }); + if (!mutation.ok) return jsonError(mutation.conflict ? 409 : 400, mutation.code ?? "mdtodo_mutation_failed", mutation.message ?? "MDTODO task mutation failed"); + markdown = mutation.markdown; + } + const file = await sourceAdapter.writeFile(source, document.relativePath, markdown, expectedFingerprint); + const documents = await sourceAdapter.discover(source); + await store.replaceProjection(source, documents); + const projection = sourceProjection(source, documents); + const tasks = await store.listTasks({ sourceId: source.sourceId, fileRef }); + const changedTaskRef = mutation?.changedTaskId ? `mdtodo:${source.sourceId}:${fileRef}:${mutation.changedTaskId}` : null; + const task = changedTaskRef ? tasks.find((item) => item.taskRef === changedTaskRef) ?? null : null; + await recordAuditEvent(store, { actor }, { action: auditAction, targetType: "mdtodo_task", targetId: targetTaskRef, outcome: "succeeded", sourceKind: source.sourceKind, fileRef, revision: file.revision, changedTaskId: mutation?.changedTaskId ?? null, affectedTaskIds: mutation?.affectedTaskIds ?? [] }); + return json(status, envelope({ task, affectedTaskIds: mutation?.affectedTaskIds ?? [], changedTaskId: mutation?.changedTaskId ?? null, file: fileSummary(file), projection })); +} + +async function getTaskByRef(store, taskRef) { + const safeTaskRef = safeOpaqueValue(taskRef); + if (!safeTaskRef) return null; + return (await store.listTasks({})).find((task) => task.taskRef === safeTaskRef) ?? null; +} + +function fileSummary(file) { + return { + sourceId: file.sourceId, + fileRef: file.fileRef, + relativePath: file.relativePath, + fingerprint: file.fingerprint, + revision: file.revision, + parserMode: file.parserMode, + taskCount: file.taskCount, + valuesRedacted: true + }; +} + async function resolveMdtodoDocument(store, sourceId, fileRef) { const source = await getSource(store, sourceId); if (!source) return { error: jsonError(404, "source_not_found", "MDTODO source was not found") }; @@ -395,6 +500,11 @@ function matchFileContent(pathname) { return match ? { fileRef: match[1] } : null; } +function matchTaskAction(pathname) { + const match = /^\/v1\/project-management\/mdtodo\/tasks\/(.+)$/u.exec(pathname); + return match ? { taskRef: decodeURIComponent(match[1]) } : null; +} + function safeOpaqueValue(value) { const text = String(value ?? "").trim(); return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null; diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index bcead1d1..655fdd04 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -32,6 +32,9 @@ interface ScenarioState { legacyRequestLedger: JsonRecord[]; chatRequests: JsonRecord[]; projectLinks: JsonRecord[]; + projectFiles: JsonRecord[]; + projectTasks: JsonRecord[]; + projectRevision: number; listOmitSelected: boolean; sessionDelayMs: number; sessionDetailDelayMs: number; @@ -290,7 +293,7 @@ function hwpodNodeOpsPayload(): JsonRecord { async function projectManagementPayload(request: IncomingMessage, response: ServerResponse, url: URL, method: string): Promise { const path = url.pathname; const source = projectManagementSource(); - const files = projectManagementFiles(); + const files = state.projectFiles; const links = state.projectLinks; const tasks = projectManagementTasks(links); @@ -320,6 +323,10 @@ async function projectManagementPayload(request: IncomingMessage, response: Serv state.projectLinks.unshift(link); return json(response, 201, projectManagementEnvelope({ link })); } + const taskMutationMatch = path.match(/^\/v1\/project-management\/mdtodo\/tasks\/(.+)$/u); + if (path === "/v1/project-management/mdtodo/tasks" && method === "POST") return projectManagementCreateTask(response, await readJson(request)); + if (taskMutationMatch && method === "PATCH") return projectManagementUpdateTask(response, decodeURIComponent(taskMutationMatch[1] ?? ""), await readJson(request)); + if (taskMutationMatch && method === "DELETE") return projectManagementDeleteTask(response, decodeURIComponent(taskMutationMatch[1] ?? ""), await readJson(request)); if (method !== "GET" && method !== "HEAD") return json(response, 405, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "method_not_allowed" }, valuesRedacted: true }); if (path === "/v1/project-management" || path === "/v1/project-management/navigation") { @@ -378,19 +385,114 @@ function projectManagementFiles(): JsonRecord[] { } function projectManagementTasks(links: JsonRecord[] = []): JsonRecord[] { + const linkCount = (taskRef: string) => links.filter((link) => link.taskRef === taskRef).length; + return state.projectTasks.map((task) => ({ ...task, linkCount: linkCount(String(task.taskRef ?? "")) })); +} + +function projectManagementSeedTasks(links: JsonRecord[] = []): JsonRecord[] { const linkCount = (taskRef: string) => links.filter((link) => link.taskRef === taskRef).length; const r1 = "mdtodo:hwlab-v03-mdtodo:file_plan:R1"; const r11 = "mdtodo:hwlab-v03-mdtodo:file_plan:R1.1"; 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), 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), 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), 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), 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", 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" } ]; } +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 }); + const file = projectFileForTask(task); + 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); + task.updatedAt = new Date().toISOString(); + touchProjectFile(String(task.fileRef)); + return json(response, 200, projectManagementEnvelope({ task, file: projectFileForTask(task), changedTaskId: task.taskId, affectedTaskIds: [task.taskId] })); +} + +function projectManagementCreateTask(response: ServerResponse, body: JsonRecord): void { + const parent = typeof body.parentTaskRef === "string" ? state.projectTasks.find((item) => item.taskRef === body.parentTaskRef) : null; + const after = typeof body.afterTaskRef === "string" ? state.projectTasks.find((item) => item.taskRef === body.afterTaskRef) : null; + const sourceId = String(parent?.sourceId ?? after?.sourceId ?? body.sourceId ?? "hwlab-v03-mdtodo"); + const fileRef = String(parent?.fileRef ?? after?.fileRef ?? body.fileRef ?? "file_plan"); + const file = state.projectFiles.find((item) => item.sourceId === sourceId && item.fileRef === fileRef); + if (!file) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "file_not_found" }, valuesRedacted: true }); + if (!projectManagementExpectedFingerprintOk(response, file, body)) return; + const title = String(body.title ?? "").trim(); + if (!title) return json(response, 400, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "title_required" }, valuesRedacted: true }); + 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() }; + state.projectTasks.push(task); + touchProjectFile(fileRef); + return json(response, 201, projectManagementEnvelope({ task, file: projectFileForTask(task), changedTaskId: taskId, affectedTaskIds: [taskId] })); +} + +function projectManagementDeleteTask(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 }); + const file = projectFileForTask(task); + if (!projectManagementExpectedFingerprintOk(response, file, body)) return; + const prefix = `${task.taskId}.`; + const affected = state.projectTasks.filter((item) => item.taskRef === taskRef || String(item.taskId).startsWith(prefix)).map((item) => String(item.taskId)); + state.projectTasks = state.projectTasks.filter((item) => !(item.taskRef === taskRef || String(item.taskId).startsWith(prefix))); + touchProjectFile(String(task.fileRef)); + return json(response, 200, projectManagementEnvelope({ task: null, file: projectFileForTask(task), changedTaskId: task.taskId, affectedTaskIds: affected })); +} + +function projectManagementExpectedFingerprintOk(response: ServerResponse, file: JsonRecord, body: JsonRecord): boolean { + const expected = String(body.expectedFingerprint ?? body.revision ?? ""); + if (!expected) { + json(response, 400, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "expected_fingerprint_required" }, valuesRedacted: true }); + return false; + } + if (expected !== file.fingerprint) { + json(response, 409, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "revision_conflict" }, valuesRedacted: true }); + return false; + } + return true; +} + +function projectFileForTask(task: JsonRecord): JsonRecord { + const file = state.projectFiles.find((item) => item.sourceId === task.sourceId && item.fileRef === task.fileRef) ?? state.projectFiles[0]; + if (!file) throw new Error("project management fake-server file fixture is missing"); + return file; +} + +function touchProjectFile(fileRef: string): void { + state.projectRevision += 1; + const file = state.projectFiles.find((item) => item.fileRef === fileRef); + if (!file) return; + file.revision = Number(file.revision ?? 1) + 1; + file.fingerprint = `sha256:e2e-${fileRef}-${state.projectRevision}`; + file.taskCount = state.projectTasks.filter((task) => task.fileRef === fileRef).length; + file.updatedAt = new Date().toISOString(); + for (const task of state.projectTasks.filter((item) => item.fileRef === fileRef)) task.sourceFingerprint = file.fingerprint; +} + +function nextProjectRootTaskId(fileRef: string): string { + const next = Math.max(0, ...state.projectTasks.filter((task) => task.fileRef === fileRef && !String(task.taskId).includes(".")).map((task) => Number(String(task.taskId).replace(/^R/u, "")) || 0)) + 1; + return `R${next}`; +} + +function nextProjectChildTaskId(fileRef: string, parentTaskId: string): string { + const prefix = `${parentTaskId}.`; + const next = Math.max(0, ...state.projectTasks.filter((task) => task.fileRef === fileRef && String(task.taskId).startsWith(prefix)).map((task) => Number(String(task.taskId).slice(prefix.length).split(".")[0]) || 0)) + 1; + return `${parentTaskId}.${next}`; +} + +function nextProjectSiblingTaskId(fileRef: string, afterTaskId: string): string { + const parent = afterTaskId.includes(".") ? afterTaskId.split(".").slice(0, -1).join(".") : ""; + return parent ? nextProjectChildTaskId(fileRef, parent) : nextProjectRootTaskId(fileRef); +} + function projectManagementSeedLinks(): JsonRecord[] { return [{ linkId: "lnk_project_task_root", projectId: "project_hwlab_v03", taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:R1", sessionId: "ses_project_seed", traceId: "trc_project_seed", role: "reference", updatedAt: "2026-06-25T09:00:00.000Z" }]; } @@ -911,6 +1013,7 @@ function createScenarioState(scenarioId: string): ScenarioState { ? "ses_running" : base.selectedSessionId; const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null; + const projectLinks = projectManagementSeedLinks(); return { scenarioId: id, providerProfile: base.providerProfile, @@ -920,7 +1023,10 @@ function createScenarioState(scenarioId: string): ScenarioState { requestLedger: [], legacyRequestLedger: [], chatRequests: [], - projectLinks: projectManagementSeedLinks(), + projectLinks, + projectFiles: projectManagementFiles(), + projectTasks: projectManagementSeedTasks(projectLinks), + projectRevision: 1, listOmitSelected: id === "selected-missing-from-list", sessionDelayMs: id === "loading" ? 2_500 : 0, sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : id === "submit-authority-race" ? 1_000 : 0, diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index f7258078..76dae965 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -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 MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; +export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, 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 93961645..78ac0a08 100644 --- a/web/hwlab-cloud-web/src/api/projectManagement.ts +++ b/web/hwlab-cloud-web/src/api/projectManagement.ts @@ -103,6 +103,7 @@ export interface MdtodoTaskRecord { sourceId?: string; fileRef?: string; taskId?: string; + rxxId?: string; title?: string; status?: string; parentTaskRef?: string | null; @@ -110,6 +111,9 @@ export interface MdtodoTaskRecord { lineNumber?: number; ordinal?: number; linkCount?: number; + sourceFingerprint?: string; + bodyPreview?: string; + parserMode?: string; updatedAt?: string; } @@ -132,6 +136,24 @@ export interface ProjectSourceProbeResponse extends ProjectManagementEnvelope { export interface ProjectSourceReindexResponse extends ProjectManagementEnvelope { projection?: ProjectProjectionSummary & { sourceKind?: string; rootRef?: string; valuesRedacted?: boolean } } export interface MdtodoFileListResponse extends ProjectManagementEnvelope { files?: MdtodoFileRecord[] } export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[] } +export interface MdtodoTaskMutationInput { + sourceId?: string; + fileRef?: string; + parentTaskRef?: string; + afterTaskRef?: string; + expectedFingerprint?: string; + revision?: string; + title?: string; + status?: string; + body?: string; +} +export interface MdtodoTaskMutationResponse extends ProjectManagementEnvelope { + task?: MdtodoTaskRecord | null; + changedTaskId?: string | null; + affectedTaskIds?: string[]; + file?: MdtodoFileRecord & { parserMode?: string; valuesRedacted?: boolean }; + projection?: ProjectProjectionSummary; +} export interface ProjectWorkbenchLinkListResponse extends ProjectManagementEnvelope { links?: ProjectWorkbenchLinkRecord[] } export interface MdtodoTaskQuery { @@ -156,6 +178,9 @@ 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 })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }), + 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" }), workbenchLinks: (query: ProjectWorkbenchLinkQuery = {}): Promise> => fetchJson(`/v1/project-management/workbench-links${queryString({ taskRef: query.taskRef, projectId: query.projectId, sessionId: query.sessionId })}`, { timeoutMs: 12000, timeoutName: "project workbench links" }) }; diff --git a/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue b/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue index ee455921..2b569495 100644 --- a/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue +++ b/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue @@ -4,7 +4,7 @@