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