dsh-taskboard 0.6.2 → 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 +12 -3
- 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 +5 -2
- 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/lib/host/routes.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { asBoardSettings, asIsolation, asPermission, asStatus, asUrgency, canTransition, checklistFromTexts, defaultIsolationOf, defaultPermissionOf, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
|
|
1
|
+
import { asBoardSettings, asIsolation, asPermission, asStatus, asUrgency, canTransition, checklistFromTexts, defaultIsolationOf, defaultPermissionOf, isValidRelRepoPath, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
|
|
2
2
|
import { WORKTREE_DIR, worktreePathOf } from "./git.js";
|
|
3
|
+
import { removeMirror, repoMainPath } from "./isolation.js";
|
|
4
|
+
import { createRepoScanner } from "./repos.js";
|
|
3
5
|
import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
|
|
4
6
|
import { ERR, ToolError } from "./tools.js";
|
|
5
7
|
import { join, resolve, sep } from "node:path";
|
|
@@ -189,23 +191,58 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
189
191
|
return true;
|
|
190
192
|
}
|
|
191
193
|
};
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
+
const sharedScanner = options.scanner ?? createRepoScanner();
|
|
195
|
+
/**
|
|
196
|
+
* Whether the workspace root itself is a git repo (the .gitignore-suggestion
|
|
197
|
+
* gate — a plain container has no repo that could ignore anything).
|
|
198
|
+
*/
|
|
199
|
+
const rootIsRepo = async (path) => {
|
|
200
|
+
try {
|
|
201
|
+
return await options.git?.detect(path) ?? false;
|
|
202
|
+
} catch {}
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Workspace repo facts for the form (0.6.3): `gitAvailable` gates the
|
|
206
|
+
* worktree option, `repoCount` feeds the mirror badge. Availability now
|
|
207
|
+
* covers PARALLEL MULTI-REPO workspaces too: a root repo qualifies as
|
|
208
|
+
* before, and a workspace whose root is NOT a repo still qualifies when
|
|
209
|
+
* the scanner finds nested repos — prepareMirror isolates exactly that
|
|
210
|
+
* container shape (mirror root = plain dir, one worktree per nested repo),
|
|
211
|
+
* so the form must not lock the capability away.
|
|
212
|
+
*/
|
|
213
|
+
const workspaceRepos = async (path) => {
|
|
214
|
+
if (options.git === void 0) return {
|
|
215
|
+
gitAvailable: false,
|
|
216
|
+
repoCount: 0
|
|
217
|
+
};
|
|
194
218
|
const hit = gitCache.get(path);
|
|
195
|
-
if (hit !== void 0 && options.now() - hit.at < GIT_DETECT_TTL_MS) return
|
|
196
|
-
|
|
219
|
+
if (hit !== void 0 && options.now() - hit.at < GIT_DETECT_TTL_MS) return {
|
|
220
|
+
gitAvailable: hit.value,
|
|
221
|
+
repoCount: hit.repoCount ?? (hit.value ? 1 : 0)
|
|
222
|
+
};
|
|
223
|
+
let rootRepo = false;
|
|
197
224
|
try {
|
|
198
|
-
|
|
225
|
+
rootRepo = await options.git.detect(path);
|
|
199
226
|
} catch {}
|
|
200
|
-
|
|
201
|
-
value,
|
|
202
|
-
at: options.now()
|
|
203
|
-
});
|
|
204
|
-
if (value && !gitHinted.has(path)) {
|
|
227
|
+
if (rootRepo && !gitHinted.has(path)) {
|
|
205
228
|
gitHinted.add(path);
|
|
206
229
|
if (await gitignoreMissing(path)) console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`);
|
|
207
230
|
}
|
|
208
|
-
|
|
231
|
+
let nestedCount = 0;
|
|
232
|
+
try {
|
|
233
|
+
nestedCount = (await sharedScanner.findNestedRepos(path)).length;
|
|
234
|
+
} catch {}
|
|
235
|
+
const value = rootRepo || nestedCount > 0;
|
|
236
|
+
const repoCount = (rootRepo ? 1 : 0) + nestedCount;
|
|
237
|
+
gitCache.set(path, {
|
|
238
|
+
value,
|
|
239
|
+
at: options.now(),
|
|
240
|
+
repoCount
|
|
241
|
+
});
|
|
242
|
+
return {
|
|
243
|
+
gitAvailable: value,
|
|
244
|
+
repoCount
|
|
245
|
+
};
|
|
209
246
|
};
|
|
210
247
|
/** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
|
|
211
248
|
const listOrphanWorktrees = async () => {
|
|
@@ -229,7 +266,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
229
266
|
const listGitignoreSuggestions = async () => {
|
|
230
267
|
const suggestions = [];
|
|
231
268
|
for (const ws of workspaces.list()) {
|
|
232
|
-
if (!await
|
|
269
|
+
if (!await rootIsRepo(ws.path)) continue;
|
|
233
270
|
if (await gitignoreMissing(ws.path)) suggestions.push({
|
|
234
271
|
workspaceId: ws.id,
|
|
235
272
|
workspacePath: ws.path
|
|
@@ -252,13 +289,14 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
252
289
|
}
|
|
253
290
|
if (pathname === `/dsh-taskboard/workspaces`) {
|
|
254
291
|
const list = workspaces.list();
|
|
255
|
-
const
|
|
292
|
+
const info = await Promise.all(list.map((ws) => workspaceRepos(ws.path)));
|
|
256
293
|
json(res, {
|
|
257
294
|
ok: true,
|
|
258
295
|
value: list.map((ws, i) => ({
|
|
259
296
|
...ws,
|
|
260
297
|
sessionCount: 0,
|
|
261
|
-
gitAvailable:
|
|
298
|
+
gitAvailable: info[i].gitAvailable,
|
|
299
|
+
repoCount: info[i].repoCount
|
|
262
300
|
}))
|
|
263
301
|
});
|
|
264
302
|
return;
|
|
@@ -294,9 +332,19 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
294
332
|
const filePath = url.searchParams.get("path");
|
|
295
333
|
const ws = workspaces.get(task.workspaceId);
|
|
296
334
|
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
297
|
-
const
|
|
298
|
-
let
|
|
299
|
-
|
|
335
|
+
const repoParam = url.searchParams.get("repo");
|
|
336
|
+
let cwd = execution.worktreePath ?? ws.path;
|
|
337
|
+
let mainRepo = ws.path;
|
|
338
|
+
let baseCommit = execution.baseCommit;
|
|
339
|
+
if (repoParam !== null) {
|
|
340
|
+
const entry = execution.repos?.find((r) => r.repo === repoParam);
|
|
341
|
+
if (entry === void 0) throw new Error("Error: invalid_input: 该执行没有此仓库的镜像记录");
|
|
342
|
+
cwd = entry.worktreePath;
|
|
343
|
+
mainRepo = repoMainPath(ws.path, entry.repo);
|
|
344
|
+
baseCommit = entry.baseCommit;
|
|
345
|
+
}
|
|
346
|
+
let result = commit !== null ? await options.git.showCommit(cwd, commit) : filePath !== null ? await options.git.showPathDiff(cwd, filePath, baseCommit) : void 0;
|
|
347
|
+
if (result === void 0 && cwd !== mainRepo) result = commit !== null ? await options.git.showCommit(mainRepo, commit) : filePath !== null && baseCommit !== void 0 ? await options.git.showPathDiff(mainRepo, filePath, baseCommit) : void 0;
|
|
300
348
|
if (result === void 0) throw new Error("Error: invalid_input: 无法获取 diff(git 报错、对象不存在,或仅存于已删除的 worktree 且无基线)");
|
|
301
349
|
json(res, {
|
|
302
350
|
ok: true,
|
|
@@ -599,17 +647,33 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
599
647
|
const path = worktreePathOf(ws.path, id);
|
|
600
648
|
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
601
649
|
try {
|
|
602
|
-
|
|
650
|
+
await removeMirror({
|
|
651
|
+
git: options.git,
|
|
652
|
+
scanner: options.scanner ?? createRepoScanner()
|
|
653
|
+
}, {
|
|
654
|
+
workspacePath: ws.path,
|
|
655
|
+
taskId: id
|
|
656
|
+
});
|
|
657
|
+
await rm(path, {
|
|
603
658
|
recursive: true,
|
|
604
659
|
force: true
|
|
605
660
|
});
|
|
606
661
|
} catch (error) {
|
|
607
662
|
const message = error instanceof Error ? error.message : String(error);
|
|
608
|
-
if (error.code === "dirty-worktree" || message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
|
|
663
|
+
if (error.code === "dirty-worktree" || error.code === "dirty-mirror" || message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
|
|
609
664
|
throw new Error(`Error: invalid_input: ${message}`);
|
|
610
665
|
}
|
|
611
|
-
|
|
612
|
-
|
|
666
|
+
const branchTargets = [];
|
|
667
|
+
if (task.branches !== void 0) for (const [repo, branch] of Object.entries(task.branches)) branchTargets.push({
|
|
668
|
+
repo,
|
|
669
|
+
branch
|
|
670
|
+
});
|
|
671
|
+
if (task.branch !== void 0 && !branchTargets.some((t) => t.repo === "")) branchTargets.push({
|
|
672
|
+
repo: "",
|
|
673
|
+
branch: task.branch
|
|
674
|
+
});
|
|
675
|
+
for (const target of branchTargets) try {
|
|
676
|
+
await options.git.deleteBranch(repoMainPath(ws.path, target.repo), target.branch);
|
|
613
677
|
} catch {}
|
|
614
678
|
}
|
|
615
679
|
}
|
|
@@ -685,51 +749,103 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
685
749
|
json(res, fail("invalid_input", "git integration unavailable").res, 501);
|
|
686
750
|
return;
|
|
687
751
|
}
|
|
688
|
-
if (task.branch === void 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
|
|
689
752
|
if (task.status === "in_progress") throw new Error("Error: invalid_input: 任务执行中,不能合并");
|
|
690
753
|
if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务执行中,不能合并");
|
|
691
754
|
const ws = workspaces.get(task.workspaceId);
|
|
692
755
|
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
756
|
+
const targets = [];
|
|
757
|
+
if (task.branches !== void 0) for (const [repo, branch] of Object.entries(task.branches)) targets.push({
|
|
758
|
+
repo,
|
|
759
|
+
branch
|
|
760
|
+
});
|
|
761
|
+
if (task.branch !== void 0 && !targets.some((t) => t.repo === "")) targets.unshift({
|
|
762
|
+
repo: "",
|
|
763
|
+
branch: task.branch
|
|
764
|
+
});
|
|
765
|
+
if (targets.length === 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
|
|
766
|
+
const multi = task.branches !== void 0;
|
|
767
|
+
const results = [];
|
|
768
|
+
for (const target of targets) {
|
|
769
|
+
if (!isValidRelRepoPath(target.repo)) throw new Error("Error: invalid_input: 非法的仓库路径");
|
|
770
|
+
const repoRoot = repoMainPath(ws.path, target.repo);
|
|
771
|
+
let noop = false;
|
|
772
|
+
try {
|
|
773
|
+
noop = await options.git.isAncestor(repoRoot, target.branch);
|
|
774
|
+
} catch {}
|
|
775
|
+
if (noop) {
|
|
776
|
+
results.push({
|
|
777
|
+
repo: target.repo,
|
|
778
|
+
branch: target.branch,
|
|
779
|
+
outcome: "noop"
|
|
780
|
+
});
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const exempt = target.repo === "" ? await (options.scanner ?? createRepoScanner()).findNestedRepos(ws.path).then((rs) => rs.map((r) => r.relPath)) : void 0;
|
|
785
|
+
await options.git.merge(repoRoot, target.branch, exempt);
|
|
786
|
+
results.push({
|
|
787
|
+
repo: target.repo,
|
|
788
|
+
branch: target.branch,
|
|
789
|
+
outcome: "merged"
|
|
790
|
+
});
|
|
791
|
+
} catch (error) {
|
|
792
|
+
results.push({
|
|
793
|
+
repo: target.repo,
|
|
794
|
+
branch: target.branch,
|
|
795
|
+
outcome: "failed",
|
|
796
|
+
error: error instanceof Error ? error.message : String(error)
|
|
797
|
+
});
|
|
798
|
+
}
|
|
712
799
|
}
|
|
713
|
-
const
|
|
714
|
-
id: newCommentId(),
|
|
715
|
-
body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`),
|
|
716
|
-
version: 1,
|
|
717
|
-
createdAt: options.now()
|
|
718
|
-
};
|
|
719
|
-
await store.mutate("comment-added", (ledger) => {
|
|
800
|
+
const pushComment = (body) => store.mutate("comment-added", (ledger) => {
|
|
720
801
|
const { index, task: fresh } = liveTaskAt(ledger, id);
|
|
721
802
|
const next = structuredClone(fresh);
|
|
722
|
-
next.comments.push(
|
|
803
|
+
next.comments.push({
|
|
804
|
+
id: newCommentId(),
|
|
805
|
+
body: normalizeBody(body),
|
|
806
|
+
version: 1,
|
|
807
|
+
createdAt: options.now()
|
|
808
|
+
});
|
|
723
809
|
next.version = fresh.version + 1;
|
|
724
810
|
next.updatedAt = options.now();
|
|
725
811
|
ledger.tasks[index] = next;
|
|
726
812
|
return [next];
|
|
727
|
-
});
|
|
813
|
+
}).then(() => void 0);
|
|
814
|
+
if (!multi) {
|
|
815
|
+
const root = results.find((r) => r.repo === "");
|
|
816
|
+
if (root === void 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
|
|
817
|
+
if (root.outcome === "noop") {
|
|
818
|
+
json(res, {
|
|
819
|
+
ok: true,
|
|
820
|
+
value: {
|
|
821
|
+
merged: false,
|
|
822
|
+
noop: true,
|
|
823
|
+
branch: root.branch
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (root.outcome === "failed") throw new Error(`Error: invalid_input: ${root.error ?? "合并失败"}`);
|
|
829
|
+
await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`);
|
|
830
|
+
json(res, {
|
|
831
|
+
ok: true,
|
|
832
|
+
value: {
|
|
833
|
+
merged: true,
|
|
834
|
+
branch: root.branch
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const labelOf = (repo) => repo === "" ? "根仓库" : repo;
|
|
840
|
+
const mergedCount = results.filter((r) => r.outcome === "merged").length;
|
|
841
|
+
const failedCount = results.filter((r) => r.outcome === "failed").length;
|
|
842
|
+
await pushComment(`[系统] 分支已按仓库合并(--no-ff):${results.map((r) => r.outcome === "merged" ? `${labelOf(r.repo)} ✓ 已合并` : r.outcome === "noop" ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? "合并失败").slice(0, 150)}`).join(";")}`);
|
|
728
843
|
json(res, {
|
|
729
844
|
ok: true,
|
|
730
845
|
value: {
|
|
731
|
-
merged:
|
|
732
|
-
|
|
846
|
+
merged: mergedCount > 0,
|
|
847
|
+
...mergedCount === 0 && failedCount === 0 ? { noop: true } : {},
|
|
848
|
+
results
|
|
733
849
|
}
|
|
734
850
|
});
|
|
735
851
|
return;
|
|
@@ -745,7 +861,14 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
745
861
|
const path = worktreePathOf(ws.path, id);
|
|
746
862
|
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
747
863
|
try {
|
|
748
|
-
|
|
864
|
+
await removeMirror({
|
|
865
|
+
git: options.git,
|
|
866
|
+
scanner: options.scanner ?? createRepoScanner()
|
|
867
|
+
}, {
|
|
868
|
+
workspacePath: ws.path,
|
|
869
|
+
taskId: id
|
|
870
|
+
});
|
|
871
|
+
await rm(path, {
|
|
749
872
|
recursive: true,
|
|
750
873
|
force: true
|
|
751
874
|
});
|
|
@@ -754,11 +877,24 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
754
877
|
}
|
|
755
878
|
let branchDeleted = false;
|
|
756
879
|
let branchError;
|
|
757
|
-
if (body.deleteBranch === true
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
880
|
+
if (body.deleteBranch === true) {
|
|
881
|
+
const branchTargets = [];
|
|
882
|
+
if (task.branches !== void 0) for (const [repo, branch] of Object.entries(task.branches)) branchTargets.push({
|
|
883
|
+
repo,
|
|
884
|
+
branch
|
|
885
|
+
});
|
|
886
|
+
if (task.branch !== void 0 && !branchTargets.some((t) => t.repo === "")) branchTargets.push({
|
|
887
|
+
repo: "",
|
|
888
|
+
branch: task.branch
|
|
889
|
+
});
|
|
890
|
+
let failures = 0;
|
|
891
|
+
for (const target of branchTargets) try {
|
|
892
|
+
await options.git.deleteBranch(repoMainPath(ws.path, target.repo), target.branch);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
failures += 1;
|
|
895
|
+
branchError = `${target.repo === "" ? "根仓库" : target.repo}:${error instanceof Error ? error.message : String(error)}`;
|
|
896
|
+
}
|
|
897
|
+
branchDeleted = failures === 0;
|
|
762
898
|
}
|
|
763
899
|
json(res, {
|
|
764
900
|
ok: true,
|
|
@@ -792,7 +928,14 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
792
928
|
const path = worktreePathOf(ws.path, taskId);
|
|
793
929
|
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
794
930
|
try {
|
|
795
|
-
|
|
931
|
+
await removeMirror({
|
|
932
|
+
git: options.git,
|
|
933
|
+
scanner: options.scanner ?? createRepoScanner()
|
|
934
|
+
}, {
|
|
935
|
+
workspacePath: ws.path,
|
|
936
|
+
taskId
|
|
937
|
+
});
|
|
938
|
+
await rm(path, {
|
|
796
939
|
recursive: true,
|
|
797
940
|
force: true
|
|
798
941
|
});
|