zen-gitsync 2.16.49 → 2.16.51

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 (66) hide show
  1. package/README.md +8 -6
  2. package/package.json +5 -2
  3. package/src/ui/public/assets/AgentView-DcTGw8-j.js +4 -0
  4. package/src/ui/public/assets/AppVersionBadge-CA5-POGA.js +2 -0
  5. package/src/ui/public/assets/BranchSelector-qn-iGHOI.js +1 -0
  6. package/src/ui/public/assets/CommitForm-DHfyJ331.js +6 -0
  7. package/src/ui/public/assets/CommonDialog-DJBixJcI.js +1 -0
  8. package/src/ui/public/assets/EditorView-BRWoa7--.js +1 -0
  9. package/src/ui/public/assets/{FlowExecutionViewer-CuID17Kg.js → FlowExecutionViewer-YoPgld-R.js} +1 -1
  10. package/src/ui/public/assets/FlowOrchestrationWorkspace-DyEjlCBW.js +21 -0
  11. package/src/ui/public/assets/LogList-_z4dIgvS.js +7 -0
  12. package/src/ui/public/assets/MindmapView-BrkcAcDh.js +2 -0
  13. package/src/ui/public/assets/MonitorView-8TG_K4m3.js +1 -0
  14. package/src/ui/public/assets/ProjectStartupButton-BqLyrik7.js +1 -0
  15. package/src/ui/public/assets/RemoteManagerDialog-LOVV6KrB.js +4 -0
  16. package/src/ui/public/assets/RemoteRepoCard-SQyKcXDG.js +7 -0
  17. package/src/ui/public/assets/SourceMapView-DckLynaL.js +3 -0
  18. package/src/ui/public/assets/{SvgIcon-koi3MuB8.js → SvgIcon-CvIX1KL7.js} +1 -1
  19. package/src/ui/public/assets/UserInputNode-Bc124-BK.js +1 -0
  20. package/src/ui/public/assets/WorkbenchView-BtQkd9Oy.js +37 -0
  21. package/src/ui/public/assets/WorkbenchView-FdhmMI-b.css +1 -0
  22. package/src/ui/public/assets/{_plugin-vue_export-helper-VkarRGOe.js → _plugin-vue_export-helper-N7dqbp6x.js} +5 -5
  23. package/src/ui/public/assets/configStore-NaVpBxPo.js +1 -0
  24. package/src/ui/public/assets/{element-plus-C3mePHEv.js → element-plus-Csu3CJKj.js} +3 -3
  25. package/src/ui/public/assets/{flow-mindmap-BzX-PWap.js → flow-mindmap-wqtwJcDW.js} +12 -12
  26. package/src/ui/public/assets/index-Ci3lbjk2.css +1 -0
  27. package/src/ui/public/assets/{index-Cn4CA40E.js → index-D9u0mCSi.js} +49 -48
  28. package/src/ui/public/assets/{office-docx-pY9m7PMg.js → office-docx-BKrAsewH.js} +6 -6
  29. package/src/ui/public/assets/{office-excel-F21cTf8V.js → office-excel-5lGzd5Yy.js} +1 -1
  30. package/src/ui/public/assets/{office-pptx-XHr0prIp.js → office-pptx-DKtkU_7r.js} +1 -1
  31. package/src/ui/public/assets/toolsStore-CNDLLgUG.js +1 -0
  32. package/src/ui/public/assets/{vendor-EBzJAuo1.js → vendor-C6ntdXkR.js} +43 -43
  33. package/src/ui/public/assets/{vendor-BkgN6wPK.css → vendor-_6pgW9Z2.css} +1 -1
  34. package/src/ui/public/assets/vue-flow-D7IxiTyP.js +39 -0
  35. package/src/ui/public/index.html +12 -12
  36. package/src/ui/server/routes/workbench/index.js +206 -5
  37. package/src/ui/server/routes/workbench/orchestratorStore.js +260 -0
  38. package/src/ui/server/routes/workbench/projectRegistry.js +385 -0
  39. package/src/ui/server/routes/workbench/projectRegistry.test.js +339 -0
  40. package/src/ui/server/routes/workbench/promptParts.js +51 -0
  41. package/src/ui/server/routes/workbench/promptParts.test.js +82 -0
  42. package/src/ui/server/routes/workbench/shared.js +5 -0
  43. package/src/ui/server/routes/workbench/taskRunner.js +3 -2
  44. package/src/ui/server/utils/directoryGitState.js +92 -7
  45. package/src/ui/server/utils/directoryGitState.test.js +135 -2
  46. package/src/ui/public/assets/AgentView-CZhZ-RtZ.js +0 -4
  47. package/src/ui/public/assets/AppVersionBadge-CGLBfOhF.js +0 -2
  48. package/src/ui/public/assets/BranchSelector-DO0xkvYF.js +0 -1
  49. package/src/ui/public/assets/CommitForm-ickhPe9o.js +0 -6
  50. package/src/ui/public/assets/CommonDialog-CliNZp_i.js +0 -1
  51. package/src/ui/public/assets/EditorView-Bj5Buc8r.js +0 -1
  52. package/src/ui/public/assets/FlowOrchestrationWorkspace-BWynSUhQ.js +0 -21
  53. package/src/ui/public/assets/LogList-C8fYaGlz.js +0 -7
  54. package/src/ui/public/assets/MindmapView-Co2ys9e3.js +0 -2
  55. package/src/ui/public/assets/MonitorView-BxFwNYjB.js +0 -1
  56. package/src/ui/public/assets/ProjectStartupButton-DKspkGJC.js +0 -1
  57. package/src/ui/public/assets/RemoteManagerDialog-CRq596ya.js +0 -4
  58. package/src/ui/public/assets/RemoteRepoCard-9_Y3iDT6.js +0 -7
  59. package/src/ui/public/assets/SourceMapView-CFv9JUGZ.js +0 -3
  60. package/src/ui/public/assets/UserInputNode-CUTCIcEc.js +0 -1
  61. package/src/ui/public/assets/WorkbenchView-Bp2YaSGS.css +0 -1
  62. package/src/ui/public/assets/WorkbenchView-COoRqTST.js +0 -28
  63. package/src/ui/public/assets/configStore-vksEBmTc.js +0 -1
  64. package/src/ui/public/assets/index-C3zoLat6.css +0 -1
  65. package/src/ui/public/assets/toolsStore-CHBnAWFM.js +0 -1
  66. package/src/ui/public/assets/vue-flow-DB6byJul.js +0 -39
@@ -97,6 +97,21 @@ import {
97
97
  collectPriorOutputsUpTo,
98
98
  waitProcessExit,
99
99
  } from './taskRunner.js';
100
+ import {
101
+ listProjects,
102
+ groupJobsByTask,
103
+ decorateTaskForBoard,
104
+ resolveTaskRepoPath,
105
+ buildTaskDetail,
106
+ } from './projectRegistry.js';
107
+ import {
108
+ readOrchestrator,
109
+ setOrchestratorActive,
110
+ appendInstruction,
111
+ isSchedulingActive,
112
+ buildActivityFeed,
113
+ buildRunningAgents,
114
+ } from './orchestratorStore.js';
100
115
 
101
116
  const { genSessionId, read: readSessionFile, write: writeSessionFile, delete: deleteSessionFile, listMeta: listSessionsMeta, enforceRetention: enforceSessionsRetention } = sessionStore;
102
117
 
@@ -897,6 +912,9 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
897
912
  const taskType = rawType === 'simple' ? 'simple' : 'complex';
898
913
  const sequential = rawSequential === false ? false : true;
899
914
  const safeOverride = typeof simpleOverride === 'string' ? simpleOverride.slice(0, 8000) : '';
915
+ // 显式指定的归属项目(多项目编排台传选中项目);不传则沿用当前项目。
916
+ // 只影响**新建**,更新分支一律保留任务原有的 projectPath(否则编辑一次就会把任务挪到当前项目去)。
917
+ const bodyProjectPath = typeof req.body?.projectPath === 'string' ? req.body.projectPath.trim() : '';
900
918
  const data = await readJson(TASKS_FILE, { tasks: [] });
901
919
  const tasks = data.tasks || [];
902
920
  const now = nowIso();
@@ -944,7 +962,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
944
962
  type: taskType,
945
963
  sequential: taskType === 'complex' ? sequential : true,
946
964
  simpleOverride: taskType === 'simple' ? safeOverride : '',
947
- projectPath: currentProjectPath || '',
965
+ projectPath: bodyProjectPath || currentProjectPath || '',
948
966
  subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
949
967
  id: s.id || genId(),
950
968
  title: s.title || '',
@@ -1013,7 +1031,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1013
1031
  if (!task.subtasks || task.subtasks.length === 0) {
1014
1032
  throw new HttpError(400, '任务没有子任务');
1015
1033
  }
1016
- const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1034
+ const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1017
1035
  // 异步执行,立即返回
1018
1036
  res.json({ success: true, message: '已开始执行' });
1019
1037
  runTaskQueue(task, repoPath, '').catch(err => {
@@ -1032,7 +1050,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1032
1050
  if (!Number.isInteger(startSubIndex) || startSubIndex < 0 || startSubIndex >= task.subtasks.length) {
1033
1051
  throw new HttpError(400, 'startSubIndex 越界');
1034
1052
  }
1035
- const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1053
+ const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1036
1054
  res.json({ success: true, message: `已从第 ${startSubIndex + 1} 个子任务开始执行` });
1037
1055
  runTaskQueue(task, repoPath, '', { fromIndex: startSubIndex }).catch(err => {
1038
1056
  publish('task:error', { taskId: task.id, error: err.message });
@@ -1046,7 +1064,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1046
1064
  if (task.type !== 'simple') {
1047
1065
  throw new HttpError(400, '该任务不是简单任务,请使用普通执行接口');
1048
1066
  }
1049
- const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1067
+ const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1050
1068
  const virtualSub = {
1051
1069
  id: `${task.id}__simple`,
1052
1070
  title: task.title,
@@ -1076,7 +1094,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1076
1094
  // 兜底:即便磁盘状态是 todo,如果磁盘里还没归档的 job 还在跑(进程被孤儿),也拦一下
1077
1095
  const liveJob = snapshotJobs().find(j => j.subId === subId && (j.status === 'running' || j.status === 'pending'));
1078
1096
  if (liveJob) throw new HttpError(400, '该子任务已有正在执行的 job');
1079
- const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1097
+ const repoPath = resolveTaskRepoPath(foundTask, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1080
1098
  res.json({ success: true, message: `已开始执行子任务:${foundSub.title || subId}` });
1081
1099
  (async () => {
1082
1100
  try {
@@ -1667,4 +1685,187 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1667
1685
  throw new HttpError(404, '文件已丢失');
1668
1686
  }
1669
1687
  }));
1688
+
1689
+ // ════════════════════════════════════════════════════════════════════════
1690
+ // §16. 多项目编排台(L1 看板 + 主 Agent 控制台)
1691
+ // GET /api/workbench/projects 项目清单 + 看板任务
1692
+ // GET /api/workbench/orchestrator 调度开关 + 指令存档 + 活动流
1693
+ // POST /api/workbench/orchestrator/state 暂停 / 恢复调度
1694
+ // POST /api/workbench/orchestrator/dispatch 派发一条人类干预指令
1695
+ //
1696
+ // 「暂停调度」的实际语义:只拦**自动派发**(dispatch 里带 autoRun),
1697
+ // 手动点执行 / 子任务执行一概不受影响 —— 暂停的是主 Agent 的自主行为,
1698
+ // 不是把用户的手也一起绑住。
1699
+ // ════════════════════════════════════════════════════════════════════════
1700
+
1701
+ /**
1702
+ * 组装「项目清单 + 看板任务」。
1703
+ *
1704
+ * 要探测 Git 状态的路径全部来自**服务端可信来源** —— configManager 的最近目录
1705
+ * 与 tasks.json 里已有的 projectPath。本路由不接受任何客户端传入的路径,
1706
+ * 因此不会被当成「任意路径 git 探测」的口子(口径同 /api/recent_directories/git-state)。
1707
+ */
1708
+ async function loadBoardPayload() {
1709
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1710
+ const tasks = data.tasks || [];
1711
+ const jobsSnap = snapshotJobs();
1712
+
1713
+ let recentDirs = [];
1714
+ try {
1715
+ if (configManager && typeof configManager.getRecentDirectories === 'function') {
1716
+ recentDirs = (await configManager.getRecentDirectories()) || [];
1717
+ }
1718
+ } catch (err) {
1719
+ // 最近目录读不到不该让整个看板挂掉:退化成"只按任务里出现过的项目"生成清单
1720
+ logger.warn('[workbench] 读取最近目录失败,项目清单退化为仅按任务路径生成:', err.message);
1721
+ }
1722
+
1723
+ const currentProjectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1724
+ // Git 探测自带 15s TTL 缓存(directoryGitState.js),看板 5s 轮询里有 2/3 是命中缓存,
1725
+ // 实际 spawn 频率被自然限制在 15s 一次,不需要再给这个接口加"跳过一次探测"的开关。
1726
+ const projects = await listProjects({ recentDirs, tasks, jobs: jobsSnap, currentProjectPath });
1727
+ const jobsByTask = groupJobsByTask(jobsSnap);
1728
+ const boardTasks = tasks.map(t => decorateTaskForBoard(t, jobsByTask.get(t.id) || []));
1729
+ return { projects, tasks: boardTasks, currentProjectPath };
1730
+ }
1731
+
1732
+ app.get('/api/workbench/projects', asyncRoute(async (_req, res) => {
1733
+ const payload = await loadBoardPayload();
1734
+ res.json({ success: true, ...payload });
1735
+ }));
1736
+
1737
+ /**
1738
+ * 单个任务的详情 —— 点看板卡片弹出的那个框用它。
1739
+ *
1740
+ * 为什么不把详情并进 /projects:那个接口是 5s 轮询的,只该发摘要
1741
+ * (decorateTaskForBoard)。把子任务描述、报错、执行输出一起推,
1742
+ * 十几条任务每 5 秒来一遍,纯属浪费带宽和解析时间。
1743
+ * 这里按需取一次,且 output 由 trimJobForDetail 在服务端截尾。
1744
+ *
1745
+ * 注册位置:字面量路径 /tasks/ai-* 都在上面且只有一段,
1746
+ * :id/detail 是两段,不会互相抢匹配。
1747
+ */
1748
+ app.get('/api/workbench/tasks/:id/detail', asyncRoute(async (req, res) => {
1749
+ const id = req.params.id;
1750
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1751
+ const task = (data.tasks || []).find(t => t && t.id === id);
1752
+ if (!task) throw new HttpError(404, '任务不存在');
1753
+
1754
+ // loadAllJobs 已经把「文件里归档的 + 内存里还活着的」合并过(含 taskTitle/subTitle)
1755
+ const allJobs = await loadAllJobs();
1756
+ const detail = buildTaskDetail(task, allJobs.filter(j => j && j.taskId === id));
1757
+ if (!detail) throw new HttpError(404, '任务不存在');
1758
+
1759
+ res.json({ success: true, ...detail });
1760
+ }));
1761
+
1762
+ app.get('/api/workbench/orchestrator', asyncRoute(async (_req, res) => {
1763
+ const state = await readOrchestrator();
1764
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1765
+ const tasks = data.tasks || [];
1766
+ const jobsSnap = snapshotJobs();
1767
+ res.json({
1768
+ success: true,
1769
+ active: state.active,
1770
+ updatedAt: state.updatedAt,
1771
+ instructions: state.instructions,
1772
+ activity: buildActivityFeed({ jobs: jobsSnap, tasks, instructions: state.instructions }),
1773
+ running: buildRunningAgents({ jobs: jobsSnap, tasks }),
1774
+ });
1775
+ }));
1776
+
1777
+ app.post('/api/workbench/orchestrator/state', asyncRoute(async (req, res) => {
1778
+ const active = req.body?.active;
1779
+ if (typeof active !== 'boolean') throw new HttpError(400, 'active 必须是布尔值');
1780
+ const state = await setOrchestratorActive(active);
1781
+ publish('orchestrator:state', { active: state.active, updatedAt: state.updatedAt });
1782
+ res.json({ success: true, active: state.active, updatedAt: state.updatedAt });
1783
+ }));
1784
+
1785
+ /**
1786
+ * 派发一条指令。
1787
+ * body: { text, projectPath?, autoRun? }
1788
+ * - projectPath 缺省 = 当前项目
1789
+ * - autoRun 默认 true;调度暂停时只建任务不执行(指令记录里会写明原因)
1790
+ *
1791
+ * 指令一律落成目标项目下的**简单任务**(一句话交给 claude 跑一轮),
1792
+ * 而不是造一个没有子任务的复杂任务 —— 后者连执行入口都没有,只会变成看板上的死卡。
1793
+ */
1794
+ app.post('/api/workbench/orchestrator/dispatch', asyncRoute(async (req, res) => {
1795
+ const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
1796
+ if (!text) throw new HttpError(400, '指令内容不能为空');
1797
+ if (text.length > 4000) throw new HttpError(400, '指令过长(上限 4000 字)');
1798
+
1799
+ const fallbackPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1800
+ const bodyPath = typeof req.body?.projectPath === 'string' ? req.body.projectPath.trim() : '';
1801
+ const targetPath = bodyPath || fallbackPath;
1802
+ if (!targetPath) throw new HttpError(400, '未选中项目,无法派发');
1803
+
1804
+ // 目标目录必须真的存在:跑在不存在的工作区上只会拿到一堆无意义的报错
1805
+ let stat = null;
1806
+ try { stat = await fsp.stat(targetPath); } catch { /* 下面统一报错 */ }
1807
+ if (!stat || !stat.isDirectory()) {
1808
+ const rejected = await appendInstruction({
1809
+ text,
1810
+ projectPath: targetPath,
1811
+ status: 'rejected',
1812
+ reason: '项目目录不存在',
1813
+ });
1814
+ publish('orchestrator:instruction', rejected);
1815
+ throw new HttpError(400, `项目目录不存在:${targetPath}`);
1816
+ }
1817
+
1818
+ const autoRun = req.body?.autoRun !== false;
1819
+ const schedulingActive = await isSchedulingActive();
1820
+
1821
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1822
+ const tasks = data.tasks || [];
1823
+ const now = nowIso();
1824
+ const task = {
1825
+ id: genId(),
1826
+ // 标题取指令首行并截断:看板卡片只占一行,整段指令塞进标题会把卡片撑爆
1827
+ title: text.split('\n')[0].slice(0, 120),
1828
+ desc: text,
1829
+ promptId: null,
1830
+ type: 'simple',
1831
+ sequential: true,
1832
+ simpleOverride: '',
1833
+ projectPath: targetPath,
1834
+ subtasks: [],
1835
+ status: 'todo',
1836
+ createdAt: now,
1837
+ updatedAt: now,
1838
+ };
1839
+ tasks.push(task);
1840
+ await writeJson(TASKS_FILE, { tasks });
1841
+
1842
+ const willRun = autoRun && schedulingActive;
1843
+ const record = await appendInstruction({
1844
+ text,
1845
+ projectPath: targetPath,
1846
+ taskId: task.id,
1847
+ status: willRun ? 'accepted' : 'created',
1848
+ reason: autoRun && !schedulingActive ? '调度已暂停,只建了任务未执行' : '',
1849
+ });
1850
+
1851
+ publish('task:created', { task });
1852
+ publish('orchestrator:instruction', record);
1853
+
1854
+ // 与 POST /tasks/:id/run-simple 走同一条执行路径,避免出现第二套执行入口
1855
+ if (willRun) {
1856
+ const virtualSub = {
1857
+ id: `${task.id}__simple`,
1858
+ title: task.title,
1859
+ desc: task.desc || '',
1860
+ status: 'todo',
1861
+ promptOverride: '',
1862
+ attachments: [],
1863
+ };
1864
+ runSingleSubtask(task, virtualSub, targetPath, '', []).catch(err => {
1865
+ publish('task:error', { taskId: task.id, error: err.message });
1866
+ });
1867
+ }
1868
+
1869
+ res.json({ success: true, task, instruction: record, ran: willRun, schedulingActive });
1870
+ }));
1670
1871
  }
@@ -0,0 +1,260 @@
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
+ // 主 Agent 编排台的状态存储 + 活动流拼装。
16
+ //
17
+ // 两件事:
18
+ // 1. 调度开关(active)+ 人类干预指令存档,持久化到 ~/.zen-gitsync/orchestrator.json。
19
+ // 「暂停调度」是服务端强制的:暂停期间自动派发会被拒绝(手动点执行不受影响)。
20
+ // 2. buildActivityFeed —— 把 job 的起止事实与人类指令合成一条时间倒序的活动流。
21
+ //
22
+ // 活动流为什么不在服务端拼好文案:
23
+ // UI 文案统一走前端 i18n(lang/{zh,en}),服务端只回**结构化事实**
24
+ // (kind + taskTitle + projectName + 时间),句子由前端 $t() 渲染。
25
+ // 后端一旦写死中文,英文界面就会漏出中文。
26
+ //
27
+ // 指令存档只是"我说过什么"的流水,不参与执行逻辑;进它不算成功,出它也不算失败,
28
+ // 失败原因记在单条指令的 status/reason 上(前端照着渲染)。
29
+
30
+ import {
31
+ ORCHESTRATOR_FILE,
32
+ MAX_ORCHESTRATOR_INSTRUCTIONS,
33
+ readJson,
34
+ writeJson,
35
+ nowIso,
36
+ genId,
37
+ } from './shared.js';
38
+ import { projectName } from './projectRegistry.js';
39
+
40
+ /** 活动流最多返回多少条(前端只渲染最近的一屏,多的不传) */
41
+ export const MAX_ACTIVITY_ENTRIES = 120;
42
+
43
+ /** 默认状态:新装 / 升级后直接是「调度中」,要停由用户自己去点暂停 */
44
+ function defaultState() {
45
+ return { version: 1, active: true, updatedAt: null, instructions: [] };
46
+ }
47
+
48
+ /** 单条指令归一:老数据/半损坏数据都要能渲染,不能让整份状态读不出来 */
49
+ function normalizeInstruction(raw) {
50
+ const it = raw && typeof raw === 'object' ? raw : {};
51
+ return {
52
+ id: typeof it.id === 'string' && it.id ? it.id : genId(),
53
+ text: typeof it.text === 'string' ? it.text : '',
54
+ projectPath: typeof it.projectPath === 'string' ? it.projectPath : '',
55
+ at: typeof it.at === 'string' ? it.at : null,
56
+ taskId: typeof it.taskId === 'string' && it.taskId ? it.taskId : null,
57
+ // accepted=已建任务 / rejected=被拒绝(暂停调度、项目不存在等)/ created=只建了草稿没执行
58
+ status: ['accepted', 'rejected', 'created'].includes(it.status) ? it.status : 'created',
59
+ reason: typeof it.reason === 'string' ? it.reason : '',
60
+ };
61
+ }
62
+
63
+ /** 归一化磁盘内容;文件缺失、损坏、字段缺失都必须能起来(编排台不能因为存档坏了就打不开) */
64
+ export function normalizeOrchestrator(raw) {
65
+ const state = raw && typeof raw === 'object' ? raw : {};
66
+ const base = defaultState();
67
+ const instructions = Array.isArray(state.instructions) ? state.instructions : [];
68
+ return {
69
+ version: 1,
70
+ // 只有显式 false 才算暂停;其余(缺字段 / 脏值)一律当调度中
71
+ active: state.active === false ? false : true,
72
+ updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : base.updatedAt,
73
+ instructions: instructions
74
+ .filter(x => x && typeof x === 'object')
75
+ .slice(-MAX_ORCHESTRATOR_INSTRUCTIONS)
76
+ .map(normalizeInstruction),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * 读-改-写串行化。
82
+ * 指令是"点一下就写一条"的高频小写操作,并发的两次读写会互相覆盖
83
+ * (后写的把前一条指令整个吞掉)。这里用一条 Promise 链把写操作排成队。
84
+ */
85
+ let writeChain = Promise.resolve();
86
+ function serialize(task) {
87
+ const next = writeChain.then(task, task);
88
+ // 吞掉链上的异常,避免一次失败让后续所有写操作都被拒绝
89
+ writeChain = next.catch(() => {});
90
+ return next;
91
+ }
92
+
93
+ export async function readOrchestrator() {
94
+ return normalizeOrchestrator(await readJson(ORCHESTRATOR_FILE, null));
95
+ }
96
+
97
+ async function persist(state) {
98
+ await writeJson(ORCHESTRATOR_FILE, state);
99
+ return state;
100
+ }
101
+
102
+ /** 暂停 / 恢复调度 */
103
+ export async function setOrchestratorActive(active) {
104
+ return serialize(async () => {
105
+ const state = await readOrchestrator();
106
+ state.active = !!active;
107
+ state.updatedAt = nowIso();
108
+ return persist(state);
109
+ });
110
+ }
111
+
112
+ /** 追加一条人类干预指令记录,返回写入后的那条记录 */
113
+ export async function appendInstruction(entry) {
114
+ return serialize(async () => {
115
+ const state = await readOrchestrator();
116
+ const record = normalizeInstruction({ id: genId(), at: nowIso(), ...entry });
117
+ state.instructions.push(record);
118
+ if (state.instructions.length > MAX_ORCHESTRATOR_INSTRUCTIONS) {
119
+ state.instructions = state.instructions.slice(-MAX_ORCHESTRATOR_INSTRUCTIONS);
120
+ }
121
+ await persist(state);
122
+ return record;
123
+ });
124
+ }
125
+
126
+ /** 回填某条指令的执行结果(建完任务后补 taskId / status) */
127
+ export async function updateInstruction(id, patch) {
128
+ return serialize(async () => {
129
+ const state = await readOrchestrator();
130
+ const i = state.instructions.findIndex(x => x.id === id);
131
+ if (i < 0) return null;
132
+ state.instructions[i] = normalizeInstruction({ ...state.instructions[i], ...patch });
133
+ await persist(state);
134
+ return state.instructions[i];
135
+ });
136
+ }
137
+
138
+ export async function clearInstructions() {
139
+ return serialize(async () => {
140
+ const state = await readOrchestrator();
141
+ state.instructions = [];
142
+ state.updatedAt = nowIso();
143
+ return persist(state);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * 正在执行的执行体清单(看板左栏「执行监控」用)。
149
+ *
150
+ * 一行 = 一个活跃 job(不是任务):一个任务并行跑 3 个子任务就是三行,
151
+ * 因为"谁在跑什么"只有到 job 粒度才说得清。job 只是执行进程,
152
+ * 所以文案上叫「执行」而不是「Agent」—— 不要把正在跑的 claude 进程
153
+ * 渲染成一个并不存在的"智能体集群"。
154
+ *
155
+ * @param {{ jobs?: object[], tasks?: object[] }} input
156
+ */
157
+ export function buildRunningAgents({ jobs = [], tasks = [] } = {}) {
158
+ const taskMap = new Map((Array.isArray(tasks) ? tasks : []).map(t => [t && t.id, t]));
159
+ return (Array.isArray(jobs) ? jobs : [])
160
+ .filter(j => j && (j.status === 'running' || j.status === 'pending'))
161
+ .map(j => {
162
+ const task = taskMap.get(j.taskId) || null;
163
+ const subs = task && Array.isArray(task.subtasks) ? task.subtasks : [];
164
+ const sub = subs.find(s => s && s.id === j.subId) || null;
165
+ const projectPath = (task && task.projectPath) || '';
166
+ return {
167
+ jobId: j.id,
168
+ taskId: j.taskId || null,
169
+ subId: j.subId || null,
170
+ taskTitle: (task && task.title) || '',
171
+ subTitle: (sub && sub.title) || '',
172
+ status: j.status,
173
+ pid: j.pid || null,
174
+ startedAt: j.startedAt || null,
175
+ projectPath,
176
+ projectName: projectName(projectPath),
177
+ };
178
+ })
179
+ .sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
180
+ }
181
+
182
+ /** 供派发路由判断"现在能不能自动执行" */
183
+ export async function isSchedulingActive() {
184
+ const state = await readOrchestrator();
185
+ return state.active;
186
+ }
187
+
188
+ /**
189
+ * 拼装活动流:job 的起止事实 + 人类指令,按时间倒序取前 MAX_ACTIVITY_ENTRIES 条。
190
+ *
191
+ * 一条 job 产出两条记录(派发 / 收尾),这是它的真实生命周期:
192
+ * dispatch ← startedAt 存在
193
+ * done / error / cancelled ← endedAt 存在,按 status 分
194
+ * 只回结构化字段,句子交给前端 i18n。
195
+ *
196
+ * @param {{ jobs?: object[], tasks?: object[], instructions?: object[] }} input
197
+ */
198
+ export function buildActivityFeed({ jobs = [], tasks = [], instructions = [] } = {}) {
199
+ const taskMap = new Map((Array.isArray(tasks) ? tasks : []).map(t => [t && t.id, t]));
200
+ const rows = [];
201
+
202
+ const projectOf = (task) => {
203
+ const p = (task && task.projectPath) || '';
204
+ return { projectPath: p, projectName: projectName(p) };
205
+ };
206
+
207
+ for (const j of Array.isArray(jobs) ? jobs : []) {
208
+ if (!j || !j.id) continue;
209
+ const task = taskMap.get(j.taskId) || null;
210
+ const subs = task && Array.isArray(task.subtasks) ? task.subtasks : [];
211
+ const sub = subs.find(s => s && s.id === j.subId) || null;
212
+ const base = {
213
+ jobId: j.id,
214
+ taskId: j.taskId || null,
215
+ subId: j.subId || null,
216
+ taskTitle: (task && task.title) || '',
217
+ subTitle: (sub && sub.title) || '',
218
+ jobStatus: j.status || '',
219
+ pid: j.pid || null,
220
+ ...projectOf(task),
221
+ };
222
+
223
+ if (j.startedAt) {
224
+ rows.push({ id: `job:${j.id}:start`, kind: 'dispatch', at: j.startedAt, ...base });
225
+ }
226
+ if (j.endedAt) {
227
+ const kind = j.status === 'done' ? 'done'
228
+ : j.status === 'cancelled' ? 'cancelled'
229
+ : 'error';
230
+ rows.push({
231
+ id: `job:${j.id}:end`,
232
+ kind,
233
+ at: j.endedAt,
234
+ exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
235
+ error: j.error || '',
236
+ ...base,
237
+ });
238
+ }
239
+ }
240
+
241
+ for (const it of Array.isArray(instructions) ? instructions : []) {
242
+ if (!it || !it.id) continue;
243
+ rows.push({
244
+ id: `ins:${it.id}`,
245
+ kind: 'user',
246
+ at: it.at,
247
+ instructionId: it.id,
248
+ text: it.text || '',
249
+ taskId: it.taskId || null,
250
+ instructionStatus: it.status || 'created',
251
+ reason: it.reason || '',
252
+ projectPath: it.projectPath || '',
253
+ projectName: projectName(it.projectPath || ''),
254
+ });
255
+ }
256
+
257
+ // 时间缺失的排最后(排序键用空串,天然沉底)
258
+ rows.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
259
+ return rows.slice(0, MAX_ACTIVITY_ENTRIES);
260
+ }