dsh-taskboard 0.5.0 → 0.5.2
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 +25 -1
- package/lib/client.js +242 -174
- package/lib/host/execution.js +80 -33
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +49 -5
- package/lib/host/git.js.map +1 -1
- package/lib/host/routes.js +180 -109
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +50 -28
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/sdk.js +7 -2
- package/lib/host/sdk.js.map +1 -1
- package/lib/host/store.js +41 -8
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +10 -3
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +124 -93
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +23 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +3 -2
- package/src/client/api.ts +19 -9
- package/src/client/board/ImportModal.tsx +1 -1
- package/src/client/board/TaskBoard.tsx +7 -38
- package/src/client/board/TaskCard.tsx +3 -5
- package/src/client/board/TaskDetail.tsx +30 -21
- package/src/client/board/TaskFormModal.tsx +30 -23
- package/src/client/board/format.ts +26 -0
- package/src/client/board/labels.ts +44 -0
- package/src/client/board-mount.tsx +9 -6
- package/src/client/controller.ts +60 -13
- package/src/client/index.ts +7 -5
- package/src/client/sidebar-entry.ts +16 -5
- package/src/client/styles.ts +5 -3
- package/src/host/execution.ts +90 -16
- package/src/host/git.ts +39 -10
- package/src/host/routes.ts +227 -126
- package/src/host/scheduler.ts +62 -36
- package/src/host/sdk.ts +12 -1
- package/src/host/store.ts +53 -7
- package/src/host/templates.ts +12 -3
- package/src/host/tools.ts +180 -123
- package/src/index.ts +10 -1
- package/src/shared/api.ts +1 -1
- package/src/shared/protocol.ts +35 -1
- package/src/shared/version.ts +1 -1
- package/src/client/board/NewTaskModal.tsx +0 -8
package/lib/host/git.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git.js","names":[],"sources":["../../src/host/git.ts"],"sourcesContent":["/**\n * Host git face (0.3.0): the ONLY place dsh-taskboard shells out to git.\n * 0.3.1: per-repo serialization of structural operations, binary probing,\n * no-op merge detection, worktree REUSE mode, and evidence size caps.\n *\n * Design invariants (plan §3.4/§3.5):\n * - NARROW interface: detect / binaryAvailable / prepareWorktree / collect /\n * merge / isAncestor / removeWorktree / deleteBranch — nothing else leaks\n * into the plugin.\n * - FAIL-SOFT: every call has a timeout and resolves to a benign result\n * (false / undefined / empty facts) on ANY git failure — a missing git,\n * a locked worktree, or a damaged repo degrades execution to the original\n * directory and NEVER fails the ledger or the run pipeline. Only the\n * explicit user actions (merge / remove / deleteBranch) throw, with a\n * readable message the GUI surfaces as-is.\n * - SERIALIZED structural ops: concurrent isolated executions on the SAME\n * repository would race on git's index/worktree locks, so every structural\n * operation (prepareWorktree / merge / removeWorktree / deleteBranch) runs\n * inside a per-root in-process mutex. Read-only collects stay concurrent.\n * - INJECTABLE runner: the exec layer is a single function so unit tests\n * script every path without a real git.\n *\n * @module dsh-taskboard/host/git\n */\nimport type { CommitInfo } from '../shared/protocol.ts'\n\n/** Timeout for quick read-only queries (rev-parse / status / log / diff). */\nconst QUICK_TIMEOUT_MS = 2_000\n\n/** Timeout for structural operations (worktree add/remove, merge, branch). */\nconst HEAVY_TIMEOUT_MS = 15_000\n\n/** Directory under a workspace where task worktrees live. */\nexport const WORKTREE_DIR = '.dsh-worktrees'\n\n/** Evidence caps: commits kept per execution record (newest first). */\nexport const MAX_COMMIT_EVIDENCE = 50\n\n/** Evidence caps: uncommitted-change lines kept per execution record. */\nexport const MAX_DIRTY_EVIDENCE = 100\n\n/** Diff viewer caps (0.4.0): raw text kept per view. */\nexport const MAX_DIFF_BYTES = 128 * 1024\n\n/** Diff viewer caps: lines kept per view. */\nexport const MAX_DIFF_LINES = 2_000\n\n/** One capped, read-only diff view (diff viewer, 0.4.0). */\nexport interface DiffResult {\n text: string\n truncated: boolean\n}\n\n/** Cap one diff payload by bytes and lines (in order, marking truncation). */\nfunction capDiff(out: string): DiffResult {\n let text = out\n let truncated = false\n if (text.length > MAX_DIFF_BYTES) {\n text = text.slice(0, MAX_DIFF_BYTES)\n truncated = true\n }\n const lines = text.split('\\n')\n if (lines.length > MAX_DIFF_LINES) {\n text = lines.slice(0, MAX_DIFF_LINES).join('\\n')\n truncated = true\n }\n return { text, truncated }\n}\n\n/** A plausible git object hash (defense against option injection). */\nfunction isHash(hash: string): boolean {\n return /^[0-9a-f]{4,64}$/i.test(hash)\n}\n\n/** Result of one underlying exec: `ok` is exit-0, output never null. */\nexport interface ExecResult { ok: boolean; stdout: string; stderr: string }\n\n/** The injectable exec layer: run `git <args>` under a cwd with a timeout. */\nexport type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>\n\n/** Facts needed to open an isolated execution. */\nexport interface WorktreeInfo {\n /** Absolute worktree path (the session's cwd). */\n path: string\n /** The task branch checked out there. */\n branch: string\n /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Settlement facts collected from a worktree (partial on best-effort basis). */\nexport interface SettlementFacts {\n headCommit?: string\n commits: CommitInfo[]\n /** Total commits before capping (equals commits.length when under the cap). */\n commitsTotal: number\n dirtyFiles: string[]\n /** Total uncommitted lines before capping. */\n dirtyFilesTotal: number\n diffStat?: string\n changedFiles: number\n}\n\n/** The narrow git face the rest of the plugin depends on. */\nexport interface GitFace {\n /** Whether `root` sits inside a usable git work tree (fail-soft → false). */\n detect(root: string): Promise<boolean>\n /** Whether a usable git binary answers at all (distinguishes 未装 git vs 非 git 仓库). */\n binaryAvailable(): Promise<boolean>\n /**\n * Ensure a worktree at `path` on `branch`. Default mode `'fresh'` resets to\n * the main worktree's current HEAD (每次全新); mode `'reuse'` keeps a live\n * worktree exactly as-is (续跑 — agent's commits and uncommitted changes\n * survive) and falls back to a fresh creation when none is alive. Resolves\n * undefined on any failure — callers degrade to the original directory.\n */\n prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>\n /** Collect settlement facts (never throws; missing pieces stay unset). */\n collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>\n /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */\n merge(root: string, branch: string): Promise<void>\n /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */\n isAncestor(root: string, branch: string): Promise<boolean>\n /** Remove a worktree; THROWS when it still has uncommitted changes. */\n removeWorktree(root: string, worktreePath: string): Promise<void>\n /** Delete a branch; THROWS (e.g. still checked out in a worktree). */\n deleteBranch(root: string, branch: string): Promise<void>\n /**\n * Show one commit (message + patch) — diff viewer (0.4.0). Fail-soft:\n * undefined on any git failure; the payload is capped.\n */\n showCommit(cwd: string, hash: string): Promise<DiffResult | undefined>\n /**\n * Show the diff of one path — diff viewer (0.4.0). Without `baseCommit`:\n * the working-tree view (staged + unstaged vs HEAD, e.g. uncommitted\n * changes in a live worktree); with `baseCommit`: the range\n * base..HEAD restricted to the path. Fail-soft: undefined on failure.\n */\n showPathDiff(cwd: string, path: string, baseCommit?: string): Promise<DiffResult | undefined>\n}\n\n/**\n * Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).\n *\n * Title sanitizing: whitespace runs collapse to `-`; git-illegal characters\n * (`~ ^ : ? * [ \\ / @ { }` and friends) are stripped; `..` collapses; the\n * segment is trimmed of leading/trailing `.-` and truncated to ~20 code\n * points; an empty result falls back to the bare `task/<taskId>`.\n * @param title - the task title (already normalized 1..200 chars).\n * @param taskId - the task id (stable suffix).\n * @returns the branch name.\n */\nexport function sanitizeBranchName(title: string, taskId: string): string {\n const segment = title.trim()\n .replace(/\\s+/g, '-')\n .replace(/[/\\\\~^:?*[\\]@{}\"'<>|#%&;$!`'=,;()]+/g, '')\n .replace(/\\.\\.+/g, '.')\n .replace(/^[-.\\s]+|[-.\\s]+$/g, '')\n const head = Array.from(segment).slice(0, 20).join('').replace(/^[-.]+|[-.]+$/g, '')\n return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`\n}\n\n/** The canonical worktree path of a task inside its workspace (forward slashes). */\nexport function worktreePathOf(workspacePath: string, taskId: string): string {\n const root = workspacePath.replace(/[\\\\/]+$/, '').replaceAll('\\\\', '/')\n return `${root}/${WORKTREE_DIR}/${taskId}`\n}\n\n/** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */\nconst realExec: ExecFn = (args, options) => new Promise(resolve => {\n void (async () => {\n const { execFile } = await import('node:child_process')\n execFile('git', args, {\n cwd: options.cwd,\n timeout: options.timeout ?? QUICK_TIMEOUT_MS,\n windowsHide: true,\n maxBuffer: 4 * 1024 * 1024,\n encoding: 'utf8',\n }, (error, stdout, stderr) => {\n resolve({ ok: error === null, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })\n })\n })().catch(() => resolve({ ok: false, stdout: '', stderr: 'exec unavailable' }))\n})\n\n/**\n * Build a {@link GitFace} over an injectable exec layer.\n * @param exec - the exec function (real `git` when omitted).\n */\nexport function createGitFace(exec: ExecFn = realExec): GitFace {\n const quick = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: QUICK_TIMEOUT_MS })\n const heavy = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: HEAVY_TIMEOUT_MS })\n\n // Per-root mutex (0.3.1): structural git ops on the SAME repository run one\n // at a time — concurrent isolated executions must not race on git's locks.\n const locks = new Map<string, Promise<unknown>>()\n const withRootLock = <T>(root: string, fn: () => Promise<T>): Promise<T> => {\n const prev = locks.get(root) ?? Promise.resolve()\n const next = prev.then(fn, fn)\n locks.set(root, next.catch(() => { /* the chain never blocks later ops */ }))\n return next\n }\n\n return {\n async detect(root) {\n const r = await quick(['rev-parse', '--is-inside-work-tree'], root)\n return r.ok && r.stdout.trim() === 'true'\n },\n\n async binaryAvailable() {\n const r = await quick(['--version'])\n return r.ok && r.stdout.startsWith('git version')\n },\n\n prepareWorktree: (root, path, branch, mode = 'fresh') => withRootLock(root, async () => {\n // 续跑: a live worktree at the path is kept EXACTLY as-is — the agent's\n // commits and uncommitted changes survive; the baseline becomes the\n // worktree's own HEAD so evidence covers only the new run.\n if (mode === 'reuse') {\n const wtHead = await quick(['rev-parse', 'HEAD'], path)\n if (wtHead.ok && wtHead.stdout.trim().length > 0) {\n return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }\n }\n // No live worktree → fall through to a fresh preparation.\n }\n\n // Baseline: the main worktree's current HEAD (also validates the repo).\n const head = await quick(['rev-parse', 'HEAD'], root)\n if (!head.ok) return undefined\n const baseCommit = head.stdout.trim()\n\n const exists = await quick(['show-ref', '--verify', `refs/heads/${branch}`], root)\n if (exists.ok) {\n // Reuse the fixed branch name, but guarantee a FRESH baseline: drop\n // any stale worktree at the path, move the branch to the current\n // HEAD, then check the branch out again (每次全新,复用仅作选项保留).\n await heavy(['worktree', 'remove', '--force', path], root)\n await heavy(['worktree', 'prune'], root)\n const moved = await heavy(['branch', '-f', branch, 'HEAD'], root)\n if (!moved.ok) return undefined\n const added = await heavy(['worktree', 'add', path, branch], root)\n if (!added.ok) return undefined\n } else {\n const added = await heavy(['worktree', 'add', '-b', branch, path], root)\n if (!added.ok) return undefined\n }\n return { path, branch, baseCommit }\n }),\n\n async collect(worktreePath, baseCommit) {\n const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }\n const range = `${baseCommit}..HEAD`\n\n const head = await quick(['rev-parse', 'HEAD'], worktreePath)\n if (head.ok) facts.headCommit = head.stdout.trim()\n\n const log = await quick(['log', '--pretty=format:%h %s', range], worktreePath)\n if (log.ok) {\n const commits = log.stdout.split('\\n')\n .map(line => line.trim())\n .filter(line => line.length > 0)\n .map(line => {\n const space = line.indexOf(' ')\n return space === -1\n ? { hash: line, subject: '' }\n : { hash: line.slice(0, space), subject: line.slice(space + 1) }\n })\n // Evidence caps (0.3.1): the ledger is rewritten whole on every\n // mutation — cap what a huge branch/status dump can add to it.\n facts.commitsTotal = commits.length\n facts.commits = commits.slice(0, MAX_COMMIT_EVIDENCE)\n }\n\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok) {\n const dirty = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n facts.dirtyFilesTotal = dirty.length\n facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)\n }\n\n const shortstat = await quick(['diff', '--shortstat', range], worktreePath)\n if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim()\n\n const names = await quick(['diff', '--name-only', range], worktreePath)\n if (names.ok) facts.changedFiles = names.stdout.split('\\n').filter(l => l.trim().length > 0).length\n\n return facts\n },\n\n merge: (root, branch) => withRootLock(root, async () => {\n // Main-clean check. The plugin's own worktree directory\n // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —\n // otherwise merging would be impossible without gitignoring it first.\n const status = await quick(['status', '--porcelain'], root)\n if (status.ok) {\n const dirtyLines = status.stdout.split('\\n')\n .map(l => l.trim())\n .filter(l => {\n if (l.length === 0) return false\n const path = l.slice(3)\n return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)\n })\n if (dirtyLines.length > 0) {\n throw new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`)\n }\n }\n const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)\n if (!merged.ok) {\n // Roll the half-finished merge back so the main worktree stays usable;\n // report the ORIGINAL failure verbatim (不自动解决冲突).\n await heavy(['merge', '--abort'], root)\n throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`)\n }\n }),\n\n async isAncestor(root, branch) {\n // exit 0 = branch is an ancestor of (or equal to) HEAD → merge no-op.\n const r = await quick(['merge-base', '--is-ancestor', branch, 'HEAD'], root)\n return r.ok\n },\n\n removeWorktree: (root, worktreePath) => withRootLock(root, async () => {\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok && status.stdout.trim().length > 0) {\n const lines = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n throw new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\\n${lines.slice(0, 10).join('\\n')}`)\n }\n const removed = await heavy(['worktree', 'remove', worktreePath], root)\n if (!removed.ok) throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)\n }),\n\n deleteBranch: (root, branch) => withRootLock(root, async () => {\n const deleted = await heavy(['branch', '-D', branch], root)\n if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)\n }),\n\n async showCommit(cwd, hash) {\n if (!isHash(hash)) return undefined\n const r = await quick(['show', '--no-color', '--format=medium', hash], cwd)\n if (!r.ok || r.stdout.trim().length === 0) return undefined\n return capDiff(r.stdout)\n },\n\n async showPathDiff(cwd, path, baseCommit) {\n const target = path.trim()\n if (target.length === 0) return undefined\n if (baseCommit !== undefined && isHash(baseCommit)) {\n const r = await quick(['diff', '--no-color', `${baseCommit}..HEAD`, '--', target], cwd)\n if (!r.ok) return undefined\n if (r.stdout.trim().length === 0) return { text: '(该文件无差异)', truncated: false }\n return capDiff(r.stdout)\n }\n // Working-tree view: staged + unstaged vs HEAD.\n const r = await quick(['diff', '--no-color', 'HEAD', '--', target], cwd)\n if (r.ok && r.stdout.trim().length > 0) return capDiff(r.stdout)\n // Untracked files never appear in `git diff` — detect one and synthesize\n // its new-file patch via --no-index (which exits 1 on differences, so\n // its stdout is trusted whenever it carries a diff header).\n const st = await quick(['status', '--porcelain', '--', target], cwd)\n if (r.ok && st.ok && st.stdout.trim().startsWith('??')) {\n const ni = await exec(['diff', '--no-color', '--no-index', '--', '/dev/null', target], { cwd, timeout: QUICK_TIMEOUT_MS })\n if (ni.stdout.includes('diff --git')) return capDiff(ni.stdout)\n return { text: `(未跟踪新文件:${target})`, truncated: false }\n }\n if (!r.ok) return undefined\n return { text: '(该文件无差异)', truncated: false }\n },\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"git.js","names":[],"sources":["../../src/host/git.ts"],"sourcesContent":["/**\n * Host git face (0.3.0): the ONLY place dsh-taskboard shells out to git.\n * 0.3.1: per-repo serialization of structural operations, binary probing,\n * no-op merge detection, worktree REUSE mode, and evidence size caps.\n *\n * Design invariants (plan §3.4/§3.5):\n * - NARROW interface: detect / binaryAvailable / prepareWorktree / collect /\n * merge / isAncestor / removeWorktree / deleteBranch — nothing else leaks\n * into the plugin.\n * - FAIL-SOFT: every call has a timeout and resolves to a benign result\n * (false / undefined / empty facts) on ANY git failure — a missing git,\n * a locked worktree, or a damaged repo degrades execution to the original\n * directory and NEVER fails the ledger or the run pipeline. Only the\n * explicit user actions (merge / remove / deleteBranch) throw, with a\n * readable message the GUI surfaces as-is.\n * - SERIALIZED structural ops: concurrent isolated executions on the SAME\n * repository would race on git's index/worktree locks, so every structural\n * operation (prepareWorktree / merge / removeWorktree / deleteBranch) runs\n * inside a per-root in-process mutex. Read-only collects stay concurrent.\n * - INJECTABLE runner: the exec layer is a single function so unit tests\n * script every path without a real git.\n *\n * @module dsh-taskboard/host/git\n */\nimport { resolve } from 'node:path'\nimport { isValidTaskId, type CommitInfo } from '../shared/protocol.ts'\n\n/** Timeout for quick read-only queries (rev-parse / status / log / diff). */\nconst QUICK_TIMEOUT_MS = 2_000\n\n/** Timeout for structural operations (worktree add/remove, merge, branch). */\nconst HEAVY_TIMEOUT_MS = 15_000\n\n/** Directory under a workspace where task worktrees live. */\nexport const WORKTREE_DIR = '.dsh-worktrees'\n\n/** Evidence caps: commits kept per execution record (newest first). */\nexport const MAX_COMMIT_EVIDENCE = 50\n\n/** Evidence caps: uncommitted-change lines kept per execution record. */\nexport const MAX_DIRTY_EVIDENCE = 100\n\n/** Diff viewer caps (0.4.0): raw text kept per view. */\nexport const MAX_DIFF_BYTES = 128 * 1024\n\n/** Diff viewer caps: lines kept per view. */\nexport const MAX_DIFF_LINES = 2_000\n\n/** One capped, read-only diff view (diff viewer, 0.4.0). */\nexport interface DiffResult {\n text: string\n truncated: boolean\n}\n\n/** Cap one diff payload by bytes and lines (in order, marking truncation). */\nfunction capDiff(out: string): DiffResult {\n let text = out\n let truncated = false\n if (text.length > MAX_DIFF_BYTES) {\n text = text.slice(0, MAX_DIFF_BYTES)\n truncated = true\n }\n const lines = text.split('\\n')\n if (lines.length > MAX_DIFF_LINES) {\n text = lines.slice(0, MAX_DIFF_LINES).join('\\n')\n truncated = true\n }\n return { text, truncated }\n}\n\n/** A plausible git object hash (defense against option injection). */\nfunction isHash(hash: string): boolean {\n return /^[0-9a-f]{4,64}$/i.test(hash)\n}\n\n/** Result of one underlying exec: `ok` is exit-0, output never null. */\nexport interface ExecResult { ok: boolean; stdout: string; stderr: string }\n\n/** The injectable exec layer: run `git <args>` under a cwd with a timeout. */\nexport type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>\n\n/** Facts needed to open an isolated execution. */\nexport interface WorktreeInfo {\n /** Absolute worktree path (the session's cwd). */\n path: string\n /** The task branch checked out there. */\n branch: string\n /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Settlement facts collected from a worktree (partial on best-effort basis). */\nexport interface SettlementFacts {\n headCommit?: string\n commits: CommitInfo[]\n /** Total commits before capping (equals commits.length when under the cap). */\n commitsTotal: number\n dirtyFiles: string[]\n /** Total uncommitted lines before capping. */\n dirtyFilesTotal: number\n diffStat?: string\n changedFiles: number\n}\n\n/** The narrow git face the rest of the plugin depends on. */\nexport interface GitFace {\n /** Whether `root` sits inside a usable git work tree (fail-soft → false). */\n detect(root: string): Promise<boolean>\n /** Whether a usable git binary answers at all (distinguishes 未装 git vs 非 git 仓库). */\n binaryAvailable(): Promise<boolean>\n /**\n * Ensure a worktree at `path` on `branch`. Default mode `'fresh'` resets to\n * the main worktree's current HEAD (每次全新); mode `'reuse'` keeps a live\n * worktree exactly as-is (续跑 — agent's commits and uncommitted changes\n * survive) and falls back to a fresh creation when none is alive. Resolves\n * undefined on any failure — callers degrade to the original directory.\n */\n prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>\n /** Collect settlement facts (never throws; missing pieces stay unset). */\n collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>\n /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */\n merge(root: string, branch: string): Promise<void>\n /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */\n isAncestor(root: string, branch: string): Promise<boolean>\n /**\n * Remove a worktree. Resolves 'removed' on success, 'unregistered' when git\n * no longer knows the path (an orphaned directory). THROWS when it still\n * has uncommitted changes, or on any other git failure (readable reason).\n */\n removeWorktree(root: string, worktreePath: string): Promise<'removed' | 'unregistered'>\n /** Delete a branch; THROWS (e.g. still checked out in a worktree). */\n deleteBranch(root: string, branch: string): Promise<void>\n /**\n * Show one commit (message + patch) — diff viewer (0.4.0). Fail-soft:\n * undefined on any git failure; the payload is capped.\n */\n showCommit(cwd: string, hash: string): Promise<DiffResult | undefined>\n /**\n * Show the diff of one path — diff viewer (0.4.0). Without `baseCommit`:\n * the working-tree view (staged + unstaged vs HEAD, e.g. uncommitted\n * changes in a live worktree); with `baseCommit`: the range\n * base..HEAD restricted to the path. Fail-soft: undefined on failure.\n */\n showPathDiff(cwd: string, path: string, baseCommit?: string): Promise<DiffResult | undefined>\n}\n\n/**\n * Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).\n *\n * Title sanitizing: whitespace runs collapse to `-`; git-illegal characters\n * (`~ ^ : ? * [ \\ / @ { }` and friends) are stripped; `..` collapses; the\n * segment is trimmed of leading/trailing `.-` and truncated to ~20 code\n * points; an empty result falls back to the bare `task/<taskId>`.\n * @param title - the task title (already normalized 1..200 chars).\n * @param taskId - the task id (stable suffix).\n * @returns the branch name.\n */\nexport function sanitizeBranchName(title: string, taskId: string): string {\n const segment = title.trim()\n .replace(/\\s+/g, '-')\n .replace(/[/\\\\~^:?*[\\]@{}\"'<>|#%&;$!`'=,;()]+/g, '')\n .replace(/\\.\\.+/g, '.')\n .replace(/^[-.\\s]+|[-.\\s]+$/g, '')\n const head = Array.from(segment).slice(0, 20).join('').replace(/^[-.]+|[-.]+$/g, '')\n return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`\n}\n\n/**\n * The canonical worktree path of a task inside its workspace (forward\n * slashes). R4②: the id is validated HERE so every present and future call\n * site is covered — a traversal-shaped id must never ride into a filesystem\n * path (the cleanup/purge flows `rm -rf` what this returns).\n */\nexport function worktreePathOf(workspacePath: string, taskId: string): string {\n if (!isValidTaskId(taskId)) {\n throw new Error(`Error: invalid_input: illegal task id ${JSON.stringify(taskId.slice(0, 40))}`)\n }\n const root = workspacePath.replace(/[\\\\/]+$/, '').replaceAll('\\\\', '/')\n return `${root}/${WORKTREE_DIR}/${taskId}`\n}\n\n/** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */\nconst realExec: ExecFn = (args, options) => new Promise(resolve => {\n void (async () => {\n const { execFile } = await import('node:child_process')\n execFile('git', args, {\n cwd: options.cwd,\n timeout: options.timeout ?? QUICK_TIMEOUT_MS,\n windowsHide: true,\n maxBuffer: 4 * 1024 * 1024,\n encoding: 'utf8',\n }, (error, stdout, stderr) => {\n resolve({ ok: error === null, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })\n })\n })().catch(() => resolve({ ok: false, stdout: '', stderr: 'exec unavailable' }))\n})\n\n/**\n * Build a {@link GitFace} over an injectable exec layer.\n * @param exec - the exec function (real `git` when omitted).\n */\nexport function createGitFace(exec: ExecFn = realExec): GitFace {\n const quick = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: QUICK_TIMEOUT_MS })\n const heavy = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: HEAVY_TIMEOUT_MS })\n\n // Per-root mutex (0.3.1): structural git ops on the SAME repository run one\n // at a time — concurrent isolated executions must not race on git's locks.\n const locks = new Map<string, Promise<unknown>>()\n const withRootLock = <T>(root: string, fn: () => Promise<T>): Promise<T> => {\n const prev = locks.get(root) ?? Promise.resolve()\n const next = prev.then(fn, fn)\n locks.set(root, next.catch(() => { /* the chain never blocks later ops */ }))\n return next\n }\n\n return {\n async detect(root) {\n const r = await quick(['rev-parse', '--is-inside-work-tree'], root)\n return r.ok && r.stdout.trim() === 'true'\n },\n\n async binaryAvailable() {\n const r = await quick(['--version'])\n return r.ok && r.stdout.startsWith('git version')\n },\n\n prepareWorktree: (root, path, branch, mode = 'fresh') => withRootLock(root, async () => {\n // 续跑: a live worktree at the path is kept EXACTLY as-is — the agent's\n // commits and uncommitted changes survive; the baseline becomes the\n // worktree's own HEAD so evidence covers only the new run.\n if (mode === 'reuse') {\n const wtHead = await quick(['rev-parse', 'HEAD'], path)\n // S14: a readable HEAD is not enough — the worktree must be on OUR\n // branch, otherwise a user-created repo at the path would be silently\n // taken over. Foreign or detached → fall through to fresh preparation.\n const wtBranch = wtHead.ok ? await quick(['rev-parse', '--abbrev-ref', 'HEAD'], path) : undefined\n if (wtHead.ok && wtHead.stdout.trim().length > 0\n && wtBranch !== undefined && wtBranch.ok && wtBranch.stdout.trim() === branch) {\n return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }\n }\n // No live worktree on our branch → fall through to a fresh preparation.\n }\n\n // Baseline: the main worktree's current HEAD (also validates the repo).\n const head = await quick(['rev-parse', 'HEAD'], root)\n if (!head.ok) return undefined\n const baseCommit = head.stdout.trim()\n\n const exists = await quick(['show-ref', '--verify', `refs/heads/${branch}`], root)\n if (exists.ok) {\n // Reuse the fixed branch name, but guarantee a FRESH baseline: drop\n // any stale worktree at the path, move the branch to the current\n // HEAD, then check the branch out again (每次全新,复用仅作选项保留).\n await heavy(['worktree', 'remove', '--force', path], root)\n await heavy(['worktree', 'prune'], root)\n const moved = await heavy(['branch', '-f', branch, 'HEAD'], root)\n if (!moved.ok) return undefined\n const added = await heavy(['worktree', 'add', path, branch], root)\n if (!added.ok) return undefined\n } else {\n const added = await heavy(['worktree', 'add', '-b', branch, path], root)\n if (!added.ok) return undefined\n }\n return { path, branch, baseCommit }\n }),\n\n async collect(worktreePath, baseCommit) {\n const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }\n const range = `${baseCommit}..HEAD`\n\n const head = await quick(['rev-parse', 'HEAD'], worktreePath)\n if (head.ok) facts.headCommit = head.stdout.trim()\n\n const log = await quick(['log', '--pretty=format:%h %s', range], worktreePath)\n if (log.ok) {\n const commits = log.stdout.split('\\n')\n .map(line => line.trim())\n .filter(line => line.length > 0)\n .map(line => {\n const space = line.indexOf(' ')\n return space === -1\n ? { hash: line, subject: '' }\n : { hash: line.slice(0, space), subject: line.slice(space + 1) }\n })\n // Evidence caps (0.3.1): the ledger is rewritten whole on every\n // mutation — cap what a huge branch/status dump can add to it.\n facts.commitsTotal = commits.length\n facts.commits = commits.slice(0, MAX_COMMIT_EVIDENCE)\n }\n\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok) {\n const dirty = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n facts.dirtyFilesTotal = dirty.length\n facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)\n }\n\n const shortstat = await quick(['diff', '--shortstat', range], worktreePath)\n if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim()\n\n const names = await quick(['diff', '--name-only', range], worktreePath)\n if (names.ok) facts.changedFiles = names.stdout.split('\\n').filter(l => l.trim().length > 0).length\n\n return facts\n },\n\n merge: (root, branch) => withRootLock(root, async () => {\n // Main-clean check. The plugin's own worktree directory\n // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —\n // otherwise merging would be impossible without gitignoring it first.\n const status = await quick(['status', '--porcelain'], root)\n if (status.ok) {\n const dirtyLines = status.stdout.split('\\n')\n .map(l => l.trim())\n .filter(l => {\n if (l.length === 0) return false\n const path = l.slice(3)\n return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)\n })\n if (dirtyLines.length > 0) {\n // Machine-readable tag: callers classify without parsing zh-CN text.\n throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })\n }\n }\n const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)\n if (!merged.ok) {\n // Roll the half-finished merge back so the main worktree stays usable;\n // report the ORIGINAL failure verbatim (不自动解决冲突).\n await heavy(['merge', '--abort'], root)\n throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`)\n }\n }),\n\n async isAncestor(root, branch) {\n // exit 0 = branch is an ancestor of (or equal to) HEAD → merge no-op.\n const r = await quick(['merge-base', '--is-ancestor', branch, 'HEAD'], root)\n return r.ok\n },\n\n removeWorktree: (root, worktreePath) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok && status.stdout.trim().length > 0) {\n const lines = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n // Machine-readable tag: purge flows classify without parsing zh-CN text.\n throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\\n${lines.slice(0, 10).join('\\n')}`), { code: 'dirty-worktree' })\n }\n const removed = await heavy(['worktree', 'remove', worktreePath], root)\n if (removed.ok) return 'removed'\n // S3: classify the failure WITHOUT parsing git's (localizable) stderr —\n // a path absent from `worktree list` is an unregistered leftover, not\n // an error the caller should relay verbatim.\n const list = await quick(['worktree', 'list', '--porcelain'], root)\n const registered = list.ok && list.stdout.split('\\n')\n .some(l => l.startsWith('worktree ')\n && resolve(l.slice('worktree '.length).trim()).toLowerCase() === resolve(worktreePath).toLowerCase())\n if (!registered) return 'unregistered'\n throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)\n }),\n\n deleteBranch: (root, branch) => withRootLock(root, async () => {\n const deleted = await heavy(['branch', '-D', branch], root)\n if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)\n }),\n\n async showCommit(cwd, hash) {\n if (!isHash(hash)) return undefined\n const r = await quick(['show', '--no-color', '--format=medium', hash], cwd)\n if (!r.ok || r.stdout.trim().length === 0) return undefined\n return capDiff(r.stdout)\n },\n\n async showPathDiff(cwd, path, baseCommit) {\n const target = path.trim()\n if (target.length === 0) return undefined\n if (baseCommit !== undefined && isHash(baseCommit)) {\n const r = await quick(['diff', '--no-color', `${baseCommit}..HEAD`, '--', target], cwd)\n if (!r.ok) return undefined\n if (r.stdout.trim().length === 0) return { text: '(该文件无差异)', truncated: false }\n return capDiff(r.stdout)\n }\n // Working-tree view: staged + unstaged vs HEAD.\n const r = await quick(['diff', '--no-color', 'HEAD', '--', target], cwd)\n if (r.ok && r.stdout.trim().length > 0) return capDiff(r.stdout)\n // Untracked files never appear in `git diff` — detect one and synthesize\n // its new-file patch via --no-index (which exits 1 on differences, so\n // its stdout is trusted whenever it carries a diff header).\n const st = await quick(['status', '--porcelain', '--', target], cwd)\n if (r.ok && st.ok && st.stdout.trim().startsWith('??')) {\n const ni = await exec(['diff', '--no-color', '--no-index', '--', '/dev/null', target], { cwd, timeout: QUICK_TIMEOUT_MS })\n if (ni.stdout.includes('diff --git')) return capDiff(ni.stdout)\n return { text: `(未跟踪新文件:${target})`, truncated: false }\n }\n if (!r.ok) return undefined\n return { text: '(该文件无差异)', truncated: false }\n },\n }\n}\n"],"mappings":"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"}
|
package/lib/host/routes.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { asBoardSettings, asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, defaultIsolationOf, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
|
|
2
2
|
import { WORKTREE_DIR, worktreePathOf } from "./git.js";
|
|
3
3
|
import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ERR, ToolError } from "./tools.js";
|
|
5
|
+
import { join, resolve, sep } from "node:path";
|
|
5
6
|
import { readdir, rm } from "node:fs/promises";
|
|
6
7
|
//#region src/host/routes.ts
|
|
7
8
|
/** Heartbeat cadence for the SSE stream. */
|
|
8
9
|
const HEARTBEAT_MS = 2e4;
|
|
10
|
+
/** Max accepted JSON body bytes (S8: unbounded buffering is a local OOM vector). */
|
|
11
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
12
|
+
/** Route shapes (T2: compiled once at module load, not on every request). */
|
|
13
|
+
const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`);
|
|
14
|
+
const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`);
|
|
15
|
+
const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`);
|
|
9
16
|
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
10
17
|
const GIT_DETECT_TTL_MS = 6e4;
|
|
11
18
|
/** Validate a template's task spec (routes-side, unknown → invalid_input). */
|
|
12
|
-
function normalizeTemplateSpec(raw) {
|
|
19
|
+
function normalizeTemplateSpec(raw, now) {
|
|
13
20
|
if (typeof raw !== "object" || raw === null) throw new Error("Error: invalid_input: task must be an object");
|
|
14
21
|
const e = raw;
|
|
15
22
|
const spec = {};
|
|
@@ -31,7 +38,7 @@ function normalizeTemplateSpec(raw) {
|
|
|
31
38
|
if (urgency !== void 0) spec.urgency = asUrgency(urgency);
|
|
32
39
|
if (isolation !== void 0) spec.isolation = asIsolation(isolation);
|
|
33
40
|
if (presetId !== void 0 && presetId.trim().length > 0) spec.presetId = presetId.trim();
|
|
34
|
-
if (e.execution !== void 0) spec.execution = normalizeExecution(e.execution,
|
|
41
|
+
if (e.execution !== void 0) spec.execution = normalizeExecution(e.execution, now);
|
|
35
42
|
if (e.model !== void 0) spec.model = normalizeModel(e.model);
|
|
36
43
|
if (e.checklist !== void 0) {
|
|
37
44
|
if (!Array.isArray(e.checklist) || e.checklist.some((c) => typeof c !== "string")) throw new Error("Error: invalid_input: task.checklist must be an array of strings");
|
|
@@ -69,10 +76,19 @@ function fail(code, message) {
|
|
|
69
76
|
status: code === "invalid_input" || code === "invalid_transition" ? 400 : code === "not_found" ? 404 : code === "version_conflict" ? 409 : code === "forbidden" ? 403 : 500
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Read one JSON body (null on parse failure). S8: rejects bodies over
|
|
81
|
+
* MAX_BODY_BYTES by throwing — the local, unauthenticated HTTP surface must
|
|
82
|
+
* not be an unbounded memory sink.
|
|
83
|
+
*/
|
|
73
84
|
async function readBody(req) {
|
|
74
85
|
const chunks = [];
|
|
75
|
-
|
|
86
|
+
let total = 0;
|
|
87
|
+
for await (const chunk of req) {
|
|
88
|
+
total += chunk.length;
|
|
89
|
+
if (total > MAX_BODY_BYTES) throw new Error("body too large");
|
|
90
|
+
chunks.push(chunk);
|
|
91
|
+
}
|
|
76
92
|
if (chunks.length === 0) return {};
|
|
77
93
|
try {
|
|
78
94
|
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
@@ -92,6 +108,15 @@ function num(body, key) {
|
|
|
92
108
|
if (v === void 0) return void 0;
|
|
93
109
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
94
110
|
}
|
|
111
|
+
/** Find a live task INSIDE a mutator (R1: guards run on the fresh draft). */
|
|
112
|
+
function liveTaskAt(ledger, id) {
|
|
113
|
+
const index = ledger.tasks.findIndex((t) => t.id === id);
|
|
114
|
+
if (index < 0 || ledger.tasks[index].trashedAt !== void 0) throw new Error("Error: not_found: no such task");
|
|
115
|
+
return {
|
|
116
|
+
index,
|
|
117
|
+
task: ledger.tasks[index]
|
|
118
|
+
};
|
|
119
|
+
}
|
|
95
120
|
/** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
|
|
96
121
|
function normalizePresetId(raw) {
|
|
97
122
|
const t = (raw ?? "").trim();
|
|
@@ -100,6 +125,17 @@ function normalizePresetId(raw) {
|
|
|
100
125
|
/** Map a thrown domain error to the envelope. */
|
|
101
126
|
function toFail(error) {
|
|
102
127
|
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
if (error instanceof ToolError) {
|
|
129
|
+
const mapped = error.code === ERR.workspaceMismatch ? "forbidden" : error.code;
|
|
130
|
+
if ([
|
|
131
|
+
"invalid_input",
|
|
132
|
+
"not_found",
|
|
133
|
+
"version_conflict",
|
|
134
|
+
"invalid_transition",
|
|
135
|
+
"forbidden",
|
|
136
|
+
"internal"
|
|
137
|
+
].includes(mapped)) return fail(mapped, message.slice(7 + error.code.length + 2));
|
|
138
|
+
}
|
|
103
139
|
const code = message.startsWith("Error: ") ? message.slice(7).split(":")[0] : void 0;
|
|
104
140
|
if (code !== void 0 && [
|
|
105
141
|
"invalid_input",
|
|
@@ -122,6 +158,12 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
122
158
|
const { store, workspaces } = options;
|
|
123
159
|
const subscribers = /* @__PURE__ */ new Set();
|
|
124
160
|
let heartbeat;
|
|
161
|
+
/** R4③: a cleanup/purge target must resolve INSIDE <ws>/.dsh-worktrees — string joining alone is never trusted with an rm. */
|
|
162
|
+
const insideWorktreeScope = (wsPath, target) => {
|
|
163
|
+
const scope = resolve(wsPath, WORKTREE_DIR);
|
|
164
|
+
const resolved = resolve(target);
|
|
165
|
+
return resolved === scope || resolved.startsWith(scope + sep);
|
|
166
|
+
};
|
|
125
167
|
const broadcast = (change) => {
|
|
126
168
|
const frame = `event: change\ndata: ${JSON.stringify({
|
|
127
169
|
revision: change.revision,
|
|
@@ -130,7 +172,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
130
172
|
})}\n\n`;
|
|
131
173
|
for (const res of subscribers) res.write(frame);
|
|
132
174
|
};
|
|
133
|
-
store.subscribe(broadcast);
|
|
175
|
+
const unsubscribeBroadcast = store.subscribe(broadcast);
|
|
134
176
|
const gitCache = /* @__PURE__ */ new Map();
|
|
135
177
|
const gitHinted = /* @__PURE__ */ new Set();
|
|
136
178
|
/** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
|
|
@@ -235,7 +277,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
235
277
|
});
|
|
236
278
|
return;
|
|
237
279
|
}
|
|
238
|
-
const diffMatch = pathname.match(
|
|
280
|
+
const diffMatch = pathname.match(TASK_DIFF_RE);
|
|
239
281
|
if (diffMatch !== null) {
|
|
240
282
|
try {
|
|
241
283
|
if (options.git === void 0) {
|
|
@@ -286,7 +328,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
286
328
|
});
|
|
287
329
|
return;
|
|
288
330
|
}
|
|
289
|
-
const taskMatch = pathname.match(
|
|
331
|
+
const taskMatch = pathname.match(TASK_RE);
|
|
290
332
|
if (taskMatch !== null) {
|
|
291
333
|
const task = store.get(taskMatch[1]);
|
|
292
334
|
if (task === void 0) {
|
|
@@ -305,7 +347,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
305
347
|
return;
|
|
306
348
|
}
|
|
307
349
|
if (req.method !== "POST") {
|
|
308
|
-
res.writeHead(405);
|
|
350
|
+
res.writeHead(405, { allow: "GET, POST" });
|
|
309
351
|
res.end();
|
|
310
352
|
return;
|
|
311
353
|
}
|
|
@@ -313,7 +355,13 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
313
355
|
json(res, fail("invalid_input", "content-type must be application/json").res, 415);
|
|
314
356
|
return;
|
|
315
357
|
}
|
|
316
|
-
|
|
358
|
+
let body;
|
|
359
|
+
try {
|
|
360
|
+
body = await readBody(req);
|
|
361
|
+
} catch {
|
|
362
|
+
json(res, fail("invalid_input", `request body exceeds ${MAX_BODY_BYTES} bytes`).res, 413);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
317
365
|
if (body === null) {
|
|
318
366
|
json(res, fail("invalid_input", "body is not a JSON object").res, 400);
|
|
319
367
|
return;
|
|
@@ -325,6 +373,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
325
373
|
if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
326
374
|
const urgency = asUrgency(str(body, "urgency") ?? "");
|
|
327
375
|
const status = str(body, "status") === null ? "todo" : asStatus(str(body, "status"));
|
|
376
|
+
if (status !== "backlog" && status !== "todo") throw new Error("Error: invalid_transition: a new task must start as backlog or todo");
|
|
328
377
|
const execution = normalizeExecution(body.execution ?? {}, options.now());
|
|
329
378
|
const model = body.model === void 0 ? void 0 : checkModel(body.model, options.modelProviders);
|
|
330
379
|
const isolationRaw = str(body, "isolation");
|
|
@@ -373,7 +422,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
373
422
|
}
|
|
374
423
|
return;
|
|
375
424
|
}
|
|
376
|
-
const actionMatch = pathname.match(
|
|
425
|
+
const actionMatch = pathname.match(TASK_ACTION_RE);
|
|
377
426
|
if (actionMatch !== null) {
|
|
378
427
|
const id = actionMatch[1];
|
|
379
428
|
const action = actionMatch[2];
|
|
@@ -383,44 +432,46 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
383
432
|
if (action === "update") {
|
|
384
433
|
const ifVersion = num(body, "ifVersion");
|
|
385
434
|
if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
|
|
386
|
-
|
|
387
|
-
const next = structuredClone(task);
|
|
388
|
-
const title = str(body, "title");
|
|
389
|
-
if (title !== null) next.title = normalizeTitle(title);
|
|
390
|
-
const description = str(body, "description");
|
|
391
|
-
if (description !== null) next.description = description.trim();
|
|
392
|
-
const prompt = str(body, "prompt");
|
|
393
|
-
if (prompt !== null) next.prompt = normalizePrompt(prompt);
|
|
394
|
-
const urgency = str(body, "urgency");
|
|
395
|
-
if (urgency !== null) next.urgency = asUrgency(urgency);
|
|
396
|
-
const workspaceId = str(body, "workspaceId");
|
|
397
|
-
if (workspaceId !== null) {
|
|
398
|
-
if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
399
|
-
next.workspaceId = workspaceId;
|
|
400
|
-
}
|
|
401
|
-
if (typeof body.blocked === "boolean") next.blocked = body.blocked;
|
|
402
|
-
if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
|
|
403
|
-
if (body.model === null) next.model = void 0;
|
|
404
|
-
else if (body.model !== void 0) next.model = checkModel(body.model, options.modelProviders);
|
|
405
|
-
const isolationRaw = str(body, "isolation");
|
|
406
|
-
if (isolationRaw !== null) {
|
|
407
|
-
if (task.executions.length > 0 || task.status === "in_progress") throw new Error("Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改");
|
|
408
|
-
next.isolation = asIsolation(isolationRaw);
|
|
409
|
-
}
|
|
410
|
-
if (body.presetId === null) delete next.presetId;
|
|
411
|
-
else if (body.presetId !== void 0) next.presetId = normalizePresetId(str(body, "presetId"));
|
|
412
|
-
if (body.checklist === null) delete next.checklist;
|
|
413
|
-
else if (body.checklist !== void 0) {
|
|
414
|
-
const items = normalizeChecklist(body.checklist);
|
|
415
|
-
if (items.length > 0) next.checklist = items;
|
|
416
|
-
else delete next.checklist;
|
|
417
|
-
}
|
|
418
|
-
next.version = task.version + 1;
|
|
419
|
-
next.updatedAt = options.now();
|
|
420
|
-
next.updatedBy = { kind: "user" };
|
|
435
|
+
let next;
|
|
421
436
|
await store.mutate("task-updated", (ledger) => {
|
|
422
|
-
const
|
|
423
|
-
|
|
437
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
438
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
439
|
+
if (task.status === "archived") throw new Error("Error: invalid_transition: archived tasks are immutable");
|
|
440
|
+
next = structuredClone(task);
|
|
441
|
+
const title = str(body, "title");
|
|
442
|
+
if (title !== null) next.title = normalizeTitle(title);
|
|
443
|
+
const description = str(body, "description");
|
|
444
|
+
if (description !== null) next.description = description.trim();
|
|
445
|
+
const prompt = str(body, "prompt");
|
|
446
|
+
if (prompt !== null) next.prompt = normalizePrompt(prompt);
|
|
447
|
+
const urgency = str(body, "urgency");
|
|
448
|
+
if (urgency !== null) next.urgency = asUrgency(urgency);
|
|
449
|
+
const workspaceId = str(body, "workspaceId");
|
|
450
|
+
if (workspaceId !== null) {
|
|
451
|
+
if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
452
|
+
next.workspaceId = workspaceId;
|
|
453
|
+
}
|
|
454
|
+
if (typeof body.blocked === "boolean") next.blocked = body.blocked;
|
|
455
|
+
if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
|
|
456
|
+
if (body.model === null) next.model = void 0;
|
|
457
|
+
else if (body.model !== void 0) next.model = checkModel(body.model, options.modelProviders);
|
|
458
|
+
const isolationRaw = str(body, "isolation");
|
|
459
|
+
if (isolationRaw !== null) {
|
|
460
|
+
if (task.executions.length > 0 || task.status === "in_progress") throw new Error("Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改");
|
|
461
|
+
next.isolation = asIsolation(isolationRaw);
|
|
462
|
+
}
|
|
463
|
+
if (body.presetId === null) delete next.presetId;
|
|
464
|
+
else if (body.presetId !== void 0) next.presetId = normalizePresetId(str(body, "presetId"));
|
|
465
|
+
if (body.checklist === null) delete next.checklist;
|
|
466
|
+
else if (body.checklist !== void 0) {
|
|
467
|
+
const items = normalizeChecklist(body.checklist);
|
|
468
|
+
if (items.length > 0) next.checklist = items;
|
|
469
|
+
else delete next.checklist;
|
|
470
|
+
}
|
|
471
|
+
next.version = task.version + 1;
|
|
472
|
+
next.updatedAt = options.now();
|
|
473
|
+
next.updatedBy = { kind: "user" };
|
|
474
|
+
ledger.tasks[index] = next;
|
|
424
475
|
return [next];
|
|
425
476
|
});
|
|
426
477
|
json(res, {
|
|
@@ -433,19 +484,20 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
433
484
|
const ifVersion = num(body, "ifVersion");
|
|
434
485
|
const status = str(body, "status") ?? "";
|
|
435
486
|
if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
|
|
436
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
437
487
|
const to = asStatus(status);
|
|
438
|
-
|
|
439
|
-
const next = structuredClone(task);
|
|
440
|
-
next.status = to;
|
|
441
|
-
next.version = task.version + 1;
|
|
442
|
-
next.updatedAt = options.now();
|
|
443
|
-
next.updatedBy = { kind: "user" };
|
|
444
|
-
if (task.status === "todo" && to === "in_progress") next.blocked = false;
|
|
445
|
-
syncClaim(next, to, options.now());
|
|
488
|
+
let next;
|
|
446
489
|
await store.mutate("task-moved", (ledger) => {
|
|
447
|
-
const
|
|
448
|
-
|
|
490
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
491
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
492
|
+
if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`);
|
|
493
|
+
next = structuredClone(task);
|
|
494
|
+
next.status = to;
|
|
495
|
+
next.version = task.version + 1;
|
|
496
|
+
next.updatedAt = options.now();
|
|
497
|
+
next.updatedBy = { kind: "user" };
|
|
498
|
+
if (task.status === "todo" && to === "in_progress") next.blocked = false;
|
|
499
|
+
syncClaim(next, to, options.now());
|
|
500
|
+
ledger.tasks[index] = next;
|
|
449
501
|
return [next];
|
|
450
502
|
});
|
|
451
503
|
json(res, {
|
|
@@ -457,24 +509,25 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
457
509
|
if (action === "reject") {
|
|
458
510
|
const ifVersion = num(body, "ifVersion");
|
|
459
511
|
if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
|
|
460
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
461
|
-
if (!canTransition(task.status, "todo")) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`);
|
|
462
|
-
const next = structuredClone(task);
|
|
463
|
-
next.status = "todo";
|
|
464
|
-
next.version = task.version + 1;
|
|
465
|
-
next.updatedAt = options.now();
|
|
466
|
-
next.updatedBy = { kind: "user" };
|
|
467
|
-
syncClaim(next, "todo", options.now());
|
|
468
512
|
const commentText = str(body, "body") ?? "";
|
|
469
|
-
|
|
470
|
-
id: newCommentId(),
|
|
471
|
-
body: normalizeBody(commentText),
|
|
472
|
-
version: 1,
|
|
473
|
-
createdAt: options.now()
|
|
474
|
-
});
|
|
513
|
+
let next;
|
|
475
514
|
await store.mutate("task-moved", (ledger) => {
|
|
476
|
-
const
|
|
477
|
-
|
|
515
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
516
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
517
|
+
if (!canTransition(task.status, "todo")) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`);
|
|
518
|
+
next = structuredClone(task);
|
|
519
|
+
next.status = "todo";
|
|
520
|
+
next.version = task.version + 1;
|
|
521
|
+
next.updatedAt = options.now();
|
|
522
|
+
next.updatedBy = { kind: "user" };
|
|
523
|
+
syncClaim(next, "todo", options.now());
|
|
524
|
+
if (commentText.trim().length > 0) next.comments.push({
|
|
525
|
+
id: newCommentId(),
|
|
526
|
+
body: normalizeBody(commentText),
|
|
527
|
+
version: 1,
|
|
528
|
+
createdAt: options.now()
|
|
529
|
+
});
|
|
530
|
+
ledger.tasks[index] = next;
|
|
478
531
|
return [next];
|
|
479
532
|
});
|
|
480
533
|
json(res, {
|
|
@@ -491,13 +544,14 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
491
544
|
version: 1,
|
|
492
545
|
createdAt: options.now()
|
|
493
546
|
};
|
|
494
|
-
const next = structuredClone(task);
|
|
495
|
-
next.comments.push(comment);
|
|
496
|
-
next.version = task.version + 1;
|
|
497
|
-
next.updatedAt = options.now();
|
|
498
547
|
await store.mutate("comment-added", (ledger) => {
|
|
499
|
-
const
|
|
500
|
-
|
|
548
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
549
|
+
if (task.status === "archived") throw new Error("Error: invalid_transition: archived tasks are immutable");
|
|
550
|
+
const next = structuredClone(task);
|
|
551
|
+
next.comments.push(comment);
|
|
552
|
+
next.version = task.version + 1;
|
|
553
|
+
next.updatedAt = options.now();
|
|
554
|
+
ledger.tasks[index] = next;
|
|
501
555
|
return [next];
|
|
502
556
|
});
|
|
503
557
|
json(res, {
|
|
@@ -513,16 +567,16 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
513
567
|
const ws = workspaces.get(task.workspaceId);
|
|
514
568
|
if (ws !== void 0) {
|
|
515
569
|
const path = worktreePathOf(ws.path, id);
|
|
570
|
+
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
516
571
|
try {
|
|
517
|
-
await options.git.removeWorktree(ws.path, path)
|
|
518
|
-
} catch (error) {
|
|
519
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
520
|
-
if (message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
|
|
521
|
-
if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
|
|
572
|
+
if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
|
|
522
573
|
recursive: true,
|
|
523
574
|
force: true
|
|
524
575
|
});
|
|
525
|
-
|
|
576
|
+
} catch (error) {
|
|
577
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
578
|
+
if (error.code === "dirty-worktree" || message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
|
|
579
|
+
throw new Error(`Error: invalid_input: ${message}`);
|
|
526
580
|
}
|
|
527
581
|
if (task.branch !== void 0) try {
|
|
528
582
|
await options.git.deleteBranch(ws.path, task.branch);
|
|
@@ -541,13 +595,17 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
541
595
|
}
|
|
542
596
|
const ifVersion = num(body, "ifVersion");
|
|
543
597
|
if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
|
|
544
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
545
|
-
const next = structuredClone(task);
|
|
546
|
-
next.trashedAt = options.now();
|
|
547
|
-
next.version = task.version + 1;
|
|
548
598
|
await store.mutate("task-deleted", (ledger) => {
|
|
549
|
-
const
|
|
550
|
-
|
|
599
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
600
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
601
|
+
if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务有正在运行的执行,请先取消或等它结束再删除");
|
|
602
|
+
const next = structuredClone(task);
|
|
603
|
+
next.trashedAt = options.now();
|
|
604
|
+
next.version = task.version + 1;
|
|
605
|
+
delete next.claimedBy;
|
|
606
|
+
delete next.claimedAt;
|
|
607
|
+
next.blocked = false;
|
|
608
|
+
ledger.tasks[index] = next;
|
|
551
609
|
return [next];
|
|
552
610
|
});
|
|
553
611
|
json(res, {
|
|
@@ -628,13 +686,13 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
628
686
|
version: 1,
|
|
629
687
|
createdAt: options.now()
|
|
630
688
|
};
|
|
631
|
-
const next = structuredClone(task);
|
|
632
|
-
next.comments.push(mergedComment);
|
|
633
|
-
next.version = task.version + 1;
|
|
634
|
-
next.updatedAt = options.now();
|
|
635
689
|
await store.mutate("comment-added", (ledger) => {
|
|
636
|
-
const
|
|
637
|
-
|
|
690
|
+
const { index, task: fresh } = liveTaskAt(ledger, id);
|
|
691
|
+
const next = structuredClone(fresh);
|
|
692
|
+
next.comments.push(mergedComment);
|
|
693
|
+
next.version = fresh.version + 1;
|
|
694
|
+
next.updatedAt = options.now();
|
|
695
|
+
ledger.tasks[index] = next;
|
|
638
696
|
return [next];
|
|
639
697
|
});
|
|
640
698
|
json(res, {
|
|
@@ -655,8 +713,12 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
655
713
|
const ws = workspaces.get(task.workspaceId);
|
|
656
714
|
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
657
715
|
const path = worktreePathOf(ws.path, id);
|
|
716
|
+
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
658
717
|
try {
|
|
659
|
-
await options.git.removeWorktree(ws.path, path)
|
|
718
|
+
if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
|
|
719
|
+
recursive: true,
|
|
720
|
+
force: true
|
|
721
|
+
});
|
|
660
722
|
} catch (error) {
|
|
661
723
|
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
|
|
662
724
|
}
|
|
@@ -698,15 +760,14 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
698
760
|
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
699
761
|
if (store.get(taskId) !== void 0) throw new Error("Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree");
|
|
700
762
|
const path = worktreePathOf(ws.path, taskId);
|
|
763
|
+
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
701
764
|
try {
|
|
702
|
-
await options.git.removeWorktree(ws.path, path)
|
|
703
|
-
} catch (error) {
|
|
704
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
705
|
-
if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
|
|
765
|
+
if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
|
|
706
766
|
recursive: true,
|
|
707
767
|
force: true
|
|
708
768
|
});
|
|
709
|
-
|
|
769
|
+
} catch (error) {
|
|
770
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
|
|
710
771
|
}
|
|
711
772
|
json(res, {
|
|
712
773
|
ok: true,
|
|
@@ -723,7 +784,8 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
723
784
|
}
|
|
724
785
|
if (pathname === `/dsh-taskboard/import/preview`) {
|
|
725
786
|
try {
|
|
726
|
-
const
|
|
787
|
+
const known = new Set(store.snapshot().tasks.map((t) => t.id));
|
|
788
|
+
const plan = validateLedgerImport(body, known, options.now());
|
|
727
789
|
json(res, {
|
|
728
790
|
ok: true,
|
|
729
791
|
value: { plan: {
|
|
@@ -756,8 +818,9 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
756
818
|
let backupFile;
|
|
757
819
|
if (mode === "replace" && store.snapshot().tasks.length > 0) backupFile = await store.backup();
|
|
758
820
|
let replacedTotal;
|
|
759
|
-
await store.mutate("
|
|
821
|
+
await store.mutate("ledger-replaced", (ledger) => {
|
|
760
822
|
if (mode === "replace") {
|
|
823
|
+
if (ledger.tasks.some((t) => t.executions.some((e) => e.outcome === "running"))) throw new Error("Error: invalid_input: 有任务正在执行,不能整册替换(请先取消或等待结束)");
|
|
761
824
|
replacedTotal = ledger.tasks.length;
|
|
762
825
|
ledger.tasks = structuredClone(imported);
|
|
763
826
|
if (plan.settings !== void 0) ledger.settings = structuredClone(plan.settings);
|
|
@@ -765,7 +828,11 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
765
828
|
return ledger.tasks;
|
|
766
829
|
}
|
|
767
830
|
const byId = new Map(ledger.tasks.map((t) => [t.id, t]));
|
|
768
|
-
for (const task of imported)
|
|
831
|
+
for (const task of imported) {
|
|
832
|
+
const existing = byId.get(task.id);
|
|
833
|
+
if (existing !== void 0 && existing.executions.some((e) => e.outcome === "running")) throw new Error(`Error: invalid_input: 任务 ${task.id} 正在执行,不能被导入覆盖`);
|
|
834
|
+
byId.set(task.id, structuredClone(task));
|
|
835
|
+
}
|
|
769
836
|
ledger.tasks = [...byId.values()];
|
|
770
837
|
return structuredClone(imported);
|
|
771
838
|
});
|
|
@@ -807,7 +874,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
807
874
|
value: await options.templates.upsert({
|
|
808
875
|
id: str(body, "id") ?? void 0,
|
|
809
876
|
name,
|
|
810
|
-
task: normalizeTemplateSpec(body.task)
|
|
877
|
+
task: normalizeTemplateSpec(body.task, options.now())
|
|
811
878
|
})
|
|
812
879
|
}, 201);
|
|
813
880
|
} catch (error) {
|
|
@@ -849,6 +916,9 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
849
916
|
res.write("retry: 2000\n\n");
|
|
850
917
|
res.write(`event: hello\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\n\n`);
|
|
851
918
|
subscribers.add(res);
|
|
919
|
+
res.on("error", () => {
|
|
920
|
+
subscribers.delete(res);
|
|
921
|
+
});
|
|
852
922
|
if (heartbeat === void 0) heartbeat = setInterval(() => {
|
|
853
923
|
for (const current of subscribers) current.write(": ping\n\n");
|
|
854
924
|
}, HEARTBEAT_MS);
|
|
@@ -870,6 +940,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
870
940
|
handler: sse
|
|
871
941
|
})];
|
|
872
942
|
return () => {
|
|
943
|
+
unsubscribeBroadcast();
|
|
873
944
|
for (const dispose of disposers) dispose();
|
|
874
945
|
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
875
946
|
for (const res of subscribers) res.end();
|