feat: add project workbench launch

This commit is contained in:
lyon
2026-06-25 17:59:19 +08:00
parent cc6561e506
commit 06284f5287
14 changed files with 575 additions and 40 deletions
+7
View File
@@ -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.
+2 -1
View File
@@ -35,7 +35,8 @@ process.stdout.write(`${JSON.stringify({
"/v1/project-management/mdtodo/sources", "/v1/project-management/mdtodo/sources",
"/v1/project-management/mdtodo/files", "/v1/project-management/mdtodo/files",
"/v1/project-management/mdtodo/tasks", "/v1/project-management/mdtodo/tasks",
"/v1/project-management/workbench-links" "GET /v1/project-management/workbench-links",
"POST /v1/project-management/workbench-links"
] ]
})}\n`); })}\n`);
+7 -2
View File
@@ -17,8 +17,8 @@ export async function handleProjectManagementProxyHttp(request, response, url, o
return; return;
} }
if (request.method !== "GET" && request.method !== "HEAD") { if (!isAllowedProjectManagementMethod(request.method, url.pathname)) {
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "Project management P1 API is read-only" } }); sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "Project management API only accepts read requests plus Workbench link writes" } });
return; 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) { function bridgeHeaders(request, auth) {
const headers = new Headers(); const headers = new Headers();
for (const name of ["accept", "x-request-id", "traceparent", "tracestate"]) { for (const name of ["accept", "x-request-id", "traceparent", "tracestate"]) {
+23 -1
View File
@@ -891,6 +891,7 @@ function factSessionSummary(session, facts = {}) {
const traceStatus = normalizeTerminalStatus(trace?.status); const traceStatus = normalizeTerminalStatus(trace?.status);
const checkpointStatus = normalizeTerminalStatus(checkpoint?.status); const checkpointStatus = normalizeTerminalStatus(checkpoint?.status);
const turnStatus = normalizeStatus(turn?.status); const turnStatus = normalizeStatus(turn?.status);
const launchContext = compactLaunchContext(session?.sessionJson?.launchContext);
// Terminal checkpoint/trace evidence is the turn lifecycle authority. A // Terminal checkpoint/trace evidence is the turn lifecycle authority. A
// stale running turn row must not keep the visible timer running after the // stale running turn row must not keep the visible timer running after the
// terminal event has already been projected. // terminal event has already been projected.
@@ -911,6 +912,7 @@ function factSessionSummary(session, facts = {}) {
staleMs: projection?.staleMs ?? null, staleMs: projection?.staleMs ?? null,
blocker: projection?.blocker ?? null, blocker: projection?.blocker ?? null,
providerProfile: textValue(session?.providerProfile ?? session?.sessionJson?.providerProfile) || null, providerProfile: textValue(session?.providerProfile ?? session?.sessionJson?.providerProfile) || null,
launchContext,
messageCount: messages.length, messageCount: messages.length,
firstUserMessagePreview: firstUserPreview(messages), firstUserMessagePreview: firstUserPreview(messages),
updatedAt: factUpdatedAt(session), updatedAt: factUpdatedAt(session),
@@ -944,7 +946,8 @@ function factSessionDetail(session, facts = {}) {
metadata: { metadata: {
startedAt: session?.startedAt ?? session?.createdAt ?? null, startedAt: session?.startedAt ?? session?.createdAt ?? null,
endedAt: session?.endedAt ?? 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, messagePageUrl: sessionId ? `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages` : null,
valuesRedacted: true, 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 = {}) { function factMessagesForSession(session, facts = {}) {
const sessionId = factSessionId(session); const sessionId = factSessionId(session);
if (!sessionId) return []; if (!sessionId) return [];
@@ -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;
}
+6
View File
@@ -70,6 +70,7 @@ import {
startAgentRunProjectionResume startAgentRunProjectionResume
} from "./server-code-agent-http.ts"; } from "./server-code-agent-http.ts";
import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-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 { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts";
import { handleSkillsHttp } from "./server-skills-http.ts"; import { handleSkillsHttp } from "./server-skills-http.ts";
@@ -723,6 +724,11 @@ async function handleRestAdapter(request, response, url, options) {
return; 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/")) { 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); await handleWorkbenchReadModelHttp(request, response, url, options);
return; return;
+16 -1
View File
@@ -7,7 +7,7 @@ import { expect, test } from "bun:test";
import { createProjectManagementApp } from "./server.ts"; import { createProjectManagementApp } from "./server.ts";
import { createProjectManagementStore } from "./store.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-")); const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-"));
try { try {
await writeFile(join(root, "MDTODO.md"), "# Demo\n\n- [ ] Launch project nav\n- [x] Wire read model\n", "utf8"); 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"); const links = await json(app, "/v1/project-management/workbench-links?projectId=project_test");
expect(links.links).toEqual([]); 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" })); const rejected = await app.fetch(new Request("http://service/v1/project-management/mdtodo/tasks", { method: "POST" }));
expect(rejected.status).toBe(405); expect(rejected.status).toBe(405);
+93 -2
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import path from "node:path"; import path from "node:path";
import { discoverMdtodoDocuments } from "./mdtodo.ts"; 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 (!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(); await initialize();
if (url.pathname === "/v1/project-management" || url.pathname === "/v1/project-management/navigation") { if (url.pathname === "/v1/project-management" || url.pathname === "/v1/project-management/navigation") {
@@ -163,7 +168,7 @@ function navigationPayload(actor) {
], ],
capabilities: { capabilities: {
mdtodoProjection: true, mdtodoProjection: true,
workbenchLaunch: false, workbenchLaunch: true,
workbenchLinks: true, workbenchLinks: true,
sourceOfTruth: "markdown-files" 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) { function json(status, payload) {
return new Response(`${JSON.stringify(payload)}\n`, { return new Response(`${JSON.stringify(payload)}\n`, {
status, status,
+58 -12
View File
@@ -285,18 +285,35 @@ export class PostgresProjectManagementStore {
ORDER BY updated_at DESC, link_id LIMIT 200`, ORDER BY updated_at DESC, link_id LIMIT 200`,
params params
); );
return result.rows.map((row) => ({ return result.rows.map(workbenchLinkFromRow);
linkId: row.link_id, }
projectId: row.project_id,
taskRef: row.task_ref, async upsertWorkbenchLink(link) {
sessionId: row.session_id, await this.ensureSchema();
traceId: row.trace_id, const result = await this.pool.query(
createdBy: row.created_by, `INSERT INTO project_workbench_links (link_id, project_id, task_ref, session_id, trace_id, created_by, role, link_json, created_at, updated_at)
role: row.role, VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())
createdAt: row.created_at, ON CONFLICT (link_id) DO UPDATE SET
updatedAt: row.updated_at, project_id = EXCLUDED.project_id,
link: row.link_json ?? {} 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() { async close() {
@@ -334,6 +351,19 @@ class MemoryProjectManagementStore {
async listWorkbenchLinks(filters = {}) { 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)); 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 { class UnconfiguredProjectManagementStore {
@@ -355,4 +385,20 @@ class UnconfiguredProjectManagementStore {
async listDocuments() { await this.ensureSchema(); } async listDocuments() { await this.ensureSchema(); }
async listTasks() { await this.ensureSchema(); } async listTasks() { await this.ensureSchema(); }
async listWorkbenchLinks() { 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 ?? {}
};
} }
@@ -31,6 +31,7 @@ interface ScenarioState {
requestLedger: JsonRecord[]; requestLedger: JsonRecord[];
legacyRequestLedger: JsonRecord[]; legacyRequestLedger: JsonRecord[];
chatRequests: JsonRecord[]; chatRequests: JsonRecord[];
projectLinks: JsonRecord[];
listOmitSelected: boolean; listOmitSelected: boolean;
sessionDelayMs: number; sessionDelayMs: number;
sessionDetailDelayMs: 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 === "/auth/login" && method === "POST") return authLoginResponse(response);
if (path === "/v1/workbench/events" && method === "GET") return sse(request, response, url); 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") { if (path === "/v1/workbench/sessions" && method === "GET") {
await delay(state.sessionDelayMs); await delay(state.sessionDelayMs);
return json(response, 200, workbenchSessionListPayload(url)); 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/live-builds") return json(response, 200, { status: "ok", builds: [] });
if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload()); if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload());
if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload()); 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 (path === "/v1/web-performance/summary") {
if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。"); if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。");
const matrixStatus = performanceSummaryErrorStatus(state.scenarioId); const matrixStatus = performanceSummaryErrorStatus(state.scenarioId);
@@ -285,13 +287,20 @@ function hwpodNodeOpsPayload(): JsonRecord {
}; };
} }
function projectManagementPayload(response: ServerResponse, url: URL, method: string): void { async function projectManagementPayload(request: IncomingMessage, response: ServerResponse, url: URL, method: string): Promise<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 });
const path = url.pathname; const path = url.pathname;
const source = projectManagementSource(); const source = projectManagementSource();
const files = projectManagementFiles(); const files = projectManagementFiles();
const tasks = projectManagementTasks(); const links = state.projectLinks;
const links = projectManagementLinks(); 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") { if (path === "/v1/project-management" || path === "/v1/project-management/navigation") {
return json(response, 200, projectManagementEnvelope({ 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: "projects", label: "Projects", apiRoute: "/v1/project-management/projects" },
{ id: "mdtodo", label: "MDTODO", apiRoute: "/v1/project-management/mdtodo/tasks" } { 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" } 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 [ 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: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: 0, 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: 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: 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" }]; 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<JsonRecord> {
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 { function webPerformanceSummaryPayload(url: URL): JsonRecord {
const sampleWindow = fakePerformanceWindow(url.searchParams.get("window")); const sampleWindow = fakePerformanceWindow(url.searchParams.get("window"));
const rows = [ const rows = [
@@ -808,6 +878,7 @@ function createScenarioState(scenarioId: string): ScenarioState {
requestLedger: [], requestLedger: [],
legacyRequestLedger: [], legacyRequestLedger: [],
chatRequests: [], chatRequests: [],
projectLinks: projectManagementSeedLinks(),
listOmitSelected: id === "selected-missing-from-list", listOmitSelected: id === "selected-missing-from-list",
sessionDelayMs: id === "loading" ? 2_500 : 0, 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, sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : id === "submit-authority-race" ? 1_000 : 0,
+35
View File
@@ -43,10 +43,45 @@ export interface WorkbenchTraceRequestOptions {
limit?: number | null; 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_LIST_TIMEOUT_MS = 30_000;
const SESSION_DETAIL_TIMEOUT_MS = 45_000; const SESSION_DETAIL_TIMEOUT_MS = 45_000;
export const workbenchAPI = { export const workbenchAPI = {
launch: (input: WorkbenchLaunchRequest): Promise<ApiResult<WorkbenchLaunchResponse>> => fetchJson("/v1/workbench/launches", { method: "POST", body: JSON.stringify(input), timeoutMs: 30000, timeoutName: "workbench launch" }),
sessions: (options: SessionListOptions = {}): Promise<ApiResult<WorkbenchSessionListResponse>> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }), sessions: (options: SessionListOptions = {}): Promise<ApiResult<WorkbenchSessionListResponse>> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }),
session: (sessionId: string, timeoutMs: number | null = null): Promise<ApiResult<WorkbenchSessionDetailResponse>> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }), session: (sessionId: string, timeoutMs: number | null = null): Promise<ApiResult<WorkbenchSessionDetailResponse>> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }),
sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise<ApiResult<WorkbenchMessagePageResponse>> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }), sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise<ApiResult<WorkbenchMessagePageResponse>> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }),
@@ -1,17 +1,21 @@
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. --> <!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -->
<!-- Responsibility: MDTODO project page consuming public ProjectTask DTOs without Workbench or Markdown internals. --> <!-- Responsibility: MDTODO project page consuming public ProjectTask DTOs and Workbench launch DTOs without Workbench or Markdown internals. -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectWorkbenchLinkRecord } from "@/api"; import { useRouter } from "vue-router";
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectWorkbenchLinkRecord } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue"; import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue"; import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue"; import PageHeader from "@/components/common/PageHeader.vue";
import type { ApiError, ErrorDiagnostic } from "@/types"; import type { ApiError, ErrorDiagnostic } from "@/types";
const router = useRouter();
const loading = ref(false); const loading = ref(false);
const taskLoading = ref(false); const taskLoading = ref(false);
const linkLoading = ref(false); const linkLoading = ref(false);
const launchLoading = ref(false);
const launchError = ref<string | null>(null);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null); const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null); const diagnostic = ref<ErrorDiagnostic | null>(null);
@@ -34,6 +38,7 @@ const taskStatusCounts = computed(() => filteredTasks.value.reduce((acc, task) =
return acc; return acc;
}, {} as Record<string, number>)); }, {} as Record<string, number>));
const workbenchLaunchEnabled = computed(() => navigation.value?.navigation?.capabilities?.workbenchLaunch === true); const workbenchLaunchEnabled = computed(() => navigation.value?.navigation?.capabilities?.workbenchLaunch === true);
const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLaunchEnabled.value || launchLoading.value);
onMounted(() => void loadPage()); onMounted(() => void loadPage());
@@ -112,6 +117,37 @@ function selectTask(taskRef: string): void {
selectedTaskRef.value = taskRef; selectedTaskRef.value = taskRef;
} }
async function launchSelectedTask(): Promise<void> {
const task = selectedTask.value;
if (!task || !task.taskRef || !task.projectId || launchButtonDisabled.value) return;
launchLoading.value = true;
launchError.value = null;
try {
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
}
});
if (!response.ok) throw response;
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);
} catch (err) {
launchError.value = projectApiError(err).error;
} finally {
launchLoading.value = false;
}
}
function setError(err: unknown): void { function setError(err: unknown): void {
const context = projectApiError(err); const context = projectApiError(err);
error.value = context.error; error.value = context.error;
@@ -169,7 +205,7 @@ function displayDate(value?: string | null): string {
<article class="metric-card"><span>Source</span><strong>{{ sources.length }}</strong><small>{{ selectedSource?.displayName || '未选择' }}</small></article> <article class="metric-card"><span>Source</span><strong>{{ sources.length }}</strong><small>{{ selectedSource?.displayName || '未选择' }}</small></article>
<article class="metric-card"><span>Files</span><strong>{{ files.length }}</strong><small>{{ selectedFile?.relativePath || 'no file' }}</small></article> <article class="metric-card"><span>Files</span><strong>{{ files.length }}</strong><small>{{ selectedFile?.relativePath || 'no file' }}</small></article>
<article class="metric-card"><span>Tasks</span><strong>{{ filteredTasks.length }}</strong><small>open {{ taskStatusCounts.open || 0 }} / done {{ taskStatusCounts.done || 0 }}</small></article> <article class="metric-card"><span>Tasks</span><strong>{{ filteredTasks.length }}</strong><small>open {{ taskStatusCounts.open || 0 }} / done {{ taskStatusCounts.done || 0 }}</small></article>
<article class="metric-card"><span>Links</span><strong>{{ links.length }}</strong><small>{{ workbenchLaunchEnabled ? 'launch enabled' : 'launch waits for P3' }}</small></article> <article class="metric-card"><span>Links</span><strong>{{ links.length }}</strong><small>{{ workbenchLaunchEnabled ? 'launch enabled' : 'launch unavailable' }}</small></article>
</section> </section>
<section class="mdtodo-workspace"> <section class="mdtodo-workspace">
@@ -217,10 +253,11 @@ function displayDate(value?: string | null): string {
<div><dt>FileRef</dt><dd><code>{{ selectedTask.fileRef || '-' }}</code></dd></div> <div><dt>FileRef</dt><dd><code>{{ selectedTask.fileRef || '-' }}</code></dd></div>
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div> <div><dt>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div>
</dl> </dl>
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="!workbenchLaunchEnabled" :aria-disabled="!workbenchLaunchEnabled"> <button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="launchButtonDisabled" :aria-disabled="launchButtonDisabled" @click="launchSelectedTask">
Workbench 执行 {{ launchLoading ? '启动中' : '在 Workbench 执行' }}
</button> </button>
<p class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ workbenchLaunchEnabled ? '公共 Workbench Launch API 已可用。' : 'Workbench Launch 在 P3 接入;P2 只展示解耦入口和 capability。' }}</p> <p class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ workbenchLaunchEnabled ? '公共 Workbench Launch API 已可用。' : 'Workbench Launch capability 暂不可用。' }}</p>
<p v-if="launchError" class="launch-blocker" data-testid="mdtodo-workbench-launch-error">{{ launchError }}</p>
<section class="task-links" data-testid="mdtodo-workbench-link-summary"> <section class="task-links" data-testid="mdtodo-workbench-link-summary">
<header><strong>Workbench Links</strong><span v-if="linkLoading">同步中</span></header> <header><strong>Workbench Links</strong><span v-if="linkLoading">同步中</span></header>
<ul v-if="links.length"> <ul v-if="links.length">
@@ -152,11 +152,11 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
<EmptyState v-else title="暂无任务投影" description="MDTODO source 当前没有可展示任务,或尚未发现 Markdown TODO 文件。" /> <EmptyState v-else title="暂无任务投影" description="MDTODO source 当前没有可展示任务,或尚未发现 Markdown TODO 文件。" />
</section> </section>
<section class="data-panel project-link-panel" data-testid="project-workbench-links"> <section class="data-panel project-link-panel" data-testid="project-workbench-links">
<header class="project-panel-header"><div><h2>Workbench Link</h2><p>P2 只展示 link 投影启动动作在 P3 接入</p></div></header> <header class="project-panel-header"><div><h2>Workbench Link</h2><p>展示任务通过公共 Workbench Launch API 产生的 link 投影</p></div></header>
<ul v-if="links.length" class="link-list"> <ul v-if="links.length" class="link-list">
<li v-for="link in links" :key="link.linkId"><strong>{{ link.sessionId || link.linkId }}</strong><small>{{ link.taskRef || link.projectId || '-' }}</small></li> <li v-for="link in links" :key="link.linkId"><strong>{{ link.sessionId || link.linkId }}</strong><small>{{ link.taskRef || link.projectId || '-' }}</small></li>
</ul> </ul>
<EmptyState v-else title="暂无 Workbench 关联" description="完成 P3 后,任务启动的 session 会在这里出现。" /> <EmptyState v-else title="暂无 Workbench 关联" description="从 MDTODO 任务启动 Workbench 后,关联 session 会在这里出现。" />
</section> </section>
</section> </section>
</template> </template>
@@ -4,7 +4,7 @@
import { expect, saveScreenshot, test } from "../fixtures/test"; import { expect, saveScreenshot, test } from "../fixtures/test";
test.describe("project management pages", () => { test.describe("project management pages", () => {
test("renders project root and MDTODO DTOs without Workbench embedding", async ({ page }, testInfo) => { test("renders project root and launches Workbench through public DTOs", async ({ page }, testInfo) => {
await page.goto("/projects"); await page.goto("/projects");
await expect(page.getByTestId("project-management-root")).toBeVisible(); await expect(page.getByTestId("project-management-root")).toBeVisible();
await expect(page.getByTestId("project-management-summary")).toContainText("Projects"); await expect(page.getByTestId("project-management-summary")).toContainText("Projects");
@@ -20,8 +20,26 @@ test.describe("project management pages", () => {
await expect(page.getByTestId("mdtodo-task-tree")).toContainText("实现项目根导航"); await expect(page.getByTestId("mdtodo-task-tree")).toContainText("实现项目根导航");
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:t1"); await expect(page.getByTestId("mdtodo-task-detail")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:t1");
await expect(page.getByTestId("mdtodo-workbench-link-summary")).toContainText("ses_project_seed"); await expect(page.getByTestId("mdtodo-workbench-link-summary")).toContainText("ses_project_seed");
await expect(page.getByTestId("mdtodo-workbench-launch")).toBeDisabled(); await expect(page.getByTestId("mdtodo-workbench-launch")).toBeEnabled();
await expect(page.locator("iframe")).toHaveCount(0); await expect(page.locator("iframe")).toHaveCount(0);
await saveScreenshot(page, testInfo, "project-management-mdtodo"); await saveScreenshot(page, testInfo, "project-management-mdtodo");
await page.getByTestId("mdtodo-workbench-launch").click();
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_project_launch_/u);
await expect(page.locator("#workspace")).toBeVisible();
const sessionId = page.url().match(/\/workbench\/sessions\/([^/?#]+)/u)?.[1] ?? "";
expect(sessionId).toMatch(/^ses_project_launch_/u);
const sessionDetail = await page.request.get(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`);
expect(sessionDetail.ok()).toBeTruthy();
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:t1");
const links = await page.request.get("/v1/project-management/workbench-links?taskRef=mdtodo:hwlab-v03-mdtodo:file_plan:t1");
expect(links.ok()).toBeTruthy();
const linksPayload = await links.json();
expect(linksPayload.links.some((link: { sessionId?: string }) => link.sessionId === sessionId)).toBeTruthy();
await saveScreenshot(page, testInfo, "project-management-workbench-launch");
}); });
}); });