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.
- package/README.md +50 -4
- package/lib/client.js +818 -77
- package/lib/host/execution.js +194 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +234 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/routes.js +282 -5
- package/lib/host/routes.js.map +1 -1
- package/lib/host/tools.js +16 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +17 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +10 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +23 -3
- package/src/client/board/TaskBoard.tsx +73 -0
- package/src/client/board/TaskCard.tsx +73 -5
- package/src/client/board/TaskDetail.tsx +173 -1
- package/src/client/board/TaskFormModal.tsx +100 -1
- package/src/client/controller.ts +108 -6
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +60 -0
- package/src/host/execution.ts +291 -65
- package/src/host/git.ts +293 -0
- package/src/host/routes.ts +294 -6
- package/src/host/tools.ts +17 -0
- package/src/index.ts +24 -1
- package/src/shared/api.ts +42 -3
- package/src/shared/protocol.ts +64 -0
- package/src/shared/version.ts +1 -1
package/src/host/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
81
|
-
export type RunTaskBody =
|
|
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
|