diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index be3cd6a4..0f4e31d3 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -6,8 +6,39 @@ import { expect, test } from "bun:test"; import { executeHwpodNodeOpsPlan } from "../../tools/src/hwpod-node-lib.ts"; import { createProjectManagementApp } from "./server.ts"; +import { createMdtodoSourceAdapter, normalizeProjectSourceInput } from "./source-adapter.ts"; import { createProjectManagementStore } from "./store.ts"; +test("project management source adapter types HWPOD transport failures", async () => { + const normalized = normalizeProjectSourceInput({ + sourceId: "transport-hwpod-mdtodo", + sourceKind: "hwpod-workspace", + projectId: "project_transport", + hwpodId: "transport-hwpod", + nodeId: "transport-node", + workspaceRootRef: "C:/Work/Transport", + mdtodoRootRef: "docs/MDTODO" + }); + expect(normalized.ok).toBe(true); + const adapter = createMdtodoSourceAdapter({ + env: { HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: "http://hwpod.invalid/node-ops" }, + fetchImpl: async () => { throw new TypeError("connection refused with internal endpoint"); } + }); + let caught; + try { + await adapter.readFile(normalized.source, "MDTODO.md"); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ + code: "hwpod_transport_unavailable", + statusCode: 502, + sourceKind: "hwpod-workspace", + message: "HWPOD node-ops transport is unavailable" + }); + expect(String(caught?.message)).not.toContain("internal endpoint"); +}); + test("project management app exposes MDTODO read model and Workbench link writes", async () => { const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-")); try { @@ -348,6 +379,85 @@ test("project management app bootstraps HWPOD sources from a mounted JSON file", } }); +test("project management task detail falls back to the last HWPOD projection when the node is offline", async () => { + const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-partial-")); + const defaultRoot = join(root, "default"); + const configPath = join(root, "sources.json"); + let hwpodState = "ready"; + try { + await mkdir(defaultRoot, { recursive: true }); + await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8"); + await writeFile(configPath, JSON.stringify({ + sources: [{ + sourceId: "offline-hwpod-mdtodo", + sourceKind: "hwpod-workspace", + displayName: "Offline HWPOD MDTODO", + projectId: "project_hwpod_partial", + hwpodId: "offline-hwpod", + nodeId: "node-offline", + workspaceRootRef: "F:/Work/Offline", + mdtodoRootRef: "docs/MDTODO" + }] + }), "utf8"); + const app = createProjectManagementApp({ + env: { HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: configPath }, + store: createProjectManagementStore({ kind: "memory" }), + sourceRoot: defaultRoot, + sourceId: "default-mdtodo", + projectId: "project_test", + hwpodNodeOpsHandler: (plan) => { + if (hwpodState === "bug") throw new Error("programmer bug with internal detail"); + if (hwpodState === "offline") { + const blocker = { code: "hwpod_node_offline", summary: "node-offline is not connected" }; + return { ok: false, status: "blocked", blocker, results: [{ opId: "op_01", op: plan.intent, ok: false, status: "blocked", blocker }] }; + } + const output = plan.intent === "workspace.ls" + ? { entries: [{ name: "offline.md", type: "file" }] } + : { content: "# Offline\n\n## R1 Projected task [in_progress]\n\nProjected body. See [report](./reports/projected.md).\n" }; + return { ok: true, status: "completed", results: [{ opId: "op_01", op: plan.intent, ok: true, status: "completed", output }] }; + } + }); + + expect((await app.fetch(new Request("http://service/health/ready"))).status).toBe(200); + const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=offline-hwpod-mdtodo"); + const tasks = await json(app, `/v1/project-management/mdtodo/tasks?sourceId=offline-hwpod-mdtodo&fileRef=${encodeURIComponent(files.files[0].fileRef)}`); + hwpodState = "offline"; + + const response = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`)); + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.status).toBe("partial"); + expect(payload.task).toMatchObject({ taskId: "R1", title: "Projected task" }); + expect(payload.task.bodyPreview).toContain("Projected body."); + expect(payload.detail).toBeNull(); + expect(payload.file).toMatchObject({ fileRef: files.files[0].fileRef, taskCount: 1 }); + expect(payload.detailAuthority).toMatchObject({ + state: "unavailable", + authority: "project-management-projection", + sourceKind: "hwpod-workspace", + code: "hwpod_node_offline", + retryable: true, + valuesRedacted: true + }); + expect(JSON.stringify(payload)).not.toContain("node-offline is not connected"); + + const projectedLink = tasks.tasks[0].links[0]; + const reportResponse = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/report-preview?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}&linkId=${encodeURIComponent(projectedLink.linkId)}`)); + expect(reportResponse.status).toBe(503); + const reportPayload = await reportResponse.json(); + expect(reportPayload).toMatchObject({ error: { code: "hwpod_node_offline", message: "HWPOD 源当前不可读;报告预览需等待 Node 恢复。", status: 503 } }); + expect(JSON.stringify(reportPayload)).not.toContain("node-offline is not connected"); + + hwpodState = "bug"; + const programmerError = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`)); + expect(programmerError.status).toBe(500); + expect(await programmerError.json()).toMatchObject({ error: { code: "project_management_error", status: 500 } }); + await app.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("project management HWPOD source adapter authenticates node-ops requests from configured env", async () => { const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-auth-")); const defaultRoot = join(root, "default"); diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts index d6c04550..9b214eea 100644 --- a/internal/project-management/server.ts +++ b/internal/project-management/server.ts @@ -390,12 +390,23 @@ async function handleReadMdtodoFile(url, store, sourceAdapter, fileRef) { 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 }); + const detail = await loadTaskDetail({ store, sourceAdapter, taskRef, allowProjectedFallback: true }); if (detail.error) return detail.error; + if (detail.availability?.state === "unavailable") { + return json(200, envelope({ + status: "partial", + task: detail.task, + detail: null, + file: fileSummary(detail.document), + detailAuthority: detail.availability + })); + } return json(200, envelope({ + status: "ready", task: { ...detail.task, bodyPreview: detail.detail.bodyPreview, linkCount: detail.detail.links.length }, detail: detail.detail, - file: fileSummary(detail.file) + file: fileSummary(detail.file), + detailAuthority: taskDetailAuthority("ready", detail.source) })); } @@ -417,12 +428,24 @@ async function handleReadMdtodoReportPreview(url, store, sourceAdapter) { 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 }); + let detail; + try { + detail = await loadTaskDetail({ store, sourceAdapter, taskRef }); + } catch (error) { + if (isHwpodAvailabilityError(error)) return hwpodAvailabilityError(error); + throw error; + } 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); + let report; + try { + report = await sourceAdapter.readLinkedMarkdown(detail.source, detail.document.relativePath, link.href); + } catch (error) { + if (isHwpodAvailabilityError(error)) return hwpodAvailabilityError(error); + throw error; + } return json(200, envelope({ task: detail.task, link, @@ -437,12 +460,27 @@ async function handleReadMdtodoReportPreview(url, store, sourceAdapter) { })); } -async function loadTaskDetail({ store, sourceAdapter, taskRef }) { +async function loadTaskDetail({ store, sourceAdapter, taskRef, allowProjectedFallback = false }) { 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); + let file; + try { + file = await sourceAdapter.readFile(source, document.relativePath); + } catch (readError) { + if (allowProjectedFallback && source.sourceKind === "hwpod-workspace" && isHwpodAvailabilityError(readError)) { + return { + source, + document, + file: null, + task, + detail: null, + availability: taskDetailAuthority("unavailable", source, readError) + }; + } + throw readError; + } const detail = readMdtodoTaskDetail(file.content, { sourceId: source.sourceId, fileRef: document.fileRef, @@ -455,6 +493,34 @@ async function loadTaskDetail({ store, sourceAdapter, taskRef }) { return { source, document, file, task, detail }; } +function isHwpodAvailabilityError(error) { + const statusCode = Number(error?.statusCode); + return error?.sourceKind === "hwpod-workspace" && [502, 503, 504].includes(statusCode); +} + +function hwpodAvailabilityError(error) { + return jsonError( + Number(error?.statusCode), + safeToken(error?.code, "hwpod_source_unavailable"), + "HWPOD 源当前不可读;报告预览需等待 Node 恢复。" + ); +} + +function taskDetailAuthority(state, source, error = null) { + const unavailable = state === "unavailable"; + return { + state, + authority: unavailable ? "project-management-projection" : "source-markdown", + sourceKind: source?.sourceKind ?? null, + code: unavailable ? safeToken(error?.code, "hwpod_source_unavailable") : null, + message: unavailable + ? "HWPOD 源当前不可读;页面继续展示最近一次项目投影,正文编辑和报告预览需等待 Node 恢复。" + : null, + retryable: unavailable, + valuesRedacted: true + }; +} + 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); diff --git a/internal/project-management/source-adapter.ts b/internal/project-management/source-adapter.ts index 750bb2e7..cd88d161 100644 --- a/internal/project-management/source-adapter.ts +++ b/internal/project-management/source-adapter.ts @@ -128,13 +128,26 @@ function createHwpodNodeOpsHttpClient({ env, fetchImpl }) { return async (plan) => { const headers = { "content-type": "application/json", "x-source-service-id": "hwlab-project-management" }; if (apiKey) headers.authorization = `Bearer ${apiKey}`; - const response = await fetchImpl(endpoint, { - method: "POST", - headers, - body: JSON.stringify(plan), - signal: AbortSignal.timeout(30000) - }); - return response.json(); + let response; + try { + response = await fetchImpl(endpoint, { + method: "POST", + headers, + body: JSON.stringify(plan), + signal: AbortSignal.timeout(30000) + }); + } catch { + throw sourceError("hwpod_transport_unavailable", "HWPOD node-ops transport is unavailable", 502, { + sourceKind: "hwpod-workspace" + }); + } + try { + return await response.json(); + } catch { + throw sourceError("hwpod_invalid_response", "HWPOD node-ops returned an invalid response", 502, { + sourceKind: "hwpod-workspace" + }); + } }; } @@ -167,7 +180,11 @@ async function listHwpodMarkdownFiles(hwpodNodeOpsHandler, source) { } async function runHwpodOp(hwpodNodeOpsHandler, source, op, args) { - if (!hwpodNodeOpsHandler) throw sourceError("hwpod_adapter_unconfigured", "HWPOD node-ops adapter is not configured", 503); + if (!hwpodNodeOpsHandler) { + throw sourceError("hwpod_adapter_unconfigured", "HWPOD node-ops adapter is not configured", 503, { + sourceKind: "hwpod-workspace" + }); + } const plan = { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId: `hwpod_plan_${randomUUID()}`, @@ -184,6 +201,7 @@ async function runHwpodOp(hwpodNodeOpsHandler, source, op, args) { const summary = String(blocker.summary ?? blocker.message ?? "HWPOD node-ops request failed"); const traceLine = otelTraceId ? `OTel traceId: ${otelTraceId}` : null; throw sourceError(blocker.code ?? "hwpod_node_ops_failed", traceLine ? `${summary}\n${traceLine}` : summary, payload?.status === "blocked" ? 503 : 502, { + sourceKind: "hwpod-workspace", hwpodStatus: payload?.status ?? null, otelTraceId: otelTraceId ?? null, traceLine, diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index bfabd7a6..a36169d8 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -16,7 +16,7 @@ export { apiKeysAPI } from "./apiKeys"; export { usageAPI } from "./usage"; export { billingAPI } from "./billing"; export { systemAPI, type SkillUploadFileInput } from "./system"; -export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, 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"; +export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; import { fetchJson } from "./client"; import { accessAPI } from "./access"; diff --git a/web/hwlab-cloud-web/src/api/projectManagement.ts b/web/hwlab-cloud-web/src/api/projectManagement.ts index 8797eb37..043d8541 100644 --- a/web/hwlab-cloud-web/src/api/projectManagement.ts +++ b/web/hwlab-cloud-web/src/api/projectManagement.ts @@ -156,6 +156,16 @@ export interface MdtodoTaskDetailRecord { valuesRedacted?: boolean; } +export interface MdtodoTaskDetailAuthority { + state: "ready" | "unavailable"; + authority: "source-markdown" | "project-management-projection"; + sourceKind?: string | null; + code?: string | null; + message?: string | null; + retryable?: boolean; + valuesRedacted?: boolean; +} + export interface MdtodoExecutionContext { sourceId?: string | null; sourceKind?: string | null; @@ -216,7 +226,13 @@ 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 MdtodoTaskDetailResponse extends ProjectManagementEnvelope { + status?: "ready" | "partial"; + task?: MdtodoTaskRecord; + detail?: MdtodoTaskDetailRecord | null; + file?: MdtodoFileRecord; + detailAuthority?: MdtodoTaskDetailAuthority; +} export interface MdtodoLaunchContextResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; source?: Partial; executionContext?: MdtodoExecutionContext; launchContext?: Record } export interface MdtodoReportPreviewResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; link?: MdtodoTaskLinkRecord; report?: MdtodoReportPreviewRecord; file?: MdtodoFileRecord } export interface MdtodoTaskMutationInput { diff --git a/web/hwlab-cloud-web/src/components/mdtodo/MdtodoCreateTaskDialog.vue b/web/hwlab-cloud-web/src/components/mdtodo/MdtodoCreateTaskDialog.vue index 52476214..91a084b1 100644 --- a/web/hwlab-cloud-web/src/components/mdtodo/MdtodoCreateTaskDialog.vue +++ b/web/hwlab-cloud-web/src/components/mdtodo/MdtodoCreateTaskDialog.vue @@ -9,6 +9,7 @@ defineProps<{ newTitle: string; newBody: string; loading: boolean; + disabled: boolean; error: string | null; hasSelectedTask: boolean; }>(); @@ -24,15 +25,16 @@ const emit = defineEmits<{