dsh-taskboard 0.6.1 → 0.6.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 +14 -3
- package/lib/client.js +14 -6308
- package/lib/host/execution.js +160 -78
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +41 -13
- package/lib/host/git.js.map +1 -1
- package/lib/host/isolation.js +192 -0
- package/lib/host/isolation.js.map +1 -0
- package/lib/host/repos.js +91 -0
- package/lib/host/repos.js.map +1 -0
- package/lib/host/routes.js +203 -60
- package/lib/host/routes.js.map +1 -1
- package/lib/host/tools.js +1 -1
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +62 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +14 -1
- package/src/client/api.ts +2 -1
- package/src/client/board/TaskDetail.tsx +100 -32
- package/src/client/board/TaskFormModal.tsx +7 -0
- package/src/client/controller.ts +15 -6
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/styles.ts +7 -0
- package/src/host/execution.ts +199 -115
- package/src/host/git.ts +84 -18
- package/src/host/isolation.ts +268 -0
- package/src/host/repos.ts +146 -0
- package/src/host/routes.ts +211 -73
- package/src/host/tools.ts +2 -2
- package/src/index.ts +6 -1
- package/src/shared/api.ts +29 -5
- package/src/shared/protocol.ts +124 -0
- package/src/shared/version.ts +1 -1
package/src/host/routes.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
checklistFromTexts,
|
|
26
26
|
defaultIsolationOf,
|
|
27
27
|
defaultPermissionOf,
|
|
28
|
+
isValidRelRepoPath,
|
|
28
29
|
newCommentId,
|
|
29
30
|
newTaskId,
|
|
30
31
|
normalizeBody,
|
|
@@ -41,7 +42,9 @@ import {
|
|
|
41
42
|
type TaskRecord,
|
|
42
43
|
} from '../shared/protocol.ts'
|
|
43
44
|
import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
|
|
44
|
-
import
|
|
45
|
+
import { removeMirror, repoMainPath } from './isolation.ts'
|
|
46
|
+
import { createRepoScanner, type RepoScanner } from './repos.ts'
|
|
47
|
+
import type { CatalogModelItem, CatalogPresetItem, MergeRepoResult, TaskTemplate } from '../shared/api.ts'
|
|
45
48
|
import type { TemplateStore } from './templates.ts'
|
|
46
49
|
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
47
50
|
import type { TaskStore } from './store.ts'
|
|
@@ -81,6 +84,8 @@ export interface TaskboardRoutesOptions {
|
|
|
81
84
|
modelProviders?: () => string[] | undefined
|
|
82
85
|
/** Git face for worktree actions + workspace git detection; absent → 501 on git actions. */
|
|
83
86
|
git?: GitFace
|
|
87
|
+
/** Nested-repo scanner for mirror removal; absent → a default one is built on first use. */
|
|
88
|
+
scanner?: RepoScanner
|
|
84
89
|
/** Task-template store (0.4.0); absent → 501 on template actions. */
|
|
85
90
|
templates?: TemplateStore
|
|
86
91
|
/** Prompt completions face (0.5.5; dynamically discovers skills & commands). */
|
|
@@ -257,7 +262,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
257
262
|
|
|
258
263
|
// Workspace git detection, TTL-cached and fail-soft (false on any error):
|
|
259
264
|
// feeds the create-form isolation toggle and the diagnostics panel.
|
|
260
|
-
const gitCache = new Map<string, { value: boolean; at: number }>()
|
|
265
|
+
const gitCache = new Map<string, { value: boolean; at: number; repoCount?: number }>()
|
|
261
266
|
const gitHinted = new Set<string>()
|
|
262
267
|
|
|
263
268
|
/** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
|
|
@@ -274,24 +279,60 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
274
279
|
}
|
|
275
280
|
}
|
|
276
281
|
|
|
277
|
-
|
|
278
|
-
|
|
282
|
+
// One scanner for every route-side discovery (the injected one or a
|
|
283
|
+
// shared real-IO fallback whose TTL cache actually helps — 0.6.3).
|
|
284
|
+
const sharedScanner: RepoScanner = options.scanner ?? createRepoScanner()
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether the workspace root itself is a git repo (the .gitignore-suggestion
|
|
288
|
+
* gate — a plain container has no repo that could ignore anything).
|
|
289
|
+
*/
|
|
290
|
+
const rootIsRepo = async (path: string): Promise<boolean> => {
|
|
291
|
+
try {
|
|
292
|
+
return (await options.git?.detect(path)) === true
|
|
293
|
+
} catch {
|
|
294
|
+
return false // fail-soft
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Workspace repo facts for the form (0.6.3): `gitAvailable` gates the
|
|
300
|
+
* worktree option, `repoCount` feeds the mirror badge. Availability now
|
|
301
|
+
* covers PARALLEL MULTI-REPO workspaces too: a root repo qualifies as
|
|
302
|
+
* before, and a workspace whose root is NOT a repo still qualifies when
|
|
303
|
+
* the scanner finds nested repos — prepareMirror isolates exactly that
|
|
304
|
+
* container shape (mirror root = plain dir, one worktree per nested repo),
|
|
305
|
+
* so the form must not lock the capability away.
|
|
306
|
+
*/
|
|
307
|
+
const workspaceRepos = async (path: string): Promise<{ gitAvailable: boolean; repoCount: number }> => {
|
|
308
|
+
if (options.git === undefined) return { gitAvailable: false, repoCount: 0 }
|
|
279
309
|
const hit = gitCache.get(path)
|
|
280
|
-
if (hit !== undefined && options.now() - hit.at < GIT_DETECT_TTL_MS)
|
|
281
|
-
|
|
310
|
+
if (hit !== undefined && options.now() - hit.at < GIT_DETECT_TTL_MS) {
|
|
311
|
+
return { gitAvailable: hit.value, repoCount: hit.repoCount ?? (hit.value ? 1 : 0) }
|
|
312
|
+
}
|
|
313
|
+
let rootRepo = false
|
|
282
314
|
try {
|
|
283
|
-
|
|
315
|
+
rootRepo = await options.git.detect(path)
|
|
284
316
|
} catch { /* fail-soft → false */ }
|
|
285
|
-
gitCache.set(path, { value, at: options.now() })
|
|
286
317
|
// gitignore 建议 (plan §3.2): suggest (never write) ignoring our
|
|
287
|
-
// worktree directory, once per workspace per host run.
|
|
288
|
-
if (
|
|
318
|
+
// worktree directory, once per workspace per host run. Root repos only.
|
|
319
|
+
if (rootRepo && !gitHinted.has(path)) {
|
|
289
320
|
gitHinted.add(path)
|
|
290
321
|
if (await gitignoreMissing(path)) {
|
|
291
322
|
console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)
|
|
292
323
|
}
|
|
293
324
|
}
|
|
294
|
-
|
|
325
|
+
// The nested scan always runs: repoCount needs it even when the root
|
|
326
|
+
// is a repo (root repo + nested repos = the mirror shape). The scanner's
|
|
327
|
+
// own TTL cache keeps repeated workspace polls cheap.
|
|
328
|
+
let nestedCount = 0
|
|
329
|
+
try {
|
|
330
|
+
nestedCount = (await sharedScanner.findNestedRepos(path)).length
|
|
331
|
+
} catch { /* fail-soft → 0 */ }
|
|
332
|
+
const value = rootRepo || nestedCount > 0
|
|
333
|
+
const repoCount = (rootRepo ? 1 : 0) + nestedCount
|
|
334
|
+
gitCache.set(path, { value, at: options.now(), repoCount })
|
|
335
|
+
return { gitAvailable: value, repoCount }
|
|
295
336
|
}
|
|
296
337
|
|
|
297
338
|
/** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
|
|
@@ -315,7 +356,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
315
356
|
const listGitignoreSuggestions = async (): Promise<Array<{ workspaceId: string; workspacePath: string }>> => {
|
|
316
357
|
const suggestions: Array<{ workspaceId: string; workspacePath: string }> = []
|
|
317
358
|
for (const ws of workspaces.list()) {
|
|
318
|
-
|
|
359
|
+
// Root repos only: a plain container has no .gitignore any repo reads.
|
|
360
|
+
if (!(await rootIsRepo(ws.path))) continue
|
|
319
361
|
if (await gitignoreMissing(ws.path)) suggestions.push({ workspaceId: ws.id, workspacePath: ws.path })
|
|
320
362
|
}
|
|
321
363
|
return suggestions
|
|
@@ -335,10 +377,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
335
377
|
}
|
|
336
378
|
if (pathname === `${ROUTE_PREFIX}/workspaces`) {
|
|
337
379
|
const list = workspaces.list()
|
|
338
|
-
const
|
|
380
|
+
const info = await Promise.all(list.map(ws => workspaceRepos(ws.path)))
|
|
339
381
|
json(res, {
|
|
340
382
|
ok: true,
|
|
341
|
-
value: list.map((ws, i) => ({ ...ws, sessionCount: 0, gitAvailable:
|
|
383
|
+
value: list.map((ws, i) => ({ ...ws, sessionCount: 0, gitAvailable: info[i]!.gitAvailable, repoCount: info[i]!.repoCount })),
|
|
342
384
|
})
|
|
343
385
|
return
|
|
344
386
|
}
|
|
@@ -379,17 +421,29 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
379
421
|
const filePath = url.searchParams.get('path')
|
|
380
422
|
const ws = workspaces.get(task.workspaceId)
|
|
381
423
|
if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
382
|
-
|
|
424
|
+
// 0.6.3: `?repo=` picks one repo of a multi-repo mirror ('' = the
|
|
425
|
+
// root); without it the legacy flat fields decide, byte-identical.
|
|
426
|
+
const repoParam = url.searchParams.get('repo')
|
|
427
|
+
let cwd = execution.worktreePath ?? ws.path
|
|
428
|
+
let mainRepo = ws.path
|
|
429
|
+
let baseCommit = execution.baseCommit
|
|
430
|
+
if (repoParam !== null) {
|
|
431
|
+
const entry = execution.repos?.find(r => r.repo === repoParam)
|
|
432
|
+
if (entry === undefined) throw new Error('Error: invalid_input: 该执行没有此仓库的镜像记录')
|
|
433
|
+
cwd = entry.worktreePath
|
|
434
|
+
mainRepo = repoMainPath(ws.path, entry.repo)
|
|
435
|
+
baseCommit = entry.baseCommit
|
|
436
|
+
}
|
|
383
437
|
let result = commit !== null
|
|
384
438
|
? await options.git.showCommit(cwd, commit)
|
|
385
|
-
: filePath !== null ? await options.git.showPathDiff(cwd, filePath,
|
|
439
|
+
: filePath !== null ? await options.git.showPathDiff(cwd, filePath, baseCommit) : undefined
|
|
386
440
|
// Fallback: the worktree may be gone — commits and committed
|
|
387
441
|
// ranges still resolve in the main repo.
|
|
388
|
-
if (result === undefined &&
|
|
442
|
+
if (result === undefined && cwd !== mainRepo) {
|
|
389
443
|
result = commit !== null
|
|
390
|
-
? await options.git.showCommit(
|
|
391
|
-
: filePath !== null &&
|
|
392
|
-
? await options.git.showPathDiff(
|
|
444
|
+
? await options.git.showCommit(mainRepo, commit)
|
|
445
|
+
: filePath !== null && baseCommit !== undefined
|
|
446
|
+
? await options.git.showPathDiff(mainRepo, filePath, baseCommit)
|
|
393
447
|
: undefined
|
|
394
448
|
}
|
|
395
449
|
if (result === undefined) {
|
|
@@ -702,25 +756,37 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
702
756
|
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
703
757
|
}
|
|
704
758
|
try {
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
759
|
+
// 0.6.3: the whole mirror (every repo worktree) goes
|
|
760
|
+
// through the aggregated children-first removal — one
|
|
761
|
+
// dirty repo refuses BEFORE anything is deleted.
|
|
762
|
+
await removeMirror(
|
|
763
|
+
{ git: options.git, scanner: options.scanner ?? createRepoScanner() },
|
|
764
|
+
{ workspacePath: ws.path, taskId: id },
|
|
765
|
+
)
|
|
766
|
+
// Leftovers unknown to git ('unregistered' worktrees, or a
|
|
767
|
+
// plain mirror dir when the workspace root is no repo).
|
|
768
|
+
await rm(path, { recursive: true, force: true })
|
|
711
769
|
} catch (error) {
|
|
712
770
|
const message = error instanceof Error ? error.message : String(error)
|
|
713
771
|
// Structured classification (review P2): git tags its dirty
|
|
714
772
|
// rejections with a code; the keyword stays as a fallback.
|
|
715
|
-
const dirty = (error as { code?: string }).code === 'dirty-worktree'
|
|
773
|
+
const dirty = (error as { code?: string }).code === 'dirty-worktree'
|
|
774
|
+
|| (error as { code?: string }).code === 'dirty-mirror'
|
|
775
|
+
|| message.includes('未提交修改')
|
|
716
776
|
if (dirty) {
|
|
717
777
|
throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
|
|
718
778
|
}
|
|
719
779
|
throw new Error(`Error: invalid_input: ${message}`)
|
|
720
780
|
}
|
|
721
|
-
|
|
781
|
+
// Branches die with the task (best effort, per repo).
|
|
782
|
+
const branchTargets: Array<{ repo: string; branch: string }> = []
|
|
783
|
+
if (task.branches !== undefined) {
|
|
784
|
+
for (const [repo, branch] of Object.entries(task.branches)) branchTargets.push({ repo, branch })
|
|
785
|
+
}
|
|
786
|
+
if (task.branch !== undefined && !branchTargets.some(t => t.repo === '')) branchTargets.push({ repo: '', branch: task.branch })
|
|
787
|
+
for (const target of branchTargets) {
|
|
722
788
|
try {
|
|
723
|
-
await options.git.deleteBranch(ws.path,
|
|
789
|
+
await options.git.deleteBranch(repoMainPath(ws.path, target.repo), target.branch)
|
|
724
790
|
} catch { /* best effort: the branch may outlive the task */ }
|
|
725
791
|
}
|
|
726
792
|
}
|
|
@@ -786,47 +852,105 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
786
852
|
return
|
|
787
853
|
}
|
|
788
854
|
if (action === 'merge') {
|
|
789
|
-
// ⇥ 合并 (detail page, user-only): merge the task branch into
|
|
790
|
-
// main worktree with --no-ff; conflicts are reported
|
|
855
|
+
// ⇥ 合并 (detail page, user-only): merge the task branch(es) into
|
|
856
|
+
// the main worktree(s) with --no-ff; conflicts are reported
|
|
857
|
+
// verbatim. 0.6.3: multi-repo mirror tasks merge PER REPO —
|
|
858
|
+
// sequentially, a failed repo never blocking the others.
|
|
791
859
|
if (options.git === undefined) {
|
|
792
860
|
const f = fail('invalid_input', 'git integration unavailable')
|
|
793
861
|
json(res, f.res, 501)
|
|
794
862
|
return
|
|
795
863
|
}
|
|
796
|
-
if (task.branch === undefined) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
|
|
797
864
|
if (task.status === 'in_progress') throw new Error('Error: invalid_input: 任务执行中,不能合并')
|
|
798
865
|
if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能合并')
|
|
799
866
|
const ws = workspaces.get(task.workspaceId)
|
|
800
867
|
if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
let noop = false
|
|
805
|
-
try {
|
|
806
|
-
noop = await options.git.isAncestor(ws.path, task.branch)
|
|
807
|
-
} catch { /* fail-soft: proceed to the real merge */ }
|
|
808
|
-
if (noop) {
|
|
809
|
-
json(res, { ok: true, value: { merged: false, noop: true, branch: task.branch } })
|
|
810
|
-
return
|
|
868
|
+
const targets: Array<{ repo: string; branch: string }> = []
|
|
869
|
+
if (task.branches !== undefined) {
|
|
870
|
+
for (const [repo, branch] of Object.entries(task.branches)) targets.push({ repo, branch })
|
|
811
871
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
872
|
+
if (task.branch !== undefined && !targets.some(t => t.repo === '')) targets.unshift({ repo: '', branch: task.branch })
|
|
873
|
+
if (targets.length === 0) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
|
|
874
|
+
const multi = task.branches !== undefined
|
|
875
|
+
const results: MergeRepoResult[] = []
|
|
876
|
+
for (const target of targets) {
|
|
877
|
+
if (!isValidRelRepoPath(target.repo)) throw new Error('Error: invalid_input: 非法的仓库路径')
|
|
878
|
+
const repoRoot = repoMainPath(ws.path, target.repo)
|
|
879
|
+
// No-op detection (0.3.1): a branch with no commits over HEAD
|
|
880
|
+
// merges as "already up to date" — report that instead of landing
|
|
881
|
+
// a bogus 已合并 comment.
|
|
882
|
+
let noop = false
|
|
883
|
+
try {
|
|
884
|
+
noop = await options.git.isAncestor(repoRoot, target.branch)
|
|
885
|
+
} catch { /* fail-soft: proceed to the real merge */ }
|
|
886
|
+
if (noop) {
|
|
887
|
+
results.push({ repo: target.repo, branch: target.branch, outcome: 'noop' })
|
|
888
|
+
continue
|
|
889
|
+
}
|
|
890
|
+
try {
|
|
891
|
+
// 0.6.3 review fix: parallel repos are self-governing — their
|
|
892
|
+
// content AND their gitlink entries in the ROOT main checkout
|
|
893
|
+
// (e.g. a container repo tracking `dsh-taskboard` as an embedded
|
|
894
|
+
// repo) don't gate the root repo's merge. Child repos merge with
|
|
895
|
+
// no extra exemption (their own .dsh-worktrees rule suffices).
|
|
896
|
+
const exempt = target.repo === ''
|
|
897
|
+
? await (options.scanner ?? createRepoScanner()).findNestedRepos(ws.path).then(rs => rs.map(r => r.relPath))
|
|
898
|
+
: undefined
|
|
899
|
+
await options.git.merge(repoRoot, target.branch, exempt)
|
|
900
|
+
results.push({ repo: target.repo, branch: target.branch, outcome: 'merged' })
|
|
901
|
+
} catch (error) {
|
|
902
|
+
results.push({
|
|
903
|
+
repo: target.repo,
|
|
904
|
+
branch: target.branch,
|
|
905
|
+
outcome: 'failed',
|
|
906
|
+
error: error instanceof Error ? error.message : String(error),
|
|
907
|
+
})
|
|
908
|
+
}
|
|
816
909
|
}
|
|
817
|
-
|
|
818
|
-
// R1: the git merge above is slow — re-find the FRESH task inside
|
|
910
|
+
// R1: the git merges above are slow — re-find the FRESH task inside
|
|
819
911
|
// the mutation so a concurrent comment is never overwritten.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
912
|
+
const pushComment = (body: string): Promise<void> =>
|
|
913
|
+
store.mutate('comment-added', ledger => {
|
|
914
|
+
const { index, task: fresh } = liveTaskAt(ledger, id)
|
|
915
|
+
const next = structuredClone(fresh)
|
|
916
|
+
next.comments.push({ id: newCommentId(), body: normalizeBody(body), version: 1, createdAt: options.now() })
|
|
917
|
+
next.version = fresh.version + 1
|
|
918
|
+
next.updatedAt = options.now()
|
|
919
|
+
ledger.tasks[index] = next
|
|
920
|
+
return [next]
|
|
921
|
+
}).then(() => undefined)
|
|
922
|
+
if (!multi) {
|
|
923
|
+
// Legacy single-repo shape, byte-identical to 0.3.x–0.6.x.
|
|
924
|
+
const root = results.find(r => r.repo === '')
|
|
925
|
+
if (root === undefined) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
|
|
926
|
+
if (root.outcome === 'noop') {
|
|
927
|
+
json(res, { ok: true, value: { merged: false, noop: true, branch: root.branch } })
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
if (root.outcome === 'failed') {
|
|
931
|
+
throw new Error(`Error: invalid_input: ${root.error ?? '合并失败'}`)
|
|
932
|
+
}
|
|
933
|
+
await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`)
|
|
934
|
+
json(res, { ok: true, value: { merged: true, branch: root.branch } })
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
const labelOf = (repo: string): string => repo === '' ? '根仓库' : repo
|
|
938
|
+
const mergedCount = results.filter(r => r.outcome === 'merged').length
|
|
939
|
+
const failedCount = results.filter(r => r.outcome === 'failed').length
|
|
940
|
+
const summary = results
|
|
941
|
+
.map(r => r.outcome === 'merged'
|
|
942
|
+
? `${labelOf(r.repo)} ✓ 已合并`
|
|
943
|
+
: r.outcome === 'noop' ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? '合并失败').slice(0, 150)}`)
|
|
944
|
+
.join(';')
|
|
945
|
+
await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`)
|
|
946
|
+
json(res, {
|
|
947
|
+
ok: true,
|
|
948
|
+
value: {
|
|
949
|
+
merged: mergedCount > 0,
|
|
950
|
+
...(mergedCount === 0 && failedCount === 0 ? { noop: true } : {}),
|
|
951
|
+
results,
|
|
952
|
+
},
|
|
828
953
|
})
|
|
829
|
-
json(res, { ok: true, value: { merged: true, branch: task.branch } })
|
|
830
954
|
return
|
|
831
955
|
}
|
|
832
956
|
if (action === 'worktree-remove') {
|
|
@@ -845,23 +969,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
845
969
|
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
846
970
|
}
|
|
847
971
|
try {
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
972
|
+
// 0.6.3: aggregated children-first mirror removal (see purge).
|
|
973
|
+
await removeMirror(
|
|
974
|
+
{ git: options.git, scanner: options.scanner ?? createRepoScanner() },
|
|
975
|
+
{ workspacePath: ws.path, taskId: id },
|
|
976
|
+
)
|
|
977
|
+
await rm(path, { recursive: true, force: true })
|
|
853
978
|
} catch (error) {
|
|
854
979
|
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
855
980
|
}
|
|
856
981
|
let branchDeleted = false
|
|
857
982
|
let branchError: string | undefined
|
|
858
|
-
if (body.deleteBranch === true
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
863
|
-
|
|
983
|
+
if (body.deleteBranch === true) {
|
|
984
|
+
const branchTargets: Array<{ repo: string; branch: string }> = []
|
|
985
|
+
if (task.branches !== undefined) {
|
|
986
|
+
for (const [repo, branch] of Object.entries(task.branches)) branchTargets.push({ repo, branch })
|
|
987
|
+
}
|
|
988
|
+
if (task.branch !== undefined && !branchTargets.some(t => t.repo === '')) branchTargets.push({ repo: '', branch: task.branch })
|
|
989
|
+
let failures = 0
|
|
990
|
+
for (const target of branchTargets) {
|
|
991
|
+
try {
|
|
992
|
+
await options.git.deleteBranch(repoMainPath(ws.path, target.repo), target.branch)
|
|
993
|
+
} catch (error) {
|
|
994
|
+
failures += 1
|
|
995
|
+
const label = target.repo === '' ? '根仓库' : target.repo
|
|
996
|
+
branchError = `${label}:${error instanceof Error ? error.message : String(error)}`
|
|
997
|
+
}
|
|
864
998
|
}
|
|
999
|
+
branchDeleted = failures === 0
|
|
865
1000
|
}
|
|
866
1001
|
json(res, { ok: true, value: { removed: true, branchDeleted, ...(branchError !== undefined ? { branchError } : {}) } })
|
|
867
1002
|
return
|
|
@@ -898,11 +1033,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
898
1033
|
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
899
1034
|
}
|
|
900
1035
|
try {
|
|
901
|
-
//
|
|
902
|
-
// the leftover
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1036
|
+
// 0.6.3: an orphan mirror may hold several repo worktrees —
|
|
1037
|
+
// aggregated children-first removal, then the leftover fs rm
|
|
1038
|
+
// (scope-verified above).
|
|
1039
|
+
await removeMirror(
|
|
1040
|
+
{ git: options.git, scanner: options.scanner ?? createRepoScanner() },
|
|
1041
|
+
{ workspacePath: ws.path, taskId },
|
|
1042
|
+
)
|
|
1043
|
+
await rm(path, { recursive: true, force: true })
|
|
906
1044
|
} catch (error) {
|
|
907
1045
|
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
908
1046
|
}
|
package/src/host/tools.ts
CHANGED
|
@@ -86,7 +86,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
|
|
|
86
86
|
`任务 ${t.id} 「${t.title}」`,
|
|
87
87
|
`状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
|
|
88
88
|
`执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
|
|
89
|
-
`隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}`,
|
|
89
|
+
`隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}${t.branches !== undefined ? `(多仓库镜像 ${Object.keys(t.branches).length + (t.branch !== undefined ? 1 : 0)} 个仓库)` : ''}`,
|
|
90
90
|
]
|
|
91
91
|
const holder = isClaimedBy(t)
|
|
92
92
|
if (holder !== undefined) lines.push(`认领: agent ${String(holder).slice(0, 24)}(持有期间其他会话不可移动)`)
|
|
@@ -925,4 +925,4 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
925
925
|
})) as () => void)
|
|
926
926
|
|
|
927
927
|
return disposers
|
|
928
|
-
}
|
|
928
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ 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
24
|
import { createGitFace } from './host/git.ts'
|
|
25
|
+
import { createRepoScanner } from './host/repos.ts'
|
|
25
26
|
import { registerTaskboardRoutes } from './host/routes.ts'
|
|
26
27
|
import { SchedulerService } from './host/scheduler.ts'
|
|
27
28
|
import { dshHomePath } from './host/sdk.ts'
|
|
@@ -124,8 +125,10 @@ export function apply(ctx: Context): void {
|
|
|
124
125
|
disposers.push(() => sessionSync.dispose())
|
|
125
126
|
|
|
126
127
|
// The narrow git face shared by execution (worktree isolation) and the
|
|
127
|
-
// routes (merge / remove / workspace detection)
|
|
128
|
+
// routes (merge / remove / workspace detection), plus the shared
|
|
129
|
+
// nested-repo scanner for multi-repo mirrors (0.6.3).
|
|
128
130
|
const git = createGitFace()
|
|
131
|
+
const scanner = createRepoScanner()
|
|
129
132
|
|
|
130
133
|
wsCtx.inject(['agents'], (agentCtx: Context) => {
|
|
131
134
|
agentSessions = agentCtx.get('sessions') as { get?: (id: string) => unknown; list?: () => unknown[] } | undefined
|
|
@@ -144,6 +147,7 @@ export function apply(ctx: Context): void {
|
|
|
144
147
|
events,
|
|
145
148
|
now,
|
|
146
149
|
git,
|
|
150
|
+
scanner,
|
|
147
151
|
// Preset composition (0.3.3): mirror apiproxy's composeAgent — resolve
|
|
148
152
|
// the id BEFORE creation (the session header snapshots meta), mount
|
|
149
153
|
// inside the factory's setup callback. No roster service → undefined
|
|
@@ -201,6 +205,7 @@ export function apply(ctx: Context): void {
|
|
|
201
205
|
cancel: (taskId: string) => execution.cancel(taskId),
|
|
202
206
|
modelProviders,
|
|
203
207
|
git,
|
|
208
|
+
scanner,
|
|
204
209
|
templates,
|
|
205
210
|
promptCompletions: async () => {
|
|
206
211
|
try {
|
package/src/shared/api.ts
CHANGED
|
@@ -40,8 +40,12 @@ export type ApiResult<T> = ApiOk<T> | ApiFail
|
|
|
40
40
|
/** Full-state response (the reconnect baseline after an SSE gap). */
|
|
41
41
|
export type StateResponse = TaskLedger
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Workspace listing for the UI pickers. `repoCount` (0.6.3): how many repos a
|
|
45
|
+
* task mirror of this workspace would cover (root repo + nested) — the form's
|
|
46
|
+
* worktree option shows the mirror badge when it exceeds 1.
|
|
47
|
+
*/
|
|
48
|
+
export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean; repoCount?: number }
|
|
45
49
|
|
|
46
50
|
/** Create-task request body (actor is always the GUI user). */
|
|
47
51
|
export type CreateTaskBody = {
|
|
@@ -103,8 +107,28 @@ export type DeleteTaskBody = { ifVersion?: number; purge?: boolean }
|
|
|
103
107
|
/** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */
|
|
104
108
|
export type RunTaskBody = { reuse?: boolean }
|
|
105
109
|
|
|
106
|
-
/**
|
|
107
|
-
export type
|
|
110
|
+
/** One repo's merge outcome in a multi-repo merge (0.6.3; `repo: ''` = the workspace root repo). */
|
|
111
|
+
export type MergeRepoResult = {
|
|
112
|
+
repo: string
|
|
113
|
+
branch: string
|
|
114
|
+
outcome: 'merged' | 'noop' | 'failed'
|
|
115
|
+
/** Failure reason (verbatim git message) when outcome = 'failed'. */
|
|
116
|
+
error?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Merge outcome. Legacy single-repo tasks keep the flat shape; multi-repo
|
|
121
|
+
* mirror tasks (0.6.3) additionally return per-repo results — merges run
|
|
122
|
+
* sequentially and a failed repo does not block the others (plan §4.5).
|
|
123
|
+
*/
|
|
124
|
+
export type MergeBranchResponse = {
|
|
125
|
+
merged: boolean
|
|
126
|
+
noop?: boolean
|
|
127
|
+
/** The merged task branch (legacy single-repo shape; multi-repo responses omit it). */
|
|
128
|
+
branch?: string
|
|
129
|
+
/** Present only on multi-repo mirror merges (0.6.3). */
|
|
130
|
+
results?: MergeRepoResult[]
|
|
131
|
+
}
|
|
108
132
|
|
|
109
133
|
/** Remove a task's worktree; optionally delete its branch too. */
|
|
110
134
|
export type WorktreeRemoveBody = { deleteBranch?: boolean }
|
|
@@ -246,4 +270,4 @@ export type ChangeEvent = {
|
|
|
246
270
|
revision: number
|
|
247
271
|
kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'
|
|
248
272
|
tasks: TaskSummary[]
|
|
249
|
-
}
|
|
273
|
+
}
|