dsh-taskboard 0.6.6 → 0.7.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 +305 -288
- package/lib/client.js +985 -931
- package/lib/host/archive-sessions.js +30 -0
- package/lib/host/archive-sessions.js.map +1 -0
- package/lib/host/assets.js +139 -0
- package/lib/host/assets.js.map +1 -0
- package/lib/host/execution.js +3 -0
- package/lib/host/execution.js.map +1 -1
- package/lib/host/locale.js +17 -0
- package/lib/host/locale.js.map +1 -0
- package/lib/host/routes.js +132 -6
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +2 -0
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/session-sync.js +4 -1
- package/lib/host/session-sync.js.map +1 -1
- package/lib/host/storage-queue.js +14 -0
- package/lib/host/storage-queue.js.map +1 -0
- package/lib/host/storage.js +249 -0
- package/lib/host/storage.js.map +1 -0
- package/lib/host/store.js +34 -7
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +73 -113
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +23 -8
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +83 -27
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/builtin-templates.js +155 -0
- package/lib/shared/builtin-templates.js.map +1 -0
- package/lib/shared/protocol.js +39 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +90 -89
- package/src/client/api.ts +35 -1
- package/src/client/board/SettingsModal.tsx +67 -4
- package/src/client/board/SlashPromptInput.tsx +80 -1
- package/src/client/board/TaskBoard.tsx +16 -11
- package/src/client/board/TaskDetail.tsx +186 -17
- package/src/client/board/TaskFormModal.tsx +8 -5
- package/src/client/board/TemplateManager.tsx +22 -10
- package/src/client/controller.ts +67 -5
- package/src/client/i18n/en.ts +35 -3
- package/src/client/i18n/templates.ts +25 -0
- package/src/client/i18n/zh.ts +35 -3
- package/src/client/image-insert.ts +29 -0
- package/src/client/styles.ts +35 -2
- package/src/host/archive-sessions.ts +18 -0
- package/src/host/assets.ts +120 -0
- package/src/host/execution.ts +4 -1
- package/src/host/locale.ts +44 -0
- package/src/host/routes.ts +132 -7
- package/src/host/scheduler.ts +2 -0
- package/src/host/session-sync.ts +4 -1
- package/src/host/storage-queue.ts +10 -0
- package/src/host/storage.ts +212 -0
- package/src/host/store.ts +40 -12
- package/src/host/templates.ts +38 -66
- package/src/host/tools.ts +28 -11
- package/src/index.ts +88 -33
- package/src/shared/api.ts +32 -3
- package/src/shared/builtin-templates.ts +153 -0
- package/src/shared/protocol.ts +64 -0
- package/src/shared/version.ts +9 -9
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-sync.js","names":[],"sources":["../../src/host/session-sync.ts"],"sourcesContent":["/**\n * External workspace session synchronization service.\n *\n * When `settings.syncExternalSessions` is enabled (0.5.4):\n * - Listens to session lifecycle events from outside the taskboard.\n * - On `turn/start`: automatically captures or resumes the session on the board\n * (status: `in_progress`, claimedBy: sessionId).\n * - On `user/message` / `session/title`: enriches/updates task title & description.\n * - On `turn/end`: settles the execution (success -> `in_review` 待验收, failure -> `todo`).\n *\n * @module dsh-taskboard/host/session-sync\n */\nimport {\n defaultSyncExternalSessionsOf,\n newCommentId,\n newExecutionId,\n newTaskId,\n normalizeBody,\n normalizeTitle,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport type { EventsFace } from './execution.ts'\nimport type { TaskStore } from './store.ts'\nimport type { WorkspaceFace } from './tools.ts'\n\n/** Extract text content from a user message event payload. */\nexport function extractUserMessageText(msg: unknown): string {\n if (typeof msg !== 'object' || msg === null) return ''\n const content = (msg as { content?: unknown }).content\n if (typeof content === 'string') return content\n if (Array.isArray(content)) {\n return content\n .map(part => {\n if (typeof part === 'string') return part\n if (typeof part === 'object' && part !== null && 'text' in part && typeof (part as { text: unknown }).text === 'string') {\n return (part as { text: string }).text\n }\n return ''\n })\n .filter(Boolean)\n .join('\\n')\n }\n return ''\n}\n\n/** Extract a short one-line title from prompt text. */\nexport function titleFromText(text: string): string {\n const clean = text.trim().replace(/^#+\\s*/, '')\n const firstLine = clean.split('\\n')[0]?.trim() ?? ''\n return firstLine.slice(0, 50).trim()\n}\n\n/**\n * Detect whether a session represents a subagent child conversation.\n * Subagents are created by agent delegation (e.g. invoke_subagent / subagents service)\n * and should never be automatically converted into user tasks on the taskboard.\n */\nexport function isSubagentSession(\n sessionId: string,\n sessionMeta?: unknown,\n event?: { type: string; data?: unknown },\n): boolean {\n if (typeof sessionId === 'string') {\n if (sessionId.startsWith('subagent-') || sessionId.startsWith('child-') || sessionId.startsWith('delegate-')) {\n return true\n }\n }\n\n if (typeof sessionMeta === 'object' && sessionMeta !== null) {\n const s = sessionMeta as {\n header?: Record<string, unknown>\n meta?: Record<string, unknown>\n options?: Record<string, unknown>\n }\n\n const header = s.header\n const meta = s.meta\n const options = s.options\n\n // Check origin\n if (header?.origin === 'subagent' || meta?.origin === 'subagent') return true\n\n // Check parent session lineage\n if (\n header?.parentSession !== undefined\n || header?.parentSessionId !== undefined\n || meta?.parentSession !== undefined\n || meta?.parentSessionId !== undefined\n ) {\n return true\n }\n\n // Check delegation depth\n if (typeof header?.delegationDepth === 'number' && header.delegationDepth > 0) return true\n if (typeof meta?.delegationDepth === 'number' && meta.delegationDepth > 0) return true\n if (typeof options?.subagentDepth === 'number' && options.subagentDepth > 0) return true\n }\n\n if (event !== undefined) {\n if (event.type === 'subagent/descriptor' || event.type === 'subagent/start' || event.type === 'subagent/end') {\n return true\n }\n if (typeof event.data === 'object' && event.data !== null) {\n const d = event.data as Record<string, unknown>\n if (\n d.origin === 'subagent'\n || d.subagent === true\n || typeof d.subagentId === 'string'\n || typeof d.parentSession === 'string'\n ) {\n return true\n }\n }\n }\n\n return false\n}\n\n/**\n * Detect whether a session represents an active working conversation.\n * Inspects state, status, isWorking/isBusy methods, running flags, and active turns.\n */\nexport function isSessionActiveWorking(session: unknown): boolean {\n if (typeof session !== 'object' || session === null) return false\n const s = session as Record<string, unknown>\n\n // 1. Method checks\n if (typeof s.isWorking === 'function') {\n try { if (Boolean((s.isWorking as () => boolean)())) return true } catch { /* ignore */ }\n } else if (s.isWorking === true) {\n return true\n }\n\n if (typeof s.isBusy === 'function') {\n try { if (Boolean((s.isBusy as () => boolean)())) return true } catch { /* ignore */ }\n } else if (s.busy === true || s.isBusy === true) {\n return true\n }\n\n // 2. Boolean flags\n if (s.running === true || s.active === true || s.isGenerating === true || s.generating === true) {\n return true\n }\n\n // 3. State string\n if (typeof s.state === 'string') {\n const st = s.state.toLowerCase()\n if (st === 'running' || st === 'working' || st === 'busy' || st === 'generating' || st === 'executing') {\n return true\n }\n }\n\n // 4. Status string\n if (typeof s.status === 'string') {\n const st = s.status.toLowerCase()\n if (st === 'running' || st === 'working' || st === 'busy' || st === 'active' || st === 'generating' || st === 'executing') {\n return true\n }\n }\n\n // 5. Active turn checks\n if (s.activeTurn !== undefined && s.activeTurn !== null && s.activeTurn !== false) {\n return true\n }\n if (s.currentTurn !== undefined && s.currentTurn !== null) {\n if (typeof s.currentTurn === 'object') {\n const ct = s.currentTurn as Record<string, unknown>\n if (ct.status === 'running' || ct.state === 'running' || ct.outcome === 'running' || ct.endedAt === undefined) {\n return true\n }\n } else {\n return true\n }\n }\n\n // 6. Turns list\n if (Array.isArray(s.turns) && s.turns.length > 0) {\n const lastTurn = s.turns[s.turns.length - 1]\n if (typeof lastTurn === 'object' && lastTurn !== null) {\n const lt = lastTurn as Record<string, unknown>\n if (lt.status === 'running' || lt.state === 'running' || lt.outcome === 'running' || (lt.startedAt !== undefined && lt.endedAt === undefined)) {\n return true\n }\n }\n }\n\n return false\n}\n\n/** Dependencies required by the external session sync service. */\nexport interface SessionSyncDeps {\n store: TaskStore\n workspaces: WorkspaceFace\n events: EventsFace\n sessions?: {\n get?: (id: string) => unknown\n list?: () => unknown[]\n }\n now: () => number\n scanIntervalMs?: number\n}\n\n/** Default scan interval: 4s. */\nexport const DEFAULT_SCAN_INTERVAL_MS = 4000\n\n/**\n * Service that synchronizes external workspace sessions into the taskboard.\n */\nexport class ExternalSessionSyncService {\n private readonly unsubscribe: () => void\n private readonly ignoredSessions = new Set<string>()\n private scanTimer?: NodeJS.Timeout | number\n\n constructor(private readonly deps: SessionSyncDeps) {\n this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {\n void this.handleSessionEvent(sessionId, event, sessionMeta)\n })\n\n const interval = deps.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS\n if (interval > 0) {\n this.scanTimer = setInterval(() => {\n void this.scanActiveSessions()\n }, interval)\n }\n }\n\n /** Detach listener and clear scanner on teardown. */\n dispose(): void {\n this.unsubscribe()\n if (this.scanTimer !== undefined) {\n clearInterval(this.scanTimer as NodeJS.Timeout)\n this.scanTimer = undefined\n }\n }\n\n /**\n * Periodic active scan: checks whether external sessions linked to board tasks\n * are actively working, ensuring tasks in `in_review` / `todo` / `backlog`\n * automatically pull back to `in_progress`.\n */\n async scanActiveSessions(): Promise<void> {\n const snapshot = this.deps.store.snapshot()\n if (!defaultSyncExternalSessionsOf(snapshot.settings)) return\n if (this.deps.sessions === undefined) return\n\n const now = this.deps.now()\n const tasks = snapshot.tasks.filter(t => t.trashedAt === undefined)\n\n for (const task of tasks) {\n const sessionId = task.claimedBy ?? task.executions[task.executions.length - 1]?.sessionId\n if (sessionId === undefined || typeof sessionId !== 'string') continue\n if (this.ignoredSessions.has(sessionId) || sessionId.startsWith('session-taskboard-')) continue\n\n let session: unknown\n try {\n session = this.deps.sessions.get?.(sessionId)\n } catch { /* ignore */ }\n\n if (session === undefined && typeof this.deps.sessions.list === 'function') {\n try {\n const list = this.deps.sessions.list()\n session = list?.find(s => (s as { id?: string })?.id === sessionId)\n } catch { /* ignore */ }\n }\n\n if (session === undefined || session === null) continue\n if (isSubagentSession(sessionId, session)) {\n this.ignoredSessions.add(sessionId)\n continue\n }\n\n const isWorking = isSessionActiveWorking(session)\n if (isWorking) {\n if (task.status !== 'in_progress') {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const current = ledger.tasks.find(t => t.id === task.id)\n if (current === undefined || current.trashedAt !== undefined) return undefined\n current.status = 'in_progress'\n current.claimedBy = sessionId\n current.claimedAt = current.claimedAt ?? now\n current.updatedAt = now\n current.updatedBy = { kind: 'agent', sessionId }\n const hasRunning = current.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n current.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n }\n return [current]\n })\n }\n }\n }\n }\n\n private async handleSessionEvent(\n sessionId: string,\n event: { type: string; data?: unknown },\n sessionMeta?: {\n header?: { cwd?: string; origin?: string; parentSession?: string; parentSessionId?: string; delegationDepth?: number }\n meta?: { origin?: string; parentSession?: string; delegationDepth?: number }\n options?: { subagentDepth?: number }\n },\n ): Promise<void> {\n // 1. Check if already ignored\n if (this.ignoredSessions.has(sessionId)) return\n\n // 2. Ignore taskboard's internal execution sessions\n if (sessionId.startsWith('session-taskboard-')) {\n this.ignoredSessions.add(sessionId)\n return\n }\n\n // 3. Ignore subagent sessions (delegated children)\n if (isSubagentSession(sessionId, sessionMeta, event)) {\n this.ignoredSessions.add(sessionId)\n // If a task was previously created for this subagent before detection, clean it up\n await this.deps.store.mutate('task-deleted', (ledger) => {\n const idx = ledger.tasks.findIndex(\n t => t.claimedBy === sessionId && t.createdBy.kind === 'agent' && t.createdBy.sessionId === sessionId,\n )\n if (idx >= 0) {\n ledger.tasks.splice(idx, 1)\n return []\n }\n return undefined\n })\n return\n }\n\n // 4. Check if external session sync is enabled in board settings\n const snapshot = this.deps.store.snapshot()\n if (!defaultSyncExternalSessionsOf(snapshot.settings)) return\n\n const now = this.deps.now()\n\n if (event.type === 'turn/start') {\n await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now)\n return\n }\n\n if (event.type === 'user/message') {\n await this.handleUserMessage(sessionId, event.data, now)\n return\n }\n\n if (\n event.type === 'turn/step'\n || event.type === 'turn/progress'\n || event.type === 'agent/step'\n || event.type === 'agent/thought'\n || event.type === 'agent/turn/start'\n ) {\n await this.ensureSessionInProgress(sessionId, now)\n return\n }\n\n if (event.type === 'session/title') {\n await this.handleSessionTitle(sessionId, event.data, now)\n return\n }\n\n if (event.type === 'turn/end') {\n await this.handleTurnEnd(sessionId, event.data, now)\n return\n }\n }\n\n private async ensureSessionInProgress(sessionId: string, now: number): Promise<void> {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n let changed = false\n if (task.status !== 'in_progress') {\n task.status = 'in_progress'\n task.claimedBy = sessionId\n task.claimedAt = task.claimedAt ?? now\n changed = true\n }\n const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n task.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n changed = true\n }\n if (changed) {\n task.updatedAt = now\n task.updatedBy = { kind: 'agent', sessionId }\n return [task]\n }\n return undefined\n })\n }\n\n private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {\n // Resolve workspace\n let wsId: string | undefined\n if (cwd !== undefined && cwd.length > 0) {\n const resolved = await this.deps.workspaces.resolveByPath(cwd)\n wsId = resolved?.id\n }\n if (wsId === undefined) {\n wsId = this.deps.workspaces.list()[0]?.id ?? 'default'\n }\n\n await this.deps.store.mutate('task-created', (ledger) => {\n // Find existing task linked to this session\n const existing = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n\n if (existing !== undefined) {\n if (existing.trashedAt !== undefined) return undefined\n // If already in_progress and holding claim, ensure running execution\n if (existing.status === 'in_progress' && existing.claimedBy === sessionId) {\n const hasRunning = existing.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n existing.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n existing.updatedAt = now\n existing.updatedBy = { kind: 'agent', sessionId }\n return [existing]\n }\n return undefined\n }\n\n // Resumed or continued turn (e.g. from in_review or todo)\n existing.status = 'in_progress'\n existing.claimedBy = sessionId\n existing.claimedAt = now\n existing.updatedAt = now\n existing.updatedBy = { kind: 'agent', sessionId }\n existing.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n return [existing]\n }\n\n // Create new task for this external session\n const shortId = sessionId.replace(/^session-/, '').slice(0, 8)\n const newTask: TaskRecord = {\n id: newTaskId(),\n title: `会话 ${shortId}`,\n description: '',\n prompt: '',\n workspaceId: wsId,\n urgency: 'normal',\n status: 'in_progress',\n blocked: false,\n execution: { mode: 'claim' },\n isolation: 'none',\n claimedBy: sessionId,\n claimedAt: now,\n version: 1,\n createdAt: now,\n updatedAt: now,\n createdBy: { kind: 'agent', sessionId },\n updatedBy: { kind: 'agent', sessionId },\n comments: [],\n executions: [\n {\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n },\n ],\n }\n ledger.tasks.push(newTask)\n return [newTask]\n })\n }\n\n private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {\n const text = extractUserMessageText(msgData)\n\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n let changed = false\n if (text.trim().length > 0) {\n // If title is default placeholder \"会话 ...\", replace with prompt summary\n if (task.title.startsWith('会话 ') && task.title.length <= 16) {\n const derived = titleFromText(text)\n if (derived.length > 0) {\n task.title = normalizeTitle(derived)\n changed = true\n }\n }\n // If description is empty, record initial prompt\n if (task.description.length === 0) {\n task.description = text.slice(0, 2000)\n changed = true\n }\n }\n\n // If task was in in_review, todo, or backlog, user message resumes work -> move to in_progress\n if (task.status !== 'in_progress') {\n task.status = 'in_progress'\n task.claimedBy = sessionId\n task.claimedAt = now\n changed = true\n }\n\n // Ensure running execution exists\n const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n task.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n changed = true\n }\n\n if (changed) {\n task.updatedAt = now\n task.updatedBy = { kind: 'user' }\n return [task]\n }\n return undefined\n })\n }\n\n private async handleSessionTitle(sessionId: string, titleData: unknown, now: number): Promise<void> {\n const rawTitle = typeof titleData === 'object' && titleData !== null && 'title' in titleData && typeof (titleData as { title: unknown }).title === 'string'\n ? (titleData as { title: string }).title\n : typeof titleData === 'string'\n ? titleData\n : ''\n if (rawTitle.trim().length === 0) return\n\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n task.title = normalizeTitle(rawTitle)\n task.updatedAt = now\n task.updatedBy = { kind: 'user' }\n return [task]\n })\n }\n\n private async handleTurnEnd(sessionId: string, endData: unknown, now: number): Promise<void> {\n const reason = typeof endData === 'object' && endData !== null && 'reason' in endData\n ? (endData as { reason: unknown }).reason\n : endData\n\n // Check if error or failure\n let isFailure = false\n let errorMessage = ''\n if (typeof reason === 'object' && reason !== null) {\n const r = reason as Record<string, unknown>\n if (r.kind === 'error' || r.kind === 'failure') {\n isFailure = true\n errorMessage = typeof r.error === 'string' ? r.error : typeof r.message === 'string' ? r.message : 'turn error'\n } else if (r.kind === 'cancel') {\n isFailure = true\n errorMessage = 'cancelled'\n }\n } else if (typeof reason === 'string' && (reason.includes('error') || reason.includes('fail'))) {\n isFailure = true\n errorMessage = reason\n }\n\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n // Settle running execution\n for (const exec of task.executions) {\n if (exec.sessionId === sessionId && exec.outcome === 'running') {\n exec.endedAt = now\n if (isFailure) {\n exec.outcome = 'failed'\n exec.error = errorMessage.slice(0, 500)\n } else {\n exec.outcome = 'succeeded'\n }\n }\n }\n\n delete task.claimedBy\n delete task.claimedAt\n task.updatedAt = now\n task.updatedBy = { kind: 'agent', sessionId }\n\n if (isFailure) {\n // Failed session hands back to todo with comment\n if (task.status === 'in_progress') {\n task.status = 'todo'\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),\n version: 1,\n createdAt: now,\n })\n }\n } else {\n // Successful settlement automatically moves to in_review (待验收)\n if (task.status === 'in_progress') {\n task.status = 'in_review'\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),\n version: 1,\n createdAt: now,\n })\n }\n }\n return [task]\n })\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"session-sync.js","names":[],"sources":["../../src/host/session-sync.ts"],"sourcesContent":["/**\n * External workspace session synchronization service.\n *\n * When `settings.syncExternalSessions` is enabled (0.5.4):\n * - Listens to session lifecycle events from outside the taskboard.\n * - On `turn/start`: automatically captures or resumes the session on the board\n * (status: `in_progress`, claimedBy: sessionId).\n * - On `user/message` / `session/title`: enriches/updates task title & description.\n * - On `turn/end`: settles the execution (success -> `in_review` 待验收, failure -> `todo`).\n *\n * @module dsh-taskboard/host/session-sync\n */\nimport {\n defaultSyncExternalSessionsOf,\n newCommentId,\n newExecutionId,\n newTaskId,\n normalizeBody,\n normalizeTitle,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport type { EventsFace } from './execution.ts'\nimport type { TaskStore } from './store.ts'\nimport type { WorkspaceFace } from './tools.ts'\n\n/** Extract text content from a user message event payload. */\nexport function extractUserMessageText(msg: unknown): string {\n if (typeof msg !== 'object' || msg === null) return ''\n const content = (msg as { content?: unknown }).content\n if (typeof content === 'string') return content\n if (Array.isArray(content)) {\n return content\n .map(part => {\n if (typeof part === 'string') return part\n if (typeof part === 'object' && part !== null && 'text' in part && typeof (part as { text: unknown }).text === 'string') {\n return (part as { text: string }).text\n }\n return ''\n })\n .filter(Boolean)\n .join('\\n')\n }\n return ''\n}\n\n/** Extract a short one-line title from prompt text. */\nexport function titleFromText(text: string): string {\n const clean = text.trim().replace(/^#+\\s*/, '')\n const firstLine = clean.split('\\n')[0]?.trim() ?? ''\n return firstLine.slice(0, 50).trim()\n}\n\n/**\n * Detect whether a session represents a subagent child conversation.\n * Subagents are created by agent delegation (e.g. invoke_subagent / subagents service)\n * and should never be automatically converted into user tasks on the taskboard.\n */\nexport function isSubagentSession(\n sessionId: string,\n sessionMeta?: unknown,\n event?: { type: string; data?: unknown },\n): boolean {\n if (typeof sessionId === 'string') {\n if (sessionId.startsWith('subagent-') || sessionId.startsWith('child-') || sessionId.startsWith('delegate-')) {\n return true\n }\n }\n\n if (typeof sessionMeta === 'object' && sessionMeta !== null) {\n const s = sessionMeta as {\n header?: Record<string, unknown>\n meta?: Record<string, unknown>\n options?: Record<string, unknown>\n }\n\n const header = s.header\n const meta = s.meta\n const options = s.options\n\n // Check origin\n if (header?.origin === 'subagent' || meta?.origin === 'subagent') return true\n\n // Check parent session lineage\n if (\n header?.parentSession !== undefined\n || header?.parentSessionId !== undefined\n || meta?.parentSession !== undefined\n || meta?.parentSessionId !== undefined\n ) {\n return true\n }\n\n // Check delegation depth\n if (typeof header?.delegationDepth === 'number' && header.delegationDepth > 0) return true\n if (typeof meta?.delegationDepth === 'number' && meta.delegationDepth > 0) return true\n if (typeof options?.subagentDepth === 'number' && options.subagentDepth > 0) return true\n }\n\n if (event !== undefined) {\n if (event.type === 'subagent/descriptor' || event.type === 'subagent/start' || event.type === 'subagent/end') {\n return true\n }\n if (typeof event.data === 'object' && event.data !== null) {\n const d = event.data as Record<string, unknown>\n if (\n d.origin === 'subagent'\n || d.subagent === true\n || typeof d.subagentId === 'string'\n || typeof d.parentSession === 'string'\n ) {\n return true\n }\n }\n }\n\n return false\n}\n\n/**\n * Detect whether a session represents an active working conversation.\n * Inspects state, status, isWorking/isBusy methods, running flags, and active turns.\n */\nexport function isSessionActiveWorking(session: unknown): boolean {\n if (typeof session !== 'object' || session === null) return false\n const s = session as Record<string, unknown>\n\n // 1. Method checks\n if (typeof s.isWorking === 'function') {\n try { if (Boolean((s.isWorking as () => boolean)())) return true } catch { /* ignore */ }\n } else if (s.isWorking === true) {\n return true\n }\n\n if (typeof s.isBusy === 'function') {\n try { if (Boolean((s.isBusy as () => boolean)())) return true } catch { /* ignore */ }\n } else if (s.busy === true || s.isBusy === true) {\n return true\n }\n\n // 2. Boolean flags\n if (s.running === true || s.active === true || s.isGenerating === true || s.generating === true) {\n return true\n }\n\n // 3. State string\n if (typeof s.state === 'string') {\n const st = s.state.toLowerCase()\n if (st === 'running' || st === 'working' || st === 'busy' || st === 'generating' || st === 'executing') {\n return true\n }\n }\n\n // 4. Status string\n if (typeof s.status === 'string') {\n const st = s.status.toLowerCase()\n if (st === 'running' || st === 'working' || st === 'busy' || st === 'active' || st === 'generating' || st === 'executing') {\n return true\n }\n }\n\n // 5. Active turn checks\n if (s.activeTurn !== undefined && s.activeTurn !== null && s.activeTurn !== false) {\n return true\n }\n if (s.currentTurn !== undefined && s.currentTurn !== null) {\n if (typeof s.currentTurn === 'object') {\n const ct = s.currentTurn as Record<string, unknown>\n if (ct.status === 'running' || ct.state === 'running' || ct.outcome === 'running' || ct.endedAt === undefined) {\n return true\n }\n } else {\n return true\n }\n }\n\n // 6. Turns list\n if (Array.isArray(s.turns) && s.turns.length > 0) {\n const lastTurn = s.turns[s.turns.length - 1]\n if (typeof lastTurn === 'object' && lastTurn !== null) {\n const lt = lastTurn as Record<string, unknown>\n if (lt.status === 'running' || lt.state === 'running' || lt.outcome === 'running' || (lt.startedAt !== undefined && lt.endedAt === undefined)) {\n return true\n }\n }\n }\n\n return false\n}\n\n/** Dependencies required by the external session sync service. */\nexport interface SessionSyncDeps {\n store: TaskStore\n workspaces: WorkspaceFace\n events: EventsFace\n sessions?: {\n get?: (id: string) => unknown\n list?: () => unknown[]\n }\n now: () => number\n scanIntervalMs?: number\n}\n\n/** Default scan interval: 4s. */\nexport const DEFAULT_SCAN_INTERVAL_MS = 4000\n\n/**\n * Service that synchronizes external workspace sessions into the taskboard.\n */\nexport class ExternalSessionSyncService {\n private readonly unsubscribe: () => void\n private readonly ignoredSessions = new Set<string>()\n private scanTimer?: NodeJS.Timeout | number\n\n constructor(private readonly deps: SessionSyncDeps) {\n this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {\n return this.handleSessionEvent(sessionId, event, sessionMeta)\n })\n\n const interval = deps.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS\n if (interval > 0) {\n this.scanTimer = setInterval(() => {\n void this.scanActiveSessions()\n }, interval)\n }\n }\n\n /** Detach listener and clear scanner on teardown. */\n dispose(): void {\n this.unsubscribe()\n if (this.scanTimer !== undefined) {\n clearInterval(this.scanTimer as NodeJS.Timeout)\n this.scanTimer = undefined\n }\n }\n\n /**\n * Periodic active scan: checks whether external sessions linked to board tasks\n * are actively working, ensuring tasks in `in_review` / `todo` / `backlog`\n * automatically pull back to `in_progress`.\n */\n async scanActiveSessions(): Promise<void> {\n const snapshot = this.deps.store.snapshot()\n if (!defaultSyncExternalSessionsOf(snapshot.settings)) return\n if (this.deps.sessions === undefined) return\n\n const now = this.deps.now()\n const tasks = snapshot.tasks.filter(t => t.trashedAt === undefined)\n\n for (const task of tasks) {\n const sessionId = task.claimedBy ?? task.executions[task.executions.length - 1]?.sessionId\n if (sessionId === undefined || typeof sessionId !== 'string') continue\n if (this.ignoredSessions.has(sessionId) || sessionId.startsWith('session-taskboard-')) continue\n\n let session: unknown\n try {\n session = this.deps.sessions.get?.(sessionId)\n } catch { /* ignore */ }\n\n if (session === undefined && typeof this.deps.sessions.list === 'function') {\n try {\n const list = this.deps.sessions.list()\n session = list?.find(s => (s as { id?: string })?.id === sessionId)\n } catch { /* ignore */ }\n }\n\n if (session === undefined || session === null) continue\n if (isSubagentSession(sessionId, session)) {\n this.ignoredSessions.add(sessionId)\n continue\n }\n\n const isWorking = isSessionActiveWorking(session)\n if (isWorking) {\n if (task.status !== 'in_progress') {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const current = ledger.tasks.find(t => t.id === task.id)\n if (current === undefined || current.trashedAt !== undefined) return undefined\n current.status = 'in_progress'\n current.claimedBy = sessionId\n current.claimedAt = current.claimedAt ?? now\n current.updatedAt = now\n current.updatedBy = { kind: 'agent', sessionId }\n const hasRunning = current.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n current.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n }\n return [current]\n })\n }\n }\n }\n }\n\n private async handleSessionEvent(\n sessionId: string,\n event: { type: string; data?: unknown },\n sessionMeta?: {\n header?: { cwd?: string; origin?: string; parentSession?: string; parentSessionId?: string; delegationDepth?: number }\n meta?: { origin?: string; parentSession?: string; delegationDepth?: number }\n options?: { subagentDepth?: number }\n },\n ): Promise<void> {\n // 1. Check if already ignored\n if (this.ignoredSessions.has(sessionId)) return\n\n // 2. Ignore taskboard's internal execution sessions\n if (sessionId.startsWith('session-taskboard-')) {\n this.ignoredSessions.add(sessionId)\n return\n }\n\n // 3. Ignore subagent sessions (delegated children)\n if (isSubagentSession(sessionId, sessionMeta, event)) {\n this.ignoredSessions.add(sessionId)\n // If a task was previously created for this subagent before detection, clean it up\n await this.deps.store.mutate('task-deleted', (ledger) => {\n const idx = ledger.tasks.findIndex(\n t => t.claimedBy === sessionId && t.createdBy.kind === 'agent' && t.createdBy.sessionId === sessionId,\n )\n if (idx >= 0) {\n ledger.tasks.splice(idx, 1)\n return []\n }\n return undefined\n })\n return\n }\n\n // 4. Check if external session sync is enabled in board settings\n const snapshot = this.deps.store.snapshot()\n if (!defaultSyncExternalSessionsOf(snapshot.settings)) return\n\n const now = this.deps.now()\n\n if (event.type === 'turn/start') {\n await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now)\n return\n }\n\n if (event.type === 'user/message') {\n await this.handleUserMessage(sessionId, event.data, now)\n return\n }\n\n if (\n event.type === 'turn/step'\n || event.type === 'turn/progress'\n || event.type === 'agent/step'\n || event.type === 'agent/thought'\n || event.type === 'agent/turn/start'\n ) {\n await this.ensureSessionInProgress(sessionId, now)\n return\n }\n\n if (event.type === 'session/title') {\n await this.handleSessionTitle(sessionId, event.data, now)\n return\n }\n\n if (event.type === 'turn/end') {\n await this.handleTurnEnd(sessionId, event.data, now)\n return\n }\n }\n\n private async ensureSessionInProgress(sessionId: string, now: number): Promise<void> {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n let changed = false\n if (task.status !== 'in_progress') {\n task.status = 'in_progress'\n task.claimedBy = sessionId\n task.claimedAt = task.claimedAt ?? now\n changed = true\n }\n const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n task.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n changed = true\n }\n if (changed) {\n task.updatedAt = now\n task.updatedBy = { kind: 'agent', sessionId }\n return [task]\n }\n return undefined\n })\n }\n\n private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {\n // Resolve workspace\n let wsId: string | undefined\n if (cwd !== undefined && cwd.length > 0) {\n const resolved = await this.deps.workspaces.resolveByPath(cwd)\n wsId = resolved?.id\n }\n if (wsId === undefined) {\n wsId = this.deps.workspaces.list()[0]?.id ?? 'default'\n }\n\n await this.deps.store.mutate('task-created', (ledger) => {\n // Find existing task linked to this session\n const existing = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n\n if (existing !== undefined) {\n if (existing.trashedAt !== undefined) return undefined\n // If already in_progress and holding claim, ensure running execution\n if (existing.status === 'in_progress' && existing.claimedBy === sessionId) {\n const hasRunning = existing.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n existing.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n existing.updatedAt = now\n existing.updatedBy = { kind: 'agent', sessionId }\n return [existing]\n }\n return undefined\n }\n\n // Resumed or continued turn (e.g. from in_review or todo)\n existing.status = 'in_progress'\n existing.claimedBy = sessionId\n existing.claimedAt = now\n existing.updatedAt = now\n existing.updatedBy = { kind: 'agent', sessionId }\n existing.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n return [existing]\n }\n\n // Create new task for this external session\n const shortId = sessionId.replace(/^session-/, '').slice(0, 8)\n const newTask: TaskRecord = {\n id: newTaskId(),\n title: `会话 ${shortId}`,\n description: '',\n prompt: '',\n workspaceId: wsId,\n urgency: 'normal',\n status: 'in_progress',\n blocked: false,\n execution: { mode: 'claim' },\n isolation: 'none',\n claimedBy: sessionId,\n claimedAt: now,\n version: 1,\n createdAt: now,\n updatedAt: now,\n createdBy: { kind: 'agent', sessionId },\n updatedBy: { kind: 'agent', sessionId },\n comments: [],\n executions: [\n {\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n },\n ],\n }\n ledger.tasks.push(newTask)\n return [newTask]\n })\n }\n\n private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {\n const text = extractUserMessageText(msgData)\n\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n let changed = false\n if (text.trim().length > 0) {\n // If title is default placeholder \"会话 ...\", replace with prompt summary\n if (task.title.startsWith('会话 ') && task.title.length <= 16) {\n const derived = titleFromText(text)\n if (derived.length > 0) {\n task.title = normalizeTitle(derived)\n changed = true\n }\n }\n // If description is empty, record initial prompt\n if (task.description.length === 0) {\n task.description = text.slice(0, 2000)\n changed = true\n }\n }\n\n // If task was in in_review, todo, or backlog, user message resumes work -> move to in_progress\n if (task.status !== 'in_progress') {\n task.status = 'in_progress'\n task.claimedBy = sessionId\n task.claimedAt = now\n changed = true\n }\n\n // Ensure running execution exists\n const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n task.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n changed = true\n }\n\n if (changed) {\n task.updatedAt = now\n task.updatedBy = { kind: 'user' }\n return [task]\n }\n return undefined\n })\n }\n\n private async handleSessionTitle(sessionId: string, titleData: unknown, now: number): Promise<void> {\n const rawTitle = typeof titleData === 'object' && titleData !== null && 'title' in titleData && typeof (titleData as { title: unknown }).title === 'string'\n ? (titleData as { title: string }).title\n : typeof titleData === 'string'\n ? titleData\n : ''\n if (rawTitle.trim().length === 0) return\n\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n task.title = normalizeTitle(rawTitle)\n task.updatedAt = now\n task.updatedBy = { kind: 'user' }\n return [task]\n })\n }\n\n private async handleTurnEnd(sessionId: string, endData: unknown, now: number): Promise<void> {\n const reason = typeof endData === 'object' && endData !== null && 'reason' in endData\n ? (endData as { reason: unknown }).reason\n : endData\n\n // Check if error or failure\n let isFailure = false\n let errorMessage = ''\n if (typeof reason === 'object' && reason !== null) {\n const r = reason as Record<string, unknown>\n if (r.kind === 'error' || r.kind === 'failure') {\n isFailure = true\n errorMessage = typeof r.error === 'string' ? r.error : typeof r.message === 'string' ? r.message : 'turn error'\n } else if (r.kind === 'cancel') {\n isFailure = true\n errorMessage = 'cancelled'\n }\n } else if (typeof reason === 'string' && (reason.includes('error') || reason.includes('fail'))) {\n isFailure = true\n errorMessage = reason\n }\n\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n // Settle running execution\n for (const exec of task.executions) {\n if (exec.sessionId === sessionId && exec.outcome === 'running') {\n exec.endedAt = now\n if (isFailure) {\n exec.outcome = 'failed'\n exec.error = errorMessage.slice(0, 500)\n } else {\n exec.outcome = 'succeeded'\n }\n }\n }\n\n delete task.claimedBy\n delete task.claimedAt\n task.updatedAt = now\n task.updatedBy = { kind: 'agent', sessionId }\n\n if (isFailure) {\n // Failed session hands back to todo with comment\n if (task.status === 'in_progress') {\n task.status = 'todo'\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),\n systemKey: 'sys.sessionError',\n systemParams: { error: errorMessage.slice(0, 300) },\n version: 1,\n createdAt: now,\n })\n }\n } else {\n // Successful settlement automatically moves to in_review (待验收)\n if (task.status === 'in_progress') {\n task.status = 'in_review'\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),\n systemKey: 'sys.sessionDone',\n version: 1,\n createdAt: now,\n })\n }\n }\n return [task]\n })\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/host/storage-queue.ts
|
|
2
|
+
/** One process-wide serial queue shared by all taskboard persistence. */
|
|
3
|
+
var StorageQueue = class {
|
|
4
|
+
tail = Promise.resolve();
|
|
5
|
+
run(operation) {
|
|
6
|
+
const result = this.tail.then(operation, operation);
|
|
7
|
+
this.tail = result.then(() => void 0, () => void 0);
|
|
8
|
+
return result;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { StorageQueue };
|
|
13
|
+
|
|
14
|
+
//# sourceMappingURL=storage-queue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage-queue.js","names":[],"sources":["../../src/host/storage-queue.ts"],"sourcesContent":["/** One process-wide serial queue shared by all taskboard persistence. */\nexport class StorageQueue {\n private tail: Promise<unknown> = Promise.resolve()\n\n run<T>(operation: () => Promise<T>): Promise<T> {\n const result = this.tail.then(operation, operation)\n this.tail = result.then(() => undefined, () => undefined)\n return result\n }\n}\n"],"mappings":";;AACA,IAAa,eAAb,MAA0B;CACxB,OAAiC,QAAQ,QAAQ;CAEjD,IAAO,WAAyC;EAC9C,MAAM,SAAS,KAAK,KAAK,KAAK,WAAW,SAAS;EAClD,KAAK,OAAO,OAAO,WAAW,KAAA,SAAiB,KAAA,CAAS;EACxD,OAAO;CACT;AACF"}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { StorageQueue } from "./storage-queue.js";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { access, constants, copyFile, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
//#region src/host/storage.ts
|
|
7
|
+
/** Configurable taskboard data directory and crash-safe three-part migration. */
|
|
8
|
+
const STORAGE_CONFIG_FILE = "dsh-taskboard-storage.json";
|
|
9
|
+
const CONFIG_SCHEMA_VERSION = 1;
|
|
10
|
+
function normalized(path) {
|
|
11
|
+
const value = resolve(path.trim());
|
|
12
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
13
|
+
}
|
|
14
|
+
function samePath(a, b) {
|
|
15
|
+
return normalized(a) === normalized(b);
|
|
16
|
+
}
|
|
17
|
+
function inside(parent, child) {
|
|
18
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
19
|
+
return rel.length === 0 || !rel.startsWith("..") && !isAbsolute(rel);
|
|
20
|
+
}
|
|
21
|
+
async function exists(path) {
|
|
22
|
+
try {
|
|
23
|
+
await stat(path);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function persistConfig(file, config) {
|
|
30
|
+
await mkdir(dirname(file), { recursive: true });
|
|
31
|
+
const temp = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
|
|
32
|
+
const handle = await open(temp, "w");
|
|
33
|
+
try {
|
|
34
|
+
await handle.writeFile(JSON.stringify(config, null, 2), "utf8");
|
|
35
|
+
await handle.sync();
|
|
36
|
+
} finally {
|
|
37
|
+
await handle.close();
|
|
38
|
+
}
|
|
39
|
+
await rename(temp, file);
|
|
40
|
+
}
|
|
41
|
+
function configuredDirectory(options) {
|
|
42
|
+
if (!existsSync(options.configFile)) return {
|
|
43
|
+
directory: resolve(options.defaultDirectory),
|
|
44
|
+
configured: false
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(options.configFile, "utf8"));
|
|
48
|
+
if (parsed.schemaVersion !== CONFIG_SCHEMA_VERSION || typeof parsed.dataDirectory !== "string" || !isAbsolute(parsed.dataDirectory)) return {
|
|
49
|
+
directory: resolve(options.defaultDirectory),
|
|
50
|
+
configured: false,
|
|
51
|
+
error: "invalid storage location config; using the default directory"
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
directory: resolve(parsed.dataDirectory),
|
|
55
|
+
configured: true
|
|
56
|
+
};
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
directory: resolve(options.defaultDirectory),
|
|
60
|
+
configured: false,
|
|
61
|
+
error: `cannot read storage location config: ${error instanceof Error ? error.message : String(error)}`
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Coordinates all persistent stores so migration cannot race normal writes. */
|
|
66
|
+
var StorageCoordinator = class {
|
|
67
|
+
options;
|
|
68
|
+
queue = new StorageQueue();
|
|
69
|
+
currentDirectory;
|
|
70
|
+
configured;
|
|
71
|
+
startupError;
|
|
72
|
+
stores;
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
const selected = configuredDirectory(options);
|
|
76
|
+
this.currentDirectory = selected.directory;
|
|
77
|
+
this.configured = selected.configured;
|
|
78
|
+
if (selected.error !== void 0) console.warn(`[dsh-taskboard] ${selected.error}`);
|
|
79
|
+
if (selected.configured && !existsSync(selected.directory)) this.startupError = `configured storage directory is unavailable: ${selected.directory}`;
|
|
80
|
+
}
|
|
81
|
+
attach(stores) {
|
|
82
|
+
this.stores = stores;
|
|
83
|
+
}
|
|
84
|
+
directory() {
|
|
85
|
+
return this.currentDirectory;
|
|
86
|
+
}
|
|
87
|
+
ledgerPath(directory = this.currentDirectory) {
|
|
88
|
+
return join(directory, this.options.ledgerName);
|
|
89
|
+
}
|
|
90
|
+
templatesPath(directory = this.currentDirectory) {
|
|
91
|
+
return join(directory, this.options.templatesName);
|
|
92
|
+
}
|
|
93
|
+
assetsPath(directory = this.currentDirectory) {
|
|
94
|
+
return join(directory, this.options.assetsName);
|
|
95
|
+
}
|
|
96
|
+
async ready() {
|
|
97
|
+
if (this.startupError !== void 0) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`);
|
|
98
|
+
await mkdir(this.currentDirectory, { recursive: true });
|
|
99
|
+
await access(this.currentDirectory, constants.R_OK | constants.W_OK);
|
|
100
|
+
}
|
|
101
|
+
requireStores() {
|
|
102
|
+
if (this.stores === void 0) throw new Error("storage coordinator is not attached");
|
|
103
|
+
return this.stores;
|
|
104
|
+
}
|
|
105
|
+
async status() {
|
|
106
|
+
let assetCount = 0;
|
|
107
|
+
let assetBytes = 0;
|
|
108
|
+
try {
|
|
109
|
+
for (const entry of await readdir(this.assetsPath(), { withFileTypes: true })) {
|
|
110
|
+
if (!entry.isFile()) continue;
|
|
111
|
+
assetCount += 1;
|
|
112
|
+
try {
|
|
113
|
+
assetBytes += (await stat(join(this.assetsPath(), entry.name))).size;
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
} catch {}
|
|
117
|
+
return {
|
|
118
|
+
currentDirectory: this.currentDirectory,
|
|
119
|
+
defaultDirectory: resolve(this.options.defaultDirectory),
|
|
120
|
+
isDefault: samePath(this.currentDirectory, this.options.defaultDirectory),
|
|
121
|
+
configured: this.configured,
|
|
122
|
+
writable: this.startupError === void 0,
|
|
123
|
+
assetCount,
|
|
124
|
+
assetBytes,
|
|
125
|
+
...this.startupError === void 0 ? {} : { error: this.startupError }
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async check(directory) {
|
|
129
|
+
const target = this.validateTarget(directory);
|
|
130
|
+
await mkdir(target, { recursive: true });
|
|
131
|
+
await this.assertTargetAvailable(target);
|
|
132
|
+
const probe = join(target, `.dsh-taskboard-write-${randomUUID()}.tmp`);
|
|
133
|
+
const handle = await open(probe, "wx");
|
|
134
|
+
try {
|
|
135
|
+
await handle.writeFile("ok", "utf8");
|
|
136
|
+
await handle.sync();
|
|
137
|
+
} finally {
|
|
138
|
+
await handle.close();
|
|
139
|
+
await rm(probe, { force: true });
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
...await this.status(),
|
|
143
|
+
checkedDirectory: target,
|
|
144
|
+
writable: true
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
validateTarget(directory) {
|
|
148
|
+
if (typeof directory !== "string" || directory.trim().length === 0) return resolve(this.options.defaultDirectory);
|
|
149
|
+
if (!isAbsolute(directory.trim())) throw new Error("storage directory must be an absolute path");
|
|
150
|
+
const target = resolve(directory.trim());
|
|
151
|
+
if (inside(this.assetsPath(), target)) throw new Error("storage directory cannot be inside the current attachment directory");
|
|
152
|
+
return target;
|
|
153
|
+
}
|
|
154
|
+
async assertTargetAvailable(target) {
|
|
155
|
+
if (samePath(target, this.currentDirectory)) return;
|
|
156
|
+
for (const path of [
|
|
157
|
+
this.ledgerPath(target),
|
|
158
|
+
this.templatesPath(target),
|
|
159
|
+
this.assetsPath(target)
|
|
160
|
+
]) if (await exists(path)) throw new Error(`target already contains ${basename(path)}`);
|
|
161
|
+
}
|
|
162
|
+
async migrate(directory) {
|
|
163
|
+
const target = this.validateTarget(directory);
|
|
164
|
+
return this.queue.run(async () => {
|
|
165
|
+
if (samePath(target, this.currentDirectory)) return {
|
|
166
|
+
...await this.status(),
|
|
167
|
+
migrated: false,
|
|
168
|
+
warnings: []
|
|
169
|
+
};
|
|
170
|
+
if (this.startupError !== void 0) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`);
|
|
171
|
+
const stores = this.requireStores();
|
|
172
|
+
await stores.ledger.load();
|
|
173
|
+
await mkdir(target, { recursive: true });
|
|
174
|
+
await this.assertTargetAvailable(target);
|
|
175
|
+
const oldDirectory = this.currentDirectory;
|
|
176
|
+
const stage = join(target, `.dsh-taskboard-migration-${randomUUID()}`);
|
|
177
|
+
const stageLedger = this.ledgerPath(stage);
|
|
178
|
+
const stageTemplates = this.templatesPath(stage);
|
|
179
|
+
const stageAssets = this.assetsPath(stage);
|
|
180
|
+
const warnings = [];
|
|
181
|
+
try {
|
|
182
|
+
await mkdir(stage, { recursive: true });
|
|
183
|
+
await stores.ledger.writeCopy(stageLedger);
|
|
184
|
+
await stores.templates.writeCopy(stageTemplates);
|
|
185
|
+
await mkdir(stageAssets, { recursive: true });
|
|
186
|
+
try {
|
|
187
|
+
for (const entry of await readdir(stores.assets.location(), { withFileTypes: true })) if (entry.isFile()) await copyFile(join(stores.assets.location(), entry.name), join(stageAssets, entry.name), constants.COPYFILE_EXCL);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error.code !== "ENOENT") throw error;
|
|
190
|
+
}
|
|
191
|
+
JSON.parse(await readFile(stageLedger, "utf8"));
|
|
192
|
+
JSON.parse(await readFile(stageTemplates, "utf8"));
|
|
193
|
+
await rename(stageLedger, this.ledgerPath(target));
|
|
194
|
+
await rename(stageTemplates, this.templatesPath(target));
|
|
195
|
+
await rename(stageAssets, this.assetsPath(target));
|
|
196
|
+
if (samePath(target, this.options.defaultDirectory)) await rm(this.options.configFile, { force: true });
|
|
197
|
+
else await persistConfig(this.options.configFile, {
|
|
198
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
199
|
+
dataDirectory: target
|
|
200
|
+
});
|
|
201
|
+
stores.ledger.setLocation(this.ledgerPath(target));
|
|
202
|
+
stores.templates.setLocation(this.templatesPath(target));
|
|
203
|
+
stores.assets.setLocation(this.assetsPath(target));
|
|
204
|
+
this.currentDirectory = target;
|
|
205
|
+
this.configured = !samePath(target, this.options.defaultDirectory);
|
|
206
|
+
this.startupError = void 0;
|
|
207
|
+
for (const oldPath of [
|
|
208
|
+
this.ledgerPath(oldDirectory),
|
|
209
|
+
this.templatesPath(oldDirectory),
|
|
210
|
+
this.assetsPath(oldDirectory)
|
|
211
|
+
]) try {
|
|
212
|
+
await rm(oldPath, {
|
|
213
|
+
recursive: true,
|
|
214
|
+
force: true
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
warnings.push(`could not remove ${oldPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
218
|
+
}
|
|
219
|
+
await rm(stage, {
|
|
220
|
+
recursive: true,
|
|
221
|
+
force: true
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
...await this.status(),
|
|
225
|
+
migrated: true,
|
|
226
|
+
warnings
|
|
227
|
+
};
|
|
228
|
+
} catch (error) {
|
|
229
|
+
await rm(stage, {
|
|
230
|
+
recursive: true,
|
|
231
|
+
force: true
|
|
232
|
+
}).catch(() => void 0);
|
|
233
|
+
if (samePath(this.currentDirectory, oldDirectory)) for (const path of [
|
|
234
|
+
this.ledgerPath(target),
|
|
235
|
+
this.templatesPath(target),
|
|
236
|
+
this.assetsPath(target)
|
|
237
|
+
]) await rm(path, {
|
|
238
|
+
recursive: true,
|
|
239
|
+
force: true
|
|
240
|
+
}).catch(() => void 0);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
//#endregion
|
|
247
|
+
export { STORAGE_CONFIG_FILE, StorageCoordinator };
|
|
248
|
+
|
|
249
|
+
//# sourceMappingURL=storage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.js","names":[],"sources":["../../src/host/storage.ts"],"sourcesContent":["/** Configurable taskboard data directory and crash-safe three-part migration. */\nimport { randomUUID } from 'node:crypto'\nimport { constants, copyFile, access, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'\nimport { existsSync, readFileSync } from 'node:fs'\nimport { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'\nimport type { StorageStatus, StorageMigrationResult } from '../shared/api.ts'\nimport type { AssetStore } from './assets.ts'\nimport type { TaskStore } from './store.ts'\nimport type { TemplateStore } from './templates.ts'\nimport { StorageQueue } from './storage-queue.ts'\n\nexport const STORAGE_CONFIG_FILE = 'dsh-taskboard-storage.json'\nconst CONFIG_SCHEMA_VERSION = 1\n\ntype StorageConfig = { schemaVersion: number; dataDirectory: string }\n\nexport interface StorageCoordinatorOptions {\n defaultDirectory: string\n configFile: string\n ledgerName: string\n templatesName: string\n assetsName: string\n}\n\nfunction normalized(path: string): string {\n const value = resolve(path.trim())\n return process.platform === 'win32' ? value.toLowerCase() : value\n}\n\nfunction samePath(a: string, b: string): boolean { return normalized(a) === normalized(b) }\n\nfunction inside(parent: string, child: string): boolean {\n const rel = relative(resolve(parent), resolve(child))\n return rel.length === 0 || (!rel.startsWith('..') && !isAbsolute(rel))\n}\n\nasync function exists(path: string): Promise<boolean> {\n try { await stat(path); return true } catch { return false }\n}\n\nasync function persistConfig(file: string, config: StorageConfig): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`)\n const handle = await open(temp, 'w')\n try {\n await handle.writeFile(JSON.stringify(config, null, 2), 'utf8')\n await handle.sync()\n } finally { await handle.close() }\n await rename(temp, file)\n}\n\nfunction configuredDirectory(options: StorageCoordinatorOptions): { directory: string; configured: boolean; error?: string } {\n if (!existsSync(options.configFile)) return { directory: resolve(options.defaultDirectory), configured: false }\n try {\n const parsed = JSON.parse(readFileSync(options.configFile, 'utf8')) as Partial<StorageConfig>\n if (parsed.schemaVersion !== CONFIG_SCHEMA_VERSION || typeof parsed.dataDirectory !== 'string' || !isAbsolute(parsed.dataDirectory)) {\n return { directory: resolve(options.defaultDirectory), configured: false, error: 'invalid storage location config; using the default directory' }\n }\n return { directory: resolve(parsed.dataDirectory), configured: true }\n } catch (error) {\n return { directory: resolve(options.defaultDirectory), configured: false, error: `cannot read storage location config: ${error instanceof Error ? error.message : String(error)}` }\n }\n}\n\n/** Coordinates all persistent stores so migration cannot race normal writes. */\nexport class StorageCoordinator {\n readonly queue = new StorageQueue()\n private currentDirectory: string\n private configured: boolean\n private startupError?: string\n private stores?: { ledger: TaskStore; templates: TemplateStore; assets: AssetStore }\n\n constructor(private readonly options: StorageCoordinatorOptions) {\n const selected = configuredDirectory(options)\n this.currentDirectory = selected.directory\n this.configured = selected.configured\n if (selected.error !== undefined) console.warn(`[dsh-taskboard] ${selected.error}`)\n if (selected.configured && !existsSync(selected.directory)) {\n this.startupError = `configured storage directory is unavailable: ${selected.directory}`\n }\n }\n\n attach(stores: { ledger: TaskStore; templates: TemplateStore; assets: AssetStore }): void { this.stores = stores }\n\n directory(): string { return this.currentDirectory }\n ledgerPath(directory = this.currentDirectory): string { return join(directory, this.options.ledgerName) }\n templatesPath(directory = this.currentDirectory): string { return join(directory, this.options.templatesName) }\n assetsPath(directory = this.currentDirectory): string { return join(directory, this.options.assetsName) }\n\n async ready(): Promise<void> {\n if (this.startupError !== undefined) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`)\n await mkdir(this.currentDirectory, { recursive: true })\n await access(this.currentDirectory, constants.R_OK | constants.W_OK)\n }\n\n private requireStores(): { ledger: TaskStore; templates: TemplateStore; assets: AssetStore } {\n if (this.stores === undefined) throw new Error('storage coordinator is not attached')\n return this.stores\n }\n\n async status(): Promise<StorageStatus> {\n let assetCount = 0\n let assetBytes = 0\n try {\n for (const entry of await readdir(this.assetsPath(), { withFileTypes: true })) {\n if (!entry.isFile()) continue\n assetCount += 1\n try { assetBytes += (await stat(join(this.assetsPath(), entry.name))).size } catch { /* best effort */ }\n }\n } catch { /* no attachment directory yet */ }\n return {\n currentDirectory: this.currentDirectory,\n defaultDirectory: resolve(this.options.defaultDirectory),\n isDefault: samePath(this.currentDirectory, this.options.defaultDirectory),\n configured: this.configured,\n writable: this.startupError === undefined,\n assetCount,\n assetBytes,\n ...(this.startupError === undefined ? {} : { error: this.startupError }),\n }\n }\n\n async check(directory: string): Promise<StorageStatus> {\n const target = this.validateTarget(directory)\n await mkdir(target, { recursive: true })\n await this.assertTargetAvailable(target)\n const probe = join(target, `.dsh-taskboard-write-${randomUUID()}.tmp`)\n const handle = await open(probe, 'wx')\n try { await handle.writeFile('ok', 'utf8'); await handle.sync() } finally { await handle.close(); await rm(probe, { force: true }) }\n return { ...(await this.status()), checkedDirectory: target, writable: true }\n }\n\n private validateTarget(directory: string): string {\n if (typeof directory !== 'string' || directory.trim().length === 0) return resolve(this.options.defaultDirectory)\n if (!isAbsolute(directory.trim())) throw new Error('storage directory must be an absolute path')\n const target = resolve(directory.trim())\n if (inside(this.assetsPath(), target)) throw new Error('storage directory cannot be inside the current attachment directory')\n return target\n }\n\n private async assertTargetAvailable(target: string): Promise<void> {\n if (samePath(target, this.currentDirectory)) return\n for (const path of [this.ledgerPath(target), this.templatesPath(target), this.assetsPath(target)]) {\n if (await exists(path)) throw new Error(`target already contains ${basename(path)}`)\n }\n }\n\n async migrate(directory: string): Promise<StorageMigrationResult> {\n const target = this.validateTarget(directory)\n return this.queue.run(async () => {\n // Re-check only after acquiring the shared queue: another migration may\n // have switched to this target while this request was waiting.\n if (samePath(target, this.currentDirectory)) return { ...(await this.status()), migrated: false, warnings: [] }\n if (this.startupError !== undefined) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`)\n const stores = this.requireStores()\n await stores.ledger.load()\n await mkdir(target, { recursive: true })\n await this.assertTargetAvailable(target)\n\n const oldDirectory = this.currentDirectory\n const stage = join(target, `.dsh-taskboard-migration-${randomUUID()}`)\n const stageLedger = this.ledgerPath(stage)\n const stageTemplates = this.templatesPath(stage)\n const stageAssets = this.assetsPath(stage)\n const warnings: string[] = []\n try {\n await mkdir(stage, { recursive: true })\n await stores.ledger.writeCopy(stageLedger)\n await stores.templates.writeCopy(stageTemplates)\n await mkdir(stageAssets, { recursive: true })\n try {\n for (const entry of await readdir(stores.assets.location(), { withFileTypes: true })) {\n if (entry.isFile()) await copyFile(join(stores.assets.location(), entry.name), join(stageAssets, entry.name), constants.COPYFILE_EXCL)\n }\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n }\n JSON.parse(await readFile(stageLedger, 'utf8'))\n JSON.parse(await readFile(stageTemplates, 'utf8'))\n\n await rename(stageLedger, this.ledgerPath(target))\n await rename(stageTemplates, this.templatesPath(target))\n await rename(stageAssets, this.assetsPath(target))\n if (samePath(target, this.options.defaultDirectory)) await rm(this.options.configFile, { force: true })\n else await persistConfig(this.options.configFile, { schemaVersion: CONFIG_SCHEMA_VERSION, dataDirectory: target })\n\n stores.ledger.setLocation(this.ledgerPath(target))\n stores.templates.setLocation(this.templatesPath(target))\n stores.assets.setLocation(this.assetsPath(target))\n this.currentDirectory = target\n this.configured = !samePath(target, this.options.defaultDirectory)\n this.startupError = undefined\n\n for (const oldPath of [this.ledgerPath(oldDirectory), this.templatesPath(oldDirectory), this.assetsPath(oldDirectory)]) {\n try { await rm(oldPath, { recursive: true, force: true }) } catch (error) { warnings.push(`could not remove ${oldPath}: ${error instanceof Error ? error.message : String(error)}`) }\n }\n await rm(stage, { recursive: true, force: true })\n return { ...(await this.status()), migrated: true, warnings }\n } catch (error) {\n await rm(stage, { recursive: true, force: true }).catch(() => undefined)\n // The pointer is the commit point. Before it changes, prepared target\n // files are safe to remove and the old directory remains authoritative.\n if (samePath(this.currentDirectory, oldDirectory)) {\n for (const path of [this.ledgerPath(target), this.templatesPath(target), this.assetsPath(target)]) {\n await rm(path, { recursive: true, force: true }).catch(() => undefined)\n }\n }\n throw error\n }\n })\n }\n}\n"],"mappings":"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"}
|
package/lib/host/store.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { mkdir, open, readFile, rename } from "node:fs/promises";
|
|
4
4
|
//#region src/host/store.ts
|
|
5
5
|
/**
|
|
6
|
-
* Host-side task ledger: one JSON file under the
|
|
6
|
+
* Host-side task ledger: one JSON file under the active data directory, mutated through a
|
|
7
7
|
* serial write queue, published as immutable snapshots with a global
|
|
8
8
|
* monotonic revision. Change subscribers (P2: SSE route) observe every
|
|
9
9
|
* committed mutation.
|
|
@@ -17,17 +17,39 @@ import { mkdir, open, readFile, rename } from "node:fs/promises";
|
|
|
17
17
|
*/
|
|
18
18
|
var TaskStore = class {
|
|
19
19
|
file;
|
|
20
|
+
storageQueue;
|
|
20
21
|
ledger = emptyLedger();
|
|
21
22
|
subscribers = /* @__PURE__ */ new Set();
|
|
22
23
|
queue = Promise.resolve();
|
|
23
24
|
loaded = false;
|
|
25
|
+
loadPromise;
|
|
24
26
|
/** @param options - file location. */
|
|
25
27
|
constructor(options) {
|
|
26
28
|
this.file = options.file;
|
|
29
|
+
this.storageQueue = options.queue;
|
|
30
|
+
}
|
|
31
|
+
/** Current absolute ledger path. */
|
|
32
|
+
location() {
|
|
33
|
+
return this.file;
|
|
34
|
+
}
|
|
35
|
+
/** Persist the live in-memory ledger to another file without switching. */
|
|
36
|
+
async writeCopy(file) {
|
|
37
|
+
await this.load();
|
|
38
|
+
await persistAtomic(file, JSON.stringify(this.ledger));
|
|
39
|
+
}
|
|
40
|
+
/** Switch future writes after a prepared migration commits. */
|
|
41
|
+
setLocation(file) {
|
|
42
|
+
this.file = file;
|
|
27
43
|
}
|
|
28
44
|
/** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */
|
|
29
|
-
|
|
30
|
-
if (this.loaded) return;
|
|
45
|
+
load() {
|
|
46
|
+
if (this.loaded) return Promise.resolve();
|
|
47
|
+
if (this.loadPromise !== void 0) return this.loadPromise;
|
|
48
|
+
this.loadPromise = this.loadOnce();
|
|
49
|
+
return this.loadPromise;
|
|
50
|
+
}
|
|
51
|
+
/** Perform the single physical ledger read shared by all startup callers. */
|
|
52
|
+
async loadOnce() {
|
|
31
53
|
try {
|
|
32
54
|
const raw = await readFile(this.file, "utf8");
|
|
33
55
|
const parsed = JSON.parse(raw);
|
|
@@ -92,10 +114,13 @@ var TaskStore = class {
|
|
|
92
114
|
* @returns the backup file path.
|
|
93
115
|
*/
|
|
94
116
|
async backup() {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
const run = async () => {
|
|
118
|
+
await this.load();
|
|
119
|
+
const target = `${this.file}.backup-${Date.now()}`;
|
|
120
|
+
await persistAtomic(target, JSON.stringify(this.ledger, null, 2));
|
|
121
|
+
return target;
|
|
122
|
+
};
|
|
123
|
+
return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
|
|
99
124
|
}
|
|
100
125
|
/**
|
|
101
126
|
* Run one mutation inside the serial queue. The mutator works on a
|
|
@@ -130,6 +155,7 @@ var TaskStore = class {
|
|
|
130
155
|
changed: changed.map((t) => deepFreeze(structuredClone(t)))
|
|
131
156
|
};
|
|
132
157
|
};
|
|
158
|
+
if (this.storageQueue !== void 0) return this.storageQueue.run(run);
|
|
133
159
|
return this.queue = this.queue.then(run, run);
|
|
134
160
|
}
|
|
135
161
|
/**
|
|
@@ -143,6 +169,7 @@ var TaskStore = class {
|
|
|
143
169
|
await this.load();
|
|
144
170
|
return fn(deepFreeze(structuredClone(this.ledger)));
|
|
145
171
|
};
|
|
172
|
+
if (this.storageQueue !== void 0) return this.storageQueue.run(run);
|
|
146
173
|
return this.queue = this.queue.then(run, run);
|
|
147
174
|
}
|
|
148
175
|
};
|
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, open, readFile, rename } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n asBoardSettings,\n emptyLedger,\n isPlausibleTaskRecord,\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' | 'settings-updated' | 'ledger-replaced'\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 // S11: trust no record wholesale — drop structurally broken entries\n // (including R4's traversal-shaped ids from a hand-edited file) with\n // a notice instead of letting them reach the path-building layers.\n const plausible: TaskRecord[] = []\n for (const entry of parsed.tasks as unknown[]) {\n if (!isPlausibleTaskRecord(entry)) {\n const rawId = (entry as { id?: unknown })?.id\n const id = typeof rawId === 'string' ? rawId.slice(0, 60) : String(rawId)\n console.warn('[dsh-taskboard] dropping implausible ledger entry on load:', id)\n continue\n }\n plausible.push(entry as TaskRecord)\n }\n const tasks = plausible\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 let settings = undefined\n if (parsed.settings !== undefined) {\n try {\n settings = asBoardSettings(parsed.settings)\n } catch {\n console.warn('[dsh-taskboard] dropping invalid board settings on load')\n }\n }\n this.ledger = {\n schemaVersion: LEDGER_SCHEMA_VERSION,\n revision: parsed.revision,\n tasks,\n ...(settings !== undefined ? { settings } : {}),\n }\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 // S9 parity: even a no-op mutation hands out a frozen clone — never\n // the live internal ledger.\n return { ledger: deepFreeze(structuredClone(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 // S9: hand out frozen clones — the return value used to BE the new\n // internal ledger; callers must never mutate internal state in place.\n return {\n ledger: deepFreeze(structuredClone(draft)),\n changed: changed.map(t => deepFreeze(structuredClone(t))),\n }\n }\n const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n return result\n }\n\n /**\n * Run a read INSIDE the serial queue (R3): observes exactly the ledger\n * state after all previously enqueued mutations — immune to the\n * write-then-publish window around `mutate`'s persistence. Read-only: the\n * callback receives a frozen deep clone and nothing is written.\n */\n async read<T>(fn: (ledger: TaskLedger) => T): Promise<T> {\n const run = async (): Promise<T> => {\n await this.load()\n return fn(deepFreeze(structuredClone(this.ledger)))\n }\n const result = (this.queue = this.queue.then(run, run)) as Promise<T>\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/**\n * Atomic file persist: write temp, fsync, then rename over the target (S10:\n * without the sync, a power loss after rename can leave a zero-length file —\n * the next load would quarantine the ledger and start empty).\n */\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 const fh = await open(temp, 'w')\n try {\n await fh.writeFile(contents, 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAyCA,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;IAItE,MAAM,YAA0B,CAAC;IACjC,KAAK,MAAM,SAAS,OAAO,OAAoB;KAC7C,IAAI,CAAC,sBAAsB,KAAK,GAAG;MACjC,MAAM,QAAS,OAA4B;MAC3C,MAAM,KAAK,OAAO,UAAU,WAAW,MAAM,MAAM,GAAG,EAAE,IAAI,OAAO,KAAK;MACxE,QAAQ,KAAK,8DAA8D,EAAE;MAC7E;KACF;KACA,UAAU,KAAK,KAAmB;IACpC;IACA,MAAM,QAAQ;IAId,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,IAAI,WAAW,KAAA;IACf,IAAI,OAAO,aAAa,KAAA,GACtB,IAAI;KACF,WAAW,gBAAgB,OAAO,QAAQ;IAC5C,QAAQ;KACN,QAAQ,KAAK,yDAAyD;IACxE;IAEF,KAAK,SAAS;KACZ,eAAA;KACA,UAAU,OAAO;KACjB;KACA,GAAI,aAAa,KAAA,IAAY,EAAE,SAAS,IAAI,CAAC;IAC/C;GACF;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,SAA0B;EAC9B,MAAM,KAAK,KAAK;EAChB,MAAM,SAAS,GAAG,KAAK,KAAK,UAAU,KAAK,IAAI;EAC/C,MAAM,cAAc,QAAQ,KAAK,UAAU,KAAK,QAAQ,MAAM,CAAC,CAAC;EAChE,OAAO;CACT;;;;;;;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,GAGd,OAAO;IAAE,QAAQ,WAAW,gBAAgB,KAAK,MAAM,CAAC;IAAG,SAAS,CAAC;GAAE;GAIzE,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;GAI1D,OAAO;IACL,QAAQ,WAAW,gBAAgB,KAAK,CAAC;IACzC,SAAS,QAAQ,KAAI,MAAK,WAAW,gBAAgB,CAAC,CAAC,CAAC;GAC1D;EACF;EAEA,OAAO,KADc,QAAQ,KAAK,MAAM,KAAK,KAAK,GAAG;CAEvD;;;;;;;CAQA,MAAM,KAAQ,IAA2C;EACvD,MAAM,MAAM,YAAwB;GAClC,MAAM,KAAK,KAAK;GAChB,OAAO,GAAG,WAAW,gBAAgB,KAAK,MAAM,CAAC,CAAC;EACpD;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;;;;;;AAOA,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,KAAK,MAAM,KAAK,MAAM,GAAG;CAC/B,IAAI;EACF,MAAM,GAAG,UAAU,UAAU,MAAM;EACnC,MAAM,GAAG,KAAK;CAChB,UAAU;EACR,MAAM,GAAG,MAAM;CACjB;CACA,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 active data directory, 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, open, readFile, rename } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n asBoardSettings,\n emptyLedger,\n isPlausibleTaskRecord,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport type { StorageQueue } from './storage-queue.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' | 'settings-updated' | 'ledger-replaced'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n /** Optional queue shared with templates/assets and storage migration. */\n queue?: StorageQueue\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 file: string\n private readonly storageQueue?: StorageQueue\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 private loadPromise: Promise<void> | undefined\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n this.storageQueue = options.queue\n }\n\n /** Current absolute ledger path. */\n location(): string { return this.file }\n\n /** Persist the live in-memory ledger to another file without switching. */\n async writeCopy(file: string): Promise<void> {\n await this.load()\n await persistAtomic(file, JSON.stringify(this.ledger))\n }\n\n /** Switch future writes after a prepared migration commits. */\n setLocation(file: string): void { this.file = file }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n load(): Promise<void> {\n if (this.loaded) return Promise.resolve()\n if (this.loadPromise !== undefined) return this.loadPromise\n this.loadPromise = this.loadOnce()\n return this.loadPromise\n }\n\n /** Perform the single physical ledger read shared by all startup callers. */\n private async loadOnce(): Promise<void> {\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 // S11: trust no record wholesale — drop structurally broken entries\n // (including R4's traversal-shaped ids from a hand-edited file) with\n // a notice instead of letting them reach the path-building layers.\n const plausible: TaskRecord[] = []\n for (const entry of parsed.tasks as unknown[]) {\n if (!isPlausibleTaskRecord(entry)) {\n const rawId = (entry as { id?: unknown })?.id\n const id = typeof rawId === 'string' ? rawId.slice(0, 60) : String(rawId)\n console.warn('[dsh-taskboard] dropping implausible ledger entry on load:', id)\n continue\n }\n plausible.push(entry as TaskRecord)\n }\n const tasks = plausible\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 let settings = undefined\n if (parsed.settings !== undefined) {\n try {\n settings = asBoardSettings(parsed.settings)\n } catch {\n console.warn('[dsh-taskboard] dropping invalid board settings on load')\n }\n }\n this.ledger = {\n schemaVersion: LEDGER_SCHEMA_VERSION,\n revision: parsed.revision,\n tasks,\n ...(settings !== undefined ? { settings } : {}),\n }\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 const run = async (): 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 return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\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 // S9 parity: even a no-op mutation hands out a frozen clone — never\n // the live internal ledger.\n return { ledger: deepFreeze(structuredClone(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 // S9: hand out frozen clones — the return value used to BE the new\n // internal ledger; callers must never mutate internal state in place.\n return {\n ledger: deepFreeze(structuredClone(draft)),\n changed: changed.map(t => deepFreeze(structuredClone(t))),\n }\n }\n if (this.storageQueue !== undefined) return this.storageQueue.run(run)\n return (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n }\n\n /**\n * Run a read INSIDE the serial queue (R3): observes exactly the ledger\n * state after all previously enqueued mutations — immune to the\n * write-then-publish window around `mutate`'s persistence. Read-only: the\n * callback receives a frozen deep clone and nothing is written.\n */\n async read<T>(fn: (ledger: TaskLedger) => T): Promise<T> {\n const run = async (): Promise<T> => {\n await this.load()\n return fn(deepFreeze(structuredClone(this.ledger)))\n }\n if (this.storageQueue !== undefined) return this.storageQueue.run(run)\n return (this.queue = this.queue.then(run, run)) as Promise<T>\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/**\n * Atomic file persist: write temp, fsync, then rename over the target (S10:\n * without the sync, a power loss after rename can leave a zero-length file —\n * the next load would quarantine the ledger and start empty).\n */\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 const fh = await open(temp, 'w')\n try {\n await fh.writeFile(contents, 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n}\n"],"mappings":"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"}
|