nx-kv 0.20.0 → 0.22.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 (56) hide show
  1. package/CHANGELOG.md +64 -40
  2. package/package.json +1 -1
  3. package/src/core/workflow/project.js +12 -15
  4. package/src/core/workflow/readiness.js +58 -40
  5. package/src/core/workflow/types.js +15 -0
  6. package/src/modules/localtask/deletion.js +129 -0
  7. package/src/modules/localtask/events.js +207 -0
  8. package/src/modules/localtask/move-prims.js +98 -0
  9. package/src/modules/localtask/mutator/validate.js +5 -14
  10. package/src/modules/localtask/panel-presence.js +322 -0
  11. package/src/modules/localtask/paths.js +89 -0
  12. package/src/modules/localtask/remote-import.js +137 -0
  13. package/src/modules/localtask/repo-ctx.js +23 -0
  14. package/src/modules/localtask/service.js +20 -2388
  15. package/src/modules/localtask/store-io.js +269 -0
  16. package/src/modules/localtask/task-lifecycle.js +197 -0
  17. package/src/modules/localtask/task-ops.js +300 -0
  18. package/src/modules/localtask/task-remove.js +172 -0
  19. package/src/modules/localtask/task-templates.js +77 -0
  20. package/src/modules/localtask/task-view.js +213 -0
  21. package/src/modules/localtask/topics.js +151 -0
  22. package/src/modules/localtask/waiters.js +71 -0
  23. package/src/modules/localtask/waiting.js +86 -0
  24. package/src/modules/localtask/writes.js +3 -4
  25. package/src/modules/tasktool/panel-escort.js +121 -0
  26. package/src/modules/tasktool/wait.js +28 -117
  27. package/src/shared/graph.js +40 -0
  28. package/src/web/frontend/components/md/ack-rail.jsx +133 -0
  29. package/src/web/frontend/modules/board/ack-nodes.js +89 -0
  30. package/src/web/frontend/modules/md/style.css +6 -0
  31. package/src/web/frontend/page/dag/index.jsx +4 -14
  32. package/src/web/frontend/page/dir/dag-editor.jsx +4 -13
  33. package/src/web/frontend/page/dir/task.jsx +24 -0
  34. package/src/web/public/assets/{Introduction-BnbM3LXZ.js → Introduction-G2Mw-KEH.js} +1 -1
  35. package/src/web/public/assets/{cloud-DlVOlEuX.js → cloud-Dv2fhr8x.js} +1 -1
  36. package/src/web/public/assets/dag-oJ7hnl1H.js +1 -0
  37. package/src/web/public/assets/dir-CkCIG1NX.js +3 -0
  38. package/src/web/public/assets/graph-DM-zDRdL.js +1 -0
  39. package/src/web/public/assets/{group-CQ9U4NiQ.js → group-BobOLn0C.js} +1 -1
  40. package/src/web/public/assets/{index-Cg0WaaAi.css → index-CX3sgrm7.css} +1 -1
  41. package/src/web/public/assets/{index-DFHqf4ue.js → index-CrTd_Xvm.js} +2 -2
  42. package/src/web/public/assets/{md-history-DDQvoTFy.js → md-history-B1WBG-Rt.js} +1 -1
  43. package/src/web/public/assets/{member-picker-zypl1Wuc.js → member-picker-LfIrRJdd.js} +1 -1
  44. package/src/web/public/assets/{overview-uWj8gdmN.js → overview-ZcU0DXJg.js} +1 -1
  45. package/src/web/public/assets/{progress-D2cyYcIw.js → progress-DtAd2xVh.js} +1 -1
  46. package/src/web/public/assets/{repos-lSywsxfo.js → repos-GOP6cAdF.js} +1 -1
  47. package/src/web/public/assets/{route-crumb-BI31PVXo.js → route-crumb-B29yU-j2.js} +1 -1
  48. package/src/web/public/assets/{settings-BVcCJUvg.js → settings-oCBbLwUI.js} +1 -1
  49. package/src/web/public/assets/task-DBMOQqn8.js +5 -0
  50. package/src/web/public/assets/{template-slots-BjfVnHFQ.js → template-slots-CmvT1pGq.js} +1 -1
  51. package/src/web/public/assets/{templates-B9VLtDMa.js → templates-Dx6B9B9x.js} +1 -1
  52. package/src/web/public/assets/{upload-DvoSV9Cl.js → upload-Cs-NDNy8.js} +1 -1
  53. package/src/web/public/index.html +2 -2
  54. package/src/web/public/assets/dag-B_EovnBK.js +0 -1
  55. package/src/web/public/assets/dir-DOQkmLNI.js +0 -3
  56. package/src/web/public/assets/task-Dj77xNZX.js +0 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.22.0] - 2026-11-05 任务页 ack 定位轨
6
+
7
+ ### Added
8
+
9
+ - **任务页 ack 定位轨(任务 #48)**:反复 ack 反馈的任务(正文一轮一轮变长,#34 有 5 轮、
10
+ 正文 13903 字符)在右侧多一条定位轨——**一轮一个节点**,点一下直接跳到那一轮的
11
+ 「上一轮完成总结」。复用简介页那套定位轨的视觉(`.doc-rail`),但定位方式不同:
12
+ 按**块序号**跳,不按标题锚点跳。
13
+ - 为什么不用标题锚点:实时预览态逐块渲染,每块各有一套标题 id 生成器,于是同名标题在
14
+ DOM 里拿到的是**重复的同一个 id**(两个「## 上一轮完成总结」都是同一个 id)——
15
+ 按 id 跳永远落在第一轮。块序号(`data-i`)才唯一。
16
+ - 阅读态(整篇一次性渲染)没有块元素,改按「第 N 个 ack 标题」定位——该态标题 id 互不相同。
17
+ - 轮次从**正文**抽(每轮一个「## 上一轮完成总结」段):正文即历史,改完正文轨立刻跟着变。
18
+ - 只有一个节点时不显示(没有可跳的多轮,挂了也是噪音)。
19
+
20
+ ## [0.21.0] - 2026-11-05 队列与编排的职责单一化 + store 损坏防护
21
+
22
+ ### Fixed
23
+
24
+ - **store 损坏不再静默清空队列**:localtasks.json 读不出/解析失败时,先把坏文件**改名保留**为
25
+ `<store>.corrupt-<时间戳>`(同目录最多留 3 份,超出删最旧;改名失败则保留原文件等下次),
26
+ 再落到空 store。此前坏文件留在原地,下一次任何写操作会把它原地覆盖——一次磁盘抖动
27
+ 等于全部主题无声清零。
28
+
29
+ ### Changed
30
+
31
+ - **localtask service 拆分**(原 2458 行单文件 → 16 个单一职责文件 + 47 行门面):
32
+ 对外 API 路径与行为**零变化**(CLI/HTTP/面板与所有调用方无感),模块内部分工见各文件头注释。
33
+ - **就绪判定双实现合一**:任务行入口与快照入口此前是两套算法(闸门语义被编码两次),
34
+ 现在共用同一张图与同一个内核判定;跨队列依赖(`"topicKey:id"`)的 fail-open 口径不变。
35
+ 新增跨模型一致性测试,逐条钉住两个入口对同一份数据的结论必须相等。
36
+ - **闸门谓词统一**(`isGateNode`):此前散落 5 处且判定字段漂移(有的只认插件名、
37
+ 有的连 scope 一起认),现收成一处并集口径。
38
+ - **环检测实现下沉 `src/shared/graph.js`**:mutator 与两个前端组件(画布编排面板、目录编排
39
+ 编辑器)共用一份 DFS——此前三份拷贝,其中一处曾因方向写反漏判 2 节点环。
40
+ - **wait 编排拆分**:面板随行(拉起主面板/深链/窗口重开防风暴)搬进
41
+ `tasktool/panel-escort.js`,`waitForTask` 回归纯队列出口语义;三个 500ms 节拍的轮询
42
+ 合并为一个 ticker。返回契约与 `--no-open` 行为逐字不变。
43
+
5
44
  ## [0.20.0] - 2026-11-04 任务编排(DAG)进面板 + 面包屑/设置面板收拾 + 改归属项目即搬家
6
45
 
7
46
  ### Added
@@ -18,51 +57,23 @@
18
57
  - 闸门徽章升级:显示被挡后续数(`闸门×N`,悬停列出 #id)。
19
58
  - 组任务行尾新增「⋮」行菜单(编排依赖 / 闸门开关),待领取行悬停可见。
20
59
 
60
+ - `localtask config --suffix-list "一条,二条"` / `--ack-list "一条,二条"`:**整体替换**多条
61
+ 提示词列表(逗号分隔;HTTP body 传数组)。此前 action 只暴露单条 `--suffix`/`--ack`,
62
+ 面板的多条保存无处可传(#41 实测发现并修)。
63
+
21
64
  ### Changed
22
65
 
23
66
  - **设置面板观感重做(任务 #41 重开反馈「ui 太丑了」→ 二轮「更丑了」)**:交互语义不变,只收拾
24
67
  密度与层级——「提示词 / 模板」栏的竖排 tab 侧栏改成顶部一排药丸、内容限宽;提示词条目行去描边、
25
68
  编号不用圆圈、**收起标题洗掉原始模板标记**(不再直接甩 `{{#goal}}〔…〕` 给人看);操作条分隔线分组;
26
69
  项目级设置从大卡片改成紧凑行(项目多时一屏 8 个而不是 2 个),「持续 get」关着时只留一枚行内小开关;
27
- 「提示词 / 模板」栏不再套卡(盒中盒显脏)。:交互语义不变,只收拾
28
- 密度与层级——「提示词 / 模板」栏的竖排 tab 侧栏改成顶部一排药丸、内容限宽;
29
- 提示词条目行去描边、编号不用圆圈;操作条分隔线分组;项目级设置从大卡片改成
30
- 紧凑行(项目多时一屏 8 个而不是 2 个),「持续 get」关着时只留一枚行内小开关。
31
-
32
- ### Changed
70
+ 「提示词 / 模板」栏不再套卡(盒中盒显脏)。
33
71
 
34
72
  - **面包屑移到 maincontent 面板内顶部(任务 #44)**:路径条不再占窗口顶部通栏
35
73
  (那是侧栏的地盘),改成挂在**当前主面板**顶部的一枚小药丸——目录任务表 /
36
74
  任务详情页 / 项目组 / 全部目录总览 / 云端总览 / 仓库管理 / 任务模板 各面板
37
75
  根部都有一条;MD 工作台里则直接放进顶部工具栏(`mdw-top`)内、标题左侧;
38
- 「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。:路径条不再占窗口顶部通栏
39
- (那是侧栏的地盘),改成挂在**当前主面板**顶部的一枚小药丸——目录任务表 /
40
- 任务详情页 / 项目组 / 全部目录总览 / 云端总览 / 仓库管理 / 任务模板 各面板
41
- 根部都有一条;「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。
42
-
43
- ### Fixed
44
-
45
- - **改归属项目即搬家(任务 #43「提交任务后发现归属项目填错了,重新修改提交无效」)**:
46
- 此前「归属决定落点」只在提交那一刻生效——提交后发现归属填错,改正文里的 project 再保存,
47
- 正文改了队列没动:任务还挂在旧项目队列里,旧项目的 agent 照旧领走,新项目的 agent 永远
48
- 看不见,组面板/目录页也都显示旧归属。现在 `localtask update --text` / `localtask reopen --text`
49
- / 面板「保存并同步 / 重做」走与派任务同一套落点判定:effective frontmatter.project 解析到
50
- 哪个登记项目,任务就迁到哪个队列;介绍引用(projectIntro/projectDesc)随归属刷新。
51
- 约束如实说明:已领取的任务不能改归属(要改先 reopen);带队列内依赖/闸门的任务搬运会
52
- 跨队列断链,先解除再改(明确报错,不静默);搬家后任务拿到**新队列的 id**(面板页签自动
53
- 跟到新号,CLI 输出标注迁移);指向它的跨队列引用(`topicKey:id`)改写到新地址。
54
- 另自愈历史脏数据:归属字段与所在队列对不上(上一轮只改字段没搬队列的残留)时,
55
- 保存即搬到归属队列;已领取/带队列内依赖的脏数据不搬也不拦普通编辑。
56
-
57
- - **顶部面包屑真正可见 + 补返回键(任务 #42)**:面包屑此前**渲染了却看不见**——
58
- FlexLayout 的根是绝对定位(inset:0),铺满工作区把 26px 的路径条盖在下面
59
- (侧栏面板压在上面,路径条点不到也看不见)。现在工作区有面包屑时布局整体下移一条;
60
- 侧栏也不再出页签条(#33「无页签化」的残留,页签条同样压在路径条上)。
61
- 面包屑条左侧新增「← 返回」:按**来处**返回(会话内路由历史栈)——
62
- 从项目组点任务(任务住在 dir 队列里,详情页落在 dir 作用域)时,返回会回到**组**
63
- 而不是掉进目录;没有上一级时不画返回键。
64
-
65
- ### Changed
76
+ 「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。
66
77
 
67
78
  - **可视化编排面板(任务 #40 验收反馈重开)**:目录与项目组**共用**一块编排面板,
68
79
  从任务面板**头部右侧的绿色超链**进入(开在右侧主内容区):
@@ -83,13 +94,27 @@
83
94
  - **项目级「持续 get」一句话配置**:选模板(内置默认 + 全局回调提示词条目)→ 改一句话
84
95
  (`%s` = 任务编号,存取时与 `{{task.id}}` 互转)→ 添加;不再手写整段模板。
85
96
 
86
- ### Added
97
+ ### Fixed
87
98
 
88
- - `localtask config --suffix-list "一条,二条"` / `--ack-list "一条,二条"`:**整体替换**多条
89
- 提示词列表(逗号分隔;HTTP body 传数组)。此前 action 只暴露单条 `--suffix`/`--ack`,
90
- 面板的多条保存无处可传(#41 实测发现并修)。
99
+ - **改归属项目即搬家(任务 #43「提交任务后发现归属项目填错了,重新修改提交无效」)**:
100
+ 此前「归属决定落点」只在提交那一刻生效——提交后发现归属填错,改正文里的 project 再保存,
101
+ 正文改了队列没动:任务还挂在旧项目队列里,旧项目的 agent 照旧领走,新项目的 agent 永远
102
+ 看不见,组面板/目录页也都显示旧归属。现在 `localtask update --text` / `localtask reopen --text`
103
+ / 面板「保存并同步 / 重做」走与派任务同一套落点判定:effective frontmatter.project 解析到
104
+ 哪个登记项目,任务就迁到哪个队列;介绍引用(projectIntro/projectDesc)随归属刷新。
105
+ 约束如实说明:已领取的任务不能改归属(要改先 reopen);带队列内依赖/闸门的任务搬运会
106
+ 跨队列断链,先解除再改(明确报错,不静默);搬家后任务拿到**新队列的 id**(面板页签自动
107
+ 跟到新号,CLI 输出标注迁移);指向它的跨队列引用(`topicKey:id`)改写到新地址。
108
+ 另自愈历史脏数据:归属字段与所在队列对不上(上一轮只改字段没搬队列的残留)时,
109
+ 保存即搬到归属队列;已领取/带队列内依赖的脏数据不搬也不拦普通编辑。
91
110
 
92
- ### Fixed
111
+ - **顶部面包屑真正可见 + 补返回键(任务 #42)**:面包屑此前**渲染了却看不见**——
112
+ FlexLayout 的根是绝对定位(inset:0),铺满工作区把 26px 的路径条盖在下面
113
+ (侧栏面板压在上面,路径条点不到也看不见)。现在工作区有面包屑时布局整体下移一条;
114
+ 侧栏也不再出页签条(#33「无页签化」的残留,页签条同样压在路径条上)。
115
+ 面包屑条左侧新增「← 返回」:按**来处**返回(会话内路由历史栈)——
116
+ 从项目组点任务(任务住在 dir 队列里,详情页落在 dir 作用域)时,返回会回到**组**
117
+ 而不是掉进目录;没有上一级时不画返回键。
93
118
 
94
119
  - **首次打开设置面板爆栈**(任务 #41 实测发现,#33 遗留):`openPanel` 按区域分发时
95
120
  FLOAT → `openFloatingPanel` → `openPanel`(未带 area)→ 又判成 FLOAT → 无限递归
@@ -551,7 +576,6 @@
551
576
  - **实时预览块级查找**:Ctrl/⌘F 在预览态可用,n/m 计数 + 命中块高亮 + 滚动定位
552
577
  - **无障碍**:编辑区 aria-label 补齐、状态栏小字对比度提到 WCAG AA
553
578
 
554
-
555
579
  - **仓库获得全局唯一 key,导入导出改以 key 为身份(v3)+ 全屏导入导出面板**:
556
580
  - **key**:归一化远程地址派生(`git@c:o/r.git`、`https://c/o/r`、
557
581
  `ssh://git@ssh.github.com:443/o/r.git`、带端口、带 `.git` 都是同一个
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
  // 存盘形状仍是任务行;快照是引擎视图。正文不进快照(payload.textRef)。
3
3
  // 跨队列 blockBy("topicKey:id")不进引擎边,投影回写时必须保留。
4
4
 
5
- import { BUILTIN_GOALS, edgesFromTasks, taskToNode } from './types.js';
5
+ import { edgesFromTasks, isGateNode, taskToNode } from './types.js';
6
6
  import { normalizeBlockBy, parseDepRef } from './readiness.js';
7
7
 
8
8
  /** @typedef {import('./types.js').WorkflowSnapshot} WorkflowSnapshot */
@@ -64,22 +64,20 @@ export function applySnapshotToTopic(topic, snap) {
64
64
  syncTaskFromNode(task, node);
65
65
  }
66
66
 
67
- // 边 → blockBy(显式依赖);闸门边由 blocksFollowing 表达,不写进每条 blockBy
67
+ // 边 → blockBy(显式依赖);闸门边由 blocksFollowing 表达,不写进每条 blockBy。
68
+ // 本地依赖取「现有 ∪ 快照边」的**并集**而不是按边重建:快照可能滞后于外部任务行写
69
+ // (引擎在途写),按边重建会把外部刚设好的 blockBy 洗掉。依赖是「只能多、不能凭空少」
70
+ // 的保守口径——解除依赖一律走外部 clear 先落盘,再由 syncTaskDagUpdate 拆边,
71
+ // 不存在「只删引擎边、不删任务行」的路径,所以并集不会让解除失效。
68
72
  const gateIds = new Set(
69
- (snap.nodes || [])
70
- .filter(
71
- (n) =>
72
- n.kind === 'control' ||
73
- (n.plugins || []).some((p) => p.name === BUILTIN_GOALS.blockgoal),
74
- )
75
- .map((n) => String(n.id)),
73
+ (snap.nodes || []).filter((n) => isGateNode(n)).map((n) => String(n.id)),
76
74
  );
77
75
 
78
76
  for (const task of tasks) {
79
77
  if (task.deletedAt || task.migratedTo) continue;
80
- const keepForeign = normalizeBlockBy(task.blockBy, task.id).filter(
81
- (d) => parseDepRef(d)?.kind === 'foreign',
82
- );
78
+ const keep = normalizeBlockBy(task.blockBy, task.id);
79
+ const keepForeign = keep.filter((d) => parseDepRef(d)?.kind === 'foreign');
80
+ const keepLocal = keep.filter((d) => parseDepRef(d)?.kind === 'local');
83
81
  const incoming = (snap.edges || []).filter((e) => String(e.to) === String(task.id));
84
82
  const localDeps = [];
85
83
  for (const e of incoming) {
@@ -88,7 +86,7 @@ export function applySnapshotToTopic(topic, snap) {
88
86
  const n = Number(e.from);
89
87
  if (Number.isFinite(n) && n > 0) localDeps.push(n);
90
88
  }
91
- task.blockBy = normalizeBlockBy([...localDeps, ...keepForeign], task.id);
89
+ task.blockBy = normalizeBlockBy([...keepLocal, ...localDeps, ...keepForeign], task.id);
92
90
  }
93
91
 
94
92
  topic.workflowRevision = Number(snap.revision) || 0;
@@ -100,8 +98,7 @@ export function applySnapshotToTopic(topic, snap) {
100
98
 
101
99
  /** @param {object} task @param {WfNode} node */
102
100
  function syncTaskFromNode(task, node) {
103
- const hasBlock = (node.plugins || []).some((p) => p.name === BUILTIN_GOALS.blockgoal);
104
- task.blocksFollowing = node.kind === 'control' || hasBlock;
101
+ task.blocksFollowing = isGateNode(node);
105
102
 
106
103
  if (node.state === 'success') {
107
104
  if (!task.completedAt) {
@@ -1,8 +1,14 @@
1
- // 拓扑就绪(自 localtask/dag.js 下沉)。任务行与快照节点两套入口。
1
+ // 拓扑就绪(自 localtask/dag.js 下沉)。任务行与快照节点两套入口,**一个内核**:
2
+ // 任务行先投影成轻量图(taskToNode + edgesFromTasks),本地依赖与闸门判定直接
3
+ // 复用 isNodeReady——两个模型对同一份数据产出同一张图,结论必然一致
4
+ // (一致性由 tests/unit/readiness-consistency.test.mjs 钉死)。
2
5
  //
3
6
  // blockBy 支持:
4
- // - 本地 id(number)——同队列依赖(兼容旧存盘)
5
- // - 跨队列引用 `"<topicKey>:<taskId>"`——组编排跨项目边;get 时跨 topic 解析
7
+ // - 本地 id(number)——同队列依赖(兼容旧存盘);入图,走内核判定
8
+ // - 跨队列引用 `"<topicKey>:<taskId>"`——组编排跨项目边;不入引擎图
9
+ // (edgesFromTasks 跳过),由 resolveForeign 前置直查,fail-open
10
+
11
+ import { edgesFromTasks, taskToNode, isGateNode } from './types.js';
6
12
 
7
13
  /** topicKey:id —— key 允许字母数字与 _-(含 __GLOBAL__) */
8
14
  const FOREIGN_RE = /^([A-Za-z0-9_-]+):(\d+)$/;
@@ -107,54 +113,69 @@ function isPendingLike(t) {
107
113
  * 跨队列依赖解析;缺席或返回空 = fail-open(挡不住)
108
114
  */
109
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
+
110
151
  /**
111
152
  * 某条 pending 现在能不能领(任务行模型)。
153
+ * 本地依赖 + 闸门走快照同一内核(isNodeReady);跨队列依赖走 resolveForeign 前置直查。
154
+ * 前驱判定口径:completedAt ⇔ 节点 success(taskToNode 映射),闸门 = control 屏障。
112
155
  * @param {object} task
113
156
  * @param {object[]} all
114
157
  * @param {ReadyOpts} [opts]
115
158
  */
116
159
  export function isReadyToClaim(task, all = [], opts = {}) {
117
160
  if (!isPendingLike(task)) return false;
118
- const byId = new Map((all || []).filter(isAlive).map((t) => [Number(t.id), t]));
119
-
120
- const deps = normalizeBlockBy(task.blockBy, task.id);
121
- for (const d of deps) {
122
- const ref = parseDepRef(d);
123
- if (!ref) continue;
124
- let dep = null;
125
- if (ref.kind === 'local') {
126
- dep = byId.get(ref.id) || null;
127
- } else if (typeof opts.resolveForeign === 'function') {
128
- const found = opts.resolveForeign(ref.key, ref.id);
129
- dep = found && isAlive(found) ? found : null;
130
- } else {
131
- // 无解析器:跨队列依赖无法验证 → fail-open(与「依赖缺失」同口径)
132
- continue;
133
- }
134
- if (!dep) continue;
135
- if (!isCompleted(dep)) return false;
136
- }
137
-
138
- const idx = (all || []).indexOf(task);
139
- const order = all || [];
140
- const myPos = idx >= 0 ? idx : order.findIndex((t) => Number(t.id) === Number(task.id));
141
- if (myPos < 0) return true;
142
- for (let i = 0; i < myPos; i += 1) {
143
- const prev = order[i];
144
- if (!isAlive(prev) || !prev.blocksFollowing) continue;
145
- if (!isCompleted(prev)) return false;
146
- }
147
- return true;
161
+ if (foreignBlocked(task, opts)) return false;
162
+ return readyInGraph(rowsGraph(all), task.id);
148
163
  }
149
164
 
150
165
  /**
151
- * 按数组序找第一条可领取的 pending;没有 → null
166
+ * 按数组序找第一条可领取的 pending;没有 → null。
167
+ * 图一次构建逐条判定(结论与逐条 isReadyToClaim 相同,省掉每条重建图)。
152
168
  * @param {object[]} tasks
153
169
  * @param {ReadyOpts} [opts]
154
170
  */
155
171
  export function findFirstReadyPending(tasks = [], opts = {}) {
156
- for (const t of tasks || []) {
157
- if (isReadyToClaim(t, tasks, opts)) return t;
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;
158
179
  }
159
180
  return null;
160
181
  }
@@ -177,10 +198,7 @@ export function isNodeReady(snap, node, isBarrierOpen) {
177
198
  for (const e of preds) {
178
199
  const pred = byId.get(String(e.from));
179
200
  if (!pred) continue;
180
- const isControl =
181
- pred.kind === 'control' ||
182
- (pred.plugins || []).some((p) => p.name === 'blockgoal' || p.scope === 'control');
183
- if (isControl) {
201
+ if (isGateNode(pred)) {
184
202
  const open = isBarrierOpen
185
203
  ? isBarrierOpen(snap, pred.id)
186
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
@@ -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
+ }