zen-gitsync 2.16.51 → 2.17.1

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.
Files changed (119) hide show
  1. package/package.json +12 -2
  2. package/src/cli/ai/agent.js +2 -2
  3. package/src/cli/ai/images.js +5 -3
  4. package/src/cli/ai/modelSetup.js +1 -1
  5. package/src/cli/ai/sessionStore.js +4 -6
  6. package/src/config.js +178 -39
  7. package/src/gitCommit.js +8 -0
  8. package/src/ui/public/assets/AgentView-BsN9y4Kg.css +1 -0
  9. package/src/ui/public/assets/AgentView-YDATCFcA.js +4 -0
  10. package/src/ui/public/assets/AppVersionBadge-Bw4-tHkL.js +2 -0
  11. package/src/ui/public/assets/{AppVersionBadge-CJrDr7GV.css → AppVersionBadge-D5tJzJfa.css} +1 -1
  12. package/src/ui/public/assets/BranchSelector-BZTHIIaF.js +1 -0
  13. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +1 -0
  14. package/src/ui/public/assets/CommitForm-DKedPnu-.js +6 -0
  15. package/src/ui/public/assets/CommitForm-Oij8R6P-.css +1 -0
  16. package/src/ui/public/assets/CommonDialog-C_RjF9Mp.css +1 -0
  17. package/src/ui/public/assets/CommonDialog-OCekbVQM.js +1 -0
  18. package/src/ui/public/assets/EditorView-BgA-_8WV.css +1 -0
  19. package/src/ui/public/assets/EditorView-C5O3ihKq.js +1 -0
  20. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +1 -0
  21. package/src/ui/public/assets/FlowExecutionViewer-vIvDP419.js +1 -0
  22. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +1 -0
  23. package/src/ui/public/assets/FlowOrchestrationWorkspace-SlVXZt60.js +21 -0
  24. package/src/ui/public/assets/LogList-DlPJsPy_.js +7 -0
  25. package/src/ui/public/assets/LogList-DlWwSZet.css +1 -0
  26. package/src/ui/public/assets/MindmapView-B1Go82pJ.js +1 -0
  27. package/src/ui/public/assets/MindmapView-CS2t30Ko.css +1 -0
  28. package/src/ui/public/assets/MonitorView-CphDOlQu.css +1 -0
  29. package/src/ui/public/assets/MonitorView-GQ34uXPs.js +1 -0
  30. package/src/ui/public/assets/ProjectStartupButton-0UNF9CeP.js +1 -0
  31. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +1 -0
  32. package/src/ui/public/assets/RemoteManagerDialog-D51q3x7T.js +1 -0
  33. package/src/ui/public/assets/RemoteManagerDialog-bpwaVAvX.css +1 -0
  34. package/src/ui/public/assets/RemoteRepoCard-Bin9d3GZ.js +1 -0
  35. package/src/ui/public/assets/RemoteRepoCard-CDykPlPj.css +1 -0
  36. package/src/ui/public/assets/SourceMapView-DG_a_076.css +1 -0
  37. package/src/ui/public/assets/SourceMapView-OxlKvnEc.js +3 -0
  38. package/src/ui/public/assets/{SvgIcon-CDNsk4-Q.css → SvgIcon-BQ6bR7l0.css} +1 -1
  39. package/src/ui/public/assets/SvgIcon-BpKq_phu.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-D2EDkZ_H.js +1 -0
  41. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +1 -0
  42. package/src/ui/public/assets/WorkbenchView-DWMdGe8g.css +1 -0
  43. package/src/ui/public/assets/WorkbenchView-DyhIOjGD.js +13 -0
  44. package/src/ui/public/assets/{_plugin-vue_export-helper-N7dqbp6x.js → _plugin-vue_export-helper-BaSe6lmR.js} +3 -3
  45. package/src/ui/public/assets/configStore-CyAQ0a-U.js +1 -0
  46. package/src/ui/public/assets/element-plus-H7kW-grr.js +19 -0
  47. package/src/ui/public/assets/{flow-mindmap-wqtwJcDW.js → flow-mindmap-DEwSVurC.js} +12 -12
  48. package/src/ui/public/assets/index-BXdqTR9A.css +1 -0
  49. package/src/ui/public/assets/index-SjRTI8Of.js +46 -0
  50. package/src/ui/public/assets/office-docx-C67al1tG.js +31 -0
  51. package/src/ui/public/assets/{office-excel-5lGzd5Yy.js → office-excel-ffLec5k6.js} +1 -1
  52. package/src/ui/public/assets/office-pptx-vRGbnHIO.js +60 -0
  53. package/src/ui/public/assets/toolsStore-BcJP9HmK.js +1 -0
  54. package/src/ui/public/assets/{vendor-C6ntdXkR.js → vendor-Dwln8rZH.js} +39 -46
  55. package/src/ui/public/assets/vue-flow-BfBE1dKN.js +39 -0
  56. package/src/ui/public/index.html +13 -13
  57. package/src/ui/server/index.js +9 -0
  58. package/src/ui/server/routes/config.js +9 -6
  59. package/src/ui/server/routes/git/aiDiffSummary.js +2 -2
  60. package/src/ui/server/routes/gitOps.js +63 -7
  61. package/src/ui/server/routes/workbench/agentSessionStore.js +6 -9
  62. package/src/ui/server/routes/workbench/attachmentUtils.js +93 -0
  63. package/src/ui/server/routes/workbench/attachmentUtils.test.js +100 -0
  64. package/src/ui/server/routes/workbench/envContext.js +137 -0
  65. package/src/ui/server/routes/workbench/envContext.test.js +178 -0
  66. package/src/ui/server/routes/workbench/index.js +210 -8
  67. package/src/ui/server/routes/workbench/orchestratorStore.js +6 -0
  68. package/src/ui/server/routes/workbench/pathParity.test.js +73 -0
  69. package/src/ui/server/routes/workbench/projectRegistry.js +37 -16
  70. package/src/ui/server/routes/workbench/projectRegistry.test.js +62 -17
  71. package/src/ui/server/routes/workbench/shared.js +7 -2
  72. package/src/ui/server/routes/workbench/targetResolver.js +260 -0
  73. package/src/ui/server/routes/workbench/targetResolver.test.js +224 -0
  74. package/src/ui/server/routes/workbench/taskRunner.js +42 -0
  75. package/src/ui/server/utils/gitExitCode.js +116 -0
  76. package/src/ui/server/utils/gitExitCode.test.js +108 -0
  77. package/src/ui/server/utils/instanceRegistry.js +17 -6
  78. package/src/ui/public/assets/AgentView-8-xsOmE6.css +0 -1
  79. package/src/ui/public/assets/AgentView-DcTGw8-j.js +0 -4
  80. package/src/ui/public/assets/AppVersionBadge-CA5-POGA.js +0 -2
  81. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  82. package/src/ui/public/assets/BranchSelector-qn-iGHOI.js +0 -1
  83. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  84. package/src/ui/public/assets/CommitForm-DHfyJ331.js +0 -6
  85. package/src/ui/public/assets/CommonDialog-DJBixJcI.js +0 -1
  86. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  87. package/src/ui/public/assets/EditorView-BRWoa7--.js +0 -1
  88. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  89. package/src/ui/public/assets/FlowExecutionViewer-YoPgld-R.js +0 -1
  90. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
  91. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  92. package/src/ui/public/assets/FlowOrchestrationWorkspace-DyEjlCBW.js +0 -21
  93. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  94. package/src/ui/public/assets/LogList-_z4dIgvS.js +0 -7
  95. package/src/ui/public/assets/MindmapView-BQTqx842.css +0 -1
  96. package/src/ui/public/assets/MindmapView-BrkcAcDh.js +0 -2
  97. package/src/ui/public/assets/MonitorView-8TG_K4m3.js +0 -1
  98. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  99. package/src/ui/public/assets/ProjectStartupButton-BqLyrik7.js +0 -1
  100. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  101. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  102. package/src/ui/public/assets/RemoteManagerDialog-LOVV6KrB.js +0 -4
  103. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  104. package/src/ui/public/assets/RemoteRepoCard-SQyKcXDG.js +0 -7
  105. package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +0 -1
  106. package/src/ui/public/assets/SourceMapView-DckLynaL.js +0 -3
  107. package/src/ui/public/assets/SvgIcon-CvIX1KL7.js +0 -1
  108. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  109. package/src/ui/public/assets/UserInputNode-Bc124-BK.js +0 -1
  110. package/src/ui/public/assets/WorkbenchView-BtQkd9Oy.js +0 -37
  111. package/src/ui/public/assets/WorkbenchView-FdhmMI-b.css +0 -1
  112. package/src/ui/public/assets/configStore-NaVpBxPo.js +0 -1
  113. package/src/ui/public/assets/element-plus-Csu3CJKj.js +0 -39
  114. package/src/ui/public/assets/index-Ci3lbjk2.css +0 -1
  115. package/src/ui/public/assets/index-D9u0mCSi.js +0 -98
  116. package/src/ui/public/assets/office-docx-BKrAsewH.js +0 -32
  117. package/src/ui/public/assets/office-pptx-DKtkU_7r.js +0 -74
  118. package/src/ui/public/assets/toolsStore-CNDLLgUG.js +0 -1
  119. package/src/ui/public/assets/vue-flow-D7IxiTyP.js +0 -39
@@ -0,0 +1,178 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 运行环境上下文(envContext.js)纯函数单测。
16
+ //
17
+ // 这里守的是两件事:
18
+ // 1. 注入出去的块里**真的**有项目名/路径/计数与真相源文件路径 ——
19
+ // 否则"Agent 知道我有哪些项目"就是空话(旧行为:prompt 里只有用户原话);
20
+ // 2. 计数口径与看板列一致(复用 summarizeProjectTasks,不另立一套)。
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+
25
+ import { buildEnvContextBlock, ENV_CONTEXT_MAX_PROJECTS } from './envContext.js';
26
+ import { canonicalProjectPath, TASK_COLUMNS } from './projectRegistry.js';
27
+
28
+ const entry = (path) => ({
29
+ path,
30
+ key: canonicalProjectPath(path),
31
+ name: path.split(/[\\/]/).filter(Boolean).pop(),
32
+ source: 'recent',
33
+ });
34
+
35
+ const job = (id, taskId, status) => ({
36
+ id, taskId, subId: `${taskId}__x`, title: '', status,
37
+ startedAt: '2026-09-18T02:00:00.000Z',
38
+ endedAt: status === 'running' ? null : '2026-09-18T02:01:00.000Z',
39
+ });
40
+
41
+ const PATHS = {
42
+ tasksFile: '/home/u/.zen-gitsync/tasks.json',
43
+ jobsFile: '/home/u/.zen-gitsync/jobs.json',
44
+ orchestratorFile: '/home/u/.zen-gitsync/orchestrator.json',
45
+ configFile: '/home/u/.zen-gitsync/config.json',
46
+ };
47
+
48
+ test('没有任何项目时返回 null(不注入空块浪费 token)', () => {
49
+ assert.equal(buildEnvContextBlock({}), null);
50
+ assert.equal(buildEnvContextBlock(), null);
51
+ assert.equal(buildEnvContextBlock({ projects: [] }), null);
52
+ // 只有 key 缺项的脏条目也算没有项目
53
+ assert.equal(buildEnvContextBlock({ projects: [{ name: 'x', path: '' }] }), null);
54
+ });
55
+
56
+ test('项目清单带名称、路径与三列计数,并标出当前项目', () => {
57
+ const projects = [entry('D:\\ws\\zen-gitsync'), entry('D:\\ws\\article-generator')];
58
+ const tasks = [
59
+ // 已完成:simple 任务跑过一次且 job 是 done
60
+ { id: 't1', projectPath: 'D:\\ws\\zen-gitsync', type: 'simple', subtasks: [] },
61
+ // 进行中:有 job 处于 running
62
+ { id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple', subtasks: [] },
63
+ // 待处理:没子任务也没执行过
64
+ { id: 't3', projectPath: 'D:\\ws\\article-generator', type: 'simple', subtasks: [] },
65
+ ];
66
+ const jobs = [job('j1', 't1', 'done'), job('j2', 't2', 'running')];
67
+
68
+ const block = buildEnvContextBlock({
69
+ currentProjectPath: 'D:\\ws\\zen-gitsync',
70
+ projects, tasks, jobs, ...PATHS,
71
+ });
72
+
73
+ assert.ok(block, '应当产出上下文块');
74
+ // 项目名 + 路径都在
75
+ assert.match(block, /zen-gitsync/);
76
+ assert.match(block, /article-generator/);
77
+ assert.match(block, /D:\\ws\\article-generator/);
78
+ // 三列计数:zen-gitsync 1 条已完成 → 0/0/1;article-generator 1 进行中 1 待处理 → 1/1/0
79
+ assert.match(block, /- zen-gitsync \| D:\\ws\\zen-gitsync \| 0\/0\/1\s+← 当前/);
80
+ assert.match(block, /- article-generator \| D:\\ws\\article-generator \| 1\/1\/0/);
81
+ // 合计:3 条,各列各一条
82
+ assert.match(block, /全部项目合计 3 条 —— 待处理 1 \/ 进行中 1 \/ 已完成 1/);
83
+ // 真相源路径:这是"够得着"的关键,缺了 Agent 还是只能猜
84
+ for (const p of Object.values(PATHS)) assert.ok(block.includes(p), `缺少路径 ${p}`);
85
+ // 明确要求它去读文件,而不是回"我看不到"
86
+ assert.match(block, /不要回答"我看不到\/无法访问"/);
87
+ });
88
+
89
+ test('当前项目标记必须归一化后比较(大小写不同也算同一个)', () => {
90
+ // 条目 key 是归一化过的(小写 + 反斜杠),而 cwd 常常是另一种写法 ——
91
+ // 直接拿 path 跟 key 比会永远不相等,表现为"当前项目"标记丢失。
92
+ const projects = [entry('C:\\Users\\xuze3'), entry('D:\\ws\\other')];
93
+ const block = buildEnvContextBlock({
94
+ // 不只盘符:目录段大小写也不一样(左栏重复项那个 bug 的同款写法差异)
95
+ currentProjectPath: 'c:/users/XUZE3',
96
+ projects, tasks: [], jobs: [], ...PATHS,
97
+ });
98
+ assert.match(block, /- xuze3 \| C:\\Users\\xuze3 \| 0\/0\/0\s+← 当前/);
99
+ assert.match(block, /- other \| D:\\ws\\other \| 0\/0\/0\n/);
100
+ });
101
+
102
+ test('项目数超过上限时只列前 N 个,但合计仍按全量算', () => {
103
+ const projects = [];
104
+ const tasks = [];
105
+ const total = ENV_CONTEXT_MAX_PROJECTS + 5;
106
+ for (let i = 0; i < total; i += 1) {
107
+ const p = `D:\\ws\\p${i}`;
108
+ projects.push(entry(p));
109
+ tasks.push({ id: `t${i}`, projectPath: p, type: 'simple', subtasks: [] });
110
+ }
111
+
112
+ const block = buildEnvContextBlock({
113
+ currentProjectPath: '', projects, tasks, jobs: [], ...PATHS,
114
+ });
115
+
116
+ const listed = block.split('\n').filter(l => l.startsWith('- p') && l.includes('| D:'));
117
+ assert.equal(listed.length, ENV_CONTEXT_MAX_PROJECTS);
118
+ assert.match(block, new RegExp(`共 ${total} 个`));
119
+ assert.match(block, /还有 5 个未列出/);
120
+ // 合计不受截断影响:另外 5 个也要算进去
121
+ assert.match(block, new RegExp(`全部项目合计 ${total} 条`));
122
+ });
123
+
124
+ test('maxProjects 可覆盖,且非法值回落到默认上限', () => {
125
+ const projects = [entry('D:\\ws\\a'), entry('D:\\ws\\b'), entry('D:\\ws\\c')];
126
+ const short = buildEnvContextBlock({ projects, tasks: [], jobs: [], maxProjects: 1, ...PATHS });
127
+ assert.equal(short.split('\n').filter(l => l.startsWith('- a') || l.startsWith('- b') || l.startsWith('- c')).length, 1);
128
+
129
+ const bad = buildEnvContextBlock({ projects, tasks: [], jobs: [], maxProjects: 0, ...PATHS });
130
+ assert.equal(bad.split('\n').filter(l => l.startsWith('- ') && l.includes('| D:')).length, 3);
131
+ });
132
+
133
+ test('没关联项目的任务不计入项目行,但计入合计', () => {
134
+ const projects = [entry('D:\\ws\\a')];
135
+ const tasks = [
136
+ { id: 't1', projectPath: 'D:\\ws\\a', type: 'simple', subtasks: [] },
137
+ { id: 't2', projectPath: '', type: 'simple', subtasks: [] },
138
+ ];
139
+ const block = buildEnvContextBlock({ projects, tasks, jobs: [], ...PATHS });
140
+ // 项目行只认 key 匹配得上的那条(t1)→ 待处理 1
141
+ assert.match(block, /- a \| D:\\ws\\a \| 1\/0\/0/);
142
+ // 而合计走 NO_PROJECT_KEY 那桶一起算 → 2 条;两者不相等正是这段断言要守的
143
+ assert.match(block, /全部项目合计 2 条/);
144
+ // 无项目归属的任务不该凭空造出一行项目
145
+ assert.equal(block.split('\n').filter(l => l.startsWith('- ') && l.includes('|')).length, 1);
146
+ });
147
+
148
+ // ── 列数一致性:这一条是"改列别再漏一处"的守门员 ──────────────────────
149
+ //
150
+ // 去掉「评审中」那一轮,输出从四列变三列,但这个文件里是**逐条正则硬写的数字**,
151
+ // 只顺手改了最显眼的一行 → 另外三处照红。下面这条不比对具体数字,只守"段数 == 看板列数",
152
+ // 下次增删列时它和上面几条会一起红,而不是让某一条孤零零地报一个对不上的字符串。
153
+ test('项目行/合计的段数必须与 TASK_COLUMNS 一致,且每列都有中文标签', () => {
154
+ const projects = [entry('D:\\ws\\a')];
155
+ const tasks = [
156
+ { id: 't1', projectPath: 'D:\\ws\\a', type: 'simple', subtasks: [] },
157
+ { id: 't2', projectPath: 'D:\\ws\\a', type: 'simple', subtasks: [] },
158
+ ];
159
+ const block = buildEnvContextBlock({ projects, tasks, jobs: [], ...PATHS });
160
+
161
+ // 表头里的「待处理/进行中/已完成」:段数 = 列数,且不能有一列回落成 key 本身
162
+ const headerLabels = block.match(/格式: 名称 \| 路径 \| ([^)]+)/)[1].split('/');
163
+ assert.equal(headerLabels.length, TASK_COLUMNS.length, '表头标签数应与看板列数一致');
164
+ TASK_COLUMNS.forEach((key, i) => {
165
+ assert.notEqual(headerLabels[i], key, `列「${key}」缺中文标签(标签表里没这个名字)`);
166
+ });
167
+
168
+ // 项目行的计数组数
169
+ const rowCounts = block.match(/- a \| D:\\ws\\a \| ([^\n]+)/)[1].trim().split('/');
170
+ assert.equal(rowCounts.length, TASK_COLUMNS.length, '项目行的计数组数应与看板列数一致');
171
+
172
+ // 合计行的段数
173
+ const overview = block.match(/全部项目合计 \d+ 条 —— ([^\n]+)/)[1].split(' / ');
174
+ assert.equal(overview.length, TASK_COLUMNS.length, '合计行的段数应与看板列数一致');
175
+
176
+ // 两个任务都是待处理 → 第一段是 2,其余列是 0(顺序由 TASK_COLUMNS 决定,不是写死的下标)
177
+ assert.equal(rowCounts[TASK_COLUMNS.indexOf('todo')], '2');
178
+ });
@@ -44,7 +44,9 @@ import {
44
44
  fsp,
45
45
  PROMPTS_FILE,
46
46
  TASKS_FILE,
47
+ CONFIG_FILE,
47
48
  JOBS_FILE,
49
+ ORCHESTRATOR_FILE,
48
50
  IMAGES_DIR,
49
51
  SUBTASK_INSTRUCTION_FILE,
50
52
  MAX_IMAGE_BYTES,
@@ -63,7 +65,18 @@ import {
63
65
  listDirTree,
64
66
  safeReadFile,
65
67
  } from './projectScan.js';
66
- import { isImageExt, resolveExt, ALLOWED_EXTS, ensureImagesDir } from './attachmentUtils.js';
68
+ import {
69
+ isImageExt,
70
+ resolveExt,
71
+ ALLOWED_EXTS,
72
+ ensureImagesDir,
73
+ DISPATCH_STAGING_DIR,
74
+ isSafeAttId,
75
+ mimeForExt,
76
+ stagingPath,
77
+ findStagingFile,
78
+ cleanupDispatchStaging,
79
+ } from './attachmentUtils.js';
67
80
  import { sessionStore } from './sessionStore.js';
68
81
  import {
69
82
  DEFAULT_INSTRUCTION,
@@ -96,6 +109,7 @@ import {
96
109
  collectPriorOutputs,
97
110
  collectPriorOutputsUpTo,
98
111
  waitProcessExit,
112
+ setEnvContextProvider,
99
113
  } from './taskRunner.js';
100
114
  import {
101
115
  listProjects,
@@ -103,7 +117,10 @@ import {
103
117
  decorateTaskForBoard,
104
118
  resolveTaskRepoPath,
105
119
  buildTaskDetail,
120
+ buildProjectEntries,
106
121
  } from './projectRegistry.js';
122
+ import { buildEnvContextBlock } from './envContext.js';
123
+ import { resolveInstructionTarget } from './targetResolver.js';
107
124
  import {
108
125
  readOrchestrator,
109
126
  setOrchestratorActive,
@@ -1729,6 +1746,45 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1729
1746
  return { projects, tasks: boardTasks, currentProjectPath };
1730
1747
  }
1731
1748
 
1749
+ /**
1750
+ * 注册「运行环境上下文」的提供者 —— 执行引擎(taskRunner)拼 prompt 时会回调它。
1751
+ *
1752
+ * 为什么在这一层注册:只有这里同时拿得到 configManager(最近目录)与 jobStore
1753
+ * 的 job 快照;taskRunner 两个都没有,放它那儿就得再造一条「读最近目录」的路径,
1754
+ * 也就是又一次口径分叉。
1755
+ *
1756
+ * 复用 buildProjectEntries + summarizeProjectTasks(与看板同一套口径),
1757
+ * 但**刻意不走** listProjects:那是给看板用的,会去 spawn git 探状态。
1758
+ * 分支、改动数这类信息 Agent 在自己的 cwd 里一条 git 命令就有,
1759
+ * 不值得为它在这里每次执行都多探一轮。
1760
+ */
1761
+ setEnvContextProvider(async ({ repoPath } = {}) => {
1762
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1763
+ const tasks = data.tasks || [];
1764
+
1765
+ let recentDirs = [];
1766
+ try {
1767
+ if (configManager && typeof configManager.getRecentDirectories === 'function') {
1768
+ recentDirs = (await configManager.getRecentDirectories()) || [];
1769
+ }
1770
+ } catch (err) {
1771
+ // 与 loadBoardPayload 同一处理:读不到最近目录就退化成"只按任务里出现过的项目"
1772
+ logger.warn('[workbench] 注入运行环境上下文时读取最近目录失败,退化为仅按任务路径:', err.message);
1773
+ }
1774
+
1775
+ const projects = buildProjectEntries({ recentDirs, tasks });
1776
+ return buildEnvContextBlock({
1777
+ currentProjectPath: repoPath || '',
1778
+ projects,
1779
+ tasks,
1780
+ jobs: snapshotJobs(),
1781
+ tasksFile: TASKS_FILE,
1782
+ jobsFile: JOBS_FILE,
1783
+ orchestratorFile: ORCHESTRATOR_FILE,
1784
+ configFile: CONFIG_FILE,
1785
+ });
1786
+ });
1787
+
1732
1788
  app.get('/api/workbench/projects', asyncRoute(async (_req, res) => {
1733
1789
  const payload = await loadBoardPayload();
1734
1790
  res.json({ success: true, ...payload });
@@ -1785,7 +1841,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1785
1841
  /**
1786
1842
  * 派发一条指令。
1787
1843
  * body: { text, projectPath?, autoRun? }
1788
- * - projectPath 缺省 = 当前项目
1844
+ * - projectPath 缺省 -> 交给 targetResolver 判断落到哪个项目(显式指定 > 指令里点名
1845
+ * > 主 Agent 判断 > 应用当前项目)。用户不必先选项目,落点会如实记进指令流水
1789
1846
  * - autoRun 默认 true;调度暂停时只建任务不执行(指令记录里会写明原因)
1790
1847
  *
1791
1848
  * 指令一律落成目标项目下的**简单任务**(一句话交给 claude 跑一轮),
@@ -1798,8 +1855,43 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1798
1855
 
1799
1856
  const fallbackPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1800
1857
  const bodyPath = typeof req.body?.projectPath === 'string' ? req.body.projectPath.trim() : '';
1801
- const targetPath = bodyPath || fallbackPath;
1802
- if (!targetPath) throw new HttpError(400, '未选中项目,无法派发');
1858
+
1859
+ // 先把任务与项目清单读出来:解析落点要用它们。
1860
+ // 清单复用 buildProjectEntries(与看板 / 运行环境上下文同一套口径),
1861
+ // 刻意**不走** listProjects —— 那会 spawn git,派发不值得为它多等一轮。
1862
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1863
+ const tasks = data.tasks || [];
1864
+
1865
+ let recentDirs = [];
1866
+ try {
1867
+ if (configManager && typeof configManager.getRecentDirectories === 'function') {
1868
+ recentDirs = (await configManager.getRecentDirectories()) || [];
1869
+ }
1870
+ } catch (err) {
1871
+ logger.warn('[workbench] 派发时读取最近目录失败,落点解析退化为仅按任务路径:', err.message);
1872
+ }
1873
+
1874
+ const target = await resolveInstructionTarget({
1875
+ text,
1876
+ projects: buildProjectEntries({ recentDirs, tasks }),
1877
+ explicitPath: bodyPath,
1878
+ defaultPath: fallbackPath,
1879
+ // 懒取模型:只有「指令里没点名、也没被显式指定」时才会真的读 config 去问模型
1880
+ getModel: async () => {
1881
+ if (!configManager) return null;
1882
+ const rawConfig = await configManager.readRawConfigFile();
1883
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
1884
+ return models.find(m => m.isDefault) || models[0] || null;
1885
+ },
1886
+ onAgentError: (err) => {
1887
+ logger.warn('[workbench] 主 Agent 判断指令落点失败,退到默认项目:', err.message);
1888
+ },
1889
+ });
1890
+
1891
+ const targetPath = target.path;
1892
+ if (!targetPath) {
1893
+ throw new HttpError(400, '没能识别出目标项目,也没有默认项目可用 —— 先在左侧打开或选一个项目');
1894
+ }
1803
1895
 
1804
1896
  // 目标目录必须真的存在:跑在不存在的工作区上只会拿到一堆无意义的报错
1805
1897
  let stat = null;
@@ -1810,6 +1902,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1810
1902
  projectPath: targetPath,
1811
1903
  status: 'rejected',
1812
1904
  reason: '项目目录不存在',
1905
+ targetSource: target.source,
1813
1906
  });
1814
1907
  publish('orchestrator:instruction', rejected);
1815
1908
  throw new HttpError(400, `项目目录不存在:${targetPath}`);
@@ -1818,11 +1911,69 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1818
1911
  const autoRun = req.body?.autoRun !== false;
1819
1912
  const schedulingActive = await isSchedulingActive();
1820
1913
 
1821
- const data = await readJson(TASKS_FILE, { tasks: [] });
1822
- const tasks = data.tasks || [];
1914
+ // ── 附件:前端只回传 { id, ext, originalName },服务端按 id 回暂存区找文件 ──
1915
+ // 路径完全由服务端拼(stagingPath 会同时校验 id 形状与 ext 白名单),
1916
+ // 所以不存在"前端指定任意路径"这回事 —— 比"信任 absolutePath 再校验前缀"干净。
1917
+ // 数量与文件存在性在这里统一校验:上传时服务端是无状态的,压根不知道前端一共攒了几个。
1918
+ const rawAttachments = Array.isArray(req.body?.attachments) ? req.body.attachments : [];
1919
+ if (rawAttachments.length > MAX_ATTACHMENTS_PER_SUBTASK) {
1920
+ throw new HttpError(400, `附件最多 ${MAX_ATTACHMENTS_PER_SUBTASK} 个`);
1921
+ }
1922
+ const staged = [];
1923
+ for (const item of rawAttachments) {
1924
+ const attId = typeof item?.id === 'string' ? item.id : '';
1925
+ const ext = typeof item?.ext === 'string' ? item.ext : '';
1926
+ const from = stagingPath(attId, ext);
1927
+ if (!from) throw new HttpError(400, '附件参数不合法');
1928
+ let attStat = null;
1929
+ try { attStat = await fsp.stat(from); } catch { /* 下面统一报错 */ }
1930
+ if (!attStat || !attStat.isFile()) throw new HttpError(400, '附件已失效,请重新添加');
1931
+ staged.push({
1932
+ id: attId,
1933
+ ext,
1934
+ from,
1935
+ size: attStat.size,
1936
+ originalName: String(item?.originalName || `attachment.${ext}`).slice(0, 200),
1937
+ });
1938
+ }
1939
+
1940
+ // tasks / data 已在上面读好(落点解析要用),这里不再重复读一遍
1823
1941
  const now = nowIso();
1942
+ const taskId = genId();
1943
+
1944
+ // 附件从暂存区搬进任务自己的目录(`_task-{id}/`),这样删除任务时会被一并清掉
1945
+ const attachmentDir = path.join(IMAGES_DIR, '_task-' + taskId);
1946
+ const attachments = [];
1947
+ if (staged.length > 0) {
1948
+ await fsp.mkdir(attachmentDir, { recursive: true });
1949
+ for (const s of staged) {
1950
+ const storedName = `${s.id}.${s.ext}`;
1951
+ const dest = path.join(attachmentDir, storedName);
1952
+ try {
1953
+ await fsp.rename(s.from, dest);
1954
+ } catch {
1955
+ // 极端情况(跨卷等)退化成复制 + 删除;仍失败就跳过这个附件,
1956
+ // 而不是让整条指令派发不出去 —— 指令本身是好的,不该被一个文件拖死。
1957
+ try {
1958
+ await fsp.copyFile(s.from, dest);
1959
+ await fsp.unlink(s.from);
1960
+ } catch { continue; }
1961
+ }
1962
+ attachments.push({
1963
+ id: s.id,
1964
+ originalName: s.originalName,
1965
+ mimeType: mimeForExt(s.ext),
1966
+ size: s.size,
1967
+ ext: s.ext,
1968
+ storedName,
1969
+ absolutePath: dest,
1970
+ createdAt: now,
1971
+ });
1972
+ }
1973
+ }
1974
+
1824
1975
  const task = {
1825
- id: genId(),
1976
+ id: taskId,
1826
1977
  // 标题取指令首行并截断:看板卡片只占一行,整段指令塞进标题会把卡片撑爆
1827
1978
  title: text.split('\n')[0].slice(0, 120),
1828
1979
  desc: text,
@@ -1831,6 +1982,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1831
1982
  sequential: true,
1832
1983
  simpleOverride: '',
1833
1984
  projectPath: targetPath,
1985
+ attachments,
1834
1986
  subtasks: [],
1835
1987
  status: 'todo',
1836
1988
  createdAt: now,
@@ -1846,6 +1998,9 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1846
1998
  taskId: task.id,
1847
1999
  status: willRun ? 'accepted' : 'created',
1848
2000
  reason: autoRun && !schedulingActive ? '调度已暂停,只建了任务未执行' : '',
2001
+ // 落点是怎么定下来的(explicit / mention / agent / default),
2002
+ // 记下来才能在流水里回答用户那句"为什么派到这儿了"
2003
+ targetSource: target.source,
1849
2004
  });
1850
2005
 
1851
2006
  publish('task:created', { task });
@@ -1866,6 +2021,53 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1866
2021
  });
1867
2022
  }
1868
2023
 
1869
- res.json({ success: true, task, instruction: record, ran: willRun, schedulingActive });
2024
+ res.json({ success: true, task, instruction: record, ran: willRun, schedulingActive, target });
2025
+ }));
2026
+
2027
+ /**
2028
+ * 派发前把附件落到暂存区。
2029
+ *
2030
+ * 为什么需要这一层:§17 的上传端点都要求 taskId / subId 当挂载点,而派发这一刻
2031
+ * 任务还不存在。所以先写进 `_dispatch/`,**不写任何 JSON** —— 附件记录回给前端持有,
2032
+ * 派发时再按 id 搬进任务目录。
2033
+ * 代价是服务端在这里无状态:它不知道前端一共攒了几个,附件数上限只能在派发时统一卡。
2034
+ */
2035
+ app.post('/api/workbench/orchestrator/attachments', rawAttachment, asyncRoute(async (req, res) => {
2036
+ await fsp.mkdir(DISPATCH_STAGING_DIR, { recursive: true });
2037
+ const target = { attachments: [], storageDir: DISPATCH_STAGING_DIR };
2038
+ const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
2039
+ res.json({ success: true, attachment: att });
2040
+ }));
2041
+
2042
+ /** 撤掉一个还没派发的附件(用户点了 ×)。id 形状不合法一律 404,不做路径拼接。 */
2043
+ app.delete('/api/workbench/orchestrator/attachments/:attId', asyncRoute(async (req, res) => {
2044
+ const full = await findStagingFile(req.params.attId);
2045
+ if (!full) throw new HttpError(404, '附件不存在');
2046
+ try { await fsp.unlink(full); } catch { /* 文件可能已不在 */ }
2047
+ res.json({ success: true });
1870
2048
  }));
2049
+
2050
+ /** 暂存附件的缩略图 / 预览。与 §17 的 raw 端点同形,只是从暂存区取 */
2051
+ app.get('/api/workbench/orchestrator/attachments/:attId/raw', asyncRoute(async (req, res) => {
2052
+ const full = await findStagingFile(req.params.attId);
2053
+ if (!full) throw new HttpError(404, '附件不存在');
2054
+ const ext = path.extname(full).slice(1).toLowerCase();
2055
+ try {
2056
+ const st = await fsp.stat(full);
2057
+ res.set('Content-Type', mimeForExt(ext));
2058
+ res.set('Content-Length', String(st.size));
2059
+ res.set('Cache-Control', 'private, max-age=3600');
2060
+ const stream = fs.createReadStream(full);
2061
+ stream.on('error', () => res.end());
2062
+ stream.pipe(res);
2063
+ } catch {
2064
+ throw new HttpError(404, '文件已丢失');
2065
+ }
2066
+ }));
2067
+
2068
+ // 暂存区兜底清理:粘了图却没派发就关页面的痕迹,不该永久占着磁盘。
2069
+ // 不上定时器 —— 暂存区的生命周期本来就是"几分钟内被派发掉",启动清一次足够。
2070
+ cleanupDispatchStaging().catch(err => {
2071
+ logger.warn('[workbench] 清理派发附件暂存区失败:', err.message);
2072
+ });
1871
2073
  }
@@ -36,6 +36,7 @@ import {
36
36
  genId,
37
37
  } from './shared.js';
38
38
  import { projectName } from './projectRegistry.js';
39
+ import { TARGET_SOURCES } from './targetResolver.js';
39
40
 
40
41
  /** 活动流最多返回多少条(前端只渲染最近的一屏,多的不传) */
41
42
  export const MAX_ACTIVITY_ENTRIES = 120;
@@ -57,6 +58,9 @@ function normalizeInstruction(raw) {
57
58
  // accepted=已建任务 / rejected=被拒绝(暂停调度、项目不存在等)/ created=只建了草稿没执行
58
59
  status: ['accepted', 'rejected', 'created'].includes(it.status) ? it.status : 'created',
59
60
  reason: typeof it.reason === 'string' ? it.reason : '',
61
+ // 落点是怎么定下来的(explicit / mention / agent / default)。
62
+ // 老记录没有这个字段 —— 留空串,别硬塞一个默认值冒充"当时就是这么判断的"。
63
+ targetSource: TARGET_SOURCES.includes(it.targetSource) ? it.targetSource : '',
60
64
  };
61
65
  }
62
66
 
@@ -251,6 +255,8 @@ export function buildActivityFeed({ jobs = [], tasks = [], instructions = [] } =
251
255
  reason: it.reason || '',
252
256
  projectPath: it.projectPath || '',
253
257
  projectName: projectName(it.projectPath || ''),
258
+ // 落点来源('' = 本次升级前的老记录)。前端据此说明"为什么派到这儿了"
259
+ targetSource: it.targetSource || '',
254
260
  });
255
261
  }
256
262
 
@@ -0,0 +1,73 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 前后端项目路径归一的**口径一致性**守卫。
16
+ //
17
+ // 为什么值得单独一个测试文件:canonicalProjectPath 在前后端各实现一遍
18
+ // (后端 routes/workbench/projectRegistry.js、前端 utils/path.ts),
19
+ // 而它是「同一目录算不算同一个项目」的唯一判据 —— 两侧只要差一点点,
20
+ // 同一个目录就会在两侧落到不同 key 上,表现为项目凭空分裂成两个。
21
+ // 这个坑在本仓库**已经踩过两次**(斜杠方向一次、目录段大小写一次),
22
+ // 两次的原因都不是逻辑写错,而是"改了一侧忘了另一侧"。
23
+ //
24
+ // 所以这里不验证行为(行为由 projectRegistry.test.js 覆盖),只验证
25
+ // **两份源码里的归一表达式长得一样**。断言故意把规则内容也钉住:
26
+ // 谁要改规则,就得同时改两侧、并来这里把期望值一起改掉 —— 这正是想要的效果。
27
+
28
+ import { test } from 'node:test';
29
+ import assert from 'node:assert/strict';
30
+ import { readFileSync } from 'node:fs';
31
+ import path from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const here = path.dirname(fileURLToPath(import.meta.url));
35
+ const SERVER_FILE = path.join(here, 'projectRegistry.js');
36
+ const CLIENT_FILE = path.join(here, '..', '..', '..', 'client', 'src', 'utils', 'path.ts');
37
+
38
+ /**
39
+ * 从源码里抠出归一规则那条 `return` 语句。
40
+ *
41
+ * 只匹配到行尾就够 —— 这条规则必须是一行纯表达式。哪天有人把它写成多行,
42
+ * 这里会直接断言失败,比"默默不同步"好得多。
43
+ */
44
+ function extractRule(file, label) {
45
+ const src = readFileSync(file, 'utf8');
46
+ const anchor = src.indexOf('export function canonicalProjectPath');
47
+ assert.ok(anchor >= 0, `${label}(${file}) 里找不到 canonicalProjectPath 的定义`);
48
+ const m = src.slice(anchor).match(/return\s+s\.replace\([^\n]*/);
49
+ assert.ok(m, `${label} 的 canonicalProjectPath 里找不到 return s.replace(...) 这条归一表达式`);
50
+ // 去掉行尾分号与多余空白,让"分号写不写"这种无关差异不参与比较
51
+ return m[0].replace(/;\s*$/, '').replace(/\s+/g, ' ').trim();
52
+ }
53
+
54
+ test('前后端 canonicalProjectPath 的归一规则逐字一致(两侧分叉 = 项目会分裂成两个)', () => {
55
+ const server = extractRule(SERVER_FILE, '后端');
56
+ const client = extractRule(CLIENT_FILE, '前端');
57
+ assert.equal(
58
+ client,
59
+ server,
60
+ '前端 utils/path.ts 与后端 projectRegistry.js 的归一规则必须逐字一致;\n' +
61
+ ` 后端: ${server}\n 前端: ${client}`,
62
+ );
63
+ });
64
+
65
+ test('归一规则里的"大小写"那一步被钉住(两侧一起改错也是分叉)', () => {
66
+ const rule = extractRule(SERVER_FILE, '后端');
67
+ // 只钉"大小写"这一步:它是这轮新加的、也是踩过坑的那一步。
68
+ // 斜杠归一与 POSIX 不动这两条由 projectRegistry.test.js 的行为断言守
69
+ // (那边能直接验 `D:/ws/y` === `D:\ws\y` 和 `/home/Me/A` 原样返回,
70
+ // 比在源码文本上抠字符串可靠得多)。
71
+ assert.match(rule, /toLowerCase\(\)/, '必须小写化,否则 C:\\Users\\x 与 c:\\users\\x 会裂成两个项目');
72
+ assert.doesNotMatch(rule, /toUpperCase\(\)/, '不该再转大写(旧口径只转盘符,已证明治不住目录段)');
73
+ });