import { defineDomain } from "@deepseek-ai/dsh-storage-domain"; import { z } from "zod"; import { randomUUID } from "node:crypto"; import { isExactIdSet, nextSortIndex, parseName } from "./tree-utils.js"; const name = "dsh-project-tree"; const inject = ["webServer", "storageDomain", "workspaceRegistry"]; const ROUTE_PREFIX = "/dsh-project-tree"; const MAX_BODY_BYTES = 65536; const SESSION_ID_RE = /^(session-)?[0-9a-fA-F-]+$/; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const projectSchema = z.object({ id: z.string(), name: z.string(), sortIndex: z.number().int().min(0).optional() }); const groupSchema = z.object({ id: z.string(), projectId: z.string().optional(), workspaceId: z.string().optional(), name: z.string(), sortIndex: z.number().int().min(0).optional() }); const taskSchema = z.object({ id: z.string(), groupId: z.string(), name: z.string(), sessionIds: z.array(z.string()), sortIndex: z.number().int().min(0).optional() }); const treeDomainSpec = defineDomain({ name: "dsh_project_tree", version: 1, global: { schema: z.object({ projects: z.array(projectSchema).optional(), groups: z.array(groupSchema), tasks: z.array(taskSchema), inWork: z.array(z.string()).optional() }), initial: { projects: [], groups: [], tasks: [], inWork: [] } }, tables: {} }); function readJsonBody(req) { return new Promise((resolve, reject) => { let data = ""; req.on("data", (chunk) => { data += chunk; if (data.length > MAX_BODY_BYTES) { req.destroy(); const err = new Error("request body too large"); err.clientError = true; reject(err); } }); req.on("end", () => { if (data.length === 0) return resolve({}); try { resolve(JSON.parse(data)); } catch { const err = new Error("invalid JSON body"); err.clientError = true; reject(err); } }); req.on("error", reject); }); } function respond(res, status, payload) { const body = JSON.stringify(payload); res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(body) }); res.end(body); } function normalize(record) { return { projects: Array.isArray(record?.projects) ? record.projects : [], groups: Array.isArray(record?.groups) ? record.groups : [], tasks: Array.isArray(record?.tasks) ? record.tasks : [], inWork: Array.isArray(record?.inWork) ? record.inWork : [] }; } function migrate(ctx, record) { const next = normalize(record); const workspaceToProject = new Map(); let changed = !Array.isArray(record?.projects); for (const group of next.groups) { if (typeof group.projectId === "string" && group.projectId.length > 0) continue; const workspaceId = group.workspaceId ?? "__legacy__"; let projectId = workspaceToProject.get(workspaceId); if (projectId === undefined) { const workspace = ctx.workspaceRegistry.list().find((candidate) => candidate.id === workspaceId); projectId = randomUUID(); next.projects.push({ id: projectId, name: workspace?.title ?? "Imported", sortIndex: nextSortIndex(next.projects) }); workspaceToProject.set(workspaceId, projectId); } group.projectId = projectId; delete group.workspaceId; changed = true; } return { record: next, changed }; } function apply(ctx) { return ctx.storageDomain.open(treeDomainSpec).then(async (treeDomain) => { const migrated = migrate(ctx, treeDomain.global.get()); if (migrated.changed) await treeDomain.global.set(migrated.record); const readRecord = () => normalize(treeDomain.global.get()); const writeRecord = (record) => treeDomain.global.set(normalize(record)); let mutationTail = Promise.resolve(); const withMutationLock = (operation) => { const result = mutationTail.then(operation, operation); mutationTail = result.then(() => void 0, () => void 0); return result; }; const ws = ctx.webServer; const route = (path, handler) => { ws.register({ kind: "exact", path: ROUTE_PREFIX + "/" + path, handler: async (req, res) => { if (req.method !== "POST") return respond(res, 405, { error: "method-not-allowed" }); const contentType = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase(); if (contentType !== "application/json") return respond(res, 400, { error: "bad-request" }); if (req.headers["sec-fetch-site"] === "cross-site") return respond(res, 403, { error: "cross-origin-denied" }); const origin = req.headers["origin"]; if (typeof origin === "string" && origin.length > 0) { try { if (new URL(origin).host !== req.headers.host) return respond(res, 403, { error: "cross-origin-denied" }); } catch { return respond(res, 403, { error: "cross-origin-denied" }); } } try { const body = await readJsonBody(req); await handler(body, res); } catch (error) { if (error.clientError === true) return respond(res, 400, { error: "bad-request" }); ctx.logger.warn("[dsh-project-tree] route failed:", error); respond(res, 500, { error: "internal-error" }); } } }); }; route("list", async (_body, res) => { const record = readRecord(); const aliveProjects = new Set(record.projects.map((project) => project.id)); const groups = record.groups.filter((group) => aliveProjects.has(group.projectId)); const aliveGroups = new Set(groups.map((group) => group.id)); const tasks = record.tasks.filter((task) => aliveGroups.has(task.groupId)); const workspaces = ctx.workspaceRegistry.list().map((workspace) => ({ id: workspace.id, title: workspace.title, path: workspace.path })); respond(res, 200, { projects: record.projects, groups, tasks, workspaces, inWork: record.inWork }); }); route("in-work", async (body, res) => { const id = body?.id; const value = body?.value; if (typeof id !== "string" || !SESSION_ID_RE.test(id) || typeof value !== "boolean") return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); const has = record.inWork.includes(id); if (value === has) return respond(res, 200, { ok: true }); const inWork = value ? [...record.inWork, id] : record.inWork.filter((candidate) => candidate !== id); await writeRecord({ ...record, inWork }); respond(res, 200, { ok: true }); }); }); route("project-create", async (body, res) => { const projectName = parseName(body?.name); if (projectName === undefined) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); let id; do { id = randomUUID(); } while (record.projects.some((project) => project.id === id)); await writeRecord({ ...record, projects: [...record.projects, { id, name: projectName, sortIndex: nextSortIndex(record.projects) }] }); respond(res, 200, { id }); }); }); route("project-rename", async (body, res) => { const projectId = body?.projectId; const projectName = parseName(body?.name); if (typeof projectId !== "string" || projectId.length === 0 || projectName === undefined) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" }); await writeRecord({ ...record, projects: record.projects.map((project) => project.id === projectId ? { ...project, name: projectName } : project) }); respond(res, 200, { ok: true }); }); }); route("project-delete", async (body, res) => { const projectId = body?.projectId; if (typeof projectId !== "string" || projectId.length === 0) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); const doomedGroups = new Set(record.groups.filter((group) => group.projectId === projectId).map((group) => group.id)); const doomedTasks = new Set(record.tasks.filter((task) => doomedGroups.has(task.groupId)).map((task) => task.id)); const doomed = new Set([projectId, ...doomedGroups, ...doomedTasks]); await writeRecord({ projects: record.projects.filter((project) => project.id !== projectId), groups: record.groups.filter((group) => group.projectId !== projectId), tasks: record.tasks.filter((task) => !doomedGroups.has(task.groupId)), inWork: record.inWork.filter((id) => !doomed.has(id)) }); respond(res, 200, { ok: true }); }); }); route("projects-reorder", async (body, res) => { const orderedIds = body?.orderedIds; if (!Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!isExactIdSet(orderedIds, new Set(record.projects.map((project) => project.id)))) return respond(res, 400, { error: "bad-request" }); const indexOf = new Map(orderedIds.map((id, index) => [id, index])); await writeRecord({ ...record, projects: record.projects.map((project) => ({ ...project, sortIndex: indexOf.get(project.id) })) }); respond(res, 200, { ok: true }); }); }); route("group-create", async (body, res) => { const projectId = body?.projectId; const groupName = parseName(body?.name); if (typeof projectId !== "string" || projectId.length === 0 || groupName === undefined) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" }); const siblings = record.groups.filter((group) => group.projectId === projectId); let id; do { id = randomUUID(); } while (record.groups.some((group) => group.id === id)); await writeRecord({ ...record, groups: [...record.groups, { id, projectId, name: groupName, sortIndex: nextSortIndex(siblings) }] }); respond(res, 200, { id }); }); }); route("group-rename", async (body, res) => { const groupId = body?.groupId; const groupName = parseName(body?.name); if (typeof groupId !== "string" || groupId.length === 0 || groupName === undefined) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" }); await writeRecord({ ...record, groups: record.groups.map((group) => group.id === groupId ? { ...group, name: groupName } : group) }); respond(res, 200, { ok: true }); }); }); route("group-delete", async (body, res) => { const groupId = body?.groupId; if (typeof groupId !== "string" || groupId.length === 0) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" }); const doomedTasks = new Set(record.tasks.filter((task) => task.groupId === groupId).map((task) => task.id)); const doomed = new Set([groupId, ...doomedTasks]); await writeRecord({ ...record, groups: record.groups.filter((group) => group.id !== groupId), tasks: record.tasks.filter((task) => task.groupId !== groupId), inWork: record.inWork.filter((id) => !doomed.has(id)) }); respond(res, 200, { ok: true }); }); }); route("group-move", async (body, res) => { const groupId = body?.groupId; const projectId = body?.projectId; if (typeof groupId !== "string" || typeof projectId !== "string" || groupId.length === 0 || projectId.length === 0) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" }); if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" }); const targetSiblings = record.groups.filter((group) => group.projectId === projectId && group.id !== groupId); await writeRecord({ ...record, groups: record.groups.map((group) => group.id === groupId ? { ...group, projectId, sortIndex: nextSortIndex(targetSiblings) } : group) }); respond(res, 200, { ok: true }); }); }); route("groups-reorder", async (body, res) => { const projectId = body?.projectId; const orderedIds = body?.orderedIds; if (typeof projectId !== "string" || projectId.length === 0 || !Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); const ownedIds = new Set(record.groups.filter((group) => group.projectId === projectId).map((group) => group.id)); if (!isExactIdSet(orderedIds, ownedIds)) return respond(res, 400, { error: "bad-request" }); const indexOf = new Map(orderedIds.map((id, index) => [id, index])); await writeRecord({ ...record, groups: record.groups.map((group) => group.projectId === projectId ? { ...group, sortIndex: indexOf.get(group.id) } : group) }); respond(res, 200, { ok: true }); }); }); route("task-create", async (body, res) => { const groupId = body?.groupId; const taskName = parseName(body?.name); if (typeof groupId !== "string" || groupId.length === 0 || taskName === undefined) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" }); const siblings = record.tasks.filter((task) => task.groupId === groupId); let id; do { id = randomUUID(); } while (record.tasks.some((task) => task.id === id)); await writeRecord({ ...record, tasks: [...record.tasks, { id, groupId, name: taskName, sessionIds: [], sortIndex: nextSortIndex(siblings) }] }); respond(res, 200, { id }); }); }); route("task-rename", async (body, res) => { const taskId = body?.taskId; const taskName = parseName(body?.name); if (typeof taskId !== "string" || taskId.length === 0 || taskName === undefined) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" }); await writeRecord({ ...record, tasks: record.tasks.map((task) => task.id === taskId ? { ...task, name: taskName } : task) }); respond(res, 200, { ok: true }); }); }); route("task-delete", async (body, res) => { const taskId = body?.taskId; if (typeof taskId !== "string" || taskId.length === 0) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" }); await writeRecord({ ...record, tasks: record.tasks.filter((task) => task.id !== taskId), inWork: record.inWork.filter((id) => id !== taskId) }); respond(res, 200, { ok: true }); }); }); route("task-move", async (body, res) => { const taskId = body?.taskId; const groupId = body?.groupId; if (typeof taskId !== "string" || typeof groupId !== "string" || taskId.length === 0 || groupId.length === 0) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" }); if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" }); const targetSiblings = record.tasks.filter((task) => task.groupId === groupId && task.id !== taskId); await writeRecord({ ...record, tasks: record.tasks.map((task) => task.id === taskId ? { ...task, groupId, sortIndex: nextSortIndex(targetSiblings) } : task) }); respond(res, 200, { ok: true }); }); }); route("tasks-reorder", async (body, res) => { const groupId = body?.groupId; const orderedIds = body?.orderedIds; if (typeof groupId !== "string" || groupId.length === 0 || !Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); const ownedIds = new Set(record.tasks.filter((task) => task.groupId === groupId).map((task) => task.id)); if (!isExactIdSet(orderedIds, ownedIds)) return respond(res, 400, { error: "bad-request" }); const indexOf = new Map(orderedIds.map((id, index) => [id, index])); await writeRecord({ ...record, tasks: record.tasks.map((task) => task.groupId === groupId ? { ...task, sortIndex: indexOf.get(task.id) } : task) }); respond(res, 200, { ok: true }); }); }); route("session-assign", async (body, res) => { const sessionId = body?.sessionId; const taskId = body?.taskId; if (typeof sessionId !== "string" || !SESSION_ID_RE.test(sessionId)) return respond(res, 400, { error: "bad-request" }); if (taskId !== null && (typeof taskId !== "string" || !UUID_RE.test(taskId))) return respond(res, 400, { error: "bad-request" }); return withMutationLock(async () => { const record = readRecord(); const without = record.tasks.map((task) => ({ ...task, sessionIds: task.sessionIds.filter((id) => id !== sessionId) })); if (taskId === null) { await writeRecord({ ...record, tasks: without }); return respond(res, 200, { ok: true }); } if (!without.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" }); await writeRecord({ ...record, tasks: without.map((task) => task.id === taskId ? { ...task, sessionIds: [sessionId, ...task.sessionIds] } : task) }); respond(res, 200, { ok: true }); }); }); return () => treeDomain.close(); }); } export { apply, inject, name };