dsh-taskboard 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -6
- package/lib/client.js +1839 -74
- package/lib/host/execution.js +199 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +327 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +435 -5
- package/lib/host/routes.js.map +1 -1
- package/lib/host/store.js +12 -0
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +166 -0
- package/lib/host/templates.js.map +1 -0
- package/lib/host/tools.js +217 -3
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +23 -3
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +286 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +47 -3
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +118 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +360 -4
- package/src/client/board/TaskFormModal.tsx +193 -12
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +238 -11
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +198 -0
- package/src/host/execution.ts +301 -67
- package/src/host/git.ts +370 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +483 -5
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +215 -2
- package/src/index.ts +30 -1
- package/src/shared/api.ts +89 -3
- package/src/shared/protocol.ts +408 -0
- package/src/shared/version.ts +1 -1
package/lib/host/routes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.js","names":[],"sources":["../../src/host/routes.ts"],"sourcesContent":["/**\n * /dsh-taskboard routes on the shared DSH webserver: a JSON API for the\n * GUI's human operations (create/update/move/comment/delete — actor `user`,\n * the done move IS allowed here) plus an SSE stream mirroring every\n * committed ledger mutation.\n *\n * All domain validation goes through the shared protocol pure functions; the\n * route layer only maps transport to envelope.\n *\n * @module dsh-taskboard/host/routes\n */\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only: pulls the webServer Context merge (ctx.webServer).\nimport type {} from '@deepseek-ai/dsh-host-webserver'\nimport {\n asStatus,\n asUrgency,\n canTransition,\n newCommentId,\n newTaskId,\n normalizeBody,\n normalizeExecution,\n normalizeModel,\n normalizePrompt,\n normalizeTitle,\n summarize,\n syncClaim,\n type TaskModel,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'\nimport type { TaskStore } from './store.ts'\nimport type { WorkspaceFace } from './tools.ts'\n\n/** Heartbeat cadence for the SSE stream. */\nconst HEARTBEAT_MS = 20_000\n\n/** The workspaces face routes need (same narrow shape as tools). */\nexport type RoutesWorkspaceFace = WorkspaceFace\n\n/** Options. */\nexport interface TaskboardRoutesOptions {\n store: TaskStore\n workspaces: RoutesWorkspaceFace\n now: () => number\n /** Manual-run hook (the execution service); absent → 501. */\n run?: (taskId: string) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>\n /** Cancel hook (the execution service); absent → 501. */\n cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>\n /**\n * Registered model provider routes (from the host llm runtime), for\n * advisory validation of pinned models; undefined = runtime unavailable.\n */\n modelProviders?: () => string[] | undefined\n}\n\n/** Validate a pinned model: structural check always, provider route when known. */\nfunction checkModel(raw: unknown, modelProviders?: () => string[] | undefined): TaskModel {\n const model = normalizeModel(raw)\n const providers = modelProviders?.()\n if (providers !== undefined && !providers.includes(model.provider)) {\n throw new Error(`Error: invalid_input: model provider \"${model.provider}\" has no registered route (available: ${providers.join(', ')})`)\n }\n return model\n}\n\n/** JSON-envelope writer. */\nfunction json(res: ServerResponse, payload: ApiResult<unknown>, status = 200): void {\n const body = JSON.stringify(payload)\n res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })\n res.end(body)\n}\n\n/** Domain failure → envelope + HTTP status. */\nfunction fail(code: ApiFail['error']['code'], message: string): { res: ApiFail; status: number } {\n const status = code === 'invalid_input' || code === 'invalid_transition' ? 400\n : code === 'not_found' ? 404\n : code === 'version_conflict' ? 409\n : code === 'forbidden' ? 403\n : 500\n return { res: { ok: false, error: { code, message } }, status }\n}\n\n/** Read one JSON body (null on parse failure). */\nasync function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {\n const chunks: Buffer[] = []\n for await (const chunk of req) chunks.push(chunk as Buffer)\n if (chunks.length === 0) return {}\n try {\n const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))\n return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null\n } catch {\n return null\n }\n}\n\n/** String field accessor (null when absent/not a string). */\nfunction str(body: Record<string, unknown>, key: string): string | null {\n const v = body[key]\n return typeof v === 'string' ? v : null\n}\n\n/** Number field accessor (undefined when absent; null when present but not a number). */\nfunction num(body: Record<string, unknown>, key: string): number | undefined | null {\n const v = body[key]\n if (v === undefined) return undefined\n return typeof v === 'number' && Number.isFinite(v) ? v : null\n}\n\n/** Map a thrown domain error to the envelope. */\nfunction toFail(error: unknown): { res: ApiFail; status: number } {\n const message = error instanceof Error ? error.message : String(error)\n const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined\n const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']\n if (code !== undefined && (known as string[]).includes(code)) {\n return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))\n }\n if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))\n return fail('invalid_input', message)\n}\n\n/**\n * Register the taskboard routes.\n * @param ctx - context carrying the webServer service.\n * @param options - store + workspaces + clock.\n * @returns the disposer.\n */\nexport function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOptions): () => void {\n const { store, workspaces } = options\n const subscribers = new Set<ServerResponse>()\n let heartbeat: NodeJS.Timeout | undefined\n\n const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {\n const frame = `event: change\\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\\n\\n`\n for (const res of subscribers) res.write(frame)\n }\n store.subscribe(broadcast)\n\n const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n try {\n const url = new URL(req.url ?? '/', 'http://x')\n const pathname = url.pathname\n\n // ---------------------------------------------------------------- GET\n if (req.method === 'GET') {\n if (pathname === `${ROUTE_PREFIX}/state`) {\n await store.load()\n json(res, { ok: true, value: store.snapshot() })\n return\n }\n if (pathname === `${ROUTE_PREFIX}/workspaces`) {\n json(res, { ok: true, value: workspaces.list() })\n return\n }\n const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))\n if (taskMatch !== null) {\n const task = store.get(taskMatch[1]!)\n if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }\n json(res, { ok: true, value: task })\n return\n }\n res.writeHead(404)\n res.end()\n return\n }\n\n if (req.method !== 'POST') {\n res.writeHead(405)\n res.end()\n return\n }\n // CSRF fence: cross-site simple requests cannot set application/json.\n const contentType = req.headers['content-type'] ?? ''\n if (!contentType.toLowerCase().startsWith('application/json')) {\n const f = fail('invalid_input', 'content-type must be application/json')\n json(res, f.res, 415)\n return\n }\n const body = await readBody(req)\n if (body === null) {\n const f = fail('invalid_input', 'body is not a JSON object')\n json(res, f.res, 400)\n return\n }\n\n // ------------------------------------------------- POST /tasks (create)\n if (pathname === `${ROUTE_PREFIX}/tasks`) {\n try {\n const title = normalizeTitle(str(body, 'title') ?? '')\n const workspaceId = str(body, 'workspaceId') ?? ''\n if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')\n const urgency = asUrgency(str(body, 'urgency') ?? '')\n const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)\n const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())\n const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)\n const now = options.now()\n const task: TaskRecord = {\n id: newTaskId(),\n title,\n description: (str(body, 'description') ?? '').trim(),\n prompt: normalizePrompt(str(body, 'prompt') ?? undefined),\n workspaceId,\n urgency,\n status,\n blocked: false,\n execution,\n model,\n version: 1,\n createdAt: now,\n updatedAt: now,\n createdBy: { kind: 'user' },\n updatedBy: { kind: 'user' },\n comments: [],\n executions: [],\n }\n await store.mutate('task-created', ledger => {\n ledger.tasks.push(task)\n return [task]\n })\n json(res, { ok: true, value: summarize(task) }, 201)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // ------------------------------------------- POST /tasks/:id/{action}\n const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\\\w+)$`))\n if (actionMatch !== null) {\n const id = actionMatch[1]!\n const action = actionMatch[2]!\n try {\n const task = store.get(id)\n if (task === undefined) throw new Error('Error: not_found: no such task')\n if (action === 'update') {\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const next = structuredClone(task)\n const title = str(body, 'title')\n if (title !== null) next.title = normalizeTitle(title)\n const description = str(body, 'description')\n if (description !== null) next.description = description.trim()\n const prompt = str(body, 'prompt')\n if (prompt !== null) next.prompt = normalizePrompt(prompt)\n const urgency = str(body, 'urgency')\n if (urgency !== null) next.urgency = asUrgency(urgency)\n // GUI-only rebind to another project; validated against the workspace registry.\n const workspaceId = str(body, 'workspaceId')\n if (workspaceId !== null) {\n if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')\n next.workspaceId = workspaceId\n }\n if (typeof body.blocked === 'boolean') next.blocked = body.blocked\n // The GUI (task owner surface) may edit model/execution; null clears the model.\n if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())\n if (body.model === null) next.model = undefined\n else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n await store.mutate('task-updated', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'move') {\n const ifVersion = num(body, 'ifVersion')\n const status = str(body, 'status') ?? ''\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const to = asStatus(status)\n if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)\n const next = structuredClone(task)\n next.status = to\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n if (task.status === 'todo' && to === 'in_progress') next.blocked = false\n // A user move records no holder; leaving in_progress releases any hold.\n syncClaim(next, to, options.now())\n await store.mutate('task-moved', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'reject') {\n // Card quick-reject: back to todo + optional user comment in one\n // atomic mutation (a failed move never strands an orphan comment).\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)\n const next = structuredClone(task)\n next.status = 'todo'\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n syncClaim(next, 'todo', options.now())\n const commentText = str(body, 'body') ?? ''\n if (commentText.trim().length > 0) {\n next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })\n }\n await store.mutate('task-moved', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'comment') {\n const bodyText = str(body, 'body') ?? ''\n const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }\n const next = structuredClone(task)\n next.comments.push(comment)\n next.version = task.version + 1\n next.updatedAt = options.now()\n await store.mutate('comment-added', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: comment }, 201)\n return\n }\n if (action === 'delete') {\n const purge = body.purge === true\n if (purge) {\n if (task.trashedAt === undefined) throw new Error('Error: invalid_input: purge requires a trashed task (soft-delete first)')\n await store.mutate('task-deleted', ledger => {\n ledger.tasks = ledger.tasks.filter(t => t.id !== id)\n return []\n })\n json(res, { ok: true, value: { purged: true } })\n return\n }\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const next = structuredClone(task)\n next.trashedAt = options.now()\n next.version = task.version + 1\n await store.mutate('task-deleted', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: { trashed: true } })\n return\n }\n if (action === 'run') {\n if (options.run === undefined) {\n const f = fail('invalid_input', 'execution service unavailable')\n json(res, f.res, 501)\n return\n }\n const result = await options.run(id)\n if (result.ok) json(res, { ok: true, value: result }, 202)\n else {\n const f = fail('invalid_input', result.error)\n json(res, f.res, f.status)\n }\n return\n }\n if (action === 'cancel') {\n if (options.cancel === undefined) {\n const f = fail('invalid_input', 'execution service unavailable')\n json(res, f.res, 501)\n return\n }\n const result = await options.cancel(id)\n if (result.ok) json(res, { ok: true, value: { cancelled: true, executionId: result.executionId } }, 202)\n else {\n const f = fail('invalid_input', result.error)\n json(res, f.res, f.status)\n }\n return\n }\n const f = fail('not_found', `unknown action ${action}`)\n json(res, f.res, f.status)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n res.writeHead(404)\n res.end()\n } catch (error) {\n const f = fail('internal', error instanceof Error ? error.message : String(error))\n json(res, f.res, f.status)\n }\n }\n\n const sse = (req: IncomingMessage, res: ServerResponse): void => {\n res.writeHead(200, {\n 'content-type': 'text/event-stream; charset=utf-8',\n 'cache-control': 'no-cache',\n connection: 'keep-alive',\n })\n res.write('retry: 2000\\n\\n')\n // Baseline frame: the client reconciles by revision and refetches state on gaps.\n res.write(`event: hello\\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\\n\\n`)\n subscribers.add(res)\n if (heartbeat === undefined) {\n heartbeat = setInterval(() => {\n for (const current of subscribers) current.write(': ping\\n\\n')\n }, HEARTBEAT_MS)\n }\n req.on('close', () => {\n subscribers.delete(res)\n if (subscribers.size === 0 && heartbeat !== undefined) {\n clearInterval(heartbeat)\n heartbeat = undefined\n }\n })\n }\n\n const disposers = [\n ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }),\n ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),\n ]\n return () => {\n for (const dispose of disposers) dispose()\n if (heartbeat !== undefined) clearInterval(heartbeat)\n for (const res of subscribers) res.end()\n subscribers.clear()\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"routes.js","names":[],"sources":["../../src/host/routes.ts"],"sourcesContent":["/**\n * /dsh-taskboard routes on the shared DSH webserver: a JSON API for the\n * GUI's human operations (create/update/move/comment/delete — actor `user`,\n * the done move IS allowed here) plus an SSE stream mirroring every\n * committed ledger mutation.\n *\n * All domain validation goes through the shared protocol pure functions; the\n * route layer only maps transport to envelope.\n *\n * @module dsh-taskboard/host/routes\n */\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport { readdir, rm } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only: pulls the webServer Context merge (ctx.webServer).\nimport type {} from '@deepseek-ai/dsh-host-webserver'\nimport {\n asIsolation,\n asStatus,\n asUrgency,\n canTransition,\n checklistFromTexts,\n newCommentId,\n newTaskId,\n normalizeBody,\n normalizeChecklist,\n normalizeExecution,\n normalizeModel,\n normalizePrompt,\n normalizeTitle,\n summarize,\n syncClaim,\n validateLedgerImport,\n type TaskModel,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'\nimport type { TaskTemplate } from '../shared/api.ts'\nimport type { TemplateStore } from './templates.ts'\nimport { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'\nimport type { TaskStore } from './store.ts'\nimport type { WorkspaceFace } from './tools.ts'\n\n/** Heartbeat cadence for the SSE stream. */\nconst HEARTBEAT_MS = 20_000\n\n/** How long a workspace git-detection result stays cached (fail-soft). */\nconst GIT_DETECT_TTL_MS = 60_000\n\n/** The workspaces face routes need (same narrow shape as tools). */\nexport type RoutesWorkspaceFace = WorkspaceFace\n\n/** Options. */\nexport interface TaskboardRoutesOptions {\n store: TaskStore\n workspaces: RoutesWorkspaceFace\n now: () => number\n /** Manual-run hook (the execution service); absent → 501. Options carry `reuseWorktree` (续跑). */\n run?: (taskId: string, options?: { reuseWorktree?: boolean }) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>\n /** Cancel hook (the execution service); absent → 501. */\n cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>\n /**\n * Registered model provider routes (from the host llm runtime), for\n * advisory validation of pinned models; undefined = runtime unavailable.\n */\n modelProviders?: () => string[] | undefined\n /** Git face for worktree actions + workspace git detection; absent → 501 on git actions. */\n git?: GitFace\n /** Task-template store (0.4.0); absent → 501 on template actions. */\n templates?: TemplateStore\n}\n\n/** Validate a template's task spec (routes-side, unknown → invalid_input). */\nfunction normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {\n if (typeof raw !== 'object' || raw === null) throw new Error('Error: invalid_input: task must be an object')\n const e = raw as Record<string, unknown>\n const spec: TaskTemplate['task'] = {}\n const str = (key: string): string | undefined => {\n const v = e[key]\n if (v === undefined) return undefined\n if (typeof v !== 'string') throw new Error(`Error: invalid_input: task.${key} must be a string`)\n return v\n }\n const title = str('title')\n const description = str('description')\n const prompt = str('prompt')\n const urgency = str('urgency')\n const isolation = str('isolation')\n const presetId = str('presetId')\n if (title !== undefined) spec.title = normalizeTitle(title)\n if (description !== undefined) spec.description = description\n if (prompt !== undefined) spec.prompt = normalizePrompt(prompt)\n if (urgency !== undefined) spec.urgency = asUrgency(urgency)\n if (isolation !== undefined) spec.isolation = asIsolation(isolation)\n if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()\n if (e.execution !== undefined) {\n spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, Date.now())\n }\n if (e.model !== undefined) spec.model = normalizeModel(e.model)\n if (e.checklist !== undefined) {\n if (!Array.isArray(e.checklist) || e.checklist.some(c => typeof c !== 'string')) {\n throw new Error('Error: invalid_input: task.checklist must be an array of strings')\n }\n checklistFromTexts(e.checklist as string[]) // validates count + texts\n spec.checklist = e.checklist as string[]\n }\n return spec\n}\n\n/** Validate a pinned model: structural check always, provider route when known. */\nfunction checkModel(raw: unknown, modelProviders?: () => string[] | undefined): TaskModel {\n const model = normalizeModel(raw)\n const providers = modelProviders?.()\n if (providers !== undefined && !providers.includes(model.provider)) {\n throw new Error(`Error: invalid_input: model provider \"${model.provider}\" has no registered route (available: ${providers.join(', ')})`)\n }\n return model\n}\n\n/** JSON-envelope writer. */\nfunction json(res: ServerResponse, payload: ApiResult<unknown>, status = 200): void {\n const body = JSON.stringify(payload)\n res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })\n res.end(body)\n}\n\n/** Domain failure → envelope + HTTP status. */\nfunction fail(code: ApiFail['error']['code'], message: string): { res: ApiFail; status: number } {\n const status = code === 'invalid_input' || code === 'invalid_transition' ? 400\n : code === 'not_found' ? 404\n : code === 'version_conflict' ? 409\n : code === 'forbidden' ? 403\n : 500\n return { res: { ok: false, error: { code, message } }, status }\n}\n\n/** Read one JSON body (null on parse failure). */\nasync function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {\n const chunks: Buffer[] = []\n for await (const chunk of req) chunks.push(chunk as Buffer)\n if (chunks.length === 0) return {}\n try {\n const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))\n return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null\n } catch {\n return null\n }\n}\n\n/** String field accessor (null when absent/not a string). */\nfunction str(body: Record<string, unknown>, key: string): string | null {\n const v = body[key]\n return typeof v === 'string' ? v : null\n}\n\n/** Number field accessor (undefined when absent; null when present but not a number). */\nfunction num(body: Record<string, unknown>, key: string): number | undefined | null {\n const v = body[key]\n if (v === undefined) return undefined\n return typeof v === 'number' && Number.isFinite(v) ? v : null\n}\n\n/** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */\nfunction normalizePresetId(raw: string | null): string | undefined {\n const t = (raw ?? '').trim()\n return t.length === 0 ? undefined : t\n}\n\n/** Map a thrown domain error to the envelope. */\nfunction toFail(error: unknown): { res: ApiFail; status: number } {\n const message = error instanceof Error ? error.message : String(error)\n const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined\n const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']\n if (code !== undefined && (known as string[]).includes(code)) {\n return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))\n }\n if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))\n return fail('invalid_input', message)\n}\n\n/**\n * Register the taskboard routes.\n * @param ctx - context carrying the webServer service.\n * @param options - store + workspaces + clock.\n * @returns the disposer.\n */\nexport function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOptions): () => void {\n const { store, workspaces } = options\n const subscribers = new Set<ServerResponse>()\n let heartbeat: NodeJS.Timeout | undefined\n\n const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {\n const frame = `event: change\\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\\n\\n`\n for (const res of subscribers) res.write(frame)\n }\n store.subscribe(broadcast)\n\n // Workspace git detection, TTL-cached and fail-soft (false on any error):\n // feeds the create-form isolation toggle and the diagnostics panel.\n const gitCache = new Map<string, { value: boolean; at: number }>()\n const gitHinted = new Set<string>()\n\n /** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */\n const gitignoreMissing = async (path: string): Promise<boolean> => {\n try {\n const { readFile } = await import('node:fs/promises')\n const ignore = await readFile(join(path, '.gitignore'), 'utf8')\n return !ignore.split('\\n').some(l => {\n const t = l.trim().replace(/\\/+$/, '')\n return t === WORKTREE_DIR || t === `/${WORKTREE_DIR}`\n })\n } catch {\n return true // no .gitignore at all (or unreadable) → suggest creating one\n }\n }\n\n const gitAvailable = async (path: string): Promise<boolean> => {\n if (options.git === undefined) return false\n const hit = gitCache.get(path)\n if (hit !== undefined && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value\n let value = false\n try {\n value = await options.git.detect(path)\n } catch { /* fail-soft → false */ }\n gitCache.set(path, { value, at: options.now() })\n // gitignore 建议 (plan §3.2): suggest (never write) ignoring our\n // worktree directory, once per workspace per host run.\n if (value && !gitHinted.has(path)) {\n gitHinted.add(path)\n if (await gitignoreMissing(path)) {\n console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)\n }\n }\n return value\n }\n\n /** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */\n const listOrphanWorktrees = async (): Promise<Array<{ workspaceId: string; workspacePath: string; taskId: string; path: string }>> => {\n const orphans: Array<{ workspaceId: string; workspacePath: string; taskId: string; path: string }> = []\n const known = new Set(store.snapshot().tasks.map(t => t.id))\n for (const ws of workspaces.list()) {\n let entries: string[] = []\n try {\n const dirents = await readdir(join(ws.path, WORKTREE_DIR), { withFileTypes: true })\n entries = dirents.filter(e => e.isDirectory()).map(e => e.name)\n } catch { /* no worktrees dir → nothing to do */ }\n for (const taskId of entries) {\n if (!known.has(taskId)) orphans.push({ workspaceId: ws.id, workspacePath: ws.path, taskId, path: worktreePathOf(ws.path, taskId) })\n }\n }\n return orphans\n }\n\n /** Git-enabled workspaces whose .gitignore does not cover the worktree dir. */\n const listGitignoreSuggestions = async (): Promise<Array<{ workspaceId: string; workspacePath: string }>> => {\n const suggestions: Array<{ workspaceId: string; workspacePath: string }> = []\n for (const ws of workspaces.list()) {\n if (!(await gitAvailable(ws.path))) continue\n if (await gitignoreMissing(ws.path)) suggestions.push({ workspaceId: ws.id, workspacePath: ws.path })\n }\n return suggestions\n }\n\n const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n try {\n const url = new URL(req.url ?? '/', 'http://x')\n const pathname = url.pathname\n\n // ---------------------------------------------------------------- GET\n if (req.method === 'GET') {\n if (pathname === `${ROUTE_PREFIX}/state`) {\n await store.load()\n json(res, { ok: true, value: store.snapshot() })\n return\n }\n if (pathname === `${ROUTE_PREFIX}/workspaces`) {\n const list = workspaces.list()\n const flags = await Promise.all(list.map(ws => gitAvailable(ws.path)))\n json(res, {\n ok: true,\n value: list.map((ws, i) => ({ ...ws, sessionCount: 0, gitAvailable: flags[i] })),\n })\n return\n }\n if (pathname === `${ROUTE_PREFIX}/diagnostics`) {\n const ledger = store.snapshot()\n let staleRunning = 0\n for (const t of ledger.tasks) {\n for (const e of t.executions) if (e.outcome === 'running') staleRunning += 1\n }\n json(res, {\n ok: true,\n value: {\n revision: ledger.revision,\n tasks: ledger.tasks.length,\n staleRunning,\n orphanWorktrees: await listOrphanWorktrees(),\n gitIgnoreSuggestions: await listGitignoreSuggestions(),\n },\n })\n return\n }\n // Diff viewer (0.4.0): read-only git show/diff for one execution's\n // commit or changed path. Prefers the live worktree (uncommitted\n // view), falls back to the main repo.\n const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`))\n if (diffMatch !== null) {\n try {\n if (options.git === undefined) {\n const f = fail('invalid_input', 'git integration unavailable')\n json(res, f.res, 501)\n return\n }\n const task = store.get(diffMatch[1]!)\n if (task === undefined) throw new Error('Error: not_found: no such task')\n const execution = task.executions.find(e => e.id === url.searchParams.get('execution'))\n if (execution === undefined) throw new Error('Error: not_found: no such execution')\n const commit = url.searchParams.get('commit')\n const filePath = url.searchParams.get('path')\n const ws = workspaces.get(task.workspaceId)\n if (ws === undefined) throw new Error('Error: not_found: unknown workspace')\n const cwd = execution.worktreePath ?? ws.path\n let result = commit !== null\n ? await options.git.showCommit(cwd, commit)\n : filePath !== null ? await options.git.showPathDiff(cwd, filePath, execution.baseCommit) : undefined\n // Fallback: the worktree may be gone — commits and committed\n // ranges still resolve in the main repo.\n if (result === undefined && execution.worktreePath !== undefined && cwd !== ws.path) {\n result = commit !== null\n ? await options.git.showCommit(ws.path, commit)\n : filePath !== null && execution.baseCommit !== undefined\n ? await options.git.showPathDiff(ws.path, filePath, execution.baseCommit)\n : undefined\n }\n if (result === undefined) {\n throw new Error('Error: invalid_input: 无法获取 diff(git 报错、对象不存在,或仅存于已删除的 worktree 且无基线)')\n }\n json(res, { ok: true, value: { diff: result.text, truncated: result.truncated } })\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // Templates listing (0.4.0).\n if (pathname === `${ROUTE_PREFIX}/templates`) {\n if (options.templates === undefined) {\n const f = fail('invalid_input', 'template store unavailable')\n json(res, f.res, 501)\n return\n }\n json(res, { ok: true, value: { templates: await options.templates.list() } })\n return\n }\n\n const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))\n if (taskMatch !== null) {\n const task = store.get(taskMatch[1]!)\n if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }\n json(res, { ok: true, value: task })\n return\n }\n res.writeHead(404)\n res.end()\n return\n }\n\n if (req.method !== 'POST') {\n res.writeHead(405)\n res.end()\n return\n }\n // CSRF fence: cross-site simple requests cannot set application/json.\n const contentType = req.headers['content-type'] ?? ''\n if (!contentType.toLowerCase().startsWith('application/json')) {\n const f = fail('invalid_input', 'content-type must be application/json')\n json(res, f.res, 415)\n return\n }\n const body = await readBody(req)\n if (body === null) {\n const f = fail('invalid_input', 'body is not a JSON object')\n json(res, f.res, 400)\n return\n }\n\n // ------------------------------------------------- POST /tasks (create)\n if (pathname === `${ROUTE_PREFIX}/tasks`) {\n try {\n const title = normalizeTitle(str(body, 'title') ?? '')\n const workspaceId = str(body, 'workspaceId') ?? ''\n if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')\n const urgency = asUrgency(str(body, 'urgency') ?? '')\n const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)\n const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())\n const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)\n const isolationRaw = str(body, 'isolation')\n const isolation = isolationRaw === null ? undefined : asIsolation(isolationRaw)\n const presetId = normalizePresetId(str(body, 'presetId'))\n let checklist: TaskRecord['checklist'] = undefined\n if (body.checklist !== undefined) {\n if (!Array.isArray(body.checklist) || body.checklist.some(c => typeof c !== 'string')) {\n throw new Error('Error: invalid_input: checklist must be an array of strings')\n }\n const texts = (body.checklist as string[]).map(c => c.trim()).filter(c => c.length > 0)\n if (texts.length > 0) checklist = checklistFromTexts(texts)\n }\n const now = options.now()\n const task: TaskRecord = {\n id: newTaskId(),\n title,\n description: (str(body, 'description') ?? '').trim(),\n prompt: normalizePrompt(str(body, 'prompt') ?? undefined),\n workspaceId,\n urgency,\n status,\n blocked: false,\n execution,\n model,\n ...(isolation !== undefined ? { isolation } : {}),\n ...(presetId !== undefined ? { presetId } : {}),\n ...(checklist !== undefined ? { checklist } : {}),\n version: 1,\n createdAt: now,\n updatedAt: now,\n createdBy: { kind: 'user' },\n updatedBy: { kind: 'user' },\n comments: [],\n executions: [],\n }\n await store.mutate('task-created', ledger => {\n ledger.tasks.push(task)\n return [task]\n })\n json(res, { ok: true, value: summarize(task) }, 201)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // ------------------------------------------- POST /tasks/:id/{action}\n // (\\w+ after the id would not match hyphenated actions like\n // worktree-remove, hence the explicit class.)\n const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\\\w-]+)$`))\n if (actionMatch !== null) {\n const id = actionMatch[1]!\n const action = actionMatch[2]!\n try {\n const task = store.get(id)\n if (task === undefined) throw new Error('Error: not_found: no such task')\n if (action === 'update') {\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const next = structuredClone(task)\n const title = str(body, 'title')\n if (title !== null) next.title = normalizeTitle(title)\n const description = str(body, 'description')\n if (description !== null) next.description = description.trim()\n const prompt = str(body, 'prompt')\n if (prompt !== null) next.prompt = normalizePrompt(prompt)\n const urgency = str(body, 'urgency')\n if (urgency !== null) next.urgency = asUrgency(urgency)\n // GUI-only rebind to another project; validated against the workspace registry.\n const workspaceId = str(body, 'workspaceId')\n if (workspaceId !== null) {\n if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')\n next.workspaceId = workspaceId\n }\n if (typeof body.blocked === 'boolean') next.blocked = body.blocked\n // The GUI (task owner surface) may edit model/execution; null clears the model.\n if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())\n if (body.model === null) next.model = undefined\n else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)\n // Isolation may change only before the first execution (分支与基线\n // 取决于该选择 — plan §3.1: 执行开始后锁定).\n const isolationRaw = str(body, 'isolation')\n if (isolationRaw !== null) {\n if (task.executions.length > 0 || task.status === 'in_progress') {\n throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')\n }\n next.isolation = asIsolation(isolationRaw)\n }\n // Preset may change any time: each run composes fresh.\n if (body.presetId === null) delete next.presetId\n else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!\n // Checklist (0.4.0): the GUI replaces the whole list; null clears.\n if (body.checklist === null) delete next.checklist\n else if (body.checklist !== undefined) {\n const items = normalizeChecklist(body.checklist)\n if (items.length > 0) next.checklist = items\n else delete next.checklist\n }\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n await store.mutate('task-updated', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'move') {\n const ifVersion = num(body, 'ifVersion')\n const status = str(body, 'status') ?? ''\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const to = asStatus(status)\n if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)\n const next = structuredClone(task)\n next.status = to\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n if (task.status === 'todo' && to === 'in_progress') next.blocked = false\n // A user move records no holder; leaving in_progress releases any hold.\n syncClaim(next, to, options.now())\n await store.mutate('task-moved', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'reject') {\n // Card quick-reject: back to todo + optional user comment in one\n // atomic mutation (a failed move never strands an orphan comment).\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)\n const next = structuredClone(task)\n next.status = 'todo'\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n syncClaim(next, 'todo', options.now())\n const commentText = str(body, 'body') ?? ''\n if (commentText.trim().length > 0) {\n next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })\n }\n await store.mutate('task-moved', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'comment') {\n const bodyText = str(body, 'body') ?? ''\n const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }\n const next = structuredClone(task)\n next.comments.push(comment)\n next.version = task.version + 1\n next.updatedAt = options.now()\n await store.mutate('comment-added', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: comment }, 201)\n return\n }\n if (action === 'delete') {\n const purge = body.purge === true\n if (purge) {\n if (task.trashedAt === undefined) throw new Error('Error: invalid_input: purge requires a trashed task (soft-delete first)')\n // Worktree safety before purge (plan §3.3, 0.3.1): refuse while\n // uncommitted work remains; otherwise clean the worktree and\n // the task branch along with the ledger entry.\n if (options.git !== undefined) {\n const ws = workspaces.get(task.workspaceId)\n if (ws !== undefined) {\n const path = worktreePathOf(ws.path, id)\n try {\n await options.git.removeWorktree(ws.path, path)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n if (message.includes('未提交修改')) {\n throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)\n }\n if (/not a working tree|not a working-tree/i.test(message)) {\n // An unregistered leftover dir: plain fs removal.\n await rm(path, { recursive: true, force: true })\n } else {\n throw new Error(`Error: invalid_input: ${message}`)\n }\n }\n if (task.branch !== undefined) {\n try {\n await options.git.deleteBranch(ws.path, task.branch)\n } catch { /* best effort: the branch may outlive the task */ }\n }\n }\n }\n await store.mutate('task-deleted', ledger => {\n ledger.tasks = ledger.tasks.filter(t => t.id !== id)\n return []\n })\n json(res, { ok: true, value: { purged: true } })\n return\n }\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const next = structuredClone(task)\n next.trashedAt = options.now()\n next.version = task.version + 1\n await store.mutate('task-deleted', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: { trashed: true } })\n return\n }\n if (action === 'run') {\n if (options.run === undefined) {\n const f = fail('invalid_input', 'execution service unavailable')\n json(res, f.res, 501)\n return\n }\n // `reuse: true` = 续跑: keep a live worktree/branch as-is instead\n // of resetting to a fresh baseline (0.3.1).\n const runOptions = body.reuse === true ? { reuseWorktree: true } : undefined\n const result = await options.run(id, runOptions)\n if (result.ok) json(res, { ok: true, value: result }, 202)\n else {\n const f = fail('invalid_input', result.error)\n json(res, f.res, f.status)\n }\n return\n }\n if (action === 'cancel') {\n if (options.cancel === undefined) {\n const f = fail('invalid_input', 'execution service unavailable')\n json(res, f.res, 501)\n return\n }\n const result = await options.cancel(id)\n if (result.ok) json(res, { ok: true, value: { cancelled: true, executionId: result.executionId } }, 202)\n else {\n const f = fail('invalid_input', result.error)\n json(res, f.res, f.status)\n }\n return\n }\n if (action === 'merge') {\n // ⇥ 合并 (detail page, user-only): merge the task branch into the\n // main worktree with --no-ff; conflicts are reported verbatim.\n if (options.git === undefined) {\n const f = fail('invalid_input', 'git integration unavailable')\n json(res, f.res, 501)\n return\n }\n if (task.branch === undefined) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')\n if (task.status === 'in_progress') throw new Error('Error: invalid_input: 任务执行中,不能合并')\n if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能合并')\n const ws = workspaces.get(task.workspaceId)\n if (ws === undefined) throw new Error('Error: not_found: unknown workspace')\n // No-op detection (0.3.1): a branch with no commits over HEAD\n // merges as \"already up to date\" — report that instead of landing\n // a bogus 已合并 comment.\n let noop = false\n try {\n noop = await options.git.isAncestor(ws.path, task.branch)\n } catch { /* fail-soft: proceed to the real merge */ }\n if (noop) {\n json(res, { ok: true, value: { merged: false, noop: true, branch: task.branch } })\n return\n }\n try {\n await options.git.merge(ws.path, task.branch)\n } catch (error) {\n throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)\n }\n const mergedComment = { id: newCommentId(), body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`), version: 1, createdAt: options.now() }\n const next = structuredClone(task)\n next.comments.push(mergedComment)\n next.version = task.version + 1\n next.updatedAt = options.now()\n await store.mutate('comment-added', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: { merged: true, branch: task.branch } })\n return\n }\n if (action === 'worktree-remove') {\n // 🗑 删除 worktree (detail page): refuses uncommitted changes;\n // optionally deletes the task branch after the worktree is gone.\n if (options.git === undefined) {\n const f = fail('invalid_input', 'git integration unavailable')\n json(res, f.res, 501)\n return\n }\n if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能删除 worktree')\n const ws = workspaces.get(task.workspaceId)\n if (ws === undefined) throw new Error('Error: not_found: unknown workspace')\n const path = worktreePathOf(ws.path, id)\n try {\n await options.git.removeWorktree(ws.path, path)\n } catch (error) {\n throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)\n }\n let branchDeleted = false\n let branchError: string | undefined\n if (body.deleteBranch === true && task.branch !== undefined) {\n try {\n await options.git.deleteBranch(ws.path, task.branch)\n branchDeleted = true\n } catch (error) {\n branchError = error instanceof Error ? error.message : String(error)\n }\n }\n json(res, { ok: true, value: { removed: true, branchDeleted, ...(branchError !== undefined ? { branchError } : {}) } })\n return\n }\n const f = fail('not_found', `unknown action ${action}`)\n json(res, f.res, f.status)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // -------------------------------------- POST /worktree-cleanup (⚙ 诊断)\n if (pathname === `${ROUTE_PREFIX}/worktree-cleanup`) {\n try {\n if (options.git === undefined) {\n const f = fail('invalid_input', 'git integration unavailable')\n json(res, f.res, 501)\n return\n }\n const workspaceId = str(body, 'workspaceId') ?? ''\n const taskId = str(body, 'taskId') ?? ''\n const ws = workspaces.get(workspaceId)\n if (ws === undefined) throw new Error('Error: not_found: unknown workspace')\n // Only dirs owned by NO ledger task may be cleaned here; live tasks\n // remove their worktree from the detail page.\n if (store.get(taskId) !== undefined) throw new Error('Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree')\n const path = worktreePathOf(ws.path, taskId)\n try {\n await options.git.removeWorktree(ws.path, path)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n // An unregistered leftover (git no longer knows this worktree):\n // fall back to direct fs removal — the dir lives inside the\n // plugin's own .dsh-worktrees scope.\n if (/not a working tree|not a working-tree/i.test(message)) {\n await rm(path, { recursive: true, force: true })\n } else {\n throw new Error(`Error: invalid_input: ${message}`)\n }\n }\n json(res, { ok: true, value: { cleaned: true, path } })\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // ---------------------------------------------- POST /import/preview\n // (0.4.0) Dry-run: classify every task in the uploaded ledger file\n // against the live one; nothing is written.\n if (pathname === `${ROUTE_PREFIX}/import/preview`) {\n try {\n const known = new Set(store.snapshot().tasks.map(t => t.id))\n const plan = validateLedgerImport(body, known, options.now())\n json(res, {\n ok: true,\n value: {\n plan: {\n create: plan.create.map(t => ({ id: t.id, title: t.title, status: t.status })),\n overwrite: plan.overwrite.map(t => ({ id: t.id, title: t.title, status: t.status })),\n invalid: plan.invalid,\n },\n },\n })\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // ------------------------------------------------------ POST /import\n // (0.4.0) Commit an import. mode=merge upserts (create + overwrite by\n // id); mode=replace swaps the WHOLE ledger (invalid entries dropped)\n // after writing a timestamped backup of the current one.\n if (pathname === `${ROUTE_PREFIX}/import`) {\n try {\n const mode = str(body, 'mode') === 'replace' ? 'replace' as const : 'merge' as const\n const raw = body.ledger\n const known = new Set(store.snapshot().tasks.map(t => t.id))\n const plan = validateLedgerImport(raw, known, options.now())\n const imported = [...plan.create, ...plan.overwrite]\n if (mode === 'replace' && imported.length === 0) {\n throw new Error('Error: invalid_input: 导入文件没有可导入的任务,已拒绝整册替换')\n }\n let backupFile: string | undefined\n if (mode === 'replace' && store.snapshot().tasks.length > 0) {\n backupFile = await store.backup()\n }\n let replacedTotal: number | undefined\n await store.mutate('task-created', ledger => {\n if (mode === 'replace') {\n replacedTotal = ledger.tasks.length\n ledger.tasks = structuredClone(imported)\n return ledger.tasks\n }\n const byId = new Map(ledger.tasks.map(t => [t.id, t]))\n for (const task of imported) byId.set(task.id, structuredClone(task))\n ledger.tasks = [...byId.values()]\n return structuredClone(imported)\n })\n json(res, {\n ok: true,\n value: {\n mode,\n created: plan.create.length,\n overwritten: plan.overwrite.length,\n ...(mode === 'replace' ? { replacedTotal } : {}),\n ...(backupFile !== undefined ? { backupFile } : {}),\n },\n })\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // ------------------------------------------- POST /templates (+delete)\n if (pathname === `${ROUTE_PREFIX}/templates` || pathname === `${ROUTE_PREFIX}/templates/delete`) {\n try {\n if (options.templates === undefined) {\n const f = fail('invalid_input', 'template store unavailable')\n json(res, f.res, 501)\n return\n }\n if (pathname.endsWith('/delete')) {\n const id = str(body, 'id') ?? ''\n if (id.length === 0) throw new Error('Error: invalid_input: id required')\n const deleted = await options.templates.remove(id)\n json(res, { ok: true, value: { deleted } })\n return\n }\n const name = str(body, 'name') ?? ''\n if (name.trim().length === 0) throw new Error('Error: invalid_input: name required')\n const template = await options.templates.upsert({\n id: str(body, 'id') ?? undefined,\n name,\n task: normalizeTemplateSpec(body.task),\n })\n json(res, { ok: true, value: template }, 201)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n res.writeHead(404)\n res.end()\n } catch (error) {\n const f = fail('internal', error instanceof Error ? error.message : String(error))\n json(res, f.res, f.status)\n }\n }\n\n const sse = (req: IncomingMessage, res: ServerResponse): void => {\n res.writeHead(200, {\n 'content-type': 'text/event-stream; charset=utf-8',\n 'cache-control': 'no-cache',\n connection: 'keep-alive',\n })\n res.write('retry: 2000\\n\\n')\n // Baseline frame: the client reconciles by revision and refetches state on gaps.\n res.write(`event: hello\\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\\n\\n`)\n subscribers.add(res)\n if (heartbeat === undefined) {\n heartbeat = setInterval(() => {\n for (const current of subscribers) current.write(': ping\\n\\n')\n }, HEARTBEAT_MS)\n }\n req.on('close', () => {\n subscribers.delete(res)\n if (subscribers.size === 0 && heartbeat !== undefined) {\n clearInterval(heartbeat)\n heartbeat = undefined\n }\n })\n }\n\n const disposers = [\n ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }),\n ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),\n ]\n return () => {\n for (const dispose of disposers) dispose()\n if (heartbeat !== undefined) clearInterval(heartbeat)\n for (const res of subscribers) res.end()\n subscribers.clear()\n }\n}\n"],"mappings":"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"}
|
package/lib/host/store.js
CHANGED
|
@@ -69,6 +69,18 @@ var TaskStore = class {
|
|
|
69
69
|
return () => this.subscribers.delete(fn);
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
+
* Write a timestamped backup copy of the current ledger next to the live
|
|
73
|
+
* file (import-replace safety, 0.4.0). Never throws the caller's flow —
|
|
74
|
+
* a backup failure fails the import itself.
|
|
75
|
+
* @returns the backup file path.
|
|
76
|
+
*/
|
|
77
|
+
async backup() {
|
|
78
|
+
await this.load();
|
|
79
|
+
const target = `${this.file}.backup-${Date.now()}`;
|
|
80
|
+
await persistAtomic(target, JSON.stringify(this.ledger, null, 2));
|
|
81
|
+
return target;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
72
84
|
* Run one mutation inside the serial queue. The mutator works on a
|
|
73
85
|
* structured clone; returning `undefined` aborts with no write.
|
|
74
86
|
* @param kind - change kind for subscribers.
|
package/lib/host/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","names":[],"sources":["../../src/host/store.ts"],"sourcesContent":["/**\n * Host-side task ledger: one JSON file under the DSH home, mutated through a\n * serial write queue, published as immutable snapshots with a global\n * monotonic revision. Change subscribers (P2: SSE route) observe every\n * committed mutation.\n *\n * @module dsh-taskboard/host/store\n */\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n emptyLedger,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\n\n/** One committed ledger mutation, handed to change subscribers. */\nexport interface LedgerChange {\n /** Revision after the mutation. */\n revision: number\n /** The mutated tasks, if any (a comment purge may touch none). */\n tasks: readonly TaskRecord[]\n /** What kind of mutation this was (for SSE event naming later). */\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n}\n\n/**\n * The durable ledger. All mutations run through {@link mutate}, which:\n * validates the resulting document, bumps the global revision, persists\n * atomically (temp file + rename), and only then notifies subscribers.\n */\nexport class TaskStore {\n private readonly file: string\n private ledger: TaskLedger = emptyLedger()\n private readonly subscribers = new Set<(change: LedgerChange) => void>()\n private queue: Promise<unknown> = Promise.resolve()\n private loaded = false\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n async load(): Promise<void> {\n if (this.loaded) return\n try {\n const raw = await readFile(this.file, 'utf8')\n const parsed = JSON.parse(raw) as TaskLedger\n if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {\n const tasks = parsed.tasks as TaskRecord[]\n // Migration from pre-claim-field ledgers: an agent-held in_progress\n // task carried its holder in updatedBy — backfill the explicit claim\n // fields so the hold survives user edits (updatedBy is audit-only).\n for (const task of tasks) {\n if (task.status === 'in_progress' && task.claimedBy === undefined\n && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {\n task.claimedBy = task.updatedBy.sessionId\n task.claimedAt = task.updatedAt\n }\n }\n this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks }\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code\n if (code !== 'ENOENT') {\n // Quarantine a corrupt ledger: rename it aside, start fresh. Never\n // take the host down over ledger damage.\n try {\n await rename(this.file, `${this.file}.corrupt-${Date.now()}`)\n } catch { /* best effort */ }\n }\n }\n this.loaded = true\n }\n\n /**\n * The current snapshot — a deep-frozen clone. Mutating the returned value\n * throws (strict mode) instead of silently bypassing the revision/persist\n * path; internal state is never handed out.\n */\n snapshot(): TaskLedger {\n return deepFreeze(structuredClone(this.ledger))\n }\n\n /** Find a task by id (frozen clone; internal state is never handed out). */\n get(id: string): TaskRecord | undefined {\n const task = this.ledger.tasks.find(t => t.id === id)\n return task === undefined ? undefined : deepFreeze(structuredClone(task))\n }\n\n /** Subscribe to committed changes; returns the unsubscribe. */\n subscribe(fn: (change: LedgerChange) => void): () => void {\n this.subscribers.add(fn)\n return () => this.subscribers.delete(fn)\n }\n\n /**\n * Run one mutation inside the serial queue. The mutator works on a\n * structured clone; returning `undefined` aborts with no write.\n * @param kind - change kind for subscribers.\n * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.\n */\n async mutate(\n kind: LedgerChange['kind'],\n mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,\n ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {\n const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {\n await this.load()\n const draft: TaskLedger = structuredClone(this.ledger)\n const changed = mutator(draft)\n if (changed === undefined) {\n return { ledger: this.ledger, changed: [] }\n }\n // Retention cap: every committed mutation re-checks the touched tasks,\n // so execution history can never grow unbounded (SSE state payload).\n for (const task of changed) pruneExecutions(task)\n draft.revision += 1\n const json = JSON.stringify(draft)\n await persistAtomic(this.file, json)\n this.ledger = draft\n const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }\n for (const fn of this.subscribers) {\n try {\n fn(change)\n } catch { /* subscriber errors never abort the write */ }\n }\n return { ledger: draft, changed }\n }\n const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n return result\n }\n}\n\n/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n if (!Object.isFrozen(value)) Object.freeze(value)\n for (const key of Object.keys(value as Record<string, unknown>)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\n/** Atomic file persist: write temp, then rename over the target. */\nasync function persistAtomic(file: string, contents: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n await writeFile(temp, contents, 'utf8')\n await rename(temp, file)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAuCA,IAAa,YAAb,MAAuB;CACrB;CACA,SAA6B,YAAY;CACzC,8BAA+B,IAAI,IAAoC;CACvE,QAAkC,QAAQ,QAAQ;CAClD,SAAiB;;CAGjB,YAAY,SAA2B;EACrC,KAAK,OAAO,QAAQ;CACtB;;CAGA,MAAM,OAAsB;EAC1B,IAAI,KAAK,QAAQ;EACjB,IAAI;GACF,MAAM,MAAM,MAAM,SAAS,KAAK,MAAM,MAAM;GAC5C,MAAM,SAAS,KAAK,MAAM,GAAG;GAC7B,IAAI,OAAO,OAAO,aAAa,YAAY,MAAM,QAAQ,OAAO,KAAK,GAAG;IACtE,MAAM,QAAQ,OAAO;IAIrB,KAAK,MAAM,QAAQ,OACjB,IAAI,KAAK,WAAW,iBAAiB,KAAK,cAAc,KAAA,KACnD,KAAK,WAAW,SAAS,WAAW,OAAO,KAAK,UAAU,cAAc,UAAU;KACrF,KAAK,YAAY,KAAK,UAAU;KAChC,KAAK,YAAY,KAAK;IACxB;IAEF,KAAK,SAAS;KAAE,eAAA;KAAsC,UAAU,OAAO;KAAU;IAAM;GACzF;EACF,SAAS,OAAO;GAEd,IADc,MAAgC,SACjC,UAGX,IAAI;IACF,MAAM,OAAO,KAAK,MAAM,GAAG,KAAK,KAAK,WAAW,KAAK,IAAI,GAAG;GAC9D,QAAQ,CAAoB;EAEhC;EACA,KAAK,SAAS;CAChB;;;;;;CAOA,WAAuB;EACrB,OAAO,WAAW,gBAAgB,KAAK,MAAM,CAAC;CAChD;;CAGA,IAAI,IAAoC;EACtC,MAAM,OAAO,KAAK,OAAO,MAAM,MAAK,MAAK,EAAE,OAAO,EAAE;EACpD,OAAO,SAAS,KAAA,IAAY,KAAA,IAAY,WAAW,gBAAgB,IAAI,CAAC;CAC1E;;CAGA,UAAU,IAAgD;EACxD,KAAK,YAAY,IAAI,EAAE;EACvB,aAAa,KAAK,YAAY,OAAO,EAAE;CACzC;;;;;;;CAQA,MAAM,OACJ,MACA,SACiE;EACjE,MAAM,MAAM,YAA6E;GACvF,MAAM,KAAK,KAAK;GAChB,MAAM,QAAoB,gBAAgB,KAAK,MAAM;GACrD,MAAM,UAAU,QAAQ,KAAK;GAC7B,IAAI,YAAY,KAAA,GACd,OAAO;IAAE,QAAQ,KAAK;IAAQ,SAAS,CAAC;GAAE;GAI5C,KAAK,MAAM,QAAQ,SAAS,gBAAgB,IAAI;GAChD,MAAM,YAAY;GAClB,MAAM,OAAO,KAAK,UAAU,KAAK;GACjC,MAAM,cAAc,KAAK,MAAM,IAAI;GACnC,KAAK,SAAS;GACd,MAAM,SAAuB;IAAE,UAAU,MAAM;IAAU,OAAO;IAAS;GAAK;GAC9E,KAAK,MAAM,MAAM,KAAK,aACpB,IAAI;IACF,GAAG,MAAM;GACX,QAAQ,CAAgD;GAE1D,OAAO;IAAE,QAAQ;IAAO;GAAQ;EAClC;EAEA,OAAO,KADc,QAAQ,KAAK,MAAM,KAAK,KAAK,GAAG;CAEvD;AACF;;AAGA,SAAS,WAAc,OAAa;CAClC,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU;EAC/C,IAAI,CAAC,OAAO,SAAS,KAAK,GAAG,OAAO,OAAO,KAAK;EAChD,KAAK,MAAM,OAAO,OAAO,KAAK,KAAgC,GAC5D,WAAY,MAAkC,IAAI;CAEtD;CACA,OAAO;AACT;;AAGA,eAAe,cAAc,MAAc,UAAiC;CAC1E,MAAM,MAAM,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;CAC9C,MAAM,OAAO,KAAK,QAAQ,IAAI,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK;CAC9E,MAAM,UAAU,MAAM,UAAU,MAAM;CACtC,MAAM,OAAO,MAAM,IAAI;AACzB"}
|
|
1
|
+
{"version":3,"file":"store.js","names":[],"sources":["../../src/host/store.ts"],"sourcesContent":["/**\n * Host-side task ledger: one JSON file under the DSH home, mutated through a\n * serial write queue, published as immutable snapshots with a global\n * monotonic revision. Change subscribers (P2: SSE route) observe every\n * committed mutation.\n *\n * @module dsh-taskboard/host/store\n */\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n emptyLedger,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\n\n/** One committed ledger mutation, handed to change subscribers. */\nexport interface LedgerChange {\n /** Revision after the mutation. */\n revision: number\n /** The mutated tasks, if any (a comment purge may touch none). */\n tasks: readonly TaskRecord[]\n /** What kind of mutation this was (for SSE event naming later). */\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n}\n\n/**\n * The durable ledger. All mutations run through {@link mutate}, which:\n * validates the resulting document, bumps the global revision, persists\n * atomically (temp file + rename), and only then notifies subscribers.\n */\nexport class TaskStore {\n private readonly file: string\n private ledger: TaskLedger = emptyLedger()\n private readonly subscribers = new Set<(change: LedgerChange) => void>()\n private queue: Promise<unknown> = Promise.resolve()\n private loaded = false\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n async load(): Promise<void> {\n if (this.loaded) return\n try {\n const raw = await readFile(this.file, 'utf8')\n const parsed = JSON.parse(raw) as TaskLedger\n if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {\n const tasks = parsed.tasks as TaskRecord[]\n // Migration from pre-claim-field ledgers: an agent-held in_progress\n // task carried its holder in updatedBy — backfill the explicit claim\n // fields so the hold survives user edits (updatedBy is audit-only).\n for (const task of tasks) {\n if (task.status === 'in_progress' && task.claimedBy === undefined\n && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {\n task.claimedBy = task.updatedBy.sessionId\n task.claimedAt = task.updatedAt\n }\n }\n this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks }\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code\n if (code !== 'ENOENT') {\n // Quarantine a corrupt ledger: rename it aside, start fresh. Never\n // take the host down over ledger damage.\n try {\n await rename(this.file, `${this.file}.corrupt-${Date.now()}`)\n } catch { /* best effort */ }\n }\n }\n this.loaded = true\n }\n\n /**\n * The current snapshot — a deep-frozen clone. Mutating the returned value\n * throws (strict mode) instead of silently bypassing the revision/persist\n * path; internal state is never handed out.\n */\n snapshot(): TaskLedger {\n return deepFreeze(structuredClone(this.ledger))\n }\n\n /** Find a task by id (frozen clone; internal state is never handed out). */\n get(id: string): TaskRecord | undefined {\n const task = this.ledger.tasks.find(t => t.id === id)\n return task === undefined ? undefined : deepFreeze(structuredClone(task))\n }\n\n /** Subscribe to committed changes; returns the unsubscribe. */\n subscribe(fn: (change: LedgerChange) => void): () => void {\n this.subscribers.add(fn)\n return () => this.subscribers.delete(fn)\n }\n\n /**\n * Write a timestamped backup copy of the current ledger next to the live\n * file (import-replace safety, 0.4.0). Never throws the caller's flow —\n * a backup failure fails the import itself.\n * @returns the backup file path.\n */\n async backup(): Promise<string> {\n await this.load()\n const target = `${this.file}.backup-${Date.now()}`\n await persistAtomic(target, JSON.stringify(this.ledger, null, 2))\n return target\n }\n\n /**\n * Run one mutation inside the serial queue. The mutator works on a\n * structured clone; returning `undefined` aborts with no write.\n * @param kind - change kind for subscribers.\n * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.\n */\n async mutate(\n kind: LedgerChange['kind'],\n mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,\n ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {\n const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {\n await this.load()\n const draft: TaskLedger = structuredClone(this.ledger)\n const changed = mutator(draft)\n if (changed === undefined) {\n return { ledger: this.ledger, changed: [] }\n }\n // Retention cap: every committed mutation re-checks the touched tasks,\n // so execution history can never grow unbounded (SSE state payload).\n for (const task of changed) pruneExecutions(task)\n draft.revision += 1\n const json = JSON.stringify(draft)\n await persistAtomic(this.file, json)\n this.ledger = draft\n const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }\n for (const fn of this.subscribers) {\n try {\n fn(change)\n } catch { /* subscriber errors never abort the write */ }\n }\n return { ledger: draft, changed }\n }\n const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n return result\n }\n}\n\n/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n if (!Object.isFrozen(value)) Object.freeze(value)\n for (const key of Object.keys(value as Record<string, unknown>)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\n/** Atomic file persist: write temp, then rename over the target. */\nasync function persistAtomic(file: string, contents: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n await writeFile(temp, contents, 'utf8')\n await rename(temp, file)\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
//#region src/host/templates.ts
|
|
3
|
+
/**
|
|
4
|
+
* Host-side task-template store (0.4.0): one JSON side file next to the
|
|
5
|
+
* ledger, seeded with the built-in templates on first load, mutated through
|
|
6
|
+
* the same atomic persist discipline as the ledger.
|
|
7
|
+
*
|
|
8
|
+
* Pure data, no Cordis deps — the routes layer owns it and tests drive it
|
|
9
|
+
* directly against a temp dir.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-taskboard/host/templates
|
|
12
|
+
*/
|
|
13
|
+
/** The built-in templates seeded when the side file does not exist yet. */
|
|
14
|
+
const BUILTIN_TEMPLATES = [
|
|
15
|
+
{
|
|
16
|
+
id: "tpl-bugfix",
|
|
17
|
+
name: "Bug 修复",
|
|
18
|
+
task: {
|
|
19
|
+
title: "修复:",
|
|
20
|
+
prompt: [
|
|
21
|
+
"修复以下问题并按序交接:",
|
|
22
|
+
"1. 复现问题(写最小复现步骤或测试)",
|
|
23
|
+
"2. 定位根因,说明为什么会发生",
|
|
24
|
+
"3. 修复并补回归测试",
|
|
25
|
+
"4. 运行相关测试套件确认无回归"
|
|
26
|
+
].join("\n"),
|
|
27
|
+
urgency: "urgent",
|
|
28
|
+
checklist: [
|
|
29
|
+
"已复现并定位根因",
|
|
30
|
+
"修复已提交到任务分支",
|
|
31
|
+
"回归测试通过"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "tpl-release",
|
|
37
|
+
name: "发布检查",
|
|
38
|
+
task: {
|
|
39
|
+
title: "发布:",
|
|
40
|
+
prompt: "执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。",
|
|
41
|
+
urgency: "normal",
|
|
42
|
+
checklist: [
|
|
43
|
+
"版本号已更新(package.json 与版本常量同步)",
|
|
44
|
+
"构建通过",
|
|
45
|
+
"全部测试通过",
|
|
46
|
+
"变更记录已写"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "tpl-patrol",
|
|
52
|
+
name: "例行巡检",
|
|
53
|
+
task: {
|
|
54
|
+
title: "巡检:",
|
|
55
|
+
prompt: [
|
|
56
|
+
"例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。",
|
|
57
|
+
"发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。",
|
|
58
|
+
"输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。"
|
|
59
|
+
].join("\n"),
|
|
60
|
+
urgency: "relaxed",
|
|
61
|
+
execution: {
|
|
62
|
+
mode: "scheduled",
|
|
63
|
+
cron: "0 9 * * 1"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
];
|
|
68
|
+
/** Mint a template id. */
|
|
69
|
+
function newTemplateId() {
|
|
70
|
+
return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The template store. NOT thread-synchronized like the ledger (template
|
|
74
|
+
* writes are rare, human-paced GUI operations; last-write-wins is fine).
|
|
75
|
+
*/
|
|
76
|
+
var TemplateStore = class {
|
|
77
|
+
file;
|
|
78
|
+
templates;
|
|
79
|
+
loaded = false;
|
|
80
|
+
/** @param file - absolute side-file path (next to the ledger). */
|
|
81
|
+
constructor(file) {
|
|
82
|
+
this.file = file;
|
|
83
|
+
}
|
|
84
|
+
/** Load once; a missing file seeds the built-ins; a corrupt file resets. */
|
|
85
|
+
async ensure() {
|
|
86
|
+
if (this.loaded) return;
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
const raw = await readFile(this.file, "utf8");
|
|
90
|
+
const value = JSON.parse(raw);
|
|
91
|
+
if (Array.isArray(value.templates)) parsed = value.templates.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.task === "object");
|
|
92
|
+
} catch {}
|
|
93
|
+
if (parsed === void 0) {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
parsed = BUILTIN_TEMPLATES.map((t, i) => ({
|
|
96
|
+
...t,
|
|
97
|
+
task: { ...t.task },
|
|
98
|
+
builtin: true,
|
|
99
|
+
createdAt: now,
|
|
100
|
+
updatedAt: now + i
|
|
101
|
+
}));
|
|
102
|
+
try {
|
|
103
|
+
await this.persist(parsed);
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
this.templates = parsed;
|
|
107
|
+
this.loaded = true;
|
|
108
|
+
}
|
|
109
|
+
/** Atomic persist (temp + rename), same discipline as the ledger. */
|
|
110
|
+
async persist(templates) {
|
|
111
|
+
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
112
|
+
const { dirname, join } = await import("node:path");
|
|
113
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
114
|
+
const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`);
|
|
115
|
+
await writeFile(temp, JSON.stringify({ templates }, null, 2), "utf8");
|
|
116
|
+
await rename(temp, this.file);
|
|
117
|
+
}
|
|
118
|
+
/** All templates (oldest first). */
|
|
119
|
+
async list() {
|
|
120
|
+
await this.ensure();
|
|
121
|
+
return (this.templates ?? []).slice();
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Create or replace a template by id (a body without id creates).
|
|
125
|
+
* @returns the stored template.
|
|
126
|
+
*/
|
|
127
|
+
async upsert(input) {
|
|
128
|
+
await this.ensure();
|
|
129
|
+
const templates = this.templates ?? [];
|
|
130
|
+
const name = input.name.trim();
|
|
131
|
+
if (name.length === 0 || name.length > 60) throw new Error("模板名必须 1..60 字符");
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const existing = input.id !== void 0 ? templates.find((t) => t.id === input.id) : void 0;
|
|
134
|
+
const stored = existing !== void 0 ? {
|
|
135
|
+
...existing,
|
|
136
|
+
name,
|
|
137
|
+
task: input.task,
|
|
138
|
+
updatedAt: now
|
|
139
|
+
} : {
|
|
140
|
+
id: input.id ?? newTemplateId(),
|
|
141
|
+
name,
|
|
142
|
+
task: input.task,
|
|
143
|
+
createdAt: now,
|
|
144
|
+
updatedAt: now
|
|
145
|
+
};
|
|
146
|
+
const index = existing !== void 0 ? templates.indexOf(existing) : -1;
|
|
147
|
+
if (index >= 0) templates[index] = stored;
|
|
148
|
+
else templates.push(stored);
|
|
149
|
+
await this.persist(templates);
|
|
150
|
+
return stored;
|
|
151
|
+
}
|
|
152
|
+
/** Delete a template by id; returns whether it existed. */
|
|
153
|
+
async remove(id) {
|
|
154
|
+
await this.ensure();
|
|
155
|
+
const templates = this.templates ?? [];
|
|
156
|
+
const index = templates.findIndex((t) => t.id === id);
|
|
157
|
+
if (index < 0) return false;
|
|
158
|
+
templates.splice(index, 1);
|
|
159
|
+
await this.persist(templates);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
//#endregion
|
|
164
|
+
export { BUILTIN_TEMPLATES, TemplateStore };
|
|
165
|
+
|
|
166
|
+
//# sourceMappingURL=templates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"templates.js","names":[],"sources":["../../src/host/templates.ts"],"sourcesContent":["/**\n * Host-side task-template store (0.4.0): one JSON side file next to the\n * ledger, seeded with the built-in templates on first load, mutated through\n * the same atomic persist discipline as the ledger.\n *\n * Pure data, no Cordis deps — the routes layer owns it and tests drive it\n * directly against a temp dir.\n *\n * @module dsh-taskboard/host/templates\n */\nimport { readFile } from 'node:fs/promises'\nimport type { TaskTemplate } from '../shared/api.ts'\n\n/** The built-in templates seeded when the side file does not exist yet. */\nexport const BUILTIN_TEMPLATES: ReadonlyArray<{ id: string; name: string; task: TaskTemplate['task'] }> = [\n {\n id: 'tpl-bugfix',\n name: 'Bug 修复',\n task: {\n title: '修复:',\n prompt: [\n '修复以下问题并按序交接:',\n '1. 复现问题(写最小复现步骤或测试)',\n '2. 定位根因,说明为什么会发生',\n '3. 修复并补回归测试',\n '4. 运行相关测试套件确认无回归',\n ].join('\\n'),\n urgency: 'urgent',\n checklist: ['已复现并定位根因', '修复已提交到任务分支', '回归测试通过'],\n },\n },\n {\n id: 'tpl-release',\n name: '发布检查',\n task: {\n title: '发布:',\n prompt: '执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。',\n urgency: 'normal',\n checklist: ['版本号已更新(package.json 与版本常量同步)', '构建通过', '全部测试通过', '变更记录已写'],\n },\n },\n {\n id: 'tpl-patrol',\n name: '例行巡检',\n task: {\n title: '巡检:',\n prompt: [\n '例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。',\n '发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。',\n '输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。',\n ].join('\\n'),\n urgency: 'relaxed',\n execution: { mode: 'scheduled', cron: '0 9 * * 1' },\n },\n },\n]\n\n/** Mint a template id. */\nfunction newTemplateId(): string {\n return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n}\n\n/**\n * The template store. NOT thread-synchronized like the ledger (template\n * writes are rare, human-paced GUI operations; last-write-wins is fine).\n */\nexport class TemplateStore {\n private templates: TaskTemplate[] | undefined\n private loaded = false\n\n /** @param file - absolute side-file path (next to the ledger). */\n constructor(private readonly file: string) {}\n\n /** Load once; a missing file seeds the built-ins; a corrupt file resets. */\n private async ensure(): Promise<void> {\n if (this.loaded) return\n let parsed: TaskTemplate[] | undefined\n try {\n const raw = await readFile(this.file, 'utf8')\n const value = JSON.parse(raw) as { templates?: unknown }\n if (Array.isArray(value.templates)) {\n parsed = value.templates.filter((t): t is TaskTemplate =>\n typeof t === 'object' && t !== null && typeof (t as TaskTemplate).id === 'string'\n && typeof (t as TaskTemplate).name === 'string' && typeof (t as TaskTemplate).task === 'object')\n }\n } catch { /* missing or corrupt → seed */ }\n if (parsed === undefined) {\n const now = Date.now()\n parsed = BUILTIN_TEMPLATES.map((t, i) => ({ ...t, task: { ...t.task }, builtin: true, createdAt: now, updatedAt: now + i }))\n try { await this.persist(parsed) } catch { /* best effort — the seed returns in-memory */ }\n }\n this.templates = parsed\n this.loaded = true\n }\n\n /** Atomic persist (temp + rename), same discipline as the ledger. */\n private async persist(templates: TaskTemplate[]): Promise<void> {\n const { mkdir, writeFile, rename } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n await mkdir(dirname(this.file), { recursive: true })\n const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`)\n await writeFile(temp, JSON.stringify({ templates }, null, 2), 'utf8')\n await rename(temp, this.file)\n }\n\n /** All templates (oldest first). */\n async list(): Promise<TaskTemplate[]> {\n await this.ensure()\n return (this.templates ?? []).slice()\n }\n\n /**\n * Create or replace a template by id (a body without id creates).\n * @returns the stored template.\n */\n async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {\n await this.ensure()\n const templates = this.templates ?? []\n const name = input.name.trim()\n if (name.length === 0 || name.length > 60) throw new Error('模板名必须 1..60 字符')\n const now = Date.now()\n const existing = input.id !== undefined ? templates.find(t => t.id === input.id) : undefined\n const stored: TaskTemplate = existing !== undefined\n ? { ...existing, name, task: input.task, updatedAt: now }\n : { id: input.id ?? newTemplateId(), name, task: input.task, createdAt: now, updatedAt: now }\n const index = existing !== undefined ? templates.indexOf(existing) : -1\n if (index >= 0) templates[index] = stored\n else templates.push(stored)\n await this.persist(templates)\n return stored\n }\n\n /** Delete a template by id; returns whether it existed. */\n async remove(id: string): Promise<boolean> {\n await this.ensure()\n const templates = this.templates ?? []\n const index = templates.findIndex(t => t.id === id)\n if (index < 0) return false\n templates.splice(index, 1)\n await this.persist(templates)\n return true\n }\n}\n"],"mappings":"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"}
|