dsh-taskboard 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +169 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +2085 -0
  5. package/lib/host/execution.js +189 -0
  6. package/lib/host/execution.js.map +1 -0
  7. package/lib/host/protocol-text.js +37 -0
  8. package/lib/host/protocol-text.js.map +1 -0
  9. package/lib/host/routes.js +369 -0
  10. package/lib/host/routes.js.map +1 -0
  11. package/lib/host/scheduler.js +91 -0
  12. package/lib/host/scheduler.js.map +1 -0
  13. package/lib/host/sdk.js +145 -0
  14. package/lib/host/sdk.js.map +1 -0
  15. package/lib/host/store.js +112 -0
  16. package/lib/host/store.js.map +1 -0
  17. package/lib/host/tools.js +620 -0
  18. package/lib/host/tools.js.map +1 -0
  19. package/lib/index.js +91 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/invariant.js +22 -0
  22. package/lib/invariant.js.map +1 -0
  23. package/lib/shared/api.js +9 -0
  24. package/lib/shared/api.js.map +1 -0
  25. package/lib/shared/protocol.js +279 -0
  26. package/lib/shared/protocol.js.map +1 -0
  27. package/package.json +74 -0
  28. package/src/client/api.ts +90 -0
  29. package/src/client/board/NewTaskModal.tsx +8 -0
  30. package/src/client/board/TaskBoard.tsx +184 -0
  31. package/src/client/board/TaskCard.tsx +61 -0
  32. package/src/client/board/TaskDetail.tsx +210 -0
  33. package/src/client/board/TaskFormModal.tsx +257 -0
  34. package/src/client/board-mount.tsx +92 -0
  35. package/src/client/controller.ts +241 -0
  36. package/src/client/index.ts +87 -0
  37. package/src/client/sidebar-entry.ts +165 -0
  38. package/src/client/styles.ts +391 -0
  39. package/src/host/execution.ts +244 -0
  40. package/src/host/protocol-text.ts +37 -0
  41. package/src/host/routes.ts +387 -0
  42. package/src/host/scheduler.ts +107 -0
  43. package/src/host/sdk.ts +200 -0
  44. package/src/host/store.ts +139 -0
  45. package/src/host/tools.ts +631 -0
  46. package/src/index.ts +124 -0
  47. package/src/invariant.ts +22 -0
  48. package/src/shared/api.ts +98 -0
  49. package/src/shared/protocol.ts +475 -0
@@ -0,0 +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 normalizePrompt,\n normalizeTitle,\n summarize,\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}\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' ? 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 taskPath = (id: string, action?: string): RegExp | null => {\n const escaped = id.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const pattern = action === undefined\n ? `^${ROUTE_PREFIX}/tasks/${escaped}$`\n : `^${ROUTE_PREFIX}/tasks/${escaped}/${action}$`\n return new RegExp(pattern)\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 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 as { provider: string; model: string } | undefined\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 = body.model as { provider: string; model: string }\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 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 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 void taskPath\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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"}
@@ -0,0 +1,91 @@
1
+ import { nextCronTime, parseCron } from "../shared/protocol.js";
2
+ //#region src/host/scheduler.ts
3
+ /**
4
+ * Host-side cron scheduler: one tick per minute over the ledger's scheduled
5
+ * tasks. A due task (nextRunAt reached, not running, not trashed) first has
6
+ * its next run advanced to the next cron match — then it executes through
7
+ * the same path as the manual button. Missed windows (host was down, tab
8
+ * closed — irrelevant here, this is the host process) simply advance: a
9
+ * nextRunAt more than one window in the past is skipped, not caught up.
10
+ *
11
+ * @module dsh-taskboard/host/scheduler
12
+ */
13
+ /** Tick cadence. */
14
+ const TICK_MS = 6e4;
15
+ /** A due window older than this is skipped (missed while the host was down). */
16
+ const SKIP_AFTER_MS = 5 * 6e4;
17
+ /**
18
+ * The cron scheduler.
19
+ */
20
+ var SchedulerService = class {
21
+ deps;
22
+ handle;
23
+ /** @param deps - store + execution + clock. */
24
+ constructor(deps) {
25
+ this.deps = deps;
26
+ }
27
+ /** Start ticking. */
28
+ start() {
29
+ const timers = this.deps.timers ?? {
30
+ setInterval: (fn, ms) => setInterval(fn, ms),
31
+ clearInterval: (handle) => clearInterval(handle)
32
+ };
33
+ this.handle = timers.setInterval(() => {
34
+ this.tick();
35
+ }, TICK_MS);
36
+ setTimeout(() => {
37
+ this.tick();
38
+ }, 3e3);
39
+ }
40
+ /** Stop ticking. */
41
+ dispose() {
42
+ if (this.handle === void 0) return;
43
+ (this.deps.timers ?? { clearInterval: (h) => clearInterval(h) }).clearInterval(this.handle);
44
+ this.handle = void 0;
45
+ }
46
+ /** One scheduler pass (exported for tests). */
47
+ async tick() {
48
+ const now = this.deps.now();
49
+ const ledger = this.deps.store.snapshot();
50
+ for (const task of ledger.tasks) {
51
+ if (task.execution.mode !== "scheduled" || task.execution.cron === void 0) continue;
52
+ if (task.execution.nextRunAt === void 0) continue;
53
+ if (task.status === "in_progress" || task.trashedAt !== void 0) continue;
54
+ if (task.execution.nextRunAt > now) continue;
55
+ const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS;
56
+ await this.advance(task.id, now);
57
+ if (missed) continue;
58
+ const lastTriggeredAt = task.execution.nextRunAt;
59
+ await this.markTriggered(task.id, lastTriggeredAt);
60
+ await this.deps.execution.run(task.id, "scheduled").catch((error) => {
61
+ console.error("[dsh-taskboard] scheduled run failed:", error);
62
+ });
63
+ }
64
+ }
65
+ /** Recompute and persist the next run for one scheduled task. */
66
+ async advance(taskId, now) {
67
+ await this.deps.store.mutate("task-updated", (ledger) => {
68
+ const task = ledger.tasks.find((t) => t.id === taskId);
69
+ if (task === void 0 || task.execution.cron === void 0) return void 0;
70
+ const match = parseCron(task.execution.cron);
71
+ const next = match === null ? void 0 : nextCronTime(match, now) ?? void 0;
72
+ if (next === void 0) return void 0;
73
+ task.execution.nextRunAt = next;
74
+ return [task];
75
+ });
76
+ }
77
+ /** Record the trigger instant on the task. */
78
+ async markTriggered(taskId, at) {
79
+ if (at === void 0) return;
80
+ await this.deps.store.mutate("task-updated", (ledger) => {
81
+ const task = ledger.tasks.find((t) => t.id === taskId);
82
+ if (task === void 0) return void 0;
83
+ task.execution.lastTriggeredAt = at;
84
+ return [task];
85
+ });
86
+ }
87
+ };
88
+ //#endregion
89
+ export { SchedulerService };
90
+
91
+ //# sourceMappingURL=scheduler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scheduler.js","names":[],"sources":["../../src/host/scheduler.ts"],"sourcesContent":["/**\n * Host-side cron scheduler: one tick per minute over the ledger's scheduled\n * tasks. A due task (nextRunAt reached, not running, not trashed) first has\n * its next run advanced to the next cron match — then it executes through\n * the same path as the manual button. Missed windows (host was down, tab\n * closed — irrelevant here, this is the host process) simply advance: a\n * nextRunAt more than one window in the past is skipped, not caught up.\n *\n * @module dsh-taskboard/host/scheduler\n */\nimport { nextCronTime, parseCron, type TaskLedger } from '../shared/protocol.ts'\nimport type { ExecutionService } from './execution.ts'\nimport type { TaskStore } from './store.ts'\n\n/** Tick cadence. */\nconst TICK_MS = 60_000\n\n/** A due window older than this is skipped (missed while the host was down). */\nconst SKIP_AFTER_MS = 5 * 60_000\n\n/** Everything the scheduler needs. */\nexport interface SchedulerDeps {\n store: TaskStore\n execution: Pick<ExecutionService, 'run'>\n now: () => number\n /** Timer face (injectable for tests). */\n timers?: {\n setInterval(fn: () => void, ms: number): unknown\n clearInterval(handle: unknown): void\n }\n}\n\n/**\n * The cron scheduler.\n */\nexport class SchedulerService {\n private handle: unknown\n\n /** @param deps - store + execution + clock. */\n constructor(private readonly deps: SchedulerDeps) {}\n\n /** Start ticking. */\n start(): void {\n const timers = this.deps.timers ?? {\n setInterval: (fn: () => void, ms: number) => setInterval(fn, ms),\n clearInterval: (handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>),\n }\n this.handle = timers.setInterval(() => { void this.tick() }, TICK_MS)\n // Catch up promptly on host restart: run one tick soon after start.\n setTimeout(() => { void this.tick() }, 3_000)\n }\n\n /** Stop ticking. */\n dispose(): void {\n if (this.handle === undefined) return\n const timers = this.deps.timers ?? { clearInterval: (h: unknown) => clearInterval(h as ReturnType<typeof setInterval>) }\n timers.clearInterval(this.handle)\n this.handle = undefined\n }\n\n /** One scheduler pass (exported for tests). */\n async tick(): Promise<void> {\n const now = this.deps.now()\n const ledger: TaskLedger = this.deps.store.snapshot()\n for (const task of ledger.tasks) {\n if (task.execution.mode !== 'scheduled' || task.execution.cron === undefined) continue\n if (task.execution.nextRunAt === undefined) continue\n if (task.status === 'in_progress' || task.trashedAt !== undefined) continue\n if (task.execution.nextRunAt > now) continue\n const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS\n\n // Advance the schedule FIRST (idempotent under re-ticks), then run\n // unless the window was missed entirely.\n await this.advance(task.id, now)\n if (missed) continue\n const lastTriggeredAt = task.execution.nextRunAt\n await this.markTriggered(task.id, lastTriggeredAt)\n await this.deps.execution.run(task.id, 'scheduled').catch(error => {\n console.error('[dsh-taskboard] scheduled run failed:', error)\n })\n }\n }\n\n /** Recompute and persist the next run for one scheduled task. */\n private async advance(taskId: string, now: number): Promise<void> {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(t => t.id === taskId)\n if (task === undefined || task.execution.cron === undefined) return undefined\n const match = parseCron(task.execution.cron)\n const next = match === null ? undefined : nextCronTime(match, now) ?? undefined\n if (next === undefined) return undefined\n task.execution.nextRunAt = next\n return [task]\n })\n }\n\n /** Record the trigger instant on the task. */\n private async markTriggered(taskId: string, at: number | undefined): Promise<void> {\n if (at === undefined) return\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(t => t.id === taskId)\n if (task === undefined) return undefined\n task.execution.lastTriggeredAt = at\n return [task]\n })\n }\n}\n"],"mappings":";;;;;;;;;;;;;AAeA,MAAM,UAAU;;AAGhB,MAAM,gBAAgB,IAAI;;;;AAiB1B,IAAa,mBAAb,MAA8B;CAIC;CAH7B;;CAGA,YAAY,MAAsC;EAArB,KAAA,OAAA;CAAsB;;CAGnD,QAAc;EACZ,MAAM,SAAS,KAAK,KAAK,UAAU;GACjC,cAAc,IAAgB,OAAe,YAAY,IAAI,EAAE;GAC/D,gBAAgB,WAAoB,cAAc,MAAwC;EAC5F;EACA,KAAK,SAAS,OAAO,kBAAkB;GAAE,KAAU,KAAK;EAAE,GAAG,OAAO;EAEpE,iBAAiB;GAAE,KAAU,KAAK;EAAE,GAAG,GAAK;CAC9C;;CAGA,UAAgB;EACd,IAAI,KAAK,WAAW,KAAA,GAAW;EAE/B,CADe,KAAK,KAAK,UAAU,EAAE,gBAAgB,MAAe,cAAc,CAAmC,EAAE,EAAA,CAChH,cAAc,KAAK,MAAM;EAChC,KAAK,SAAS,KAAA;CAChB;;CAGA,MAAM,OAAsB;EAC1B,MAAM,MAAM,KAAK,KAAK,IAAI;EAC1B,MAAM,SAAqB,KAAK,KAAK,MAAM,SAAS;EACpD,KAAK,MAAM,QAAQ,OAAO,OAAO;GAC/B,IAAI,KAAK,UAAU,SAAS,eAAe,KAAK,UAAU,SAAS,KAAA,GAAW;GAC9E,IAAI,KAAK,UAAU,cAAc,KAAA,GAAW;GAC5C,IAAI,KAAK,WAAW,iBAAiB,KAAK,cAAc,KAAA,GAAW;GACnE,IAAI,KAAK,UAAU,YAAY,KAAK;GACpC,MAAM,SAAS,MAAM,KAAK,UAAU,YAAY;GAIhD,MAAM,KAAK,QAAQ,KAAK,IAAI,GAAG;GAC/B,IAAI,QAAQ;GACZ,MAAM,kBAAkB,KAAK,UAAU;GACvC,MAAM,KAAK,cAAc,KAAK,IAAI,eAAe;GACjD,MAAM,KAAK,KAAK,UAAU,IAAI,KAAK,IAAI,WAAW,CAAC,CAAC,OAAM,UAAS;IACjE,QAAQ,MAAM,yCAAyC,KAAK;GAC9D,CAAC;EACH;CACF;;CAGA,MAAc,QAAQ,QAAgB,KAA4B;EAChE,MAAM,KAAK,KAAK,MAAM,OAAO,iBAAiB,WAAW;GACvD,MAAM,OAAO,OAAO,MAAM,MAAK,MAAK,EAAE,OAAO,MAAM;GACnD,IAAI,SAAS,KAAA,KAAa,KAAK,UAAU,SAAS,KAAA,GAAW,OAAO,KAAA;GACpE,MAAM,QAAQ,UAAU,KAAK,UAAU,IAAI;GAC3C,MAAM,OAAO,UAAU,OAAO,KAAA,IAAY,aAAa,OAAO,GAAG,KAAK,KAAA;GACtE,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;GAC/B,KAAK,UAAU,YAAY;GAC3B,OAAO,CAAC,IAAI;EACd,CAAC;CACH;;CAGA,MAAc,cAAc,QAAgB,IAAuC;EACjF,IAAI,OAAO,KAAA,GAAW;EACtB,MAAM,KAAK,KAAK,MAAM,OAAO,iBAAiB,WAAW;GACvD,MAAM,OAAO,OAAO,MAAM,MAAK,MAAK,EAAE,OAAO,MAAM;GACnD,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;GAC/B,KAAK,UAAU,kBAAkB;GACjC,OAAO,CAAC,IAAI;EACd,CAAC;CACH;AACF"}
@@ -0,0 +1,145 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ //#region src/host/sdk.ts
4
+ /**
5
+ * Self-contained replacements for the three @deepseek-ai runtime imports the
6
+ * host half used to take from npm-mirror SDK packages (dsh-home-paths,
7
+ * dsh-llm/brand, dsh-tools' defineTool).
8
+ *
9
+ * Why: a published copy must never resolve `@deepseek-ai/dsh-tools` from the
10
+ * profile's node_modules — an npm-mirror dsh-tools there shadows the
11
+ * CLI-internal build for the WHOLE base layer, and the agent loop's private
12
+ * scheduler symbol then misses (`Cannot read properties of undefined
13
+ * (reading 'prepare')` on every tool call). Everything here is a pure,
14
+ * structure-compatible reimplementation of the exact behavior we relied on:
15
+ *
16
+ * - `dshHomePath` mirrors `join(resolve(env.DSH_HOME ?? ~/.dsh), ...segments)`;
17
+ * - `MessageId` is the identity brand the SDK applies at runtime;
18
+ * - `defineTool` compiles our author-facing parameter specs into the same
19
+ * raw JSON-Schema subset the registry expects (object/properties/required/
20
+ * additionalProperties/scalars; the `json` node compiles to an
21
+ * annotation-only schema) and pre-validates model arguments the same way.
22
+ *
23
+ * @module dsh-taskboard/host/sdk
24
+ */
25
+ /** The ledger file's parent: the DSH user home (DSH_HOME overrides). */
26
+ function dshHomePath(...segments) {
27
+ const override = process.env.DSH_HOME;
28
+ return join(resolve(override !== void 0 && override.length > 0 ? override : join(homedir(), ".dsh")), ...segments);
29
+ }
30
+ /** Identity brand — runtime no-op, exactly like the SDK's MessageId(). */
31
+ function MessageId(id) {
32
+ return id;
33
+ }
34
+ /** Compile one value spec to the raw subset (json → annotation-only). */
35
+ function compileValue(spec) {
36
+ const node = {};
37
+ const description = spec.description;
38
+ if (typeof description === "string" && description.length > 0) node.description = description;
39
+ const type = spec.type;
40
+ if (type === void 0 || type === "json") return node;
41
+ if (type === "object") {
42
+ const objectSpec = spec;
43
+ node.type = "object";
44
+ node.additionalProperties = objectSpec.additionalProperties;
45
+ if (objectSpec.properties !== void 0) node.properties = compilePropertyMap(objectSpec.properties).properties;
46
+ return node;
47
+ }
48
+ if (type === "array") {
49
+ node.type = "array";
50
+ const items = spec.items;
51
+ if (items !== void 0) node.items = compileValue(items);
52
+ return node;
53
+ }
54
+ node.type = type;
55
+ const enumValues = spec.enum;
56
+ if (enumValues !== void 0) node.enum = [...enumValues];
57
+ const constValue = spec.const;
58
+ if (constValue !== void 0) node.const = constValue;
59
+ return node;
60
+ }
61
+ /** Compile a property map: properties + collected required list. */
62
+ function compilePropertyMap(spec) {
63
+ const properties = {};
64
+ const required = [];
65
+ for (const [name, entry] of Object.entries(spec)) {
66
+ const { required: isRequired, ...valueSpec } = entry;
67
+ properties[name] = compileValue(valueSpec);
68
+ if (isRequired === true) required.push(name);
69
+ }
70
+ return required.length > 0 ? {
71
+ properties,
72
+ required
73
+ } : { properties };
74
+ }
75
+ /** Does a JS value match a raw-subset scalar type? */
76
+ function matchesScalarType(value, type) {
77
+ switch (type) {
78
+ case "string": return typeof value === "string";
79
+ case "number": return typeof value === "number";
80
+ case "integer": return typeof value === "number" && Number.isInteger(value);
81
+ case "boolean": return typeof value === "boolean";
82
+ case "null": return value === null;
83
+ default: return true;
84
+ }
85
+ }
86
+ /** Validate a value against the compiled subset; returns path-qualified violations. */
87
+ function validateValue(schema, value, path) {
88
+ if (typeof schema.type !== "string" || schema.type.length === 0) return [];
89
+ if (schema.type === "object") {
90
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return [`${path} must be an object`];
91
+ const violations = [];
92
+ const present = value;
93
+ for (const key of schema.required ?? []) if (!(key in present)) violations.push(`${path}.${key} is required`);
94
+ if (schema.additionalProperties === false) {
95
+ const known = new Set(Object.keys(schema.properties ?? {}));
96
+ for (const key of Object.keys(present)) if (!known.has(key)) violations.push(`${path}.${key} is not a declared property`);
97
+ }
98
+ for (const [key, child] of Object.entries(schema.properties ?? {})) if (key in present) violations.push(...validateValue(child, present[key], `${path}.${key}`));
99
+ return violations;
100
+ }
101
+ if (schema.type === "array") {
102
+ if (!Array.isArray(value)) return [`${path} must be an array`];
103
+ const violations = [];
104
+ const items = schema.items;
105
+ if (items !== void 0) value.forEach((item, index) => {
106
+ violations.push(...validateValue(items, item, `${path}[${index}]`));
107
+ });
108
+ return violations;
109
+ }
110
+ return matchesScalarType(value, schema.type) ? [] : [`${path} must be ${schema.type}`];
111
+ }
112
+ /**
113
+ * Define a first-party tool: compile the parameter spec, pre-validate
114
+ * arguments (message format matches the SDK's ToolArgsError), and pass
115
+ * through the execution.
116
+ */
117
+ function defineTool(options) {
118
+ const compiled = compilePropertyMap(options.parameters);
119
+ const parameters = {
120
+ type: "object",
121
+ properties: compiled.properties
122
+ };
123
+ if (compiled.required !== void 0) parameters.required = compiled.required;
124
+ const userExecute = options.execute;
125
+ return {
126
+ name: options.name,
127
+ description: options.description,
128
+ parameters,
129
+ output: {
130
+ schema: {},
131
+ render(args, value) {
132
+ return options.output.render(args, value);
133
+ }
134
+ },
135
+ async execute(args, exec) {
136
+ const violations = validateValue(parameters, args, "arguments");
137
+ if (violations.length > 0) throw new Error(`Error: invalid arguments: ${violations.join("; ")}`);
138
+ return userExecute(args, exec);
139
+ }
140
+ };
141
+ }
142
+ //#endregion
143
+ export { MessageId, defineTool, dshHomePath };
144
+
145
+ //# sourceMappingURL=sdk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sdk.js","names":[],"sources":["../../src/host/sdk.ts"],"sourcesContent":["/**\n * Self-contained replacements for the three @deepseek-ai runtime imports the\n * host half used to take from npm-mirror SDK packages (dsh-home-paths,\n * dsh-llm/brand, dsh-tools' defineTool).\n *\n * Why: a published copy must never resolve `@deepseek-ai/dsh-tools` from the\n * profile's node_modules — an npm-mirror dsh-tools there shadows the\n * CLI-internal build for the WHOLE base layer, and the agent loop's private\n * scheduler symbol then misses (`Cannot read properties of undefined\n * (reading 'prepare')` on every tool call). Everything here is a pure,\n * structure-compatible reimplementation of the exact behavior we relied on:\n *\n * - `dshHomePath` mirrors `join(resolve(env.DSH_HOME ?? ~/.dsh), ...segments)`;\n * - `MessageId` is the identity brand the SDK applies at runtime;\n * - `defineTool` compiles our author-facing parameter specs into the same\n * raw JSON-Schema subset the registry expects (object/properties/required/\n * additionalProperties/scalars; the `json` node compiles to an\n * annotation-only schema) and pre-validates model arguments the same way.\n *\n * @module dsh-taskboard/host/sdk\n */\nimport { homedir } from 'node:os'\nimport { join, resolve } from 'node:path'\n\n/** The ledger file's parent: the DSH user home (DSH_HOME overrides). */\nexport function dshHomePath(...segments: string[]): string {\n const override = process.env.DSH_HOME\n const home = resolve(override !== undefined && override.length > 0 ? override : join(homedir(), '.dsh'))\n return join(home, ...segments)\n}\n\n/** Identity brand — runtime no-op, exactly like the SDK's MessageId(). */\nexport function MessageId(id: string): string {\n return id\n}\n\n/** Author-facing scalar spec. */\ninterface ScalarSpec {\n readonly type: 'string' | 'number' | 'integer' | 'boolean' | 'null'\n readonly description?: string\n readonly enum?: readonly unknown[]\n readonly const?: unknown\n}\n\n/** Author-facing object spec (additionalProperties is mandatory). */\ninterface ObjectSpec {\n readonly type: 'object'\n readonly additionalProperties: boolean\n readonly description?: string\n readonly properties?: Readonly<Record<string, ValueSpec>>\n}\n\n/** Author-facing value spec. */\ntype ValueSpec = ScalarSpec | ObjectSpec | { readonly type: 'json' } | { readonly type: 'array'; readonly items?: ValueSpec; readonly description?: string }\n\n/** Author-facing parameter entry (a value spec plus top-level required). */\ntype ParameterSpec = ValueSpec & { readonly required?: boolean }\n\n/** Raw JSON-Schema subset node. */\ntype RawSchema = Record<string, unknown>\n\n/** Compile one value spec to the raw subset (json → annotation-only). */\nfunction compileValue(spec: ValueSpec): RawSchema {\n const node: RawSchema = {}\n const description = (spec as { description?: string }).description\n if (typeof description === 'string' && description.length > 0) node.description = description\n const type = (spec as { type?: string }).type\n if (type === undefined || type === 'json') return node\n if (type === 'object') {\n const objectSpec = spec as ObjectSpec\n node.type = 'object'\n node.additionalProperties = objectSpec.additionalProperties\n if (objectSpec.properties !== undefined) node.properties = compilePropertyMap(objectSpec.properties).properties\n return node\n }\n if (type === 'array') {\n node.type = 'array'\n const items = (spec as { items?: ValueSpec }).items\n if (items !== undefined) node.items = compileValue(items)\n return node\n }\n node.type = type\n const enumValues = (spec as ScalarSpec).enum\n if (enumValues !== undefined) node.enum = [...enumValues]\n const constValue = (spec as ScalarSpec).const\n if (constValue !== undefined) node.const = constValue\n return node\n}\n\n/** Compile a property map: properties + collected required list. */\nfunction compilePropertyMap(spec: Readonly<Record<string, ParameterSpec>>): { properties: Record<string, RawSchema>; required?: string[] } {\n const properties: Record<string, RawSchema> = {}\n const required: string[] = []\n for (const [name, entry] of Object.entries(spec)) {\n const { required: isRequired, ...valueSpec } = entry as ParameterSpec & Record<string, unknown>\n properties[name] = compileValue(valueSpec as ValueSpec)\n if (isRequired === true) required.push(name)\n }\n return required.length > 0 ? { properties, required } : { properties }\n}\n\n/** Does a JS value match a raw-subset scalar type? */\nfunction matchesScalarType(value: unknown, type: string): boolean {\n switch (type) {\n case 'string': return typeof value === 'string'\n case 'number': return typeof value === 'number'\n case 'integer': return typeof value === 'number' && Number.isInteger(value)\n case 'boolean': return typeof value === 'boolean'\n case 'null': return value === null\n default: return true\n }\n}\n\n/** Validate a value against the compiled subset; returns path-qualified violations. */\nfunction validateValue(schema: RawSchema, value: unknown, path: string): string[] {\n if (typeof schema.type !== 'string' || schema.type.length === 0) return []\n if (schema.type === 'object') {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return [`${path} must be an object`]\n const violations: string[] = []\n const present = value as Record<string, unknown>\n for (const key of (schema.required as string[] | undefined) ?? []) {\n if (!(key in present)) violations.push(`${path}.${key} is required`)\n }\n if (schema.additionalProperties === false) {\n const known = new Set(Object.keys((schema.properties as Record<string, RawSchema> | undefined) ?? {}))\n for (const key of Object.keys(present)) {\n if (!known.has(key)) violations.push(`${path}.${key} is not a declared property`)\n }\n }\n for (const [key, child] of Object.entries((schema.properties as Record<string, RawSchema> | undefined) ?? {})) {\n if (key in present) violations.push(...validateValue(child, present[key], `${path}.${key}`))\n }\n return violations\n }\n if (schema.type === 'array') {\n if (!Array.isArray(value)) return [`${path} must be an array`]\n const violations: string[] = []\n const items = schema.items as RawSchema | undefined\n if (items !== undefined) {\n value.forEach((item, index) => { violations.push(...validateValue(items, item, `${path}[${index}]`)) })\n }\n return violations\n }\n return matchesScalarType(value, schema.type) ? [] : [`${path} must be ${schema.type}`]\n}\n\n/** Options shape we consume (a structural subset of the SDK's defineTool). */\nexport interface DefineToolOptions<A, V> {\n readonly name: string\n readonly description: string\n readonly parameters: Readonly<Record<string, ParameterSpec>>\n readonly output: {\n readonly schema: { readonly type: 'json' }\n render(args: A, value: V): Array<{ type: 'text'; text: string }>\n }\n execute(args: A, exec: unknown): Promise<V>\n}\n\n/** A registry-ready tool definition (structure-compatible with the SDK's). */\nexport interface ToolDefinition<A = unknown, V = unknown> {\n readonly name: string\n readonly description: string\n readonly parameters: RawSchema\n readonly output: {\n readonly schema: RawSchema\n render(args: A, value: V): Array<{ type: 'text'; text: string }>\n }\n execute(args: A, exec: unknown): Promise<V>\n}\n\n/**\n * Define a first-party tool: compile the parameter spec, pre-validate\n * arguments (message format matches the SDK's ToolArgsError), and pass\n * through the execution.\n */\nexport function defineTool<A extends Record<string, unknown>, V>(options: DefineToolOptions<A, V>): ToolDefinition<A, V> {\n const compiled = compilePropertyMap(options.parameters as Readonly<Record<string, ParameterSpec>>)\n const parameters: RawSchema = { type: 'object', properties: compiled.properties }\n if (compiled.required !== undefined) parameters.required = compiled.required\n const userExecute = options.execute\n return {\n name: options.name,\n description: options.description,\n parameters,\n output: {\n // The SDK compiles the `json` node to an annotation-only schema.\n schema: {},\n render(args, value) {\n return options.output.render(args, value)\n },\n },\n async execute(args, exec) {\n const violations = validateValue(parameters, args, 'arguments')\n if (violations.length > 0) {\n throw new Error(`Error: invalid arguments: ${violations.join('; ')}`)\n }\n return userExecute(args, exec)\n },\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,112 @@
1
+ import { emptyLedger } from "../shared/protocol.js";
2
+ import { dirname, join } from "node:path";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ //#region src/host/store.ts
5
+ /**
6
+ * Host-side task ledger: one JSON file under the DSH home, mutated through a
7
+ * serial write queue, published as immutable snapshots with a global
8
+ * monotonic revision. Change subscribers (P2: SSE route) observe every
9
+ * committed mutation.
10
+ *
11
+ * @module dsh-taskboard/host/store
12
+ */
13
+ /**
14
+ * The durable ledger. All mutations run through {@link mutate}, which:
15
+ * validates the resulting document, bumps the global revision, persists
16
+ * atomically (temp file + rename), and only then notifies subscribers.
17
+ */
18
+ var TaskStore = class {
19
+ file;
20
+ ledger = emptyLedger();
21
+ subscribers = /* @__PURE__ */ new Set();
22
+ queue = Promise.resolve();
23
+ loaded = false;
24
+ /** @param options - file location. */
25
+ constructor(options) {
26
+ this.file = options.file;
27
+ }
28
+ /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */
29
+ async load() {
30
+ if (this.loaded) return;
31
+ try {
32
+ const raw = await readFile(this.file, "utf8");
33
+ const parsed = JSON.parse(raw);
34
+ if (typeof parsed.revision === "number" && Array.isArray(parsed.tasks)) this.ledger = {
35
+ schemaVersion: 1,
36
+ revision: parsed.revision,
37
+ tasks: parsed.tasks
38
+ };
39
+ } catch (error) {
40
+ if (error.code !== "ENOENT") try {
41
+ await rename(this.file, `${this.file}.corrupt-${Date.now()}`);
42
+ } catch {}
43
+ }
44
+ this.loaded = true;
45
+ }
46
+ /** The current immutable snapshot. */
47
+ snapshot() {
48
+ return this.ledger;
49
+ }
50
+ /** Find a task by id. */
51
+ get(id) {
52
+ return this.ledger.tasks.find((t) => t.id === id);
53
+ }
54
+ /** Subscribe to committed changes; returns the unsubscribe. */
55
+ subscribe(fn) {
56
+ this.subscribers.add(fn);
57
+ return () => this.subscribers.delete(fn);
58
+ }
59
+ /**
60
+ * Run one mutation inside the serial queue. The mutator works on a
61
+ * structured clone; returning `undefined` aborts with no write.
62
+ * @param kind - change kind for subscribers.
63
+ * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.
64
+ */
65
+ async mutate(kind, mutator) {
66
+ const run = async () => {
67
+ await this.load();
68
+ const draft = structuredClone(this.ledger);
69
+ const changed = mutator(draft);
70
+ if (changed === void 0) return {
71
+ ledger: this.ledger,
72
+ changed: []
73
+ };
74
+ draft.revision += 1;
75
+ const json = JSON.stringify(draft);
76
+ await persistAtomic(this.file, json);
77
+ this.ledger = draft;
78
+ const change = {
79
+ revision: draft.revision,
80
+ tasks: changed,
81
+ kind
82
+ };
83
+ for (const fn of this.subscribers) try {
84
+ fn(change);
85
+ } catch {}
86
+ return {
87
+ ledger: draft,
88
+ changed
89
+ };
90
+ };
91
+ return this.queue = this.queue.then(run, run);
92
+ }
93
+ /** Persist the current ledger now (used after external reconciliation). */
94
+ async flush(kind, changed) {
95
+ await this.mutate(kind, (ledger) => {
96
+ const byId = new Map(this.ledger.tasks.map((t) => [t.id, t]));
97
+ ledger.tasks = ledger.tasks.map((t) => byId.get(t.id) ?? t);
98
+ return [...changed];
99
+ });
100
+ }
101
+ };
102
+ /** Atomic file persist: write temp, then rename over the target. */
103
+ async function persistAtomic(file, contents) {
104
+ await mkdir(dirname(file), { recursive: true });
105
+ const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`);
106
+ await writeFile(temp, contents, "utf8");
107
+ await rename(temp, file);
108
+ }
109
+ //#endregion
110
+ export { TaskStore };
111
+
112
+ //# sourceMappingURL=store.js.map
@@ -0,0 +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 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 this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks: parsed.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 /** The current immutable snapshot. */\n snapshot(): TaskLedger {\n return this.ledger\n }\n\n /** Find a task by id. */\n get(id: string): TaskRecord | undefined {\n return this.ledger.tasks.find(t => t.id === id)\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 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 /** Persist the current ledger now (used after external reconciliation). */\n async flush(kind: LedgerChange['kind'], changed: readonly TaskRecord[]): Promise<void> {\n await this.mutate(kind, (ledger) => {\n // replace tasks wholesale from the live snapshot objects\n const byId = new Map(this.ledger.tasks.map(t => [t.id, t]))\n ledger.tasks = ledger.tasks.map(t => byId.get(t.id) ?? t)\n return [...changed]\n })\n }\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":";;;;;;;;;;;;;;;;;AAsCA,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,GACnE,KAAK,SAAS;IAAE,eAAA;IAAsC,UAAU,OAAO;IAAU,OAAO,OAAO;GAAM;EAEzG,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;;CAGA,WAAuB;EACrB,OAAO,KAAK;CACd;;CAGA,IAAI,IAAoC;EACtC,OAAO,KAAK,OAAO,MAAM,MAAK,MAAK,EAAE,OAAO,EAAE;CAChD;;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;GAE5C,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;;CAGA,MAAM,MAAM,MAA4B,SAA+C;EACrF,MAAM,KAAK,OAAO,OAAO,WAAW;GAElC,MAAM,OAAO,IAAI,IAAI,KAAK,OAAO,MAAM,KAAI,MAAK,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;GAC1D,OAAO,QAAQ,OAAO,MAAM,KAAI,MAAK,KAAK,IAAI,EAAE,EAAE,KAAK,CAAC;GACxD,OAAO,CAAC,GAAG,OAAO;EACpB,CAAC;CACH;AACF;;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"}