diff --git a/MDTODO.md b/MDTODO.md new file mode 100644 index 00000000..63065321 --- /dev/null +++ b/MDTODO.md @@ -0,0 +1,7 @@ +# HWLAB v0.3 Project Management MDTODO + +- [x] Implement project management root navigation and MDTODO read page. +- [ ] Launch Workbench from a selected MDTODO task through the public Workbench API. + - [ ] Persist the `ProjectWorkbenchLink` projection for the launched session. + - [ ] Verify the Workbench session detail exposes only stable project/task metadata. +- [-] Track project-management specific web-probe observe/analyze support in the follow-up phase. diff --git a/cmd/hwlab-project-management/main.ts b/cmd/hwlab-project-management/main.ts index 68eb5d9f..6d320e5a 100644 --- a/cmd/hwlab-project-management/main.ts +++ b/cmd/hwlab-project-management/main.ts @@ -35,7 +35,8 @@ process.stdout.write(`${JSON.stringify({ "/v1/project-management/mdtodo/sources", "/v1/project-management/mdtodo/files", "/v1/project-management/mdtodo/tasks", - "/v1/project-management/workbench-links" + "GET /v1/project-management/workbench-links", + "POST /v1/project-management/workbench-links" ] })}\n`); diff --git a/internal/cloud/project-management-proxy.ts b/internal/cloud/project-management-proxy.ts index f2ed717d..f5f8451e 100644 --- a/internal/cloud/project-management-proxy.ts +++ b/internal/cloud/project-management-proxy.ts @@ -17,8 +17,8 @@ export async function handleProjectManagementProxyHttp(request, response, url, o return; } - if (request.method !== "GET" && request.method !== "HEAD") { - sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "Project management P1 API is read-only" } }); + if (!isAllowedProjectManagementMethod(request.method, url.pathname)) { + sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "Project management API only accepts read requests plus Workbench link writes" } }); return; } @@ -69,6 +69,11 @@ export async function handleProjectManagementProxyHttp(request, response, url, o } } +function isAllowedProjectManagementMethod(method, pathname) { + if (method === "GET" || method === "HEAD") return true; + return method === "POST" && pathname === "/v1/project-management/workbench-links"; +} + function bridgeHeaders(request, auth) { const headers = new Headers(); for (const name of ["accept", "x-request-id", "traceparent", "tracestate"]) { diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index a8150893..1fce5272 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -891,6 +891,7 @@ function factSessionSummary(session, facts = {}) { const traceStatus = normalizeTerminalStatus(trace?.status); const checkpointStatus = normalizeTerminalStatus(checkpoint?.status); const turnStatus = normalizeStatus(turn?.status); + const launchContext = compactLaunchContext(session?.sessionJson?.launchContext); // Terminal checkpoint/trace evidence is the turn lifecycle authority. A // stale running turn row must not keep the visible timer running after the // terminal event has already been projected. @@ -911,6 +912,7 @@ function factSessionSummary(session, facts = {}) { staleMs: projection?.staleMs ?? null, blocker: projection?.blocker ?? null, providerProfile: textValue(session?.providerProfile ?? session?.sessionJson?.providerProfile) || null, + launchContext, messageCount: messages.length, firstUserMessagePreview: firstUserPreview(messages), updatedAt: factUpdatedAt(session), @@ -944,7 +946,8 @@ function factSessionDetail(session, facts = {}) { metadata: { startedAt: session?.startedAt ?? session?.createdAt ?? null, endedAt: session?.endedAt ?? null, - ownerUserId: session?.ownerUserId ?? null + ownerUserId: session?.ownerUserId ?? null, + launchContext: compactLaunchContext(session?.sessionJson?.launchContext) }, messagePageUrl: sessionId ? `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages` : null, valuesRedacted: true, @@ -952,6 +955,25 @@ function factSessionDetail(session, facts = {}) { }; } +function compactLaunchContext(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const projectId = textValue(value.projectId) || null; + const taskRef = textValue(value.taskRef) || null; + if (!projectId && !taskRef) return null; + return { + source: textValue(value.source) || "project-management", + projectId, + taskRef, + sourceId: textValue(value.sourceId) || null, + fileRef: textValue(value.fileRef) || null, + taskId: textValue(value.taskId) || null, + title: textValue(value.title) || null, + status: textValue(value.status) || null, + route: textValue(value.route) || "/projects/mdtodo", + valuesRedacted: true + }; +} + function factMessagesForSession(session, facts = {}) { const sessionId = factSessionId(session); if (!sessionId) return []; diff --git a/internal/cloud/server-workbench-launch-http.ts b/internal/cloud/server-workbench-launch-http.ts new file mode 100644 index 00000000..d05f3886 --- /dev/null +++ b/internal/cloud/server-workbench-launch-http.ts @@ -0,0 +1,181 @@ +/* + * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-010403 API契约 draft-2026-06-18-r1. + * 职责: Workbench 公共 launch API,用稳定 project/task 元数据创建 Workbench session 并写入项目管理 link 投影。 + */ +import { createHash, randomUUID } from "node:crypto"; + +import { getHeader, readBody, safeConversationId, safeOpaqueId, safeSessionId, safeTraceId, sendJson } from "./server-http-utils.ts"; +import { writeWorkbenchSessionAdmissionFact } from "./workbench-projection-writer.ts"; + +const contractVersion = "workbench-launch-v1"; +const defaultProjectId = "project_hwlab_v03"; + +export async function handleWorkbenchLaunchHttp(request, response, options = {}) { + if (request.method !== "POST") return sendJson(response, 405, launchError("method_not_allowed", "POST required.")); + const auth = await options.accessController?.authenticate?.(request, { required: true }); + if (!auth?.ok) return sendJson(response, auth?.status ?? 401, auth ?? launchError("auth_required", "Authentication is required.")); + + const body = await readJsonObject(request, options.bodyLimitBytes); + if (!body.ok) return sendJson(response, 400, launchError(body.code, body.message, { reason: body.reason })); + + const params = body.value; + const projectId = safeToken(params.projectId, defaultProjectId); + const taskRef = safeProjectOpaque(params.taskRef); + if (!taskRef) return sendJson(response, 400, launchError("task_ref_required", "Workbench launch requires taskRef.")); + + const now = new Date().toISOString(); + const conversationId = safeConversationId(params.conversationId) || `cnv_${randomUUID()}`; + const sessionId = safeSessionId(params.sessionId) || `ses_${randomUUID()}`; + const traceId = safeTraceId(params.traceId) || null; + const providerProfile = safeToken(params.providerProfile ?? params.codeAgentProviderProfile, null); + const launchContext = normalizeLaunchContext(params.launchContext, { projectId, taskRef, now }); + const linkId = safeToken(params.linkId, stableLinkId({ projectId, taskRef, sessionId })); + + const session = await options.accessController.recordAgentSessionOwner({ + ownerUserId: auth.actor?.id, + ownerRole: auth.actor?.role, + sessionId, + projectId, + agentId: "hwlab-code-agent", + status: "idle", + conversationId, + threadId: safeOpaqueId(params.threadId), + traceId, + session: { + source: "workbench-launch", + providerProfile, + sessionStatus: "idle", + launchContext, + createdAt: now, + valuesRedacted: true, + secretMaterialStored: false + } + }); + await writeWorkbenchSessionAdmissionFact({ + runtimeStore: options.runtimeStore, + session, + ownerUserId: auth.actor?.id, + ownerRole: auth.actor?.role, + projectId, + conversationId, + threadId: safeOpaqueId(params.threadId), + status: "idle", + now + }); + + const workbenchUrl = `/workbench/sessions/${encodeURIComponent(session?.id ?? sessionId)}`; + const linkWrite = await writeProjectManagementLink(request, { + env: options.env ?? process.env, + auth, + link: { + linkId, + projectId, + taskRef, + sessionId: session?.id ?? sessionId, + traceId, + role: "launch", + createdBy: auth.actor?.id ?? null, + link: { launchContext, workbenchUrl, valuesRedacted: true } + } + }); + if (!linkWrite.ok) return sendJson(response, linkWrite.status, launchError(linkWrite.code, linkWrite.message, { sessionId: session?.id ?? sessionId, retryable: true })); + + return sendJson(response, 201, { + ok: true, + status: "created", + contractVersion, + sessionId: session?.id ?? sessionId, + conversationId, + projectId, + taskRef, + workbenchUrl, + linkId: linkWrite.link?.linkId ?? linkId, + link: linkWrite.link ?? null, + launchContext, + valuesRedacted: true, + secretMaterialStored: false + }); +} + +async function readJsonObject(request, bodyLimitBytes) { + const body = await readBody(request, bodyLimitBytes); + try { + const value = body ? JSON.parse(body) : {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, code: "invalid_params", message: "body must be a JSON object" }; + return { ok: true, value }; + } catch (error) { + return { ok: false, code: "parse_error", message: "Invalid JSON body", reason: error?.message ?? String(error) }; + } +} + +async function writeProjectManagementLink(request, { env, auth, link }) { + const baseUrl = String(env.HWLAB_PROJECT_MANAGEMENT_URL ?? "").trim(); + if (!baseUrl) return { ok: false, status: 503, code: "project_management_service_unconfigured", message: "HWLAB_PROJECT_MANAGEMENT_URL is required to write Workbench link projection." }; + let upstreamUrl; + try { + upstreamUrl = new URL("/v1/project-management/workbench-links", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); + } catch { + return { ok: false, status: 503, code: "project_management_service_url_invalid", message: "HWLAB_PROJECT_MANAGEMENT_URL is invalid." }; + } + const headers = new Headers({ accept: "application/json", "content-type": "application/json", "x-hwlab-bridge-service": "hwlab-cloud-api" }); + for (const name of ["x-request-id", "traceparent", "tracestate"]) { + const value = getHeader(request, name); + if (value) headers.set(name, value); + } + if (auth.actor?.id) headers.set("x-hwlab-actor-id", auth.actor.id); + if (auth.actor?.role) headers.set("x-hwlab-actor-role", auth.actor.role); + headers.set("x-hwlab-auth-method", auth.authMethod ?? (auth.apiKey ? "api-key" : auth.session ? "web-session" : "unknown")); + try { + const upstream = await fetch(upstreamUrl, { method: "POST", headers, body: JSON.stringify(link), signal: AbortSignal.timeout(positiveInteger(env.HWLAB_PROJECT_MANAGEMENT_PROXY_TIMEOUT_MS, 30000)) }); + const payload = await upstream.json().catch(() => null); + if (!upstream.ok) return { ok: false, status: upstream.status, code: payload?.error?.code ?? "project_management_link_write_failed", message: payload?.error?.message ?? "Project management link write failed." }; + return { ok: true, status: upstream.status, link: payload?.link ?? null }; + } catch (error) { + return { ok: false, status: 502, code: "project_management_link_proxy_failed", message: error?.message ?? "Project management link write failed." }; + } +} + +function normalizeLaunchContext(value, { projectId, taskRef, now }) { + const input = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + return { + source: "project-management", + projectId, + taskRef, + sourceId: safeToken(input.sourceId, null), + fileRef: safeToken(input.fileRef, null), + taskId: safeToken(input.taskId, null), + title: shortText(input.title, 220), + status: safeToken(input.status, null), + route: "/projects/mdtodo", + launchedAt: now, + valuesRedacted: true + }; +} + +function launchError(code, message, extra = {}) { + return { ok: false, contractVersion, error: { code, message, status: extra.status ?? null, retryable: extra.retryable ?? false, sessionId: extra.sessionId ?? null, reason: extra.reason ?? null }, valuesRedacted: true }; +} + +function stableLinkId({ projectId, taskRef, sessionId }) { + return `lnk_${createHash("sha256").update(`${projectId}\n${taskRef}\n${sessionId}`).digest("hex").slice(0, 24)}`; +} + +function safeToken(value, fallback) { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.:-]{1,160}$/u.test(text) ? text : fallback; +} + +function safeProjectOpaque(value) { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null; +} + +function shortText(value, max) { + const text = String(value ?? "").replace(/\s+/gu, " ").trim(); + return text ? text.slice(0, max) : null; +} + +function positiveInteger(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : fallback; +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 7850d7b4..b31c067e 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -70,6 +70,7 @@ import { startAgentRunProjectionResume } from "./server-code-agent-http.ts"; import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts"; +import { handleWorkbenchLaunchHttp } from "./server-workbench-launch-http.ts"; import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleSkillsHttp } from "./server-skills-http.ts"; @@ -723,6 +724,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/workbench/launches") { + await handleWorkbenchLaunchHttp(request, response, options); + return; + } + if (url.pathname === "/v1/workbench/sessions" || url.pathname.startsWith("/v1/workbench/sessions/") || url.pathname.startsWith("/v1/workbench/turns/") || url.pathname.startsWith("/v1/workbench/traces/")) { await handleWorkbenchReadModelHttp(request, response, url, options); return; diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index abe88496..c7da39dc 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -7,7 +7,7 @@ import { expect, test } from "bun:test"; import { createProjectManagementApp } from "./server.ts"; import { createProjectManagementStore } from "./store.ts"; -test("project management app exposes read-only MDTODO API matrix", async () => { +test("project management app exposes MDTODO read model and Workbench link writes", async () => { const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-")); try { await writeFile(join(root, "MDTODO.md"), "# Demo\n\n- [ ] Launch project nav\n- [x] Wire read model\n", "utf8"); @@ -38,6 +38,21 @@ test("project management app exposes read-only MDTODO API matrix", async () => { const links = await json(app, "/v1/project-management/workbench-links?projectId=project_test"); expect(links.links).toEqual([]); + const linkResponse = await app.fetch(new Request("http://service/v1/project-management/workbench-links", { + method: "POST", + headers: { "content-type": "application/json", "x-hwlab-actor-id": "usr_test" }, + body: JSON.stringify({ projectId: "project_test", taskRef: tasks.tasks[0].taskRef, sessionId: "ses_project_test", role: "launch", link: { workbenchUrl: "/workbench/sessions/ses_project_test", launchContext: { projectId: "project_test", taskRef: tasks.tasks[0].taskRef, title: tasks.tasks[0].title } } }) + })); + expect(linkResponse.status).toBe(201); + const createdLink = await linkResponse.json(); + expect(createdLink.link.sessionId).toBe("ses_project_test"); + + const linkedTasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo"); + expect(linkedTasks.tasks[0].linkCount).toBe(1); + + 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" })); expect(rejected.status).toBe(405); diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts index 9695cb05..80e0cc82 100644 --- a/internal/project-management/server.ts +++ b/internal/project-management/server.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import path from "node:path"; import { discoverMdtodoDocuments } from "./mdtodo.ts"; @@ -78,7 +79,11 @@ export function createProjectManagementApp(options = {}) { } if (!url.pathname.startsWith("/v1/project-management")) return jsonError(404, "not_found", "Project management route is not implemented"); - if (request.method !== "GET" && request.method !== "HEAD") return jsonError(405, "method_not_allowed", "Project management P1 API is read-only"); + if (url.pathname === "/v1/project-management/workbench-links" && request.method === "POST") { + await initialize(); + return handleCreateWorkbenchLink(request, store); + } + 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(); if (url.pathname === "/v1/project-management" || url.pathname === "/v1/project-management/navigation") { @@ -163,7 +168,7 @@ function navigationPayload(actor) { ], capabilities: { mdtodoProjection: true, - workbenchLaunch: false, + workbenchLaunch: true, workbenchLinks: true, sourceOfTruth: "markdown-files" } @@ -198,6 +203,92 @@ function actorFromRequest(request) { }; } +async function readJsonObject(request) { + const text = await request.text(); + if (text.length > 32768) return { ok: false, code: "body_too_large", message: "Project management link body is too large" }; + try { + const value = text ? JSON.parse(text) : {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, code: "invalid_params", message: "body must be a JSON object" }; + return { ok: true, value }; + } catch (error) { + return { ok: false, code: "parse_error", message: "Invalid JSON body", reason: error?.message ?? String(error) }; + } +} + +async function handleCreateWorkbenchLink(request, store) { + const body = await readJsonObject(request); + if (!body.ok) return jsonError(400, body.code, body.message); + const normalized = normalizeWorkbenchLinkInput(body.value, actorFromRequest(request)); + if (!normalized.ok) return jsonError(400, normalized.code, normalized.message); + const link = await store.upsertWorkbenchLink(normalized.link); + return json(201, envelope({ link })); +} + +function normalizeWorkbenchLinkInput(input, actor) { + const projectId = safeToken(input.projectId, null); + const taskRef = safeOpaqueValue(input.taskRef); + const sessionId = safeOpaqueValue(input.sessionId); + if (!projectId) return { ok: false, code: "project_id_required", message: "ProjectWorkbenchLink requires projectId" }; + if (!taskRef) return { ok: false, code: "task_ref_required", message: "ProjectWorkbenchLink requires taskRef" }; + if (!sessionId) return { ok: false, code: "session_id_required", message: "ProjectWorkbenchLink requires sessionId" }; + const traceId = safeOpaqueValue(input.traceId); + const role = safeToken(input.role, "launch"); + const link = sanitizeLinkJson(input.link); + return { + ok: true, + link: { + linkId: safeToken(input.linkId, stableLinkId({ projectId, taskRef, sessionId })), + projectId, + taskRef, + sessionId, + traceId, + createdBy: safeOpaqueValue(input.createdBy) ?? actor?.id ?? null, + role, + link + } + }; +} + +function sanitizeLinkJson(value) { + const input = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + const context = input.launchContext && typeof input.launchContext === "object" && !Array.isArray(input.launchContext) ? input.launchContext : {}; + return { + launchContext: { + source: "project-management", + projectId: safeToken(context.projectId, null), + taskRef: safeOpaqueValue(context.taskRef), + sourceId: safeToken(context.sourceId, null), + fileRef: safeToken(context.fileRef, null), + taskId: safeToken(context.taskId, null), + title: shortText(context.title, 220), + status: safeToken(context.status, null), + route: "/projects/mdtodo", + valuesRedacted: true + }, + workbenchUrl: safeWorkbenchUrl(input.workbenchUrl), + valuesRedacted: true + }; +} + +function stableLinkId({ projectId, taskRef, sessionId }) { + return `lnk_${createHash("sha256").update(`${projectId}\n${taskRef}\n${sessionId}`).digest("hex").slice(0, 24)}`; +} + +function safeOpaqueValue(value) { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null; +} + +function safeWorkbenchUrl(value) { + const text = String(value ?? "").trim(); + return /^\/workbench\/sessions\/[^/?#]+$/u.test(text) ? text : null; +} + +function shortText(value, max) { + const text = String(value ?? "").replace(/\s+/gu, " ").trim(); + return text ? text.slice(0, max) : null; +} + function json(status, payload) { return new Response(`${JSON.stringify(payload)}\n`, { status, diff --git a/internal/project-management/store.ts b/internal/project-management/store.ts index b7356940..ea079a1c 100644 --- a/internal/project-management/store.ts +++ b/internal/project-management/store.ts @@ -285,18 +285,35 @@ export class PostgresProjectManagementStore { ORDER BY updated_at DESC, link_id LIMIT 200`, params ); - return result.rows.map((row) => ({ - linkId: row.link_id, - projectId: row.project_id, - taskRef: row.task_ref, - sessionId: row.session_id, - traceId: row.trace_id, - createdBy: row.created_by, - role: row.role, - createdAt: row.created_at, - updatedAt: row.updated_at, - link: row.link_json ?? {} - })); + return result.rows.map(workbenchLinkFromRow); + } + + async upsertWorkbenchLink(link) { + await this.ensureSchema(); + const result = await this.pool.query( + `INSERT INTO project_workbench_links (link_id, project_id, task_ref, session_id, trace_id, created_by, role, link_json, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now()) + ON CONFLICT (link_id) DO UPDATE SET + project_id = EXCLUDED.project_id, + task_ref = EXCLUDED.task_ref, + session_id = EXCLUDED.session_id, + trace_id = EXCLUDED.trace_id, + created_by = COALESCE(EXCLUDED.created_by, project_workbench_links.created_by), + role = EXCLUDED.role, + link_json = EXCLUDED.link_json, + updated_at = now() + RETURNING link_id, project_id, task_ref, session_id, trace_id, created_by, role, link_json, created_at, updated_at`, + [link.linkId, link.projectId, link.taskRef ?? null, link.sessionId ?? null, link.traceId ?? null, link.createdBy ?? null, link.role ?? "launch", JSON.stringify(link.link ?? {})] + ); + if (link.taskRef) { + await this.pool.query( + `UPDATE mdtodo_task_projection + SET link_count = (SELECT COUNT(*)::int FROM project_workbench_links WHERE task_ref = $1), updated_at = now() + WHERE task_ref = $1`, + [link.taskRef] + ); + } + return workbenchLinkFromRow(result.rows[0]); } async close() { @@ -334,6 +351,19 @@ class MemoryProjectManagementStore { async listWorkbenchLinks(filters = {}) { return this.links.filter((item) => (!filters.taskRef || item.taskRef === filters.taskRef) && (!filters.projectId || item.projectId === filters.projectId) && (!filters.sessionId || item.sessionId === filters.sessionId)); } + async upsertWorkbenchLink(link) { + const now = new Date().toISOString(); + const existingIndex = this.links.findIndex((item) => item.linkId === link.linkId); + const existing = existingIndex >= 0 ? this.links[existingIndex] : null; + const saved = { ...existing, ...link, role: link.role ?? "launch", link: link.link ?? existing?.link ?? {}, createdAt: existing?.createdAt ?? now, updatedAt: now }; + if (existingIndex >= 0) this.links.splice(existingIndex, 1, saved); + else this.links.unshift(saved); + if (link.taskRef) { + const count = this.links.filter((item) => item.taskRef === link.taskRef).length; + this.tasks = this.tasks.map((task) => task.taskRef === link.taskRef ? { ...task, linkCount: count, updatedAt: now } : task); + } + return saved; + } } class UnconfiguredProjectManagementStore { @@ -355,4 +385,20 @@ class UnconfiguredProjectManagementStore { async listDocuments() { await this.ensureSchema(); } async listTasks() { await this.ensureSchema(); } async listWorkbenchLinks() { await this.ensureSchema(); } + async upsertWorkbenchLink() { await this.ensureSchema(); } +} + +function workbenchLinkFromRow(row) { + return { + linkId: row.link_id, + projectId: row.project_id, + taskRef: row.task_ref, + sessionId: row.session_id, + traceId: row.trace_id, + createdBy: row.created_by, + role: row.role, + createdAt: row.created_at, + updatedAt: row.updated_at, + link: row.link_json ?? {} + }; } diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index c3ff3395..e33ecf0b 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -31,6 +31,7 @@ interface ScenarioState { requestLedger: JsonRecord[]; legacyRequestLedger: JsonRecord[]; chatRequests: JsonRecord[]; + projectLinks: JsonRecord[]; listOmitSelected: boolean; sessionDelayMs: number; sessionDetailDelayMs: number; @@ -142,6 +143,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) } if (path === "/auth/login" && method === "POST") return authLoginResponse(response); if (path === "/v1/workbench/events" && method === "GET") return sse(request, response, url); + if (path === "/v1/workbench/launches" && method === "POST") return json(response, 201, await createWorkbenchLaunch(request)); if (path === "/v1/workbench/sessions" && method === "GET") { await delay(state.sessionDelayMs); return json(response, 200, workbenchSessionListPayload(url)); @@ -226,7 +228,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] }); if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload()); if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload()); - if (path.startsWith("/v1/project-management")) return projectManagementPayload(response, url, method); + if (path.startsWith("/v1/project-management")) return projectManagementPayload(request, response, url, method); if (path === "/v1/web-performance/summary") { if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。"); const matrixStatus = performanceSummaryErrorStatus(state.scenarioId); @@ -285,13 +287,20 @@ function hwpodNodeOpsPayload(): JsonRecord { }; } -function projectManagementPayload(response: ServerResponse, url: URL, method: string): void { - 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 }); +async function projectManagementPayload(request: IncomingMessage, response: ServerResponse, url: URL, method: string): Promise { const path = url.pathname; const source = projectManagementSource(); const files = projectManagementFiles(); - const tasks = projectManagementTasks(); - const links = projectManagementLinks(); + const links = state.projectLinks; + const tasks = projectManagementTasks(links); + + if (path === "/v1/project-management/workbench-links" && method === "POST") { + const body = await readJson(request); + const link = projectManagementLinkFromBody(body); + state.projectLinks.unshift(link); + return json(response, 201, projectManagementEnvelope({ link })); + } + 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") { return json(response, 200, projectManagementEnvelope({ @@ -303,7 +312,7 @@ function projectManagementPayload(response: ServerResponse, url: URL, method: st { id: "projects", label: "Projects", apiRoute: "/v1/project-management/projects" }, { id: "mdtodo", label: "MDTODO", apiRoute: "/v1/project-management/mdtodo/tasks" } ], - capabilities: { mdtodoProjection: true, workbenchLaunch: false, workbenchLinks: true, sourceOfTruth: "markdown-files" } + capabilities: { mdtodoProjection: true, workbenchLaunch: true, workbenchLinks: true, sourceOfTruth: "markdown-files" } }, projection: { sourceId: source.sourceId, documentCount: files.length, taskCount: tasks.length, projectedAt: "2026-06-25T09:00:00.000Z" } })); @@ -331,18 +340,79 @@ function projectManagementFiles(): JsonRecord[] { ]; } -function projectManagementTasks(): JsonRecord[] { +function projectManagementTasks(links: JsonRecord[] = []): JsonRecord[] { + const linkCount = (taskRef: string) => links.filter((link) => link.taskRef === taskRef).length; return [ - { taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "t1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: 1, updatedAt: "2026-06-25T09:00:00.000Z" }, - { taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t2", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "t2", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", depth: 1, lineNumber: 5, ordinal: 2, linkCount: 0, updatedAt: "2026-06-25T09:00:00.000Z" }, - { taskRef: "mdtodo:hwlab-v03-mdtodo:file_rollout:t1", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "t1", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 3, linkCount: 0, updatedAt: "2026-06-25T09:00:00.000Z" } + { taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "t1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: linkCount("mdtodo:hwlab-v03-mdtodo:file_plan:t1"), updatedAt: "2026-06-25T09:00:00.000Z" }, + { taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t2", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "t2", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", depth: 1, lineNumber: 5, ordinal: 2, linkCount: linkCount("mdtodo:hwlab-v03-mdtodo:file_plan:t2"), updatedAt: "2026-06-25T09:00:00.000Z" }, + { taskRef: "mdtodo:hwlab-v03-mdtodo:file_rollout:t1", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "t1", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 3, linkCount: linkCount("mdtodo:hwlab-v03-mdtodo:file_rollout:t1"), updatedAt: "2026-06-25T09:00:00.000Z" } ]; } -function projectManagementLinks(): JsonRecord[] { +function projectManagementSeedLinks(): JsonRecord[] { return [{ linkId: "lnk_project_task_root", projectId: "project_hwlab_v03", taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", sessionId: "ses_project_seed", traceId: "trc_project_seed", role: "reference", updatedAt: "2026-06-25T09:00:00.000Z" }]; } +function projectManagementLinkFromBody(body: JsonRecord): JsonRecord { + const now = new Date().toISOString(); + return { + linkId: String(body.linkId ?? `lnk_project_launch_${Date.now().toString(36)}`), + projectId: String(body.projectId ?? "project_hwlab_v03"), + taskRef: typeof body.taskRef === "string" ? body.taskRef : null, + sessionId: typeof body.sessionId === "string" ? body.sessionId : null, + traceId: typeof body.traceId === "string" ? body.traceId : null, + role: String(body.role ?? "launch"), + updatedAt: now, + link: body.link && typeof body.link === "object" ? body.link : { valuesRedacted: true } + }; +} + +async function createWorkbenchLaunch(request: IncomingMessage): Promise { + const body = await readJson(request); + const now = new Date().toISOString(); + const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_project_launch_${Date.now().toString(36)}`; + const conversationId = typeof body.conversationId === "string" && body.conversationId.trim() ? body.conversationId : `cnv_project_launch_${Date.now().toString(36)}`; + const launchContext = body.launchContext && typeof body.launchContext === "object" ? body.launchContext as JsonRecord : {}; + const session: SessionRecord = { + sessionId, + conversationId, + status: "idle", + projectId: String(body.projectId ?? launchContext.projectId ?? "project_hwlab_v03"), + launchContext, + metadata: { launchContext }, + startedAt: now, + updatedAt: now, + messageCount: 0, + firstUserMessagePreview: null, + messages: [] + }; + state.sessions.unshift(session); + const link = projectManagementLinkFromBody({ + linkId: `lnk_project_launch_${sessionId.replace(/^ses_/u, "")}`, + projectId: session.projectId, + taskRef: body.taskRef, + sessionId, + traceId: null, + role: "launch", + link: { launchContext, workbenchUrl: `/workbench/sessions/${sessionId}`, valuesRedacted: true } + }); + state.projectLinks.unshift(link); + return { + ok: true, + status: "created", + contractVersion: "workbench-launch-v1", + sessionId, + conversationId, + projectId: session.projectId, + taskRef: body.taskRef, + workbenchUrl: `/workbench/sessions/${sessionId}`, + linkId: link.linkId, + link, + launchContext, + valuesRedacted: true + }; +} + function webPerformanceSummaryPayload(url: URL): JsonRecord { const sampleWindow = fakePerformanceWindow(url.searchParams.get("window")); const rows = [ @@ -808,6 +878,7 @@ function createScenarioState(scenarioId: string): ScenarioState { requestLedger: [], legacyRequestLedger: [], chatRequests: [], + projectLinks: projectManagementSeedLinks(), 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/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index 6b14409b..38e25123 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -43,10 +43,45 @@ export interface WorkbenchTraceRequestOptions { limit?: number | null; } +export interface WorkbenchLaunchContext { + source?: string | null; + projectId: string; + taskRef: string; + sourceId?: string | null; + fileRef?: string | null; + taskId?: string | null; + title?: string | null; + status?: string | null; +} + +export interface WorkbenchLaunchRequest { + projectId: string; + taskRef: string; + sessionId?: string | null; + conversationId?: string | null; + providerProfile?: string | null; + launchContext?: WorkbenchLaunchContext; +} + +export interface WorkbenchLaunchResponse { + ok?: boolean; + status?: string; + contractVersion?: string; + sessionId?: string | null; + conversationId?: string | null; + projectId?: string | null; + taskRef?: string | null; + workbenchUrl?: string | null; + linkId?: string | null; + launchContext?: WorkbenchLaunchContext | null; + valuesRedacted?: boolean; +} + const SESSION_LIST_TIMEOUT_MS = 30_000; const SESSION_DETAIL_TIMEOUT_MS = 45_000; export const workbenchAPI = { + launch: (input: WorkbenchLaunchRequest): Promise> => fetchJson("/v1/workbench/launches", { method: "POST", body: JSON.stringify(input), timeoutMs: 30000, timeoutName: "workbench launch" }), sessions: (options: SessionListOptions = {}): Promise> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }), session: (sessionId: string, timeoutMs: number | null = null): Promise> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }), sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }), diff --git a/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue b/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue index fc0fa431..5133da8e 100644 --- a/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue +++ b/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue @@ -1,17 +1,21 @@ - +