nx-kv 0.19.0 → 0.21.0

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 (92) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/assets/kv-get/references/wait-workflow.md +3 -2
  3. package/package.json +1 -1
  4. package/src/core/workflow/project.js +12 -13
  5. package/src/core/workflow/readiness.js +150 -37
  6. package/src/core/workflow/types.js +19 -1
  7. package/src/modules/localtask/dag.js +2 -0
  8. package/src/modules/localtask/deletion.js +129 -0
  9. package/src/modules/localtask/events.js +207 -0
  10. package/src/modules/localtask/index.js +46 -13
  11. package/src/modules/localtask/move-prims.js +95 -0
  12. package/src/modules/localtask/mutator/validate.js +5 -14
  13. package/src/modules/localtask/panel-presence.js +322 -0
  14. package/src/modules/localtask/paths.js +89 -0
  15. package/src/modules/localtask/remote-import.js +137 -0
  16. package/src/modules/localtask/repo-ctx.js +23 -0
  17. package/src/modules/localtask/service.js +20 -2238
  18. package/src/modules/localtask/store-io.js +256 -0
  19. package/src/modules/localtask/task-lifecycle.js +193 -0
  20. package/src/modules/localtask/task-ops.js +295 -0
  21. package/src/modules/localtask/task-remove.js +167 -0
  22. package/src/modules/localtask/task-templates.js +77 -0
  23. package/src/modules/localtask/task-view.js +213 -0
  24. package/src/modules/localtask/topics.js +151 -0
  25. package/src/modules/localtask/waiters.js +71 -0
  26. package/src/modules/localtask/waiting.js +86 -0
  27. package/src/modules/localtask/writes.js +17 -7
  28. package/src/modules/tasktool/add.js +1 -22
  29. package/src/modules/tasktool/group-store.js +28 -0
  30. package/src/modules/tasktool/panel-escort.js +121 -0
  31. package/src/modules/tasktool/update.js +70 -0
  32. package/src/modules/tasktool/wait.js +28 -117
  33. package/src/shared/graph.js +40 -0
  34. package/src/web/frontend/App.jsx +7 -6
  35. package/src/web/frontend/components/icons.jsx +11 -0
  36. package/src/web/frontend/components/layout/breadcrumb.jsx +16 -4
  37. package/src/web/frontend/components/layout/crumbs.js +51 -0
  38. package/src/web/frontend/components/layout/route-crumb.jsx +33 -0
  39. package/src/web/frontend/components/layout/route-history.js +27 -0
  40. package/src/web/frontend/components/sidebar/index.jsx +2 -2
  41. package/src/web/frontend/components/sidebar/side-state.js +3 -2
  42. package/src/web/frontend/modules/prompts/prompt-editor.jsx +110 -26
  43. package/src/web/frontend/modules/prompts/style.css +15 -0
  44. package/src/web/frontend/page/cloud/index.jsx +2 -0
  45. package/src/web/frontend/page/dag/index.jsx +765 -0
  46. package/src/web/frontend/page/dir/dag-editor.jsx +171 -0
  47. package/src/web/frontend/page/dir/index.jsx +77 -2
  48. package/src/web/frontend/page/dir/landing.jsx +175 -66
  49. package/src/web/frontend/page/dir/task.jsx +36 -2
  50. package/src/web/frontend/page/group/index.jsx +188 -22
  51. package/src/web/frontend/page/overview/index.jsx +2 -0
  52. package/src/web/frontend/page/repos/index.jsx +2 -0
  53. package/src/web/frontend/page/settings/index.jsx +101 -41
  54. package/src/web/frontend/page/settings/prompts-section.jsx +21 -31
  55. package/src/web/frontend/page/settings/style.css +57 -17
  56. package/src/web/frontend/page/templates/index.jsx +2 -0
  57. package/src/web/frontend/panels.jsx +6 -0
  58. package/src/web/frontend/style-v2.css +225 -7
  59. package/src/web/frontend/workspace/areas.js +1 -0
  60. package/src/web/frontend/workspace.js +14 -4
  61. package/src/web/public/assets/{Introduction-C4qC8N7g.js → Introduction-BSDhb2Sj.js} +1 -1
  62. package/src/web/public/assets/{attach-DcyGhCUW.js → attach-v8BxRJ7p.js} +1 -1
  63. package/src/web/public/assets/cloud-Sfi1OGsF.js +3 -0
  64. package/src/web/public/assets/dag-BnXCpGph.js +1 -0
  65. package/src/web/public/assets/dir-DG-o4u5h.js +3 -0
  66. package/src/web/public/assets/graph-DM-zDRdL.js +1 -0
  67. package/src/web/public/assets/group-CO1dZAIi.js +1 -0
  68. package/src/web/public/assets/{index-DidFyMnw.js → index-BSQ-y9mw.js} +4 -4
  69. package/src/web/public/assets/{index-fhYYxphP.css → index-Cg0WaaAi.css} +1 -1
  70. package/src/web/public/assets/{md-history-C29-dF29.js → md-history-DwI1pcCg.js} +3 -3
  71. package/src/web/public/assets/{member-picker-B7dnMq25.js → member-picker-BgVyjr7m.js} +1 -1
  72. package/src/web/public/assets/overview-CdGfl5aQ.js +1 -0
  73. package/src/web/public/assets/{progress-CnHK_jKP.js → progress-CxIXeuOg.js} +2 -2
  74. package/src/web/public/assets/repos-DiYt0y77.js +10 -0
  75. package/src/web/public/assets/route-crumb-BEAGbZBS.js +1 -0
  76. package/src/web/public/assets/settings-BIDVlTR_.js +3 -0
  77. package/src/web/public/assets/settings-CRK86fnJ.css +1 -0
  78. package/src/web/public/assets/task-5c2dyDzF.js +2 -0
  79. package/src/web/public/assets/{template-slots-BybxMN3v.js → template-slots-B7SKP2-A.js} +1 -1
  80. package/src/web/public/assets/{template-slots-CNdlIB1I.css → template-slots-UVa4FnPn.css} +1 -1
  81. package/src/web/public/assets/templates-BOJCv21y.js +2 -0
  82. package/src/web/public/assets/{upload-jOng3KOU.js → upload-ChrVGtCq.js} +1 -1
  83. package/src/web/public/index.html +3 -3
  84. package/src/web/public/assets/cloud-D0SRl665.js +0 -3
  85. package/src/web/public/assets/dir-B4egV6kV.js +0 -3
  86. package/src/web/public/assets/group-Cbd1PVuv.js +0 -1
  87. package/src/web/public/assets/overview-CX9XZ5EO.js +0 -1
  88. package/src/web/public/assets/repos-CyxHTr1L.js +0 -10
  89. package/src/web/public/assets/settings-C5xCFk_y.css +0 -1
  90. package/src/web/public/assets/settings-DIvkm7lx.js +0 -1
  91. package/src/web/public/assets/task-CsEvkBW7.js +0 -2
  92. package/src/web/public/assets/templates-BtdK84si.js +0 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,135 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.21.0] - 2026-11-05 队列与编排的职责单一化 + store 损坏防护
6
+
7
+ ### Fixed
8
+
9
+ - **store 损坏不再静默清空队列**:localtasks.json 读不出/解析失败时,先把坏文件**改名保留**为
10
+ `<store>.corrupt-<时间戳>`(同目录最多留 3 份,超出删最旧;改名失败则保留原文件等下次),
11
+ 再落到空 store。此前坏文件留在原地,下一次任何写操作会把它原地覆盖——一次磁盘抖动
12
+ 等于全部主题无声清零。
13
+
14
+ ### Changed
15
+
16
+ - **localtask service 拆分**(原 2458 行单文件 → 16 个单一职责文件 + 47 行门面):
17
+ 对外 API 路径与行为**零变化**(CLI/HTTP/面板与所有调用方无感),模块内部分工见各文件头注释。
18
+ - **就绪判定双实现合一**:任务行入口与快照入口此前是两套算法(闸门语义被编码两次),
19
+ 现在共用同一张图与同一个内核判定;跨队列依赖(`"topicKey:id"`)的 fail-open 口径不变。
20
+ 新增跨模型一致性测试,逐条钉住两个入口对同一份数据的结论必须相等。
21
+ - **闸门谓词统一**(`isGateNode`):此前散落 5 处且判定字段漂移(有的只认插件名、
22
+ 有的连 scope 一起认),现收成一处并集口径。
23
+ - **环检测实现下沉 `src/shared/graph.js`**:mutator 与两个前端组件(画布编排面板、目录编排
24
+ 编辑器)共用一份 DFS——此前三份拷贝,其中一处曾因方向写反漏判 2 节点环。
25
+ - **wait 编排拆分**:面板随行(拉起主面板/深链/窗口重开防风暴)搬进
26
+ `tasktool/panel-escort.js`,`waitForTask` 回归纯队列出口语义;三个 500ms 节拍的轮询
27
+ 合并为一个 ticker。返回契约与 `--no-open` 行为逐字不变。
28
+
29
+ ## [0.20.0] - 2026-11-04 任务编排(DAG)进面板 + 面包屑/设置面板收拾 + 改归属项目即搬家
30
+
31
+ ### Added
32
+
33
+ - **任务编排(DAG)进面板(任务 #40)**:目录落地页任务表与项目组「本组任务」列表支持编排——
34
+ - **拖动排序**:目录任务表待领取行可拖动换位(落点=上半行前/下半行后,青绿线指示),
35
+ 走既有 `localtask reorder`(已领取行是固定历史,不可拖);
36
+ - **编排编辑器**(行尾「⋮」→「编排依赖…」):闸门开关、上游依赖(我等谁)、
37
+ 下游任务(谁等我,逐条改对方 blockBy)三段勾选,保存前客户端查环——
38
+ 会成环的组合直接禁用;闸门开启时上游段禁用并清空、下游自动覆盖(引擎约束对齐:
39
+ 闸门的隐含边与上游边成环,`applySnapshotToTopic` 亦把闸门显式后继按冗余剪除);
40
+ - **组页按队列表达**:组任务横跨多个成员队列,编排以**该任务所属队列**为作用域
41
+ (候选/闸门下游都取本队列),闸门×N 与锁定徽章上屏;
42
+ - 闸门徽章升级:显示被挡后续数(`闸门×N`,悬停列出 #id)。
43
+ - 组任务行尾新增「⋮」行菜单(编排依赖 / 闸门开关),待领取行悬停可见。
44
+
45
+ ### Changed
46
+
47
+ - **设置面板观感重做(任务 #41 重开反馈「ui 太丑了」→ 二轮「更丑了」)**:交互语义不变,只收拾
48
+ 密度与层级——「提示词 / 模板」栏的竖排 tab 侧栏改成顶部一排药丸、内容限宽;提示词条目行去描边、
49
+ 编号不用圆圈、**收起标题洗掉原始模板标记**(不再直接甩 `{{#goal}}〔…〕` 给人看);操作条分隔线分组;
50
+ 项目级设置从大卡片改成紧凑行(项目多时一屏 8 个而不是 2 个),「持续 get」关着时只留一枚行内小开关;
51
+ 「提示词 / 模板」栏不再套卡(盒中盒显脏)。:交互语义不变,只收拾
52
+ 密度与层级——「提示词 / 模板」栏的竖排 tab 侧栏改成顶部一排药丸、内容限宽;
53
+ 提示词条目行去描边、编号不用圆圈;操作条分隔线分组;项目级设置从大卡片改成
54
+ 紧凑行(项目多时一屏 8 个而不是 2 个),「持续 get」关着时只留一枚行内小开关。
55
+
56
+ ### Changed
57
+
58
+ - **面包屑移到 maincontent 面板内顶部(任务 #44)**:路径条不再占窗口顶部通栏
59
+ (那是侧栏的地盘),改成挂在**当前主面板**顶部的一枚小药丸——目录任务表 /
60
+ 任务详情页 / 项目组 / 全部目录总览 / 云端总览 / 仓库管理 / 任务模板 各面板
61
+ 根部都有一条;MD 工作台里则直接放进顶部工具栏(`mdw-top`)内、标题左侧;
62
+ 「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。:路径条不再占窗口顶部通栏
63
+ (那是侧栏的地盘),改成挂在**当前主面板**顶部的一枚小药丸——目录任务表 /
64
+ 任务详情页 / 项目组 / 全部目录总览 / 云端总览 / 仓库管理 / 任务模板 各面板
65
+ 根部都有一条;「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。
66
+
67
+ ### Fixed
68
+
69
+ - **改归属项目即搬家(任务 #43「提交任务后发现归属项目填错了,重新修改提交无效」)**:
70
+ 此前「归属决定落点」只在提交那一刻生效——提交后发现归属填错,改正文里的 project 再保存,
71
+ 正文改了队列没动:任务还挂在旧项目队列里,旧项目的 agent 照旧领走,新项目的 agent 永远
72
+ 看不见,组面板/目录页也都显示旧归属。现在 `localtask update --text` / `localtask reopen --text`
73
+ / 面板「保存并同步 / 重做」走与派任务同一套落点判定:effective frontmatter.project 解析到
74
+ 哪个登记项目,任务就迁到哪个队列;介绍引用(projectIntro/projectDesc)随归属刷新。
75
+ 约束如实说明:已领取的任务不能改归属(要改先 reopen);带队列内依赖/闸门的任务搬运会
76
+ 跨队列断链,先解除再改(明确报错,不静默);搬家后任务拿到**新队列的 id**(面板页签自动
77
+ 跟到新号,CLI 输出标注迁移);指向它的跨队列引用(`topicKey:id`)改写到新地址。
78
+ 另自愈历史脏数据:归属字段与所在队列对不上(上一轮只改字段没搬队列的残留)时,
79
+ 保存即搬到归属队列;已领取/带队列内依赖的脏数据不搬也不拦普通编辑。
80
+
81
+ - **顶部面包屑真正可见 + 补返回键(任务 #42)**:面包屑此前**渲染了却看不见**——
82
+ FlexLayout 的根是绝对定位(inset:0),铺满工作区把 26px 的路径条盖在下面
83
+ (侧栏面板压在上面,路径条点不到也看不见)。现在工作区有面包屑时布局整体下移一条;
84
+ 侧栏也不再出页签条(#33「无页签化」的残留,页签条同样压在路径条上)。
85
+ 面包屑条左侧新增「← 返回」:按**来处**返回(会话内路由历史栈)——
86
+ 从项目组点任务(任务住在 dir 队列里,详情页落在 dir 作用域)时,返回会回到**组**
87
+ 而不是掉进目录;没有上一级时不画返回键。
88
+
89
+ ### Changed
90
+
91
+ - **可视化编排面板(任务 #40 验收反馈重开)**:目录与项目组**共用**一块编排面板,
92
+ 从任务面板**头部右侧的绿色超链**进入(开在右侧主内容区):
93
+ - **节点 = 小圆角卡片**,画布带网格底;节点纵向位置 = 队列顺序;
94
+ - **拖拽排序**:拖节点即改队列顺序(已领取节点是固定历史,不可拖);
95
+ - **连线建立有向依赖**:从节点左侧把手拉到另一个节点 = 后者等前者(写入 blockBy);
96
+ 依赖边以曲线 + 箭头画在画布左侧,节点上显示「等 #N」芯片(可一键解除);
97
+ - **节点工具**:设为闸门(挡住后续)/ 加时间目标(timegoal)/ 打开任务;
98
+ - 组视图进来可在**成员队列之间切换**(DAG 的排序/依赖/闸门都是队列内的);
99
+ - 编排只改被碰过的那几项,没编排过的节点保持原队列序。
100
+ - **设置面板交互收敛(任务 #41)**:
101
+ - **左边 tab 悬停即切换**(设置外层分区导航 + 「提示词 / 模板」栏内的三个分类),
102
+ 点击保留为兜底;少一次点击。
103
+ - **提示词条目默认收起成标题行**(首行截断 + 字数),**点击才展开编辑**(单开手风琴);
104
+ 「添加一条…」新增后直接展开待填。
105
+ - **说明文字全部删除**:设置面板右侧的静态说明段(工作区/任务工具/项目级等)与
106
+ 提示词栏的提示段一并清掉,只留状态与操作。
107
+ - **项目级「持续 get」一句话配置**:选模板(内置默认 + 全局回调提示词条目)→ 改一句话
108
+ (`%s` = 任务编号,存取时与 `{{task.id}}` 互转)→ 添加;不再手写整段模板。
109
+
110
+ ### Added
111
+
112
+ - `localtask config --suffix-list "一条,二条"` / `--ack-list "一条,二条"`:**整体替换**多条
113
+ 提示词列表(逗号分隔;HTTP body 传数组)。此前 action 只暴露单条 `--suffix`/`--ack`,
114
+ 面板的多条保存无处可传(#41 实测发现并修)。
115
+
116
+ ### Fixed
117
+
118
+ - **首次打开设置面板爆栈**(任务 #41 实测发现,#33 遗留):`openPanel` 按区域分发时
119
+ FLOAT → `openFloatingPanel` → `openPanel`(未带 area)→ 又判成 FLOAT → 无限递归
120
+ ("Maximum call stack size exceeded")。老布局因已存着设置浮窗、走 existing 早退而侥幸
121
+ 没踩到,全新档案首次打开必崩。内层调用显式指定 `AREA.MAIN` 后收敛。
122
+ - **回调 / ack 提示词在面板上保存不了**(任务 #41 实测发现,#32 遗留):面板打的是
123
+ `taskSuffix`/`ackPrompt` 字段,action 未声明 → 请求 400 且**静默无效**(面板回显旧列表,
124
+ 看起来像保存成功)。改为 `suffixList`/`ackList`(对应上面新增的列表 flag)后真正落库。
125
+ - 侧栏搜索框降高响应式(任务 #38):`label.sbox` 28px→24px 显式定高;「+」按钮
126
+ `flex:none` 永不被挤掉;侧栏按实际像素宽分档降级(≤256 收间距 / ≤195 藏撤销重做 /
127
+ ≤150 藏标题+放大镜 / ≤125 藏品牌字+匹配数),高频件(搜索框、+、收起/关闭、齿轮)
128
+ 永远在场;`nx-title` 窄宽折两行一并修复。
129
+ - 面板 maximize 键全线下线(任务 #39):`tabSetEnableMaximize=false` 一关全断
130
+ (页签条 Maximize/Restore 按钮、右键菜单最大化项、双击标题栏最大化都走
131
+ `canMaximize()` 门控);连带清理死翻译与死 CSS 变量。
132
+ - 目录/组任务行的「⋮」菜单点击不再冒泡打开任务详情(stopPropagation)。
133
+
5
134
  ## [0.19.0] - 2026-11-04 提示词/模板收敛到设置面板 + 面板无页签化(面包屑 + 区域策略)+ DAG 引擎与 goal 插件族
6
135
 
7
136
  ### Added
@@ -96,9 +96,10 @@ wait 必带一句当前现状总结(≤2000 字,连续空白会折叠为单
96
96
  - **配置**:`nx-kv localtask config --suffix "<模板>"`(占位符
97
97
  `{{goal.name}} {{goal.remainingHuman}} {{goal.prompt}} {{task.text}} {{source}} {{name}}…`,
98
98
  段落 `{{#goal}}…{{/goal}}` 上下文缺失整段消失;`--no-suffix` 关闭、
99
- `--reset-suffix` 恢复默认)。字段缺席 = 已关闭或本轮没有可渲染内容。
99
+ `--reset-suffix` 恢复默认;**多条**用 `--suffix-list "第一条,第二条"`(逗号分隔)整体替换,
100
+ 或 `--suffix-add` / `--suffix-remove <序号>` 按条增删)。字段缺席 = 已关闭或本轮没有可渲染内容。
100
101
  - **ack 提示词(完成总结的格式要求)**:`nx-kv localtask config --ack "<提示词>"`
101
- (占位符同上,多条)。配置后,它会随 `instruction` 一起交给你——按它要求的格式
102
+ (占位符同上;多条用 `--ack-list "第一条,第二条"`(逗号分隔)整体替换)。配置后,它会随 `instruction` 一起交给你——按它要求的格式
102
103
  写 `localtask done --summary "<完成总结>"` 即可;没配置时 `instruction` 里没有
103
104
  这段,格式自便。设置面板「提示词 / 模板」一栏也能配。
104
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,9 @@
1
1
  // 主题队列(localtask topic.tasks)↔ WorkflowSnapshot 双向投影。
2
2
  // 存盘形状仍是任务行;快照是引擎视图。正文不进快照(payload.textRef)。
3
+ // 跨队列 blockBy("topicKey:id")不进引擎边,投影回写时必须保留。
3
4
 
4
- import { BUILTIN_GOALS, edgesFromTasks, taskToNode } from './types.js';
5
+ import { edgesFromTasks, isGateNode, taskToNode } from './types.js';
6
+ import { normalizeBlockBy, parseDepRef } from './readiness.js';
5
7
 
6
8
  /** @typedef {import('./types.js').WorkflowSnapshot} WorkflowSnapshot */
7
9
  /** @typedef {import('./types.js').WfNode} WfNode */
@@ -64,25 +66,23 @@ export function applySnapshotToTopic(topic, snap) {
64
66
 
65
67
  // 边 → blockBy(显式依赖);闸门边由 blocksFollowing 表达,不写进每条 blockBy
66
68
  const gateIds = new Set(
67
- (snap.nodes || [])
68
- .filter(
69
- (n) =>
70
- n.kind === 'control' ||
71
- (n.plugins || []).some((p) => p.name === BUILTIN_GOALS.blockgoal),
72
- )
73
- .map((n) => String(n.id)),
69
+ (snap.nodes || []).filter((n) => isGateNode(n)).map((n) => String(n.id)),
74
70
  );
75
71
 
76
72
  for (const task of tasks) {
77
73
  if (task.deletedAt || task.migratedTo) continue;
74
+ const keepForeign = normalizeBlockBy(task.blockBy, task.id).filter(
75
+ (d) => parseDepRef(d)?.kind === 'foreign',
76
+ );
78
77
  const incoming = (snap.edges || []).filter((e) => String(e.to) === String(task.id));
79
- const deps = [];
78
+ const localDeps = [];
80
79
  for (const e of incoming) {
81
80
  // 跳过「闸门挡后续」推出来的边:from 是闸门且闸门在数组序上早于本任务时由 blocksFollowing 覆盖
82
81
  if (gateIds.has(String(e.from))) continue;
83
- deps.push(e.from);
82
+ const n = Number(e.from);
83
+ if (Number.isFinite(n) && n > 0) localDeps.push(n);
84
84
  }
85
- task.blockBy = deps.map((d) => Number(d)).filter((n) => Number.isFinite(n) && n > 0);
85
+ task.blockBy = normalizeBlockBy([...localDeps, ...keepForeign], task.id);
86
86
  }
87
87
 
88
88
  topic.workflowRevision = Number(snap.revision) || 0;
@@ -94,8 +94,7 @@ export function applySnapshotToTopic(topic, snap) {
94
94
 
95
95
  /** @param {object} task @param {WfNode} node */
96
96
  function syncTaskFromNode(task, node) {
97
- const hasBlock = (node.plugins || []).some((p) => p.name === BUILTIN_GOALS.blockgoal);
98
- task.blocksFollowing = node.kind === 'control' || hasBlock;
97
+ task.blocksFollowing = isGateNode(node);
99
98
 
100
99
  if (node.state === 'success') {
101
100
  if (!task.completedAt) {
@@ -1,17 +1,96 @@
1
- // 拓扑就绪(自 localtask/dag.js 下沉)。任务行与快照节点两套入口。
1
+ // 拓扑就绪(自 localtask/dag.js 下沉)。任务行与快照节点两套入口,**一个内核**:
2
+ // 任务行先投影成轻量图(taskToNode + edgesFromTasks),本地依赖与闸门判定直接
3
+ // 复用 isNodeReady——两个模型对同一份数据产出同一张图,结论必然一致
4
+ // (一致性由 tests/unit/readiness-consistency.test.mjs 钉死)。
5
+ //
6
+ // blockBy 支持:
7
+ // - 本地 id(number)——同队列依赖(兼容旧存盘);入图,走内核判定
8
+ // - 跨队列引用 `"<topicKey>:<taskId>"`——组编排跨项目边;不入引擎图
9
+ // (edgesFromTasks 跳过),由 resolveForeign 前置直查,fail-open
2
10
 
3
- /** 规范化 blockBy:正整数 id、去重、丢掉自身 */
11
+ import { edgesFromTasks, taskToNode, isGateNode } from './types.js';
12
+
13
+ /** topicKey:id —— key 允许字母数字与 _-(含 __GLOBAL__) */
14
+ const FOREIGN_RE = /^([A-Za-z0-9_-]+):(\d+)$/;
15
+
16
+ /**
17
+ * @typedef {{ kind: 'local', id: number } | { kind: 'foreign', key: string, id: number }} DepRef
18
+ */
19
+
20
+ /**
21
+ * 解析单条依赖引用。
22
+ * @param {unknown} x
23
+ * @returns {DepRef | null}
24
+ */
25
+ export function parseDepRef(x) {
26
+ if (typeof x === 'number' && Number.isFinite(x) && x > 0) {
27
+ return { kind: 'local', id: Math.trunc(x) };
28
+ }
29
+ const s = String(x ?? '').trim();
30
+ if (!s) return null;
31
+ const m = FOREIGN_RE.exec(s);
32
+ if (m) return { kind: 'foreign', key: m[1], id: Number(m[2]) };
33
+ // 纯数字字符串 → 本地 id(避免把 "12" 当 foreign)
34
+ if (/^\d+$/.test(s)) {
35
+ const n = Number(s);
36
+ if (Number.isFinite(n) && n > 0) return { kind: 'local', id: n };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** @param {DepRef} ref @returns {number|string} */
42
+ export function formatDepRef(ref) {
43
+ if (!ref) return '';
44
+ if (ref.kind === 'foreign') return `${ref.key}:${ref.id}`;
45
+ return ref.id;
46
+ }
47
+
48
+ /**
49
+ * 规范化 blockBy:本地正整数 id + 跨队列 "key:id"、去重、丢掉本地自身。
50
+ * @param {unknown} raw
51
+ * @param {number|string} [selfId]
52
+ * @returns {Array<number|string>}
53
+ */
4
54
  export function normalizeBlockBy(raw, selfId) {
5
55
  const self = Number(selfId);
6
56
  const seen = new Set();
7
57
  const out = [];
8
- for (const x of Array.isArray(raw) ? raw : []) {
9
- const n = Number(x);
10
- if (!Number.isFinite(n) || n <= 0) continue;
11
- if (Number.isFinite(self) && n === self) continue;
12
- if (seen.has(n)) continue;
13
- seen.add(n);
14
- out.push(n);
58
+
59
+ /** @type {unknown[]} */
60
+ let items = [];
61
+ if (raw == null || raw === '') items = [];
62
+ else if (Array.isArray(raw)) items = raw;
63
+ else if (typeof raw === 'string' && /[,,\s]/.test(raw) && !FOREIGN_RE.test(raw.trim())) {
64
+ items = raw.split(/[,,\s]+/).filter(Boolean);
65
+ } else {
66
+ items = [raw];
67
+ }
68
+
69
+ // 数组元素里也可能是逗号串(HTTP 偶发)
70
+ const flat = [];
71
+ for (const x of items) {
72
+ if (typeof x === 'string' && /[,,]/.test(x) && !FOREIGN_RE.test(x.trim())) {
73
+ flat.push(...x.split(/[,,\s]+/).filter(Boolean));
74
+ } else {
75
+ flat.push(x);
76
+ }
77
+ }
78
+
79
+ for (const x of flat) {
80
+ const ref = parseDepRef(x);
81
+ if (!ref) continue;
82
+ if (ref.kind === 'local') {
83
+ if (Number.isFinite(self) && ref.id === self) continue;
84
+ const token = String(ref.id);
85
+ if (seen.has(`L:${token}`)) continue;
86
+ seen.add(`L:${token}`);
87
+ out.push(ref.id);
88
+ } else {
89
+ const token = `${ref.key}:${ref.id}`;
90
+ if (seen.has(`F:${token}`)) continue;
91
+ seen.add(`F:${token}`);
92
+ out.push(token);
93
+ }
15
94
  }
16
95
  return out;
17
96
  }
@@ -28,38 +107,75 @@ function isPendingLike(t) {
28
107
  return isAlive(t) && t.status !== 'consumed';
29
108
  }
30
109
 
110
+ /**
111
+ * @typedef {object} ReadyOpts
112
+ * @property {(topicKey: string, taskId: number) => object|null|undefined} [resolveForeign]
113
+ * 跨队列依赖解析;缺席或返回空 = fail-open(挡不住)
114
+ */
115
+
116
+ /** 跨队列依赖是否未满足(true = 挡)。无解析器 / 解析不到 / 已删已迁 → fail-open 不挡。 */
117
+ function foreignBlocked(task, opts) {
118
+ for (const d of normalizeBlockBy(task.blockBy, task.id)) {
119
+ const ref = parseDepRef(d);
120
+ if (!ref || ref.kind !== 'foreign') continue;
121
+ if (typeof opts?.resolveForeign !== 'function') continue;
122
+ const found = opts.resolveForeign(ref.key, ref.id);
123
+ const dep = found && isAlive(found) ? found : null;
124
+ if (dep && !isCompleted(dep)) return true;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ /**
130
+ * 任务行 → 轻量图(与 topicToSnapshot 同源:taskToNode 投影节点、edgesFromTasks 产边)。
131
+ * blockBy 先过 normalizeBlockBy(去自身/去非法,与引擎写入路径同一口径)再产边;
132
+ * "key:id" 外部边不入图。这是两个模型共用的唯一边源。
133
+ * @param {object[]} tasks
134
+ * @returns {{ nodes: import('./types.js').WfNode[], edges: import('./types.js').WfEdge[] }}
135
+ */
136
+ function rowsGraph(tasks) {
137
+ const alive = (tasks || []).filter(isAlive).map((t) => ({
138
+ ...t,
139
+ blockBy: normalizeBlockBy(t.blockBy, t.id),
140
+ }));
141
+ return { nodes: alive.map(taskToNode), edges: edgesFromTasks(alive) };
142
+ }
143
+
144
+ /** 图上判定:目标节点缺位(不在 all 里)→ true;否则交给 isNodeReady 内核。 */
145
+ function readyInGraph(graph, taskId) {
146
+ const node = (graph.nodes || []).find((n) => String(n.id) === String(taskId));
147
+ if (!node) return true;
148
+ return isNodeReady(graph, node);
149
+ }
150
+
31
151
  /**
32
152
  * 某条 pending 现在能不能领(任务行模型)。
153
+ * 本地依赖 + 闸门走快照同一内核(isNodeReady);跨队列依赖走 resolveForeign 前置直查。
154
+ * 前驱判定口径:completedAt ⇔ 节点 success(taskToNode 映射),闸门 = control 屏障。
33
155
  * @param {object} task
34
156
  * @param {object[]} all
157
+ * @param {ReadyOpts} [opts]
35
158
  */
36
- export function isReadyToClaim(task, all = []) {
159
+ export function isReadyToClaim(task, all = [], opts = {}) {
37
160
  if (!isPendingLike(task)) return false;
38
- const byId = new Map((all || []).filter(isAlive).map((t) => [Number(t.id), t]));
39
-
40
- const deps = normalizeBlockBy(task.blockBy, task.id);
41
- for (const id of deps) {
42
- const dep = byId.get(id);
43
- if (!dep) continue;
44
- if (!isCompleted(dep)) return false;
45
- }
46
-
47
- const idx = (all || []).indexOf(task);
48
- const order = all || [];
49
- const myPos = idx >= 0 ? idx : order.findIndex((t) => Number(t.id) === Number(task.id));
50
- if (myPos < 0) return true;
51
- for (let i = 0; i < myPos; i += 1) {
52
- const prev = order[i];
53
- if (!isAlive(prev) || !prev.blocksFollowing) continue;
54
- if (!isCompleted(prev)) return false;
55
- }
56
- return true;
161
+ if (foreignBlocked(task, opts)) return false;
162
+ return readyInGraph(rowsGraph(all), task.id);
57
163
  }
58
164
 
59
- /** 按数组序找第一条可领取的 pending;没有 → null */
60
- export function findFirstReadyPending(tasks = []) {
61
- for (const t of tasks || []) {
62
- if (isReadyToClaim(t, tasks)) return t;
165
+ /**
166
+ * 按数组序找第一条可领取的 pending;没有 → null。
167
+ * 图一次构建逐条判定(结论与逐条 isReadyToClaim 相同,省掉每条重建图)。
168
+ * @param {object[]} tasks
169
+ * @param {ReadyOpts} [opts]
170
+ */
171
+ export function findFirstReadyPending(tasks = [], opts = {}) {
172
+ const list = tasks || [];
173
+ const graph = rowsGraph(list);
174
+ for (const t of list) {
175
+ if (!isPendingLike(t)) continue;
176
+ if (foreignBlocked(t, opts)) continue;
177
+ const node = graph.nodes.find((n) => String(n.id) === String(t.id));
178
+ if (!node || isNodeReady(graph, node)) return t;
63
179
  }
64
180
  return null;
65
181
  }
@@ -82,10 +198,7 @@ export function isNodeReady(snap, node, isBarrierOpen) {
82
198
  for (const e of preds) {
83
199
  const pred = byId.get(String(e.from));
84
200
  if (!pred) continue;
85
- const isControl =
86
- pred.kind === 'control' ||
87
- (pred.plugins || []).some((p) => p.name === 'blockgoal' || p.scope === 'control');
88
- if (isControl) {
201
+ if (isGateNode(pred)) {
89
202
  const open = isBarrierOpen
90
203
  ? isBarrierOpen(snap, pred.id)
91
204
  : pred.state === 'success';
@@ -108,6 +108,21 @@ export const BUILTIN_GOALS = Object.freeze({
108
108
  timegoal: 'timegoal',
109
109
  });
110
110
 
111
+ /**
112
+ * 这个节点是不是「闸门」(控制屏障)——唯一的判定口径,全系统共用
113
+ * (曾散落 5 处且字段漂移:有的只认 blockgoal 名、有的连 scope==='control' 一起认)。
114
+ * 并集口径:kind=control,或绑了 blockgoal,或绑了任意 control-scope 插件。
115
+ * @param {WfNode} node
116
+ * @returns {boolean}
117
+ */
118
+ export function isGateNode(node) {
119
+ if (!node) return false;
120
+ if (node.kind === 'control') return true;
121
+ return (node.plugins || []).some(
122
+ (p) => p.name === BUILTIN_GOALS.blockgoal || p.scope === 'control'
123
+ );
124
+ }
125
+
111
126
  /**
112
127
  * 把现有 localtask 任务行投影成契约节点(只读适配,不改存盘形状)。
113
128
  * @param {object} task
@@ -155,7 +170,10 @@ export const edgesFromTasks = (tasks = []) => {
155
170
  const alive = (tasks || []).filter((t) => t && !t.deletedAt && !t.migratedTo);
156
171
  for (const t of alive) {
157
172
  for (const dep of Array.isArray(t.blockBy) ? t.blockBy : []) {
158
- edges.push({ from: dep, to: t.id });
173
+ // 引擎快照只认本队列数字边;跨队列 "key:id" 留给 get/ready 解析
174
+ const n = Number(dep);
175
+ if (!Number.isFinite(n) || n <= 0 || String(dep).includes(':')) continue;
176
+ edges.push({ from: n, to: t.id });
159
177
  }
160
178
  }
161
179
  for (let i = 0; i < alive.length; i += 1) {
@@ -1,5 +1,7 @@
1
1
  // 过渡再导出:实现已下沉 core/workflow/readiness.js
2
2
  export {
3
+ parseDepRef,
4
+ formatDepRef,
3
5
  normalizeBlockBy,
4
6
  isReadyToClaim,
5
7
  findFirstReadyPending,
@@ -0,0 +1,129 @@
1
+ // (#54 自 service.js 拆出)主题删除:四条删除路径共用的同一套判定(deleteGuard:
2
+ // 全局池/等待中/有未领取 → 需 --force)+ 单个/批量删除。
3
+ import { badInput } from '../../core/errors.js';
4
+ import { isGlobalKey, normalizeCwd, resolveTopicKey } from './paths.js';
5
+ import { loadStore, saveStore } from './store-io.js';
6
+ import { isLiveWaiting, isPending, topicView } from './task-view.js';
7
+ // ─── 删除判定:四条删除路径共用同一套规则 ──────────────────────────────
8
+ //
9
+ // 以前「删任务 / 删主题 / 批量删除 / prune」各有各的保护规则,
10
+ // 面板只暴露其中两套 → 同一个动作在 CLI 与面板上表现不一样,看着像乱。
11
+ // 现在规则只写一份,四个入口都调它:
12
+ //
13
+ // protected(全局共享池)→ 需要 --force
14
+ // waiting(正阻塞等待) → 需要 --force(agent 会超时收尾)
15
+ // has_pending(有未领取)→ 需要 --force(活还没人领)
16
+ // manual(手动登记的目录)→ 只在 prune 里保留,不参与普通删除
17
+ //
18
+ // force=true 仍然强删(老用法不变)。
19
+ export const DELETE_REASONS = {
20
+ protected: '全局共享池',
21
+ waiting: '等待中',
22
+ has_pending: '有未领取任务',
23
+ manual: '手动登记',
24
+ };
25
+
26
+ /**
27
+ * 判定一个主题能不能删。
28
+ * @returns {{ok:boolean, reason?:string, pending?:number, message?:string}}
29
+ */
30
+ export function deleteGuard(topic, { force = false, key = '', scope = 'topic' } = {}) {
31
+ if (isGlobalKey(key)) {
32
+ return force
33
+ ? { ok: true }
34
+ : { ok: false, reason: 'protected', message: '全局主题是共享任务池,默认不能删除(确认要清空请加 --force)' };
35
+ }
36
+ if (isLiveWaiting(topic)) {
37
+ return force
38
+ ? { ok: true }
39
+ : { ok: false, reason: 'waiting', message: '主题正在等待中,不能删除(确认要删请加 --force,agent 将超时收尾)' };
40
+ }
41
+ const pending = countPending(topic);
42
+ // prune 只清"旧脏数据",不是"删主题":它不因未领取任务而跳过
43
+ //(否则会改变 prune 既有语义——它本来就只清空主题/旧模型主题)。
44
+ if (scope !== 'prune' && pending > 0 && !force) {
45
+ return {
46
+ ok: false,
47
+ reason: 'has_pending',
48
+ pending,
49
+ message: `主题「${topic.name || key}」还有 ${pending} 条未领取任务,删除会一并丢掉(确认要删请加 --force)`,
50
+ };
51
+ }
52
+ if (scope === 'prune' && topic.manual) {
53
+ return { ok: false, reason: 'manual', message: '手动登记的目录不会被 prune 清理(请用 topic remove)' };
54
+ }
55
+ return { ok: true };
56
+ }
57
+
58
+ // 主题里还有多少条**未领取且未删除**的任务(= 还没人领的活)。
59
+ // 删除保护用它:队列里还堆着活就不该被一声不吭地清空。
60
+ function countPending(t) {
61
+ if (!t || !Array.isArray(t.tasks)) return 0;
62
+ return t.tasks.filter(isPending).length;
63
+ }
64
+
65
+ // 删除整个主题(连同其全部任务)。默认拒绝删除「等待中」主题——那会让正阻塞的
66
+ // agent 只能等到超时;force=true 才强删(agent 随后超时收尾)。
67
+ // 全局主题同样默认拒绝(共享池,删掉会让所有 worker 的候补来源消失);
68
+ // force=true 清空全局任务池。
69
+ export async function removeTopic({ cwd: rawCwd, key: rawKey, force = false } = {}) {
70
+ if (!rawKey && !rawCwd) {
71
+ throw badInput('需要 --cwd "<工作目录>" 或 --key "<主题key>"');
72
+ }
73
+ const key = resolveTopicKey({ key: rawKey, cwd: rawCwd });
74
+ const store = await loadStore();
75
+ const t = store.topics[key];
76
+ if (!t) {
77
+ const { notFound } = await import('../../core/errors.js');
78
+ throw notFound(`主题不存在(key ${key})`);
79
+ }
80
+ // 删除判定只写一份(deleteGuard):全局共享池 / 等待中 / 有未领取任务 → 都要 --force。
81
+ const guard = deleteGuard(t, { force, key });
82
+ if (!guard.ok) throw badInput(guard.message);
83
+ const view = topicView(t);
84
+ delete store.topics[key];
85
+ await saveStore(store);
86
+ return { status: 'removed', key, name: t.name, cwd: t.cwd, topic: view };
87
+ }
88
+
89
+ // 批量删除主题:keys / cwds(数组,可并用),或 all=true 清空全部。
90
+ // 默认把「等待中」「不存在」计入 skipped 而不删;force=true 连等待中一起删。
91
+ export async function removeTopics({ keys, cwds, all = false, force = false } = {}) {
92
+ const hasList = (a) => Array.isArray(a) && a.some((x) => String(x).trim());
93
+ if (!all && !hasList(keys) && !hasList(cwds)) {
94
+ throw badInput('需要 --keys "<key,key>" 或 --cwds "<目录,目录>",或 --all');
95
+ }
96
+
97
+ const store = await loadStore();
98
+ let targets;
99
+ if (all) {
100
+ targets = Object.keys(store.topics);
101
+ } else {
102
+ const list = [];
103
+ if (Array.isArray(keys)) list.push(...keys.map((x) => String(x).trim()).filter(Boolean));
104
+ if (Array.isArray(cwds)) {
105
+ for (const c of cwds) if (String(c).trim()) list.push(normalizeCwd(c).key);
106
+ }
107
+ targets = [...new Set(list)];
108
+ }
109
+
110
+ const removed = [];
111
+ const skipped = [];
112
+ for (const key of targets) {
113
+ const t = store.topics[key];
114
+ if (!t) {
115
+ skipped.push({ key, reason: 'not_found' });
116
+ continue;
117
+ }
118
+ // 判定与单删共用同一套规则(deleteGuard)——批量与单个的语义必须一致
119
+ const g = deleteGuard(t, { force, key });
120
+ if (!g.ok) {
121
+ skipped.push({ key, name: t.name, cwd: t.cwd, reason: g.reason, pending: g.pending || 0 });
122
+ continue;
123
+ }
124
+ removed.push({ key, name: t.name, cwd: t.cwd });
125
+ delete store.topics[key];
126
+ }
127
+ if (removed.length) await saveStore(store);
128
+ return { status: 'ok', all: !!all, count: removed.length, removed, skipped };
129
+ }