dsh-taskboard 0.2.1 → 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.
@@ -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. */
@@ -74,7 +83,7 @@ function json(res: ServerResponse, payload: ApiResult<unknown>, status = 200): v
74
83
 
75
84
  /** Domain failure → envelope + HTTP status. */
76
85
  function fail(code: ApiFail['error']['code'], message: string): { res: ApiFail; status: number } {
77
- const status = code === 'invalid_input' ? 400
86
+ const status = code === 'invalid_input' || code === 'invalid_transition' ? 400
78
87
  : code === 'not_found' ? 404
79
88
  : code === 'version_conflict' ? 409
80
89
  : code === 'forbidden' ? 403
@@ -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' }
@@ -292,6 +415,31 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
292
415
  json(res, { ok: true, value: summarize(next) })
293
416
  return
294
417
  }
418
+ if (action === 'reject') {
419
+ // Card quick-reject: back to todo + optional user comment in one
420
+ // atomic mutation (a failed move never strands an orphan comment).
421
+ const ifVersion = num(body, 'ifVersion')
422
+ if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
423
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
424
+ if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
425
+ const next = structuredClone(task)
426
+ next.status = 'todo'
427
+ next.version = task.version + 1
428
+ next.updatedAt = options.now()
429
+ next.updatedBy = { kind: 'user' }
430
+ syncClaim(next, 'todo', options.now())
431
+ const commentText = str(body, 'body') ?? ''
432
+ if (commentText.trim().length > 0) {
433
+ next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })
434
+ }
435
+ await store.mutate('task-moved', ledger => {
436
+ const i = ledger.tasks.findIndex(t => t.id === id)
437
+ ledger.tasks[i] = next
438
+ return [next]
439
+ })
440
+ json(res, { ok: true, value: summarize(next) })
441
+ return
442
+ }
295
443
  if (action === 'comment') {
296
444
  const bodyText = str(body, 'body') ?? ''
297
445
  const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }
@@ -311,6 +459,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
311
459
  const purge = body.purge === true
312
460
  if (purge) {
313
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
+ }
314
490
  await store.mutate('task-deleted', ledger => {
315
491
  ledger.tasks = ledger.tasks.filter(t => t.id !== id)
316
492
  return []
@@ -338,7 +514,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
338
514
  json(res, f.res, 501)
339
515
  return
340
516
  }
341
- 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)
342
521
  if (result.ok) json(res, { ok: true, value: result }, 202)
343
522
  else {
344
523
  const f = fail('invalid_input', result.error)
@@ -360,6 +539,78 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
360
539
  }
361
540
  return
362
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
+ }
363
614
  const f = fail('not_found', `unknown action ${action}`)
364
615
  json(res, f.res, f.status)
365
616
  } catch (error) {
@@ -369,6 +620,43 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
369
620
  return
370
621
  }
371
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
+
372
660
  res.writeHead(404)
373
661
  res.end()
374
662
  } catch (error) {
package/src/host/tools.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import type { WorkspaceRegistry } from '@deepseek-ai/dsh-workspace'
22
22
  import { defineTool } from './sdk.ts'
23
23
  import {
24
+ asIsolation,
24
25
  asStatus,
25
26
  asUrgency,
26
27
  canTransition,
@@ -74,11 +75,13 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
74
75
  `任务 ${t.id} 「${t.title}」`,
75
76
  `状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
76
77
  `执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
78
+ `隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}`,
77
79
  ]
78
80
  const holder = isClaimedBy(t)
79
81
  if (holder !== undefined) lines.push(`认领: agent ${String(holder).slice(0, 24)}(持有期间其他会话不可移动)`)
80
82
  if (t.execution.nextRunAt !== undefined) lines.push(`下次触发: ${new Date(t.execution.nextRunAt).toISOString()}`)
81
83
  if (t.model !== undefined) lines.push(`固定模型: ${t.model.provider}/${t.model.model}`)
84
+ if (t.presetId !== undefined) lines.push(`执行模式: ${t.presetId}(未指定时为部署默认 preset)`)
82
85
  lines.push(`描述: ${t.description.length > 0 ? t.description : '(无)'}`)
83
86
  lines.push(`执行 Prompt: ${t.effectivePrompt ?? effectivePrompt(t)}`)
84
87
  if (t.comments.length > 0) {
@@ -348,6 +351,14 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
348
351
  model: { type: 'string', description: 'Provider-owned model id.' },
349
352
  },
350
353
  },
354
+ isolation: {
355
+ type: 'string',
356
+ description: 'Code isolation for executions: "worktree" (default — each run gets a fresh git worktree on branch task/<标题>+<taskId>) or "none" (run in the project directory, zero git interaction).',
357
+ },
358
+ presetId: {
359
+ type: 'string',
360
+ description: 'Agent preset the execution session is composed from (its tool set / persona); default = the deployment default preset. Optional.',
361
+ },
351
362
  },
352
363
  output: {
353
364
  schema: JSON_OUT,
@@ -366,6 +377,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
366
377
  prompt?: string
367
378
  execution?: { mode?: string; cron?: string }
368
379
  model?: { provider?: string; model?: string }
380
+ isolation?: string
381
+ presetId?: string
369
382
  }, exec: unknown) {
370
383
  try {
371
384
  const { actor } = caller(exec as ToolRunContext)
@@ -380,6 +393,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
380
393
  }
381
394
  const execution = normalizeExecution(args.execution ?? {}, deps.now())
382
395
  const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
396
+ const isolation = args.isolation === undefined ? undefined : asIsolation(args.isolation)
397
+ const presetId = args.presetId?.trim() || undefined
383
398
  const now = deps.now()
384
399
  const task: TaskRecord = {
385
400
  id: newTaskId(),
@@ -392,6 +407,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
392
407
  blocked: false,
393
408
  execution,
394
409
  model,
410
+ ...(isolation !== undefined ? { isolation } : {}),
411
+ ...(presetId !== undefined ? { presetId } : {}),
395
412
  version: 1,
396
413
  createdAt: now,
397
414
  updatedAt: now,
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
21
21
  import type {} from '@deepseek-ai/dsh-agent'
22
22
  import { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'
23
23
  import { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'
24
+ import { createGitFace } from './host/git.ts'
24
25
  import { registerTaskboardRoutes } from './host/routes.ts'
25
26
  import { SchedulerService } from './host/scheduler.ts'
26
27
  import { dshHomePath } from './host/sdk.ts'
@@ -85,6 +86,10 @@ export function apply(ctx: Context): void {
85
86
  }),
86
87
  }
87
88
 
89
+ // The narrow git face shared by execution (worktree isolation) and the
90
+ // routes (merge / remove / workspace detection).
91
+ const git = createGitFace()
92
+
88
93
  wsCtx.inject(['agents'], (agentCtx: Context) => {
89
94
  const execution = new ExecutionService({
90
95
  store,
@@ -100,6 +105,23 @@ export function apply(ctx: Context): void {
100
105
  },
101
106
  events,
102
107
  now,
108
+ git,
109
+ // Preset composition (0.3.3): mirror apiproxy's composeAgent — resolve
110
+ // the id BEFORE creation (the session header snapshots meta), mount
111
+ // inside the factory's setup callback. No roster service → undefined
112
+ // (bare host composition, the pre-preset behavior).
113
+ composeAgent: async (presetId) => {
114
+ const presets = agentCtx.get('agentPresets') as {
115
+ resolve(id?: string): Promise<{ id: string }>
116
+ mount(agentCtx: unknown, id?: string): Promise<unknown>
117
+ } | undefined
118
+ if (presets === undefined) return undefined
119
+ const resolved = await presets.resolve(presetId)
120
+ return {
121
+ agentPreset: resolved.id,
122
+ setup: async (ctx: unknown) => { await presets.mount(ctx, resolved.id) },
123
+ }
124
+ },
103
125
  renameSession: (sessionId, title) => {
104
126
  // Best-effort: pin the execution session's title to the task title
105
127
  // through the log-backed session-title service (user-sourced rename).
@@ -127,9 +149,10 @@ export function apply(ctx: Context): void {
127
149
  store,
128
150
  workspaces: workspaceFace(wsCtx.workspaceRegistry),
129
151
  now,
130
- run: (taskId: string) => execution.run(taskId, 'manual'),
152
+ run: (taskId: string, runOptions?: { reuseWorktree?: boolean }) => execution.run(taskId, 'manual', runOptions),
131
153
  cancel: (taskId: string) => execution.cancel(taskId),
132
154
  modelProviders,
155
+ git,
133
156
  })
134
157
  return () => disposeRoutes?.()
135
158
  })
package/src/shared/api.ts CHANGED
@@ -41,7 +41,7 @@ export type ApiResult<T> = ApiOk<T> | ApiFail
41
41
  export type StateResponse = TaskLedger
42
42
 
43
43
  /** Workspace listing for the UI pickers. */
44
- export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number }
44
+ export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean }
45
45
 
46
46
  /** Create-task request body (actor is always the GUI user). */
47
47
  export type CreateTaskBody = {
@@ -52,6 +52,10 @@ export type CreateTaskBody = {
52
52
  prompt?: string
53
53
  execution?: { mode?: string; cron?: string }
54
54
  model?: { provider: string; model: string }
55
+ /** Code isolation for executions ('worktree' | 'none'); omitted = default. */
56
+ isolation?: string
57
+ /** Agent preset for execution sessions; omitted = deployment default. */
58
+ presetId?: string
55
59
  }
56
60
 
57
61
  /** Update-task request body (ifVersion mandatory). */
@@ -66,19 +70,54 @@ export type UpdateTaskBody = {
66
70
  workspaceId?: string
67
71
  execution?: { mode?: string; cron?: string }
68
72
  model?: { provider: string; model: string } | null
73
+ /** Change isolation; locked once the task has execution history. */
74
+ isolation?: string
75
+ /** Change the execution preset (takes effect on the next run). */
76
+ presetId?: string | null
69
77
  }
70
78
 
71
79
  /** Move-task request body (ifVersion mandatory; the user MAY move to done). */
72
80
  export type MoveTaskBody = { ifVersion: number; status: string }
73
81
 
82
+ /**
83
+ * Quick-reject request body (card ✗ button): move back to todo plus an
84
+ * optional user comment, committed as ONE ledger mutation so a failed move
85
+ * can never strand an orphan comment.
86
+ */
87
+ export type RejectTaskBody = { ifVersion: number; body?: string }
88
+
74
89
  /** Comment request body. */
75
90
  export type CommentBody = { body: string }
76
91
 
77
92
  /** Delete request body (purge=true physically removes a trashed task). */
78
93
  export type DeleteTaskBody = { ifVersion?: number; purge?: boolean }
79
94
 
80
- /** Run request body (P3). */
81
- export type RunTaskBody = Record<string, never>
95
+ /** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */
96
+ export type RunTaskBody = { reuse?: boolean }
97
+
98
+ /** Merge outcome: `noop: true` = the branch had no commits over HEAD (nothing merged). */
99
+ export type MergeBranchResponse = { merged: boolean; noop?: boolean; branch: string }
100
+
101
+ /** Remove a task's worktree; optionally delete its branch too. */
102
+ export type WorktreeRemoveBody = { deleteBranch?: boolean }
103
+
104
+ /** One orphan worktree directory (exists on disk, owned by no live task). */
105
+ export type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }
106
+
107
+ /** A git-enabled workspace whose .gitignore does not cover the worktree dir. */
108
+ export type GitignoreSuggestion = { workspaceId: string; workspacePath: string }
109
+
110
+ /** Health-diagnostics response (⚙ panel). */
111
+ export type DiagnosticsResponse = {
112
+ revision: number
113
+ tasks: number
114
+ /** Executions currently marked `running`. */
115
+ staleRunning: number
116
+ /** Worktree directories whose task no longer exists in the ledger. */
117
+ orphanWorktrees: OrphanWorktree[]
118
+ /** Git workspaces whose .gitignore does not ignore the worktree dir. */
119
+ gitIgnoreSuggestions: GitignoreSuggestion[]
120
+ }
82
121
 
83
122
  /** One task (full record) response. */
84
123
  export type TaskResponse = TaskRecord