dsh-taskboard 0.2.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -4
- package/lib/client.js +635 -23
- package/lib/host/execution.js +194 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +234 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/routes.js +252 -4
- package/lib/host/routes.js.map +1 -1
- package/lib/host/tools.js +16 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +17 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +10 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +19 -3
- package/src/client/board/TaskBoard.tsx +73 -0
- package/src/client/board/TaskDetail.tsx +173 -1
- package/src/client/board/TaskFormModal.tsx +100 -1
- package/src/client/controller.ts +89 -6
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +45 -0
- package/src/host/execution.ts +291 -65
- package/src/host/git.ts +293 -0
- package/src/host/routes.ts +268 -5
- package/src/host/tools.ts +17 -0
- package/src/index.ts +24 -1
- package/src/shared/api.ts +35 -3
- package/src/shared/protocol.ts +64 -0
- package/src/shared/version.ts +1 -1
package/lib/host/git.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
//#region src/host/git.ts
|
|
2
|
+
/** Timeout for quick read-only queries (rev-parse / status / log / diff). */
|
|
3
|
+
const QUICK_TIMEOUT_MS = 2e3;
|
|
4
|
+
/** Timeout for structural operations (worktree add/remove, merge, branch). */
|
|
5
|
+
const HEAVY_TIMEOUT_MS = 15e3;
|
|
6
|
+
/** Directory under a workspace where task worktrees live. */
|
|
7
|
+
const WORKTREE_DIR = ".dsh-worktrees";
|
|
8
|
+
/**
|
|
9
|
+
* Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).
|
|
10
|
+
*
|
|
11
|
+
* Title sanitizing: whitespace runs collapse to `-`; git-illegal characters
|
|
12
|
+
* (`~ ^ : ? * [ \ / @ { }` and friends) are stripped; `..` collapses; the
|
|
13
|
+
* segment is trimmed of leading/trailing `.-` and truncated to ~20 code
|
|
14
|
+
* points; an empty result falls back to the bare `task/<taskId>`.
|
|
15
|
+
* @param title - the task title (already normalized 1..200 chars).
|
|
16
|
+
* @param taskId - the task id (stable suffix).
|
|
17
|
+
* @returns the branch name.
|
|
18
|
+
*/
|
|
19
|
+
function sanitizeBranchName(title, taskId) {
|
|
20
|
+
const segment = title.trim().replace(/\s+/g, "-").replace(/[/\\~^:?*[\]@{}"'<>|#%&;$!`'=,;()]+/g, "").replace(/\.\.+/g, ".").replace(/^[-.\s]+|[-.\s]+$/g, "");
|
|
21
|
+
const head = Array.from(segment).slice(0, 20).join("").replace(/^[-.]+|[-.]+$/g, "");
|
|
22
|
+
return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`;
|
|
23
|
+
}
|
|
24
|
+
/** The canonical worktree path of a task inside its workspace (forward slashes). */
|
|
25
|
+
function worktreePathOf(workspacePath, taskId) {
|
|
26
|
+
return `${workspacePath.replace(/[\\/]+$/, "").replaceAll("\\", "/")}/${WORKTREE_DIR}/${taskId}`;
|
|
27
|
+
}
|
|
28
|
+
/** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */
|
|
29
|
+
const realExec = (args, options) => new Promise((resolve) => {
|
|
30
|
+
(async () => {
|
|
31
|
+
const { execFile } = await import("node:child_process");
|
|
32
|
+
execFile("git", args, {
|
|
33
|
+
cwd: options.cwd,
|
|
34
|
+
timeout: options.timeout ?? QUICK_TIMEOUT_MS,
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
37
|
+
encoding: "utf8"
|
|
38
|
+
}, (error, stdout, stderr) => {
|
|
39
|
+
resolve({
|
|
40
|
+
ok: error === null,
|
|
41
|
+
stdout: String(stdout ?? ""),
|
|
42
|
+
stderr: String(stderr ?? "")
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
})().catch(() => resolve({
|
|
46
|
+
ok: false,
|
|
47
|
+
stdout: "",
|
|
48
|
+
stderr: "exec unavailable"
|
|
49
|
+
}));
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Build a {@link GitFace} over an injectable exec layer.
|
|
53
|
+
* @param exec - the exec function (real `git` when omitted).
|
|
54
|
+
*/
|
|
55
|
+
function createGitFace(exec = realExec) {
|
|
56
|
+
const quick = (args, cwd) => exec(args, {
|
|
57
|
+
cwd,
|
|
58
|
+
timeout: QUICK_TIMEOUT_MS
|
|
59
|
+
});
|
|
60
|
+
const heavy = (args, cwd) => exec(args, {
|
|
61
|
+
cwd,
|
|
62
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
63
|
+
});
|
|
64
|
+
const locks = /* @__PURE__ */ new Map();
|
|
65
|
+
const withRootLock = (root, fn) => {
|
|
66
|
+
const next = (locks.get(root) ?? Promise.resolve()).then(fn, fn);
|
|
67
|
+
locks.set(root, next.catch(() => {}));
|
|
68
|
+
return next;
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
async detect(root) {
|
|
72
|
+
const r = await quick(["rev-parse", "--is-inside-work-tree"], root);
|
|
73
|
+
return r.ok && r.stdout.trim() === "true";
|
|
74
|
+
},
|
|
75
|
+
async binaryAvailable() {
|
|
76
|
+
const r = await quick(["--version"]);
|
|
77
|
+
return r.ok && r.stdout.startsWith("git version");
|
|
78
|
+
},
|
|
79
|
+
prepareWorktree: (root, path, branch, mode = "fresh") => withRootLock(root, async () => {
|
|
80
|
+
if (mode === "reuse") {
|
|
81
|
+
const wtHead = await quick(["rev-parse", "HEAD"], path);
|
|
82
|
+
if (wtHead.ok && wtHead.stdout.trim().length > 0) return {
|
|
83
|
+
path,
|
|
84
|
+
branch,
|
|
85
|
+
baseCommit: wtHead.stdout.trim(),
|
|
86
|
+
reused: true
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const head = await quick(["rev-parse", "HEAD"], root);
|
|
90
|
+
if (!head.ok) return void 0;
|
|
91
|
+
const baseCommit = head.stdout.trim();
|
|
92
|
+
if ((await quick([
|
|
93
|
+
"show-ref",
|
|
94
|
+
"--verify",
|
|
95
|
+
`refs/heads/${branch}`
|
|
96
|
+
], root)).ok) {
|
|
97
|
+
await heavy([
|
|
98
|
+
"worktree",
|
|
99
|
+
"remove",
|
|
100
|
+
"--force",
|
|
101
|
+
path
|
|
102
|
+
], root);
|
|
103
|
+
await heavy(["worktree", "prune"], root);
|
|
104
|
+
if (!(await heavy([
|
|
105
|
+
"branch",
|
|
106
|
+
"-f",
|
|
107
|
+
branch,
|
|
108
|
+
"HEAD"
|
|
109
|
+
], root)).ok) return void 0;
|
|
110
|
+
if (!(await heavy([
|
|
111
|
+
"worktree",
|
|
112
|
+
"add",
|
|
113
|
+
path,
|
|
114
|
+
branch
|
|
115
|
+
], root)).ok) return void 0;
|
|
116
|
+
} else if (!(await heavy([
|
|
117
|
+
"worktree",
|
|
118
|
+
"add",
|
|
119
|
+
"-b",
|
|
120
|
+
branch,
|
|
121
|
+
path
|
|
122
|
+
], root)).ok) return void 0;
|
|
123
|
+
return {
|
|
124
|
+
path,
|
|
125
|
+
branch,
|
|
126
|
+
baseCommit
|
|
127
|
+
};
|
|
128
|
+
}),
|
|
129
|
+
async collect(worktreePath, baseCommit) {
|
|
130
|
+
const facts = {
|
|
131
|
+
commits: [],
|
|
132
|
+
commitsTotal: 0,
|
|
133
|
+
dirtyFiles: [],
|
|
134
|
+
dirtyFilesTotal: 0,
|
|
135
|
+
changedFiles: 0
|
|
136
|
+
};
|
|
137
|
+
const range = `${baseCommit}..HEAD`;
|
|
138
|
+
const head = await quick(["rev-parse", "HEAD"], worktreePath);
|
|
139
|
+
if (head.ok) facts.headCommit = head.stdout.trim();
|
|
140
|
+
const log = await quick([
|
|
141
|
+
"log",
|
|
142
|
+
"--pretty=format:%h %s",
|
|
143
|
+
range
|
|
144
|
+
], worktreePath);
|
|
145
|
+
if (log.ok) {
|
|
146
|
+
const commits = log.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
|
|
147
|
+
const space = line.indexOf(" ");
|
|
148
|
+
return space === -1 ? {
|
|
149
|
+
hash: line,
|
|
150
|
+
subject: ""
|
|
151
|
+
} : {
|
|
152
|
+
hash: line.slice(0, space),
|
|
153
|
+
subject: line.slice(space + 1)
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
facts.commitsTotal = commits.length;
|
|
157
|
+
facts.commits = commits.slice(0, 50);
|
|
158
|
+
}
|
|
159
|
+
const status = await quick(["status", "--porcelain"], worktreePath);
|
|
160
|
+
if (status.ok) {
|
|
161
|
+
const dirty = status.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
162
|
+
facts.dirtyFilesTotal = dirty.length;
|
|
163
|
+
facts.dirtyFiles = dirty.slice(0, 100);
|
|
164
|
+
}
|
|
165
|
+
const shortstat = await quick([
|
|
166
|
+
"diff",
|
|
167
|
+
"--shortstat",
|
|
168
|
+
range
|
|
169
|
+
], worktreePath);
|
|
170
|
+
if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim();
|
|
171
|
+
const names = await quick([
|
|
172
|
+
"diff",
|
|
173
|
+
"--name-only",
|
|
174
|
+
range
|
|
175
|
+
], worktreePath);
|
|
176
|
+
if (names.ok) facts.changedFiles = names.stdout.split("\n").filter((l) => l.trim().length > 0).length;
|
|
177
|
+
return facts;
|
|
178
|
+
},
|
|
179
|
+
merge: (root, branch) => withRootLock(root, async () => {
|
|
180
|
+
const status = await quick(["status", "--porcelain"], root);
|
|
181
|
+
if (status.ok) {
|
|
182
|
+
const dirtyLines = status.stdout.split("\n").map((l) => l.trim()).filter((l) => {
|
|
183
|
+
if (l.length === 0) return false;
|
|
184
|
+
const path = l.slice(3);
|
|
185
|
+
return path !== ".dsh-worktrees" && !path.startsWith(`.dsh-worktrees/`);
|
|
186
|
+
});
|
|
187
|
+
if (dirtyLines.length > 0) throw new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`);
|
|
188
|
+
}
|
|
189
|
+
const merged = await heavy([
|
|
190
|
+
"merge",
|
|
191
|
+
"--no-ff",
|
|
192
|
+
"--no-edit",
|
|
193
|
+
branch
|
|
194
|
+
], root);
|
|
195
|
+
if (!merged.ok) {
|
|
196
|
+
await heavy(["merge", "--abort"], root);
|
|
197
|
+
throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`);
|
|
198
|
+
}
|
|
199
|
+
}),
|
|
200
|
+
async isAncestor(root, branch) {
|
|
201
|
+
return (await quick([
|
|
202
|
+
"merge-base",
|
|
203
|
+
"--is-ancestor",
|
|
204
|
+
branch,
|
|
205
|
+
"HEAD"
|
|
206
|
+
], root)).ok;
|
|
207
|
+
},
|
|
208
|
+
removeWorktree: (root, worktreePath) => withRootLock(root, async () => {
|
|
209
|
+
const status = await quick(["status", "--porcelain"], worktreePath);
|
|
210
|
+
if (status.ok && status.stdout.trim().length > 0) {
|
|
211
|
+
const lines = status.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
212
|
+
throw new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join("\n")}`);
|
|
213
|
+
}
|
|
214
|
+
const removed = await heavy([
|
|
215
|
+
"worktree",
|
|
216
|
+
"remove",
|
|
217
|
+
worktreePath
|
|
218
|
+
], root);
|
|
219
|
+
if (!removed.ok) throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`);
|
|
220
|
+
}),
|
|
221
|
+
deleteBranch: (root, branch) => withRootLock(root, async () => {
|
|
222
|
+
const deleted = await heavy([
|
|
223
|
+
"branch",
|
|
224
|
+
"-D",
|
|
225
|
+
branch
|
|
226
|
+
], root);
|
|
227
|
+
if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`);
|
|
228
|
+
})
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
export { WORKTREE_DIR, createGitFace, sanitizeBranchName, worktreePathOf };
|
|
233
|
+
|
|
234
|
+
//# sourceMappingURL=git.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git.js","names":[],"sources":["../../src/host/git.ts"],"sourcesContent":["/**\n * Host git face (0.3.0): the ONLY place dsh-taskboard shells out to git.\n * 0.3.1: per-repo serialization of structural operations, binary probing,\n * no-op merge detection, worktree REUSE mode, and evidence size caps.\n *\n * Design invariants (plan §3.4/§3.5):\n * - NARROW interface: detect / binaryAvailable / prepareWorktree / collect /\n * merge / isAncestor / removeWorktree / deleteBranch — nothing else leaks\n * into the plugin.\n * - FAIL-SOFT: every call has a timeout and resolves to a benign result\n * (false / undefined / empty facts) on ANY git failure — a missing git,\n * a locked worktree, or a damaged repo degrades execution to the original\n * directory and NEVER fails the ledger or the run pipeline. Only the\n * explicit user actions (merge / remove / deleteBranch) throw, with a\n * readable message the GUI surfaces as-is.\n * - SERIALIZED structural ops: concurrent isolated executions on the SAME\n * repository would race on git's index/worktree locks, so every structural\n * operation (prepareWorktree / merge / removeWorktree / deleteBranch) runs\n * inside a per-root in-process mutex. Read-only collects stay concurrent.\n * - INJECTABLE runner: the exec layer is a single function so unit tests\n * script every path without a real git.\n *\n * @module dsh-taskboard/host/git\n */\nimport type { CommitInfo } from '../shared/protocol.ts'\n\n/** Timeout for quick read-only queries (rev-parse / status / log / diff). */\nconst QUICK_TIMEOUT_MS = 2_000\n\n/** Timeout for structural operations (worktree add/remove, merge, branch). */\nconst HEAVY_TIMEOUT_MS = 15_000\n\n/** Directory under a workspace where task worktrees live. */\nexport const WORKTREE_DIR = '.dsh-worktrees'\n\n/** Evidence caps: commits kept per execution record (newest first). */\nexport const MAX_COMMIT_EVIDENCE = 50\n\n/** Evidence caps: uncommitted-change lines kept per execution record. */\nexport const MAX_DIRTY_EVIDENCE = 100\n\n/** Result of one underlying exec: `ok` is exit-0, output never null. */\nexport interface ExecResult { ok: boolean; stdout: string; stderr: string }\n\n/** The injectable exec layer: run `git <args>` under a cwd with a timeout. */\nexport type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>\n\n/** Facts needed to open an isolated execution. */\nexport interface WorktreeInfo {\n /** Absolute worktree path (the session's cwd). */\n path: string\n /** The task branch checked out there. */\n branch: string\n /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Settlement facts collected from a worktree (partial on best-effort basis). */\nexport interface SettlementFacts {\n headCommit?: string\n commits: CommitInfo[]\n /** Total commits before capping (equals commits.length when under the cap). */\n commitsTotal: number\n dirtyFiles: string[]\n /** Total uncommitted lines before capping. */\n dirtyFilesTotal: number\n diffStat?: string\n changedFiles: number\n}\n\n/** The narrow git face the rest of the plugin depends on. */\nexport interface GitFace {\n /** Whether `root` sits inside a usable git work tree (fail-soft → false). */\n detect(root: string): Promise<boolean>\n /** Whether a usable git binary answers at all (distinguishes 未装 git vs 非 git 仓库). */\n binaryAvailable(): Promise<boolean>\n /**\n * Ensure a worktree at `path` on `branch`. Default mode `'fresh'` resets to\n * the main worktree's current HEAD (每次全新); mode `'reuse'` keeps a live\n * worktree exactly as-is (续跑 — agent's commits and uncommitted changes\n * survive) and falls back to a fresh creation when none is alive. Resolves\n * undefined on any failure — callers degrade to the original directory.\n */\n prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>\n /** Collect settlement facts (never throws; missing pieces stay unset). */\n collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>\n /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */\n merge(root: string, branch: string): Promise<void>\n /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */\n isAncestor(root: string, branch: string): Promise<boolean>\n /** Remove a worktree; THROWS when it still has uncommitted changes. */\n removeWorktree(root: string, worktreePath: string): Promise<void>\n /** Delete a branch; THROWS (e.g. still checked out in a worktree). */\n deleteBranch(root: string, branch: string): Promise<void>\n}\n\n/**\n * Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).\n *\n * Title sanitizing: whitespace runs collapse to `-`; git-illegal characters\n * (`~ ^ : ? * [ \\ / @ { }` and friends) are stripped; `..` collapses; the\n * segment is trimmed of leading/trailing `.-` and truncated to ~20 code\n * points; an empty result falls back to the bare `task/<taskId>`.\n * @param title - the task title (already normalized 1..200 chars).\n * @param taskId - the task id (stable suffix).\n * @returns the branch name.\n */\nexport function sanitizeBranchName(title: string, taskId: string): string {\n const segment = title.trim()\n .replace(/\\s+/g, '-')\n .replace(/[/\\\\~^:?*[\\]@{}\"'<>|#%&;$!`'=,;()]+/g, '')\n .replace(/\\.\\.+/g, '.')\n .replace(/^[-.\\s]+|[-.\\s]+$/g, '')\n const head = Array.from(segment).slice(0, 20).join('').replace(/^[-.]+|[-.]+$/g, '')\n return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`\n}\n\n/** The canonical worktree path of a task inside its workspace (forward slashes). */\nexport function worktreePathOf(workspacePath: string, taskId: string): string {\n const root = workspacePath.replace(/[\\\\/]+$/, '').replaceAll('\\\\', '/')\n return `${root}/${WORKTREE_DIR}/${taskId}`\n}\n\n/** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */\nconst realExec: ExecFn = (args, options) => new Promise(resolve => {\n void (async () => {\n const { execFile } = await import('node:child_process')\n execFile('git', args, {\n cwd: options.cwd,\n timeout: options.timeout ?? QUICK_TIMEOUT_MS,\n windowsHide: true,\n maxBuffer: 4 * 1024 * 1024,\n encoding: 'utf8',\n }, (error, stdout, stderr) => {\n resolve({ ok: error === null, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })\n })\n })().catch(() => resolve({ ok: false, stdout: '', stderr: 'exec unavailable' }))\n})\n\n/**\n * Build a {@link GitFace} over an injectable exec layer.\n * @param exec - the exec function (real `git` when omitted).\n */\nexport function createGitFace(exec: ExecFn = realExec): GitFace {\n const quick = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: QUICK_TIMEOUT_MS })\n const heavy = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: HEAVY_TIMEOUT_MS })\n\n // Per-root mutex (0.3.1): structural git ops on the SAME repository run one\n // at a time — concurrent isolated executions must not race on git's locks.\n const locks = new Map<string, Promise<unknown>>()\n const withRootLock = <T>(root: string, fn: () => Promise<T>): Promise<T> => {\n const prev = locks.get(root) ?? Promise.resolve()\n const next = prev.then(fn, fn)\n locks.set(root, next.catch(() => { /* the chain never blocks later ops */ }))\n return next\n }\n\n return {\n async detect(root) {\n const r = await quick(['rev-parse', '--is-inside-work-tree'], root)\n return r.ok && r.stdout.trim() === 'true'\n },\n\n async binaryAvailable() {\n const r = await quick(['--version'])\n return r.ok && r.stdout.startsWith('git version')\n },\n\n prepareWorktree: (root, path, branch, mode = 'fresh') => withRootLock(root, async () => {\n // 续跑: a live worktree at the path is kept EXACTLY as-is — the agent's\n // commits and uncommitted changes survive; the baseline becomes the\n // worktree's own HEAD so evidence covers only the new run.\n if (mode === 'reuse') {\n const wtHead = await quick(['rev-parse', 'HEAD'], path)\n if (wtHead.ok && wtHead.stdout.trim().length > 0) {\n return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }\n }\n // No live worktree → fall through to a fresh preparation.\n }\n\n // Baseline: the main worktree's current HEAD (also validates the repo).\n const head = await quick(['rev-parse', 'HEAD'], root)\n if (!head.ok) return undefined\n const baseCommit = head.stdout.trim()\n\n const exists = await quick(['show-ref', '--verify', `refs/heads/${branch}`], root)\n if (exists.ok) {\n // Reuse the fixed branch name, but guarantee a FRESH baseline: drop\n // any stale worktree at the path, move the branch to the current\n // HEAD, then check the branch out again (每次全新,复用仅作选项保留).\n await heavy(['worktree', 'remove', '--force', path], root)\n await heavy(['worktree', 'prune'], root)\n const moved = await heavy(['branch', '-f', branch, 'HEAD'], root)\n if (!moved.ok) return undefined\n const added = await heavy(['worktree', 'add', path, branch], root)\n if (!added.ok) return undefined\n } else {\n const added = await heavy(['worktree', 'add', '-b', branch, path], root)\n if (!added.ok) return undefined\n }\n return { path, branch, baseCommit }\n }),\n\n async collect(worktreePath, baseCommit) {\n const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }\n const range = `${baseCommit}..HEAD`\n\n const head = await quick(['rev-parse', 'HEAD'], worktreePath)\n if (head.ok) facts.headCommit = head.stdout.trim()\n\n const log = await quick(['log', '--pretty=format:%h %s', range], worktreePath)\n if (log.ok) {\n const commits = log.stdout.split('\\n')\n .map(line => line.trim())\n .filter(line => line.length > 0)\n .map(line => {\n const space = line.indexOf(' ')\n return space === -1\n ? { hash: line, subject: '' }\n : { hash: line.slice(0, space), subject: line.slice(space + 1) }\n })\n // Evidence caps (0.3.1): the ledger is rewritten whole on every\n // mutation — cap what a huge branch/status dump can add to it.\n facts.commitsTotal = commits.length\n facts.commits = commits.slice(0, MAX_COMMIT_EVIDENCE)\n }\n\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok) {\n const dirty = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n facts.dirtyFilesTotal = dirty.length\n facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)\n }\n\n const shortstat = await quick(['diff', '--shortstat', range], worktreePath)\n if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim()\n\n const names = await quick(['diff', '--name-only', range], worktreePath)\n if (names.ok) facts.changedFiles = names.stdout.split('\\n').filter(l => l.trim().length > 0).length\n\n return facts\n },\n\n merge: (root, branch) => withRootLock(root, async () => {\n // Main-clean check. The plugin's own worktree directory\n // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —\n // otherwise merging would be impossible without gitignoring it first.\n const status = await quick(['status', '--porcelain'], root)\n if (status.ok) {\n const dirtyLines = status.stdout.split('\\n')\n .map(l => l.trim())\n .filter(l => {\n if (l.length === 0) return false\n const path = l.slice(3)\n return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)\n })\n if (dirtyLines.length > 0) {\n throw new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`)\n }\n }\n const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)\n if (!merged.ok) {\n // Roll the half-finished merge back so the main worktree stays usable;\n // report the ORIGINAL failure verbatim (不自动解决冲突).\n await heavy(['merge', '--abort'], root)\n throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`)\n }\n }),\n\n async isAncestor(root, branch) {\n // exit 0 = branch is an ancestor of (or equal to) HEAD → merge no-op.\n const r = await quick(['merge-base', '--is-ancestor', branch, 'HEAD'], root)\n return r.ok\n },\n\n removeWorktree: (root, worktreePath) => withRootLock(root, async () => {\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok && status.stdout.trim().length > 0) {\n const lines = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n throw new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\\n${lines.slice(0, 10).join('\\n')}`)\n }\n const removed = await heavy(['worktree', 'remove', worktreePath], root)\n if (!removed.ok) throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)\n }),\n\n deleteBranch: (root, branch) => withRootLock(root, async () => {\n const deleted = await heavy(['branch', '-D', branch], root)\n if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)\n }),\n }\n}\n"],"mappings":"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"}
|
package/lib/host/routes.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { asStatus, asUrgency, canTransition, newCommentId, newTaskId, normalizeBody, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim } from "../shared/protocol.js";
|
|
1
|
+
import { asIsolation, asStatus, asUrgency, canTransition, newCommentId, newTaskId, normalizeBody, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim } from "../shared/protocol.js";
|
|
2
|
+
import { WORKTREE_DIR, worktreePathOf } from "./git.js";
|
|
2
3
|
import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readdir, rm } from "node:fs/promises";
|
|
3
6
|
//#region src/host/routes.ts
|
|
4
7
|
/** Heartbeat cadence for the SSE stream. */
|
|
5
8
|
const HEARTBEAT_MS = 2e4;
|
|
9
|
+
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
10
|
+
const GIT_DETECT_TTL_MS = 6e4;
|
|
6
11
|
/** Validate a pinned model: structural check always, provider route when known. */
|
|
7
12
|
function checkModel(raw, modelProviders) {
|
|
8
13
|
const model = normalizeModel(raw);
|
|
@@ -55,6 +60,11 @@ function num(body, key) {
|
|
|
55
60
|
if (v === void 0) return void 0;
|
|
56
61
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
57
62
|
}
|
|
63
|
+
/** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
|
|
64
|
+
function normalizePresetId(raw) {
|
|
65
|
+
const t = (raw ?? "").trim();
|
|
66
|
+
return t.length === 0 ? void 0 : t;
|
|
67
|
+
}
|
|
58
68
|
/** Map a thrown domain error to the envelope. */
|
|
59
69
|
function toFail(error) {
|
|
60
70
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -89,6 +99,68 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
89
99
|
for (const res of subscribers) res.write(frame);
|
|
90
100
|
};
|
|
91
101
|
store.subscribe(broadcast);
|
|
102
|
+
const gitCache = /* @__PURE__ */ new Map();
|
|
103
|
+
const gitHinted = /* @__PURE__ */ new Set();
|
|
104
|
+
/** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
|
|
105
|
+
const gitignoreMissing = async (path) => {
|
|
106
|
+
try {
|
|
107
|
+
const { readFile } = await import("node:fs/promises");
|
|
108
|
+
return !(await readFile(join(path, ".gitignore"), "utf8")).split("\n").some((l) => {
|
|
109
|
+
const t = l.trim().replace(/\/+$/, "");
|
|
110
|
+
return t === ".dsh-worktrees" || t === `/.dsh-worktrees`;
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const gitAvailable = async (path) => {
|
|
117
|
+
if (options.git === void 0) return false;
|
|
118
|
+
const hit = gitCache.get(path);
|
|
119
|
+
if (hit !== void 0 && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value;
|
|
120
|
+
let value = false;
|
|
121
|
+
try {
|
|
122
|
+
value = await options.git.detect(path);
|
|
123
|
+
} catch {}
|
|
124
|
+
gitCache.set(path, {
|
|
125
|
+
value,
|
|
126
|
+
at: options.now()
|
|
127
|
+
});
|
|
128
|
+
if (value && !gitHinted.has(path)) {
|
|
129
|
+
gitHinted.add(path);
|
|
130
|
+
if (await gitignoreMissing(path)) console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
};
|
|
134
|
+
/** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
|
|
135
|
+
const listOrphanWorktrees = async () => {
|
|
136
|
+
const orphans = [];
|
|
137
|
+
const known = new Set(store.snapshot().tasks.map((t) => t.id));
|
|
138
|
+
for (const ws of workspaces.list()) {
|
|
139
|
+
let entries = [];
|
|
140
|
+
try {
|
|
141
|
+
entries = (await readdir(join(ws.path, WORKTREE_DIR), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
142
|
+
} catch {}
|
|
143
|
+
for (const taskId of entries) if (!known.has(taskId)) orphans.push({
|
|
144
|
+
workspaceId: ws.id,
|
|
145
|
+
workspacePath: ws.path,
|
|
146
|
+
taskId,
|
|
147
|
+
path: worktreePathOf(ws.path, taskId)
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return orphans;
|
|
151
|
+
};
|
|
152
|
+
/** Git-enabled workspaces whose .gitignore does not cover the worktree dir. */
|
|
153
|
+
const listGitignoreSuggestions = async () => {
|
|
154
|
+
const suggestions = [];
|
|
155
|
+
for (const ws of workspaces.list()) {
|
|
156
|
+
if (!await gitAvailable(ws.path)) continue;
|
|
157
|
+
if (await gitignoreMissing(ws.path)) suggestions.push({
|
|
158
|
+
workspaceId: ws.id,
|
|
159
|
+
workspacePath: ws.path
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return suggestions;
|
|
163
|
+
};
|
|
92
164
|
const handler = async (req, res) => {
|
|
93
165
|
try {
|
|
94
166
|
const pathname = new URL(req.url ?? "/", "http://x").pathname;
|
|
@@ -102,9 +174,31 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
102
174
|
return;
|
|
103
175
|
}
|
|
104
176
|
if (pathname === `/dsh-taskboard/workspaces`) {
|
|
177
|
+
const list = workspaces.list();
|
|
178
|
+
const flags = await Promise.all(list.map((ws) => gitAvailable(ws.path)));
|
|
105
179
|
json(res, {
|
|
106
180
|
ok: true,
|
|
107
|
-
value:
|
|
181
|
+
value: list.map((ws, i) => ({
|
|
182
|
+
...ws,
|
|
183
|
+
sessionCount: 0,
|
|
184
|
+
gitAvailable: flags[i]
|
|
185
|
+
}))
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (pathname === `/dsh-taskboard/diagnostics`) {
|
|
190
|
+
const ledger = store.snapshot();
|
|
191
|
+
let staleRunning = 0;
|
|
192
|
+
for (const t of ledger.tasks) for (const e of t.executions) if (e.outcome === "running") staleRunning += 1;
|
|
193
|
+
json(res, {
|
|
194
|
+
ok: true,
|
|
195
|
+
value: {
|
|
196
|
+
revision: ledger.revision,
|
|
197
|
+
tasks: ledger.tasks.length,
|
|
198
|
+
staleRunning,
|
|
199
|
+
orphanWorktrees: await listOrphanWorktrees(),
|
|
200
|
+
gitIgnoreSuggestions: await listGitignoreSuggestions()
|
|
201
|
+
}
|
|
108
202
|
});
|
|
109
203
|
return;
|
|
110
204
|
}
|
|
@@ -149,6 +243,9 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
149
243
|
const status = str(body, "status") === null ? "todo" : asStatus(str(body, "status"));
|
|
150
244
|
const execution = normalizeExecution(body.execution ?? {}, options.now());
|
|
151
245
|
const model = body.model === void 0 ? void 0 : checkModel(body.model, options.modelProviders);
|
|
246
|
+
const isolationRaw = str(body, "isolation");
|
|
247
|
+
const isolation = isolationRaw === null ? void 0 : asIsolation(isolationRaw);
|
|
248
|
+
const presetId = normalizePresetId(str(body, "presetId"));
|
|
152
249
|
const now = options.now();
|
|
153
250
|
const task = {
|
|
154
251
|
id: newTaskId(),
|
|
@@ -161,6 +258,8 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
161
258
|
blocked: false,
|
|
162
259
|
execution,
|
|
163
260
|
model,
|
|
261
|
+
...isolation !== void 0 ? { isolation } : {},
|
|
262
|
+
...presetId !== void 0 ? { presetId } : {},
|
|
164
263
|
version: 1,
|
|
165
264
|
createdAt: now,
|
|
166
265
|
updatedAt: now,
|
|
@@ -183,7 +282,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
183
282
|
}
|
|
184
283
|
return;
|
|
185
284
|
}
|
|
186
|
-
const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\w+)$`));
|
|
285
|
+
const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`));
|
|
187
286
|
if (actionMatch !== null) {
|
|
188
287
|
const id = actionMatch[1];
|
|
189
288
|
const action = actionMatch[2];
|
|
@@ -212,6 +311,13 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
212
311
|
if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
|
|
213
312
|
if (body.model === null) next.model = void 0;
|
|
214
313
|
else if (body.model !== void 0) next.model = checkModel(body.model, options.modelProviders);
|
|
314
|
+
const isolationRaw = str(body, "isolation");
|
|
315
|
+
if (isolationRaw !== null) {
|
|
316
|
+
if (task.executions.length > 0 || task.status === "in_progress") throw new Error("Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改");
|
|
317
|
+
next.isolation = asIsolation(isolationRaw);
|
|
318
|
+
}
|
|
319
|
+
if (body.presetId === null) delete next.presetId;
|
|
320
|
+
else if (body.presetId !== void 0) next.presetId = normalizePresetId(str(body, "presetId"));
|
|
215
321
|
next.version = task.version + 1;
|
|
216
322
|
next.updatedAt = options.now();
|
|
217
323
|
next.updatedBy = { kind: "user" };
|
|
@@ -306,6 +412,26 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
306
412
|
if (action === "delete") {
|
|
307
413
|
if (body.purge === true) {
|
|
308
414
|
if (task.trashedAt === void 0) throw new Error("Error: invalid_input: purge requires a trashed task (soft-delete first)");
|
|
415
|
+
if (options.git !== void 0) {
|
|
416
|
+
const ws = workspaces.get(task.workspaceId);
|
|
417
|
+
if (ws !== void 0) {
|
|
418
|
+
const path = worktreePathOf(ws.path, id);
|
|
419
|
+
try {
|
|
420
|
+
await options.git.removeWorktree(ws.path, path);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
423
|
+
if (message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
|
|
424
|
+
if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
|
|
425
|
+
recursive: true,
|
|
426
|
+
force: true
|
|
427
|
+
});
|
|
428
|
+
else throw new Error(`Error: invalid_input: ${message}`);
|
|
429
|
+
}
|
|
430
|
+
if (task.branch !== void 0) try {
|
|
431
|
+
await options.git.deleteBranch(ws.path, task.branch);
|
|
432
|
+
} catch {}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
309
435
|
await store.mutate("task-deleted", (ledger) => {
|
|
310
436
|
ledger.tasks = ledger.tasks.filter((t) => t.id !== id);
|
|
311
437
|
return [];
|
|
@@ -338,7 +464,8 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
338
464
|
json(res, fail("invalid_input", "execution service unavailable").res, 501);
|
|
339
465
|
return;
|
|
340
466
|
}
|
|
341
|
-
const
|
|
467
|
+
const runOptions = body.reuse === true ? { reuseWorktree: true } : void 0;
|
|
468
|
+
const result = await options.run(id, runOptions);
|
|
342
469
|
if (result.ok) json(res, {
|
|
343
470
|
ok: true,
|
|
344
471
|
value: result
|
|
@@ -368,6 +495,92 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
368
495
|
}
|
|
369
496
|
return;
|
|
370
497
|
}
|
|
498
|
+
if (action === "merge") {
|
|
499
|
+
if (options.git === void 0) {
|
|
500
|
+
json(res, fail("invalid_input", "git integration unavailable").res, 501);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (task.branch === void 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
|
|
504
|
+
if (task.status === "in_progress") throw new Error("Error: invalid_input: 任务执行中,不能合并");
|
|
505
|
+
if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务执行中,不能合并");
|
|
506
|
+
const ws = workspaces.get(task.workspaceId);
|
|
507
|
+
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
508
|
+
let noop = false;
|
|
509
|
+
try {
|
|
510
|
+
noop = await options.git.isAncestor(ws.path, task.branch);
|
|
511
|
+
} catch {}
|
|
512
|
+
if (noop) {
|
|
513
|
+
json(res, {
|
|
514
|
+
ok: true,
|
|
515
|
+
value: {
|
|
516
|
+
merged: false,
|
|
517
|
+
noop: true,
|
|
518
|
+
branch: task.branch
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
await options.git.merge(ws.path, task.branch);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
|
|
527
|
+
}
|
|
528
|
+
const mergedComment = {
|
|
529
|
+
id: newCommentId(),
|
|
530
|
+
body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`),
|
|
531
|
+
version: 1,
|
|
532
|
+
createdAt: options.now()
|
|
533
|
+
};
|
|
534
|
+
const next = structuredClone(task);
|
|
535
|
+
next.comments.push(mergedComment);
|
|
536
|
+
next.version = task.version + 1;
|
|
537
|
+
next.updatedAt = options.now();
|
|
538
|
+
await store.mutate("comment-added", (ledger) => {
|
|
539
|
+
const i = ledger.tasks.findIndex((t) => t.id === id);
|
|
540
|
+
ledger.tasks[i] = next;
|
|
541
|
+
return [next];
|
|
542
|
+
});
|
|
543
|
+
json(res, {
|
|
544
|
+
ok: true,
|
|
545
|
+
value: {
|
|
546
|
+
merged: true,
|
|
547
|
+
branch: task.branch
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (action === "worktree-remove") {
|
|
553
|
+
if (options.git === void 0) {
|
|
554
|
+
json(res, fail("invalid_input", "git integration unavailable").res, 501);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务执行中,不能删除 worktree");
|
|
558
|
+
const ws = workspaces.get(task.workspaceId);
|
|
559
|
+
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
560
|
+
const path = worktreePathOf(ws.path, id);
|
|
561
|
+
try {
|
|
562
|
+
await options.git.removeWorktree(ws.path, path);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
|
|
565
|
+
}
|
|
566
|
+
let branchDeleted = false;
|
|
567
|
+
let branchError;
|
|
568
|
+
if (body.deleteBranch === true && task.branch !== void 0) try {
|
|
569
|
+
await options.git.deleteBranch(ws.path, task.branch);
|
|
570
|
+
branchDeleted = true;
|
|
571
|
+
} catch (error) {
|
|
572
|
+
branchError = error instanceof Error ? error.message : String(error);
|
|
573
|
+
}
|
|
574
|
+
json(res, {
|
|
575
|
+
ok: true,
|
|
576
|
+
value: {
|
|
577
|
+
removed: true,
|
|
578
|
+
branchDeleted,
|
|
579
|
+
...branchError !== void 0 ? { branchError } : {}
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
371
584
|
const f = fail("not_found", `unknown action ${action}`);
|
|
372
585
|
json(res, f.res, f.status);
|
|
373
586
|
} catch (error) {
|
|
@@ -376,6 +589,41 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
376
589
|
}
|
|
377
590
|
return;
|
|
378
591
|
}
|
|
592
|
+
if (pathname === `/dsh-taskboard/worktree-cleanup`) {
|
|
593
|
+
try {
|
|
594
|
+
if (options.git === void 0) {
|
|
595
|
+
json(res, fail("invalid_input", "git integration unavailable").res, 501);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const workspaceId = str(body, "workspaceId") ?? "";
|
|
599
|
+
const taskId = str(body, "taskId") ?? "";
|
|
600
|
+
const ws = workspaces.get(workspaceId);
|
|
601
|
+
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
602
|
+
if (store.get(taskId) !== void 0) throw new Error("Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree");
|
|
603
|
+
const path = worktreePathOf(ws.path, taskId);
|
|
604
|
+
try {
|
|
605
|
+
await options.git.removeWorktree(ws.path, path);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
608
|
+
if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
|
|
609
|
+
recursive: true,
|
|
610
|
+
force: true
|
|
611
|
+
});
|
|
612
|
+
else throw new Error(`Error: invalid_input: ${message}`);
|
|
613
|
+
}
|
|
614
|
+
json(res, {
|
|
615
|
+
ok: true,
|
|
616
|
+
value: {
|
|
617
|
+
cleaned: true,
|
|
618
|
+
path
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
} catch (error) {
|
|
622
|
+
const f = toFail(error);
|
|
623
|
+
json(res, f.res, f.status);
|
|
624
|
+
}
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
379
627
|
res.writeHead(404);
|
|
380
628
|
res.end();
|
|
381
629
|
} catch (error) {
|