dsh-taskboard 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -6
- package/lib/client.js +1839 -74
- package/lib/host/execution.js +199 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +327 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +435 -5
- package/lib/host/routes.js.map +1 -1
- package/lib/host/store.js +12 -0
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +166 -0
- package/lib/host/templates.js.map +1 -0
- package/lib/host/tools.js +217 -3
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +23 -3
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +286 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +47 -3
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +118 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +360 -4
- package/src/client/board/TaskFormModal.tsx +193 -12
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +238 -11
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +198 -0
- package/src/host/execution.ts +301 -67
- package/src/host/git.ts +370 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +483 -5
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +215 -2
- package/src/index.ts +30 -1
- package/src/shared/api.ts +89 -3
- package/src/shared/protocol.ts +408 -0
- package/src/shared/version.ts +1 -1
package/src/host/routes.ts
CHANGED
|
@@ -10,25 +10,34 @@
|
|
|
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,
|
|
23
|
+
checklistFromTexts,
|
|
20
24
|
newCommentId,
|
|
21
25
|
newTaskId,
|
|
22
26
|
normalizeBody,
|
|
27
|
+
normalizeChecklist,
|
|
23
28
|
normalizeExecution,
|
|
24
29
|
normalizeModel,
|
|
25
30
|
normalizePrompt,
|
|
26
31
|
normalizeTitle,
|
|
27
32
|
summarize,
|
|
28
33
|
syncClaim,
|
|
34
|
+
validateLedgerImport,
|
|
29
35
|
type TaskModel,
|
|
30
36
|
type TaskRecord,
|
|
31
37
|
} from '../shared/protocol.ts'
|
|
38
|
+
import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
|
|
39
|
+
import type { TaskTemplate } from '../shared/api.ts'
|
|
40
|
+
import type { TemplateStore } from './templates.ts'
|
|
32
41
|
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
33
42
|
import type { TaskStore } from './store.ts'
|
|
34
43
|
import type { WorkspaceFace } from './tools.ts'
|
|
@@ -36,6 +45,9 @@ import type { WorkspaceFace } from './tools.ts'
|
|
|
36
45
|
/** Heartbeat cadence for the SSE stream. */
|
|
37
46
|
const HEARTBEAT_MS = 20_000
|
|
38
47
|
|
|
48
|
+
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
49
|
+
const GIT_DETECT_TTL_MS = 60_000
|
|
50
|
+
|
|
39
51
|
/** The workspaces face routes need (same narrow shape as tools). */
|
|
40
52
|
export type RoutesWorkspaceFace = WorkspaceFace
|
|
41
53
|
|
|
@@ -44,8 +56,8 @@ export interface TaskboardRoutesOptions {
|
|
|
44
56
|
store: TaskStore
|
|
45
57
|
workspaces: RoutesWorkspaceFace
|
|
46
58
|
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 }>
|
|
59
|
+
/** Manual-run hook (the execution service); absent → 501. Options carry `reuseWorktree` (续跑). */
|
|
60
|
+
run?: (taskId: string, options?: { reuseWorktree?: boolean }) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>
|
|
49
61
|
/** Cancel hook (the execution service); absent → 501. */
|
|
50
62
|
cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>
|
|
51
63
|
/**
|
|
@@ -53,6 +65,47 @@ export interface TaskboardRoutesOptions {
|
|
|
53
65
|
* advisory validation of pinned models; undefined = runtime unavailable.
|
|
54
66
|
*/
|
|
55
67
|
modelProviders?: () => string[] | undefined
|
|
68
|
+
/** Git face for worktree actions + workspace git detection; absent → 501 on git actions. */
|
|
69
|
+
git?: GitFace
|
|
70
|
+
/** Task-template store (0.4.0); absent → 501 on template actions. */
|
|
71
|
+
templates?: TemplateStore
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Validate a template's task spec (routes-side, unknown → invalid_input). */
|
|
75
|
+
function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
|
|
76
|
+
if (typeof raw !== 'object' || raw === null) throw new Error('Error: invalid_input: task must be an object')
|
|
77
|
+
const e = raw as Record<string, unknown>
|
|
78
|
+
const spec: TaskTemplate['task'] = {}
|
|
79
|
+
const str = (key: string): string | undefined => {
|
|
80
|
+
const v = e[key]
|
|
81
|
+
if (v === undefined) return undefined
|
|
82
|
+
if (typeof v !== 'string') throw new Error(`Error: invalid_input: task.${key} must be a string`)
|
|
83
|
+
return v
|
|
84
|
+
}
|
|
85
|
+
const title = str('title')
|
|
86
|
+
const description = str('description')
|
|
87
|
+
const prompt = str('prompt')
|
|
88
|
+
const urgency = str('urgency')
|
|
89
|
+
const isolation = str('isolation')
|
|
90
|
+
const presetId = str('presetId')
|
|
91
|
+
if (title !== undefined) spec.title = normalizeTitle(title)
|
|
92
|
+
if (description !== undefined) spec.description = description
|
|
93
|
+
if (prompt !== undefined) spec.prompt = normalizePrompt(prompt)
|
|
94
|
+
if (urgency !== undefined) spec.urgency = asUrgency(urgency)
|
|
95
|
+
if (isolation !== undefined) spec.isolation = asIsolation(isolation)
|
|
96
|
+
if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()
|
|
97
|
+
if (e.execution !== undefined) {
|
|
98
|
+
spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, Date.now())
|
|
99
|
+
}
|
|
100
|
+
if (e.model !== undefined) spec.model = normalizeModel(e.model)
|
|
101
|
+
if (e.checklist !== undefined) {
|
|
102
|
+
if (!Array.isArray(e.checklist) || e.checklist.some(c => typeof c !== 'string')) {
|
|
103
|
+
throw new Error('Error: invalid_input: task.checklist must be an array of strings')
|
|
104
|
+
}
|
|
105
|
+
checklistFromTexts(e.checklist as string[]) // validates count + texts
|
|
106
|
+
spec.checklist = e.checklist as string[]
|
|
107
|
+
}
|
|
108
|
+
return spec
|
|
56
109
|
}
|
|
57
110
|
|
|
58
111
|
/** Validate a pinned model: structural check always, provider route when known. */
|
|
@@ -108,6 +161,12 @@ function num(body: Record<string, unknown>, key: string): number | undefined | n
|
|
|
108
161
|
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
109
162
|
}
|
|
110
163
|
|
|
164
|
+
/** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
|
|
165
|
+
function normalizePresetId(raw: string | null): string | undefined {
|
|
166
|
+
const t = (raw ?? '').trim()
|
|
167
|
+
return t.length === 0 ? undefined : t
|
|
168
|
+
}
|
|
169
|
+
|
|
111
170
|
/** Map a thrown domain error to the envelope. */
|
|
112
171
|
function toFail(error: unknown): { res: ApiFail; status: number } {
|
|
113
172
|
const message = error instanceof Error ? error.message : String(error)
|
|
@@ -137,6 +196,72 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
137
196
|
}
|
|
138
197
|
store.subscribe(broadcast)
|
|
139
198
|
|
|
199
|
+
// Workspace git detection, TTL-cached and fail-soft (false on any error):
|
|
200
|
+
// feeds the create-form isolation toggle and the diagnostics panel.
|
|
201
|
+
const gitCache = new Map<string, { value: boolean; at: number }>()
|
|
202
|
+
const gitHinted = new Set<string>()
|
|
203
|
+
|
|
204
|
+
/** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
|
|
205
|
+
const gitignoreMissing = async (path: string): Promise<boolean> => {
|
|
206
|
+
try {
|
|
207
|
+
const { readFile } = await import('node:fs/promises')
|
|
208
|
+
const ignore = await readFile(join(path, '.gitignore'), 'utf8')
|
|
209
|
+
return !ignore.split('\n').some(l => {
|
|
210
|
+
const t = l.trim().replace(/\/+$/, '')
|
|
211
|
+
return t === WORKTREE_DIR || t === `/${WORKTREE_DIR}`
|
|
212
|
+
})
|
|
213
|
+
} catch {
|
|
214
|
+
return true // no .gitignore at all (or unreadable) → suggest creating one
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const gitAvailable = async (path: string): Promise<boolean> => {
|
|
219
|
+
if (options.git === undefined) return false
|
|
220
|
+
const hit = gitCache.get(path)
|
|
221
|
+
if (hit !== undefined && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value
|
|
222
|
+
let value = false
|
|
223
|
+
try {
|
|
224
|
+
value = await options.git.detect(path)
|
|
225
|
+
} catch { /* fail-soft → false */ }
|
|
226
|
+
gitCache.set(path, { value, at: options.now() })
|
|
227
|
+
// gitignore 建议 (plan §3.2): suggest (never write) ignoring our
|
|
228
|
+
// worktree directory, once per workspace per host run.
|
|
229
|
+
if (value && !gitHinted.has(path)) {
|
|
230
|
+
gitHinted.add(path)
|
|
231
|
+
if (await gitignoreMissing(path)) {
|
|
232
|
+
console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return value
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
|
|
239
|
+
const listOrphanWorktrees = async (): Promise<Array<{ workspaceId: string; workspacePath: string; taskId: string; path: string }>> => {
|
|
240
|
+
const orphans: Array<{ workspaceId: string; workspacePath: string; taskId: string; path: string }> = []
|
|
241
|
+
const known = new Set(store.snapshot().tasks.map(t => t.id))
|
|
242
|
+
for (const ws of workspaces.list()) {
|
|
243
|
+
let entries: string[] = []
|
|
244
|
+
try {
|
|
245
|
+
const dirents = await readdir(join(ws.path, WORKTREE_DIR), { withFileTypes: true })
|
|
246
|
+
entries = dirents.filter(e => e.isDirectory()).map(e => e.name)
|
|
247
|
+
} catch { /* no worktrees dir → nothing to do */ }
|
|
248
|
+
for (const taskId of entries) {
|
|
249
|
+
if (!known.has(taskId)) orphans.push({ workspaceId: ws.id, workspacePath: ws.path, taskId, path: worktreePathOf(ws.path, taskId) })
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return orphans
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Git-enabled workspaces whose .gitignore does not cover the worktree dir. */
|
|
256
|
+
const listGitignoreSuggestions = async (): Promise<Array<{ workspaceId: string; workspacePath: string }>> => {
|
|
257
|
+
const suggestions: Array<{ workspaceId: string; workspacePath: string }> = []
|
|
258
|
+
for (const ws of workspaces.list()) {
|
|
259
|
+
if (!(await gitAvailable(ws.path))) continue
|
|
260
|
+
if (await gitignoreMissing(ws.path)) suggestions.push({ workspaceId: ws.id, workspacePath: ws.path })
|
|
261
|
+
}
|
|
262
|
+
return suggestions
|
|
263
|
+
}
|
|
264
|
+
|
|
140
265
|
const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
141
266
|
try {
|
|
142
267
|
const url = new URL(req.url ?? '/', 'http://x')
|
|
@@ -150,9 +275,86 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
150
275
|
return
|
|
151
276
|
}
|
|
152
277
|
if (pathname === `${ROUTE_PREFIX}/workspaces`) {
|
|
153
|
-
|
|
278
|
+
const list = workspaces.list()
|
|
279
|
+
const flags = await Promise.all(list.map(ws => gitAvailable(ws.path)))
|
|
280
|
+
json(res, {
|
|
281
|
+
ok: true,
|
|
282
|
+
value: list.map((ws, i) => ({ ...ws, sessionCount: 0, gitAvailable: flags[i] })),
|
|
283
|
+
})
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
if (pathname === `${ROUTE_PREFIX}/diagnostics`) {
|
|
287
|
+
const ledger = store.snapshot()
|
|
288
|
+
let staleRunning = 0
|
|
289
|
+
for (const t of ledger.tasks) {
|
|
290
|
+
for (const e of t.executions) if (e.outcome === 'running') staleRunning += 1
|
|
291
|
+
}
|
|
292
|
+
json(res, {
|
|
293
|
+
ok: true,
|
|
294
|
+
value: {
|
|
295
|
+
revision: ledger.revision,
|
|
296
|
+
tasks: ledger.tasks.length,
|
|
297
|
+
staleRunning,
|
|
298
|
+
orphanWorktrees: await listOrphanWorktrees(),
|
|
299
|
+
gitIgnoreSuggestions: await listGitignoreSuggestions(),
|
|
300
|
+
},
|
|
301
|
+
})
|
|
154
302
|
return
|
|
155
303
|
}
|
|
304
|
+
// Diff viewer (0.4.0): read-only git show/diff for one execution's
|
|
305
|
+
// commit or changed path. Prefers the live worktree (uncommitted
|
|
306
|
+
// view), falls back to the main repo.
|
|
307
|
+
const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`))
|
|
308
|
+
if (diffMatch !== null) {
|
|
309
|
+
try {
|
|
310
|
+
if (options.git === undefined) {
|
|
311
|
+
const f = fail('invalid_input', 'git integration unavailable')
|
|
312
|
+
json(res, f.res, 501)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
const task = store.get(diffMatch[1]!)
|
|
316
|
+
if (task === undefined) throw new Error('Error: not_found: no such task')
|
|
317
|
+
const execution = task.executions.find(e => e.id === url.searchParams.get('execution'))
|
|
318
|
+
if (execution === undefined) throw new Error('Error: not_found: no such execution')
|
|
319
|
+
const commit = url.searchParams.get('commit')
|
|
320
|
+
const filePath = url.searchParams.get('path')
|
|
321
|
+
const ws = workspaces.get(task.workspaceId)
|
|
322
|
+
if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
323
|
+
const cwd = execution.worktreePath ?? ws.path
|
|
324
|
+
let result = commit !== null
|
|
325
|
+
? await options.git.showCommit(cwd, commit)
|
|
326
|
+
: filePath !== null ? await options.git.showPathDiff(cwd, filePath, execution.baseCommit) : undefined
|
|
327
|
+
// Fallback: the worktree may be gone — commits and committed
|
|
328
|
+
// ranges still resolve in the main repo.
|
|
329
|
+
if (result === undefined && execution.worktreePath !== undefined && cwd !== ws.path) {
|
|
330
|
+
result = commit !== null
|
|
331
|
+
? await options.git.showCommit(ws.path, commit)
|
|
332
|
+
: filePath !== null && execution.baseCommit !== undefined
|
|
333
|
+
? await options.git.showPathDiff(ws.path, filePath, execution.baseCommit)
|
|
334
|
+
: undefined
|
|
335
|
+
}
|
|
336
|
+
if (result === undefined) {
|
|
337
|
+
throw new Error('Error: invalid_input: 无法获取 diff(git 报错、对象不存在,或仅存于已删除的 worktree 且无基线)')
|
|
338
|
+
}
|
|
339
|
+
json(res, { ok: true, value: { diff: result.text, truncated: result.truncated } })
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const f = toFail(error)
|
|
342
|
+
json(res, f.res, f.status)
|
|
343
|
+
}
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Templates listing (0.4.0).
|
|
348
|
+
if (pathname === `${ROUTE_PREFIX}/templates`) {
|
|
349
|
+
if (options.templates === undefined) {
|
|
350
|
+
const f = fail('invalid_input', 'template store unavailable')
|
|
351
|
+
json(res, f.res, 501)
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
json(res, { ok: true, value: { templates: await options.templates.list() } })
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
156
358
|
const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))
|
|
157
359
|
if (taskMatch !== null) {
|
|
158
360
|
const task = store.get(taskMatch[1]!)
|
|
@@ -194,6 +396,17 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
194
396
|
const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)
|
|
195
397
|
const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
|
|
196
398
|
const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
|
|
399
|
+
const isolationRaw = str(body, 'isolation')
|
|
400
|
+
const isolation = isolationRaw === null ? undefined : asIsolation(isolationRaw)
|
|
401
|
+
const presetId = normalizePresetId(str(body, 'presetId'))
|
|
402
|
+
let checklist: TaskRecord['checklist'] = undefined
|
|
403
|
+
if (body.checklist !== undefined) {
|
|
404
|
+
if (!Array.isArray(body.checklist) || body.checklist.some(c => typeof c !== 'string')) {
|
|
405
|
+
throw new Error('Error: invalid_input: checklist must be an array of strings')
|
|
406
|
+
}
|
|
407
|
+
const texts = (body.checklist as string[]).map(c => c.trim()).filter(c => c.length > 0)
|
|
408
|
+
if (texts.length > 0) checklist = checklistFromTexts(texts)
|
|
409
|
+
}
|
|
197
410
|
const now = options.now()
|
|
198
411
|
const task: TaskRecord = {
|
|
199
412
|
id: newTaskId(),
|
|
@@ -206,6 +419,9 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
206
419
|
blocked: false,
|
|
207
420
|
execution,
|
|
208
421
|
model,
|
|
422
|
+
...(isolation !== undefined ? { isolation } : {}),
|
|
423
|
+
...(presetId !== undefined ? { presetId } : {}),
|
|
424
|
+
...(checklist !== undefined ? { checklist } : {}),
|
|
209
425
|
version: 1,
|
|
210
426
|
createdAt: now,
|
|
211
427
|
updatedAt: now,
|
|
@@ -227,7 +443,9 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
227
443
|
}
|
|
228
444
|
|
|
229
445
|
// ------------------------------------------- POST /tasks/:id/{action}
|
|
230
|
-
|
|
446
|
+
// (\w+ after the id would not match hyphenated actions like
|
|
447
|
+
// worktree-remove, hence the explicit class.)
|
|
448
|
+
const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`))
|
|
231
449
|
if (actionMatch !== null) {
|
|
232
450
|
const id = actionMatch[1]!
|
|
233
451
|
const action = actionMatch[2]!
|
|
@@ -258,6 +476,25 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
258
476
|
if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
|
|
259
477
|
if (body.model === null) next.model = undefined
|
|
260
478
|
else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
|
|
479
|
+
// Isolation may change only before the first execution (分支与基线
|
|
480
|
+
// 取决于该选择 — plan §3.1: 执行开始后锁定).
|
|
481
|
+
const isolationRaw = str(body, 'isolation')
|
|
482
|
+
if (isolationRaw !== null) {
|
|
483
|
+
if (task.executions.length > 0 || task.status === 'in_progress') {
|
|
484
|
+
throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')
|
|
485
|
+
}
|
|
486
|
+
next.isolation = asIsolation(isolationRaw)
|
|
487
|
+
}
|
|
488
|
+
// Preset may change any time: each run composes fresh.
|
|
489
|
+
if (body.presetId === null) delete next.presetId
|
|
490
|
+
else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
|
|
491
|
+
// Checklist (0.4.0): the GUI replaces the whole list; null clears.
|
|
492
|
+
if (body.checklist === null) delete next.checklist
|
|
493
|
+
else if (body.checklist !== undefined) {
|
|
494
|
+
const items = normalizeChecklist(body.checklist)
|
|
495
|
+
if (items.length > 0) next.checklist = items
|
|
496
|
+
else delete next.checklist
|
|
497
|
+
}
|
|
261
498
|
next.version = task.version + 1
|
|
262
499
|
next.updatedAt = options.now()
|
|
263
500
|
next.updatedBy = { kind: 'user' }
|
|
@@ -336,6 +573,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
336
573
|
const purge = body.purge === true
|
|
337
574
|
if (purge) {
|
|
338
575
|
if (task.trashedAt === undefined) throw new Error('Error: invalid_input: purge requires a trashed task (soft-delete first)')
|
|
576
|
+
// Worktree safety before purge (plan §3.3, 0.3.1): refuse while
|
|
577
|
+
// uncommitted work remains; otherwise clean the worktree and
|
|
578
|
+
// the task branch along with the ledger entry.
|
|
579
|
+
if (options.git !== undefined) {
|
|
580
|
+
const ws = workspaces.get(task.workspaceId)
|
|
581
|
+
if (ws !== undefined) {
|
|
582
|
+
const path = worktreePathOf(ws.path, id)
|
|
583
|
+
try {
|
|
584
|
+
await options.git.removeWorktree(ws.path, path)
|
|
585
|
+
} catch (error) {
|
|
586
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
587
|
+
if (message.includes('未提交修改')) {
|
|
588
|
+
throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
|
|
589
|
+
}
|
|
590
|
+
if (/not a working tree|not a working-tree/i.test(message)) {
|
|
591
|
+
// An unregistered leftover dir: plain fs removal.
|
|
592
|
+
await rm(path, { recursive: true, force: true })
|
|
593
|
+
} else {
|
|
594
|
+
throw new Error(`Error: invalid_input: ${message}`)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (task.branch !== undefined) {
|
|
598
|
+
try {
|
|
599
|
+
await options.git.deleteBranch(ws.path, task.branch)
|
|
600
|
+
} catch { /* best effort: the branch may outlive the task */ }
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
339
604
|
await store.mutate('task-deleted', ledger => {
|
|
340
605
|
ledger.tasks = ledger.tasks.filter(t => t.id !== id)
|
|
341
606
|
return []
|
|
@@ -363,7 +628,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
363
628
|
json(res, f.res, 501)
|
|
364
629
|
return
|
|
365
630
|
}
|
|
366
|
-
|
|
631
|
+
// `reuse: true` = 续跑: keep a live worktree/branch as-is instead
|
|
632
|
+
// of resetting to a fresh baseline (0.3.1).
|
|
633
|
+
const runOptions = body.reuse === true ? { reuseWorktree: true } : undefined
|
|
634
|
+
const result = await options.run(id, runOptions)
|
|
367
635
|
if (result.ok) json(res, { ok: true, value: result }, 202)
|
|
368
636
|
else {
|
|
369
637
|
const f = fail('invalid_input', result.error)
|
|
@@ -385,6 +653,78 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
385
653
|
}
|
|
386
654
|
return
|
|
387
655
|
}
|
|
656
|
+
if (action === 'merge') {
|
|
657
|
+
// ⇥ 合并 (detail page, user-only): merge the task branch into the
|
|
658
|
+
// main worktree with --no-ff; conflicts are reported verbatim.
|
|
659
|
+
if (options.git === undefined) {
|
|
660
|
+
const f = fail('invalid_input', 'git integration unavailable')
|
|
661
|
+
json(res, f.res, 501)
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
if (task.branch === undefined) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
|
|
665
|
+
if (task.status === 'in_progress') throw new Error('Error: invalid_input: 任务执行中,不能合并')
|
|
666
|
+
if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能合并')
|
|
667
|
+
const ws = workspaces.get(task.workspaceId)
|
|
668
|
+
if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
669
|
+
// No-op detection (0.3.1): a branch with no commits over HEAD
|
|
670
|
+
// merges as "already up to date" — report that instead of landing
|
|
671
|
+
// a bogus 已合并 comment.
|
|
672
|
+
let noop = false
|
|
673
|
+
try {
|
|
674
|
+
noop = await options.git.isAncestor(ws.path, task.branch)
|
|
675
|
+
} catch { /* fail-soft: proceed to the real merge */ }
|
|
676
|
+
if (noop) {
|
|
677
|
+
json(res, { ok: true, value: { merged: false, noop: true, branch: task.branch } })
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
await options.git.merge(ws.path, task.branch)
|
|
682
|
+
} catch (error) {
|
|
683
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
684
|
+
}
|
|
685
|
+
const mergedComment = { id: newCommentId(), body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`), version: 1, createdAt: options.now() }
|
|
686
|
+
const next = structuredClone(task)
|
|
687
|
+
next.comments.push(mergedComment)
|
|
688
|
+
next.version = task.version + 1
|
|
689
|
+
next.updatedAt = options.now()
|
|
690
|
+
await store.mutate('comment-added', ledger => {
|
|
691
|
+
const i = ledger.tasks.findIndex(t => t.id === id)
|
|
692
|
+
ledger.tasks[i] = next
|
|
693
|
+
return [next]
|
|
694
|
+
})
|
|
695
|
+
json(res, { ok: true, value: { merged: true, branch: task.branch } })
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
if (action === 'worktree-remove') {
|
|
699
|
+
// 🗑 删除 worktree (detail page): refuses uncommitted changes;
|
|
700
|
+
// optionally deletes the task branch after the worktree is gone.
|
|
701
|
+
if (options.git === undefined) {
|
|
702
|
+
const f = fail('invalid_input', 'git integration unavailable')
|
|
703
|
+
json(res, f.res, 501)
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能删除 worktree')
|
|
707
|
+
const ws = workspaces.get(task.workspaceId)
|
|
708
|
+
if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
709
|
+
const path = worktreePathOf(ws.path, id)
|
|
710
|
+
try {
|
|
711
|
+
await options.git.removeWorktree(ws.path, path)
|
|
712
|
+
} catch (error) {
|
|
713
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
714
|
+
}
|
|
715
|
+
let branchDeleted = false
|
|
716
|
+
let branchError: string | undefined
|
|
717
|
+
if (body.deleteBranch === true && task.branch !== undefined) {
|
|
718
|
+
try {
|
|
719
|
+
await options.git.deleteBranch(ws.path, task.branch)
|
|
720
|
+
branchDeleted = true
|
|
721
|
+
} catch (error) {
|
|
722
|
+
branchError = error instanceof Error ? error.message : String(error)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
json(res, { ok: true, value: { removed: true, branchDeleted, ...(branchError !== undefined ? { branchError } : {}) } })
|
|
726
|
+
return
|
|
727
|
+
}
|
|
388
728
|
const f = fail('not_found', `unknown action ${action}`)
|
|
389
729
|
json(res, f.res, f.status)
|
|
390
730
|
} catch (error) {
|
|
@@ -394,6 +734,144 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
394
734
|
return
|
|
395
735
|
}
|
|
396
736
|
|
|
737
|
+
// -------------------------------------- POST /worktree-cleanup (⚙ 诊断)
|
|
738
|
+
if (pathname === `${ROUTE_PREFIX}/worktree-cleanup`) {
|
|
739
|
+
try {
|
|
740
|
+
if (options.git === undefined) {
|
|
741
|
+
const f = fail('invalid_input', 'git integration unavailable')
|
|
742
|
+
json(res, f.res, 501)
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
const workspaceId = str(body, 'workspaceId') ?? ''
|
|
746
|
+
const taskId = str(body, 'taskId') ?? ''
|
|
747
|
+
const ws = workspaces.get(workspaceId)
|
|
748
|
+
if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
749
|
+
// Only dirs owned by NO ledger task may be cleaned here; live tasks
|
|
750
|
+
// remove their worktree from the detail page.
|
|
751
|
+
if (store.get(taskId) !== undefined) throw new Error('Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree')
|
|
752
|
+
const path = worktreePathOf(ws.path, taskId)
|
|
753
|
+
try {
|
|
754
|
+
await options.git.removeWorktree(ws.path, path)
|
|
755
|
+
} catch (error) {
|
|
756
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
757
|
+
// An unregistered leftover (git no longer knows this worktree):
|
|
758
|
+
// fall back to direct fs removal — the dir lives inside the
|
|
759
|
+
// plugin's own .dsh-worktrees scope.
|
|
760
|
+
if (/not a working tree|not a working-tree/i.test(message)) {
|
|
761
|
+
await rm(path, { recursive: true, force: true })
|
|
762
|
+
} else {
|
|
763
|
+
throw new Error(`Error: invalid_input: ${message}`)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
json(res, { ok: true, value: { cleaned: true, path } })
|
|
767
|
+
} catch (error) {
|
|
768
|
+
const f = toFail(error)
|
|
769
|
+
json(res, f.res, f.status)
|
|
770
|
+
}
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ---------------------------------------------- POST /import/preview
|
|
775
|
+
// (0.4.0) Dry-run: classify every task in the uploaded ledger file
|
|
776
|
+
// against the live one; nothing is written.
|
|
777
|
+
if (pathname === `${ROUTE_PREFIX}/import/preview`) {
|
|
778
|
+
try {
|
|
779
|
+
const known = new Set(store.snapshot().tasks.map(t => t.id))
|
|
780
|
+
const plan = validateLedgerImport(body, known, options.now())
|
|
781
|
+
json(res, {
|
|
782
|
+
ok: true,
|
|
783
|
+
value: {
|
|
784
|
+
plan: {
|
|
785
|
+
create: plan.create.map(t => ({ id: t.id, title: t.title, status: t.status })),
|
|
786
|
+
overwrite: plan.overwrite.map(t => ({ id: t.id, title: t.title, status: t.status })),
|
|
787
|
+
invalid: plan.invalid,
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
})
|
|
791
|
+
} catch (error) {
|
|
792
|
+
const f = toFail(error)
|
|
793
|
+
json(res, f.res, f.status)
|
|
794
|
+
}
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ------------------------------------------------------ POST /import
|
|
799
|
+
// (0.4.0) Commit an import. mode=merge upserts (create + overwrite by
|
|
800
|
+
// id); mode=replace swaps the WHOLE ledger (invalid entries dropped)
|
|
801
|
+
// after writing a timestamped backup of the current one.
|
|
802
|
+
if (pathname === `${ROUTE_PREFIX}/import`) {
|
|
803
|
+
try {
|
|
804
|
+
const mode = str(body, 'mode') === 'replace' ? 'replace' as const : 'merge' as const
|
|
805
|
+
const raw = body.ledger
|
|
806
|
+
const known = new Set(store.snapshot().tasks.map(t => t.id))
|
|
807
|
+
const plan = validateLedgerImport(raw, known, options.now())
|
|
808
|
+
const imported = [...plan.create, ...plan.overwrite]
|
|
809
|
+
if (mode === 'replace' && imported.length === 0) {
|
|
810
|
+
throw new Error('Error: invalid_input: 导入文件没有可导入的任务,已拒绝整册替换')
|
|
811
|
+
}
|
|
812
|
+
let backupFile: string | undefined
|
|
813
|
+
if (mode === 'replace' && store.snapshot().tasks.length > 0) {
|
|
814
|
+
backupFile = await store.backup()
|
|
815
|
+
}
|
|
816
|
+
let replacedTotal: number | undefined
|
|
817
|
+
await store.mutate('task-created', ledger => {
|
|
818
|
+
if (mode === 'replace') {
|
|
819
|
+
replacedTotal = ledger.tasks.length
|
|
820
|
+
ledger.tasks = structuredClone(imported)
|
|
821
|
+
return ledger.tasks
|
|
822
|
+
}
|
|
823
|
+
const byId = new Map(ledger.tasks.map(t => [t.id, t]))
|
|
824
|
+
for (const task of imported) byId.set(task.id, structuredClone(task))
|
|
825
|
+
ledger.tasks = [...byId.values()]
|
|
826
|
+
return structuredClone(imported)
|
|
827
|
+
})
|
|
828
|
+
json(res, {
|
|
829
|
+
ok: true,
|
|
830
|
+
value: {
|
|
831
|
+
mode,
|
|
832
|
+
created: plan.create.length,
|
|
833
|
+
overwritten: plan.overwrite.length,
|
|
834
|
+
...(mode === 'replace' ? { replacedTotal } : {}),
|
|
835
|
+
...(backupFile !== undefined ? { backupFile } : {}),
|
|
836
|
+
},
|
|
837
|
+
})
|
|
838
|
+
} catch (error) {
|
|
839
|
+
const f = toFail(error)
|
|
840
|
+
json(res, f.res, f.status)
|
|
841
|
+
}
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ------------------------------------------- POST /templates (+delete)
|
|
846
|
+
if (pathname === `${ROUTE_PREFIX}/templates` || pathname === `${ROUTE_PREFIX}/templates/delete`) {
|
|
847
|
+
try {
|
|
848
|
+
if (options.templates === undefined) {
|
|
849
|
+
const f = fail('invalid_input', 'template store unavailable')
|
|
850
|
+
json(res, f.res, 501)
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
if (pathname.endsWith('/delete')) {
|
|
854
|
+
const id = str(body, 'id') ?? ''
|
|
855
|
+
if (id.length === 0) throw new Error('Error: invalid_input: id required')
|
|
856
|
+
const deleted = await options.templates.remove(id)
|
|
857
|
+
json(res, { ok: true, value: { deleted } })
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
const name = str(body, 'name') ?? ''
|
|
861
|
+
if (name.trim().length === 0) throw new Error('Error: invalid_input: name required')
|
|
862
|
+
const template = await options.templates.upsert({
|
|
863
|
+
id: str(body, 'id') ?? undefined,
|
|
864
|
+
name,
|
|
865
|
+
task: normalizeTemplateSpec(body.task),
|
|
866
|
+
})
|
|
867
|
+
json(res, { ok: true, value: template }, 201)
|
|
868
|
+
} catch (error) {
|
|
869
|
+
const f = toFail(error)
|
|
870
|
+
json(res, f.res, f.status)
|
|
871
|
+
}
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
|
|
397
875
|
res.writeHead(404)
|
|
398
876
|
res.end()
|
|
399
877
|
} catch (error) {
|
package/src/host/store.ts
CHANGED
|
@@ -103,6 +103,19 @@ export class TaskStore {
|
|
|
103
103
|
return () => this.subscribers.delete(fn)
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Write a timestamped backup copy of the current ledger next to the live
|
|
108
|
+
* file (import-replace safety, 0.4.0). Never throws the caller's flow —
|
|
109
|
+
* a backup failure fails the import itself.
|
|
110
|
+
* @returns the backup file path.
|
|
111
|
+
*/
|
|
112
|
+
async backup(): Promise<string> {
|
|
113
|
+
await this.load()
|
|
114
|
+
const target = `${this.file}.backup-${Date.now()}`
|
|
115
|
+
await persistAtomic(target, JSON.stringify(this.ledger, null, 2))
|
|
116
|
+
return target
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
/**
|
|
107
120
|
* Run one mutation inside the serial queue. The mutator works on a
|
|
108
121
|
* structured clone; returning `undefined` aborts with no write.
|