dsh-taskboard 0.2.2 → 0.3.3

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.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Host git face (0.3.0): the ONLY place dsh-taskboard shells out to git.
3
+ * 0.3.1: per-repo serialization of structural operations, binary probing,
4
+ * no-op merge detection, worktree REUSE mode, and evidence size caps.
5
+ *
6
+ * Design invariants (plan §3.4/§3.5):
7
+ * - NARROW interface: detect / binaryAvailable / prepareWorktree / collect /
8
+ * merge / isAncestor / removeWorktree / deleteBranch — nothing else leaks
9
+ * into the plugin.
10
+ * - FAIL-SOFT: every call has a timeout and resolves to a benign result
11
+ * (false / undefined / empty facts) on ANY git failure — a missing git,
12
+ * a locked worktree, or a damaged repo degrades execution to the original
13
+ * directory and NEVER fails the ledger or the run pipeline. Only the
14
+ * explicit user actions (merge / remove / deleteBranch) throw, with a
15
+ * readable message the GUI surfaces as-is.
16
+ * - SERIALIZED structural ops: concurrent isolated executions on the SAME
17
+ * repository would race on git's index/worktree locks, so every structural
18
+ * operation (prepareWorktree / merge / removeWorktree / deleteBranch) runs
19
+ * inside a per-root in-process mutex. Read-only collects stay concurrent.
20
+ * - INJECTABLE runner: the exec layer is a single function so unit tests
21
+ * script every path without a real git.
22
+ *
23
+ * @module dsh-taskboard/host/git
24
+ */
25
+ import type { CommitInfo } from '../shared/protocol.ts'
26
+
27
+ /** Timeout for quick read-only queries (rev-parse / status / log / diff). */
28
+ const QUICK_TIMEOUT_MS = 2_000
29
+
30
+ /** Timeout for structural operations (worktree add/remove, merge, branch). */
31
+ const HEAVY_TIMEOUT_MS = 15_000
32
+
33
+ /** Directory under a workspace where task worktrees live. */
34
+ export const WORKTREE_DIR = '.dsh-worktrees'
35
+
36
+ /** Evidence caps: commits kept per execution record (newest first). */
37
+ export const MAX_COMMIT_EVIDENCE = 50
38
+
39
+ /** Evidence caps: uncommitted-change lines kept per execution record. */
40
+ export const MAX_DIRTY_EVIDENCE = 100
41
+
42
+ /** Result of one underlying exec: `ok` is exit-0, output never null. */
43
+ export interface ExecResult { ok: boolean; stdout: string; stderr: string }
44
+
45
+ /** The injectable exec layer: run `git <args>` under a cwd with a timeout. */
46
+ export type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>
47
+
48
+ /** Facts needed to open an isolated execution. */
49
+ export interface WorktreeInfo {
50
+ /** Absolute worktree path (the session's cwd). */
51
+ path: string
52
+ /** The task branch checked out there. */
53
+ branch: string
54
+ /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */
55
+ baseCommit: string
56
+ /** True when an existing live worktree was kept as-is (续跑). */
57
+ reused?: boolean
58
+ }
59
+
60
+ /** Settlement facts collected from a worktree (partial on best-effort basis). */
61
+ export interface SettlementFacts {
62
+ headCommit?: string
63
+ commits: CommitInfo[]
64
+ /** Total commits before capping (equals commits.length when under the cap). */
65
+ commitsTotal: number
66
+ dirtyFiles: string[]
67
+ /** Total uncommitted lines before capping. */
68
+ dirtyFilesTotal: number
69
+ diffStat?: string
70
+ changedFiles: number
71
+ }
72
+
73
+ /** The narrow git face the rest of the plugin depends on. */
74
+ export interface GitFace {
75
+ /** Whether `root` sits inside a usable git work tree (fail-soft → false). */
76
+ detect(root: string): Promise<boolean>
77
+ /** Whether a usable git binary answers at all (distinguishes 未装 git vs 非 git 仓库). */
78
+ binaryAvailable(): Promise<boolean>
79
+ /**
80
+ * Ensure a worktree at `path` on `branch`. Default mode `'fresh'` resets to
81
+ * the main worktree's current HEAD (每次全新); mode `'reuse'` keeps a live
82
+ * worktree exactly as-is (续跑 — agent's commits and uncommitted changes
83
+ * survive) and falls back to a fresh creation when none is alive. Resolves
84
+ * undefined on any failure — callers degrade to the original directory.
85
+ */
86
+ prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>
87
+ /** Collect settlement facts (never throws; missing pieces stay unset). */
88
+ collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>
89
+ /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */
90
+ merge(root: string, branch: string): Promise<void>
91
+ /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */
92
+ isAncestor(root: string, branch: string): Promise<boolean>
93
+ /** Remove a worktree; THROWS when it still has uncommitted changes. */
94
+ removeWorktree(root: string, worktreePath: string): Promise<void>
95
+ /** Delete a branch; THROWS (e.g. still checked out in a worktree). */
96
+ deleteBranch(root: string, branch: string): Promise<void>
97
+ }
98
+
99
+ /**
100
+ * Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).
101
+ *
102
+ * Title sanitizing: whitespace runs collapse to `-`; git-illegal characters
103
+ * (`~ ^ : ? * [ \ / @ { }` and friends) are stripped; `..` collapses; the
104
+ * segment is trimmed of leading/trailing `.-` and truncated to ~20 code
105
+ * points; an empty result falls back to the bare `task/<taskId>`.
106
+ * @param title - the task title (already normalized 1..200 chars).
107
+ * @param taskId - the task id (stable suffix).
108
+ * @returns the branch name.
109
+ */
110
+ export function sanitizeBranchName(title: string, taskId: string): string {
111
+ const segment = title.trim()
112
+ .replace(/\s+/g, '-')
113
+ .replace(/[/\\~^:?*[\]@{}"'<>|#%&;$!`'=,;()]+/g, '')
114
+ .replace(/\.\.+/g, '.')
115
+ .replace(/^[-.\s]+|[-.\s]+$/g, '')
116
+ const head = Array.from(segment).slice(0, 20).join('').replace(/^[-.]+|[-.]+$/g, '')
117
+ return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`
118
+ }
119
+
120
+ /** The canonical worktree path of a task inside its workspace (forward slashes). */
121
+ export function worktreePathOf(workspacePath: string, taskId: string): string {
122
+ const root = workspacePath.replace(/[\\/]+$/, '').replaceAll('\\', '/')
123
+ return `${root}/${WORKTREE_DIR}/${taskId}`
124
+ }
125
+
126
+ /** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */
127
+ const realExec: ExecFn = (args, options) => new Promise(resolve => {
128
+ void (async () => {
129
+ const { execFile } = await import('node:child_process')
130
+ execFile('git', args, {
131
+ cwd: options.cwd,
132
+ timeout: options.timeout ?? QUICK_TIMEOUT_MS,
133
+ windowsHide: true,
134
+ maxBuffer: 4 * 1024 * 1024,
135
+ encoding: 'utf8',
136
+ }, (error, stdout, stderr) => {
137
+ resolve({ ok: error === null, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })
138
+ })
139
+ })().catch(() => resolve({ ok: false, stdout: '', stderr: 'exec unavailable' }))
140
+ })
141
+
142
+ /**
143
+ * Build a {@link GitFace} over an injectable exec layer.
144
+ * @param exec - the exec function (real `git` when omitted).
145
+ */
146
+ export function createGitFace(exec: ExecFn = realExec): GitFace {
147
+ const quick = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: QUICK_TIMEOUT_MS })
148
+ const heavy = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: HEAVY_TIMEOUT_MS })
149
+
150
+ // Per-root mutex (0.3.1): structural git ops on the SAME repository run one
151
+ // at a time — concurrent isolated executions must not race on git's locks.
152
+ const locks = new Map<string, Promise<unknown>>()
153
+ const withRootLock = <T>(root: string, fn: () => Promise<T>): Promise<T> => {
154
+ const prev = locks.get(root) ?? Promise.resolve()
155
+ const next = prev.then(fn, fn)
156
+ locks.set(root, next.catch(() => { /* the chain never blocks later ops */ }))
157
+ return next
158
+ }
159
+
160
+ return {
161
+ async detect(root) {
162
+ const r = await quick(['rev-parse', '--is-inside-work-tree'], root)
163
+ return r.ok && r.stdout.trim() === 'true'
164
+ },
165
+
166
+ async binaryAvailable() {
167
+ const r = await quick(['--version'])
168
+ return r.ok && r.stdout.startsWith('git version')
169
+ },
170
+
171
+ prepareWorktree: (root, path, branch, mode = 'fresh') => withRootLock(root, async () => {
172
+ // 续跑: a live worktree at the path is kept EXACTLY as-is — the agent's
173
+ // commits and uncommitted changes survive; the baseline becomes the
174
+ // worktree's own HEAD so evidence covers only the new run.
175
+ if (mode === 'reuse') {
176
+ const wtHead = await quick(['rev-parse', 'HEAD'], path)
177
+ if (wtHead.ok && wtHead.stdout.trim().length > 0) {
178
+ return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }
179
+ }
180
+ // No live worktree → fall through to a fresh preparation.
181
+ }
182
+
183
+ // Baseline: the main worktree's current HEAD (also validates the repo).
184
+ const head = await quick(['rev-parse', 'HEAD'], root)
185
+ if (!head.ok) return undefined
186
+ const baseCommit = head.stdout.trim()
187
+
188
+ const exists = await quick(['show-ref', '--verify', `refs/heads/${branch}`], root)
189
+ if (exists.ok) {
190
+ // Reuse the fixed branch name, but guarantee a FRESH baseline: drop
191
+ // any stale worktree at the path, move the branch to the current
192
+ // HEAD, then check the branch out again (每次全新,复用仅作选项保留).
193
+ await heavy(['worktree', 'remove', '--force', path], root)
194
+ await heavy(['worktree', 'prune'], root)
195
+ const moved = await heavy(['branch', '-f', branch, 'HEAD'], root)
196
+ if (!moved.ok) return undefined
197
+ const added = await heavy(['worktree', 'add', path, branch], root)
198
+ if (!added.ok) return undefined
199
+ } else {
200
+ const added = await heavy(['worktree', 'add', '-b', branch, path], root)
201
+ if (!added.ok) return undefined
202
+ }
203
+ return { path, branch, baseCommit }
204
+ }),
205
+
206
+ async collect(worktreePath, baseCommit) {
207
+ const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }
208
+ const range = `${baseCommit}..HEAD`
209
+
210
+ const head = await quick(['rev-parse', 'HEAD'], worktreePath)
211
+ if (head.ok) facts.headCommit = head.stdout.trim()
212
+
213
+ const log = await quick(['log', '--pretty=format:%h %s', range], worktreePath)
214
+ if (log.ok) {
215
+ const commits = log.stdout.split('\n')
216
+ .map(line => line.trim())
217
+ .filter(line => line.length > 0)
218
+ .map(line => {
219
+ const space = line.indexOf(' ')
220
+ return space === -1
221
+ ? { hash: line, subject: '' }
222
+ : { hash: line.slice(0, space), subject: line.slice(space + 1) }
223
+ })
224
+ // Evidence caps (0.3.1): the ledger is rewritten whole on every
225
+ // mutation — cap what a huge branch/status dump can add to it.
226
+ facts.commitsTotal = commits.length
227
+ facts.commits = commits.slice(0, MAX_COMMIT_EVIDENCE)
228
+ }
229
+
230
+ const status = await quick(['status', '--porcelain'], worktreePath)
231
+ if (status.ok) {
232
+ const dirty = status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
233
+ facts.dirtyFilesTotal = dirty.length
234
+ facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)
235
+ }
236
+
237
+ const shortstat = await quick(['diff', '--shortstat', range], worktreePath)
238
+ if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim()
239
+
240
+ const names = await quick(['diff', '--name-only', range], worktreePath)
241
+ if (names.ok) facts.changedFiles = names.stdout.split('\n').filter(l => l.trim().length > 0).length
242
+
243
+ return facts
244
+ },
245
+
246
+ merge: (root, branch) => withRootLock(root, async () => {
247
+ // Main-clean check. The plugin's own worktree directory
248
+ // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —
249
+ // otherwise merging would be impossible without gitignoring it first.
250
+ const status = await quick(['status', '--porcelain'], root)
251
+ if (status.ok) {
252
+ const dirtyLines = status.stdout.split('\n')
253
+ .map(l => l.trim())
254
+ .filter(l => {
255
+ if (l.length === 0) return false
256
+ const path = l.slice(3)
257
+ return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)
258
+ })
259
+ if (dirtyLines.length > 0) {
260
+ throw new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`)
261
+ }
262
+ }
263
+ const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)
264
+ if (!merged.ok) {
265
+ // Roll the half-finished merge back so the main worktree stays usable;
266
+ // report the ORIGINAL failure verbatim (不自动解决冲突).
267
+ await heavy(['merge', '--abort'], root)
268
+ throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`)
269
+ }
270
+ }),
271
+
272
+ async isAncestor(root, branch) {
273
+ // exit 0 = branch is an ancestor of (or equal to) HEAD → merge no-op.
274
+ const r = await quick(['merge-base', '--is-ancestor', branch, 'HEAD'], root)
275
+ return r.ok
276
+ },
277
+
278
+ removeWorktree: (root, worktreePath) => withRootLock(root, async () => {
279
+ const status = await quick(['status', '--porcelain'], worktreePath)
280
+ if (status.ok && status.stdout.trim().length > 0) {
281
+ const lines = status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
282
+ throw new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`)
283
+ }
284
+ const removed = await heavy(['worktree', 'remove', worktreePath], root)
285
+ if (!removed.ok) throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)
286
+ }),
287
+
288
+ deleteBranch: (root, branch) => withRootLock(root, async () => {
289
+ const deleted = await heavy(['branch', '-D', branch], root)
290
+ if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)
291
+ }),
292
+ }
293
+ }
@@ -10,10 +10,13 @@
10
10
  * @module dsh-taskboard/host/routes
11
11
  */
12
12
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
+ import { readdir, rm } from 'node:fs/promises'
14
+ import { join } from 'node:path'
13
15
  import type { Context } from '@deepseek-ai/cordis'
14
16
  // Type-only: pulls the webServer Context merge (ctx.webServer).
15
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
16
18
  import {
19
+ asIsolation,
17
20
  asStatus,
18
21
  asUrgency,
19
22
  canTransition,
@@ -29,6 +32,7 @@ import {
29
32
  type TaskModel,
30
33
  type TaskRecord,
31
34
  } from '../shared/protocol.ts'
35
+ import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
32
36
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
33
37
  import type { TaskStore } from './store.ts'
34
38
  import type { WorkspaceFace } from './tools.ts'
@@ -36,6 +40,9 @@ import type { WorkspaceFace } from './tools.ts'
36
40
  /** Heartbeat cadence for the SSE stream. */
37
41
  const HEARTBEAT_MS = 20_000
38
42
 
43
+ /** How long a workspace git-detection result stays cached (fail-soft). */
44
+ const GIT_DETECT_TTL_MS = 60_000
45
+
39
46
  /** The workspaces face routes need (same narrow shape as tools). */
40
47
  export type RoutesWorkspaceFace = WorkspaceFace
41
48
 
@@ -44,8 +51,8 @@ export interface TaskboardRoutesOptions {
44
51
  store: TaskStore
45
52
  workspaces: RoutesWorkspaceFace
46
53
  now: () => number
47
- /** Manual-run hook (the execution service); absent → 501. */
48
- run?: (taskId: string) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>
54
+ /** Manual-run hook (the execution service); absent → 501. Options carry `reuseWorktree` (续跑). */
55
+ run?: (taskId: string, options?: { reuseWorktree?: boolean }) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>
49
56
  /** Cancel hook (the execution service); absent → 501. */
50
57
  cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>
51
58
  /**
@@ -53,6 +60,8 @@ export interface TaskboardRoutesOptions {
53
60
  * advisory validation of pinned models; undefined = runtime unavailable.
54
61
  */
55
62
  modelProviders?: () => string[] | undefined
63
+ /** Git face for worktree actions + workspace git detection; absent → 501 on git actions. */
64
+ git?: GitFace
56
65
  }
57
66
 
58
67
  /** Validate a pinned model: structural check always, provider route when known. */
@@ -108,6 +117,12 @@ function num(body: Record<string, unknown>, key: string): number | undefined | n
108
117
  return typeof v === 'number' && Number.isFinite(v) ? v : null
109
118
  }
110
119
 
120
+ /** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
121
+ function normalizePresetId(raw: string | null): string | undefined {
122
+ const t = (raw ?? '').trim()
123
+ return t.length === 0 ? undefined : t
124
+ }
125
+
111
126
  /** Map a thrown domain error to the envelope. */
112
127
  function toFail(error: unknown): { res: ApiFail; status: number } {
113
128
  const message = error instanceof Error ? error.message : String(error)
@@ -137,6 +152,72 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
137
152
  }
138
153
  store.subscribe(broadcast)
139
154
 
155
+ // Workspace git detection, TTL-cached and fail-soft (false on any error):
156
+ // feeds the create-form isolation toggle and the diagnostics panel.
157
+ const gitCache = new Map<string, { value: boolean; at: number }>()
158
+ const gitHinted = new Set<string>()
159
+
160
+ /** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
161
+ const gitignoreMissing = async (path: string): Promise<boolean> => {
162
+ try {
163
+ const { readFile } = await import('node:fs/promises')
164
+ const ignore = await readFile(join(path, '.gitignore'), 'utf8')
165
+ return !ignore.split('\n').some(l => {
166
+ const t = l.trim().replace(/\/+$/, '')
167
+ return t === WORKTREE_DIR || t === `/${WORKTREE_DIR}`
168
+ })
169
+ } catch {
170
+ return true // no .gitignore at all (or unreadable) → suggest creating one
171
+ }
172
+ }
173
+
174
+ const gitAvailable = async (path: string): Promise<boolean> => {
175
+ if (options.git === undefined) return false
176
+ const hit = gitCache.get(path)
177
+ if (hit !== undefined && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value
178
+ let value = false
179
+ try {
180
+ value = await options.git.detect(path)
181
+ } catch { /* fail-soft → false */ }
182
+ gitCache.set(path, { value, at: options.now() })
183
+ // gitignore 建议 (plan §3.2): suggest (never write) ignoring our
184
+ // worktree directory, once per workspace per host run.
185
+ if (value && !gitHinted.has(path)) {
186
+ gitHinted.add(path)
187
+ if (await gitignoreMissing(path)) {
188
+ console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)
189
+ }
190
+ }
191
+ return value
192
+ }
193
+
194
+ /** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
195
+ const listOrphanWorktrees = async (): Promise<Array<{ workspaceId: string; workspacePath: string; taskId: string; path: string }>> => {
196
+ const orphans: Array<{ workspaceId: string; workspacePath: string; taskId: string; path: string }> = []
197
+ const known = new Set(store.snapshot().tasks.map(t => t.id))
198
+ for (const ws of workspaces.list()) {
199
+ let entries: string[] = []
200
+ try {
201
+ const dirents = await readdir(join(ws.path, WORKTREE_DIR), { withFileTypes: true })
202
+ entries = dirents.filter(e => e.isDirectory()).map(e => e.name)
203
+ } catch { /* no worktrees dir → nothing to do */ }
204
+ for (const taskId of entries) {
205
+ if (!known.has(taskId)) orphans.push({ workspaceId: ws.id, workspacePath: ws.path, taskId, path: worktreePathOf(ws.path, taskId) })
206
+ }
207
+ }
208
+ return orphans
209
+ }
210
+
211
+ /** Git-enabled workspaces whose .gitignore does not cover the worktree dir. */
212
+ const listGitignoreSuggestions = async (): Promise<Array<{ workspaceId: string; workspacePath: string }>> => {
213
+ const suggestions: Array<{ workspaceId: string; workspacePath: string }> = []
214
+ for (const ws of workspaces.list()) {
215
+ if (!(await gitAvailable(ws.path))) continue
216
+ if (await gitignoreMissing(ws.path)) suggestions.push({ workspaceId: ws.id, workspacePath: ws.path })
217
+ }
218
+ return suggestions
219
+ }
220
+
140
221
  const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
141
222
  try {
142
223
  const url = new URL(req.url ?? '/', 'http://x')
@@ -150,7 +231,30 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
150
231
  return
151
232
  }
152
233
  if (pathname === `${ROUTE_PREFIX}/workspaces`) {
153
- json(res, { ok: true, value: workspaces.list() })
234
+ const list = workspaces.list()
235
+ const flags = await Promise.all(list.map(ws => gitAvailable(ws.path)))
236
+ json(res, {
237
+ ok: true,
238
+ value: list.map((ws, i) => ({ ...ws, sessionCount: 0, gitAvailable: flags[i] })),
239
+ })
240
+ return
241
+ }
242
+ if (pathname === `${ROUTE_PREFIX}/diagnostics`) {
243
+ const ledger = store.snapshot()
244
+ let staleRunning = 0
245
+ for (const t of ledger.tasks) {
246
+ for (const e of t.executions) if (e.outcome === 'running') staleRunning += 1
247
+ }
248
+ json(res, {
249
+ ok: true,
250
+ value: {
251
+ revision: ledger.revision,
252
+ tasks: ledger.tasks.length,
253
+ staleRunning,
254
+ orphanWorktrees: await listOrphanWorktrees(),
255
+ gitIgnoreSuggestions: await listGitignoreSuggestions(),
256
+ },
257
+ })
154
258
  return
155
259
  }
156
260
  const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))
@@ -194,6 +298,9 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
194
298
  const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)
195
299
  const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
196
300
  const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
301
+ const isolationRaw = str(body, 'isolation')
302
+ const isolation = isolationRaw === null ? undefined : asIsolation(isolationRaw)
303
+ const presetId = normalizePresetId(str(body, 'presetId'))
197
304
  const now = options.now()
198
305
  const task: TaskRecord = {
199
306
  id: newTaskId(),
@@ -206,6 +313,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
206
313
  blocked: false,
207
314
  execution,
208
315
  model,
316
+ ...(isolation !== undefined ? { isolation } : {}),
317
+ ...(presetId !== undefined ? { presetId } : {}),
209
318
  version: 1,
210
319
  createdAt: now,
211
320
  updatedAt: now,
@@ -227,7 +336,9 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
227
336
  }
228
337
 
229
338
  // ------------------------------------------- POST /tasks/:id/{action}
230
- const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\w+)$`))
339
+ // (\w+ after the id would not match hyphenated actions like
340
+ // worktree-remove, hence the explicit class.)
341
+ const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`))
231
342
  if (actionMatch !== null) {
232
343
  const id = actionMatch[1]!
233
344
  const action = actionMatch[2]!
@@ -258,6 +369,18 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
258
369
  if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
259
370
  if (body.model === null) next.model = undefined
260
371
  else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
372
+ // Isolation may change only before the first execution (分支与基线
373
+ // 取决于该选择 — plan §3.1: 执行开始后锁定).
374
+ const isolationRaw = str(body, 'isolation')
375
+ if (isolationRaw !== null) {
376
+ if (task.executions.length > 0 || task.status === 'in_progress') {
377
+ throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')
378
+ }
379
+ next.isolation = asIsolation(isolationRaw)
380
+ }
381
+ // Preset may change any time: each run composes fresh.
382
+ if (body.presetId === null) delete next.presetId
383
+ else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
261
384
  next.version = task.version + 1
262
385
  next.updatedAt = options.now()
263
386
  next.updatedBy = { kind: 'user' }
@@ -336,6 +459,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
336
459
  const purge = body.purge === true
337
460
  if (purge) {
338
461
  if (task.trashedAt === undefined) throw new Error('Error: invalid_input: purge requires a trashed task (soft-delete first)')
462
+ // Worktree safety before purge (plan §3.3, 0.3.1): refuse while
463
+ // uncommitted work remains; otherwise clean the worktree and
464
+ // the task branch along with the ledger entry.
465
+ if (options.git !== undefined) {
466
+ const ws = workspaces.get(task.workspaceId)
467
+ if (ws !== undefined) {
468
+ const path = worktreePathOf(ws.path, id)
469
+ try {
470
+ await options.git.removeWorktree(ws.path, path)
471
+ } catch (error) {
472
+ const message = error instanceof Error ? error.message : String(error)
473
+ if (message.includes('未提交修改')) {
474
+ throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
475
+ }
476
+ if (/not a working tree|not a working-tree/i.test(message)) {
477
+ // An unregistered leftover dir: plain fs removal.
478
+ await rm(path, { recursive: true, force: true })
479
+ } else {
480
+ throw new Error(`Error: invalid_input: ${message}`)
481
+ }
482
+ }
483
+ if (task.branch !== undefined) {
484
+ try {
485
+ await options.git.deleteBranch(ws.path, task.branch)
486
+ } catch { /* best effort: the branch may outlive the task */ }
487
+ }
488
+ }
489
+ }
339
490
  await store.mutate('task-deleted', ledger => {
340
491
  ledger.tasks = ledger.tasks.filter(t => t.id !== id)
341
492
  return []
@@ -363,7 +514,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
363
514
  json(res, f.res, 501)
364
515
  return
365
516
  }
366
- const result = await options.run(id)
517
+ // `reuse: true` = 续跑: keep a live worktree/branch as-is instead
518
+ // of resetting to a fresh baseline (0.3.1).
519
+ const runOptions = body.reuse === true ? { reuseWorktree: true } : undefined
520
+ const result = await options.run(id, runOptions)
367
521
  if (result.ok) json(res, { ok: true, value: result }, 202)
368
522
  else {
369
523
  const f = fail('invalid_input', result.error)
@@ -385,6 +539,78 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
385
539
  }
386
540
  return
387
541
  }
542
+ if (action === 'merge') {
543
+ // ⇥ 合并 (detail page, user-only): merge the task branch into the
544
+ // main worktree with --no-ff; conflicts are reported verbatim.
545
+ if (options.git === undefined) {
546
+ const f = fail('invalid_input', 'git integration unavailable')
547
+ json(res, f.res, 501)
548
+ return
549
+ }
550
+ if (task.branch === undefined) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
551
+ if (task.status === 'in_progress') throw new Error('Error: invalid_input: 任务执行中,不能合并')
552
+ if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能合并')
553
+ const ws = workspaces.get(task.workspaceId)
554
+ if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
555
+ // No-op detection (0.3.1): a branch with no commits over HEAD
556
+ // merges as "already up to date" — report that instead of landing
557
+ // a bogus 已合并 comment.
558
+ let noop = false
559
+ try {
560
+ noop = await options.git.isAncestor(ws.path, task.branch)
561
+ } catch { /* fail-soft: proceed to the real merge */ }
562
+ if (noop) {
563
+ json(res, { ok: true, value: { merged: false, noop: true, branch: task.branch } })
564
+ return
565
+ }
566
+ try {
567
+ await options.git.merge(ws.path, task.branch)
568
+ } catch (error) {
569
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
570
+ }
571
+ const mergedComment = { id: newCommentId(), body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`), version: 1, createdAt: options.now() }
572
+ const next = structuredClone(task)
573
+ next.comments.push(mergedComment)
574
+ next.version = task.version + 1
575
+ next.updatedAt = options.now()
576
+ await store.mutate('comment-added', ledger => {
577
+ const i = ledger.tasks.findIndex(t => t.id === id)
578
+ ledger.tasks[i] = next
579
+ return [next]
580
+ })
581
+ json(res, { ok: true, value: { merged: true, branch: task.branch } })
582
+ return
583
+ }
584
+ if (action === 'worktree-remove') {
585
+ // 🗑 删除 worktree (detail page): refuses uncommitted changes;
586
+ // optionally deletes the task branch after the worktree is gone.
587
+ if (options.git === undefined) {
588
+ const f = fail('invalid_input', 'git integration unavailable')
589
+ json(res, f.res, 501)
590
+ return
591
+ }
592
+ if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能删除 worktree')
593
+ const ws = workspaces.get(task.workspaceId)
594
+ if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
595
+ const path = worktreePathOf(ws.path, id)
596
+ try {
597
+ await options.git.removeWorktree(ws.path, path)
598
+ } catch (error) {
599
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
600
+ }
601
+ let branchDeleted = false
602
+ let branchError: string | undefined
603
+ if (body.deleteBranch === true && task.branch !== undefined) {
604
+ try {
605
+ await options.git.deleteBranch(ws.path, task.branch)
606
+ branchDeleted = true
607
+ } catch (error) {
608
+ branchError = error instanceof Error ? error.message : String(error)
609
+ }
610
+ }
611
+ json(res, { ok: true, value: { removed: true, branchDeleted, ...(branchError !== undefined ? { branchError } : {}) } })
612
+ return
613
+ }
388
614
  const f = fail('not_found', `unknown action ${action}`)
389
615
  json(res, f.res, f.status)
390
616
  } catch (error) {
@@ -394,6 +620,43 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
394
620
  return
395
621
  }
396
622
 
623
+ // -------------------------------------- POST /worktree-cleanup (⚙ 诊断)
624
+ if (pathname === `${ROUTE_PREFIX}/worktree-cleanup`) {
625
+ try {
626
+ if (options.git === undefined) {
627
+ const f = fail('invalid_input', 'git integration unavailable')
628
+ json(res, f.res, 501)
629
+ return
630
+ }
631
+ const workspaceId = str(body, 'workspaceId') ?? ''
632
+ const taskId = str(body, 'taskId') ?? ''
633
+ const ws = workspaces.get(workspaceId)
634
+ if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
635
+ // Only dirs owned by NO ledger task may be cleaned here; live tasks
636
+ // remove their worktree from the detail page.
637
+ if (store.get(taskId) !== undefined) throw new Error('Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree')
638
+ const path = worktreePathOf(ws.path, taskId)
639
+ try {
640
+ await options.git.removeWorktree(ws.path, path)
641
+ } catch (error) {
642
+ const message = error instanceof Error ? error.message : String(error)
643
+ // An unregistered leftover (git no longer knows this worktree):
644
+ // fall back to direct fs removal — the dir lives inside the
645
+ // plugin's own .dsh-worktrees scope.
646
+ if (/not a working tree|not a working-tree/i.test(message)) {
647
+ await rm(path, { recursive: true, force: true })
648
+ } else {
649
+ throw new Error(`Error: invalid_input: ${message}`)
650
+ }
651
+ }
652
+ json(res, { ok: true, value: { cleaned: true, path } })
653
+ } catch (error) {
654
+ const f = toFail(error)
655
+ json(res, f.res, f.status)
656
+ }
657
+ return
658
+ }
659
+
397
660
  res.writeHead(404)
398
661
  res.end()
399
662
  } catch (error) {