nx-kv 0.20.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 (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/core/workflow/project.js +3 -10
  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 +95 -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 +256 -0
  16. package/src/modules/localtask/task-lifecycle.js +193 -0
  17. package/src/modules/localtask/task-ops.js +295 -0
  18. package/src/modules/localtask/task-remove.js +167 -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/page/dag/index.jsx +4 -14
  29. package/src/web/frontend/page/dir/dag-editor.jsx +4 -13
  30. package/src/web/public/assets/{Introduction-BnbM3LXZ.js → Introduction-BSDhb2Sj.js} +1 -1
  31. package/src/web/public/assets/{cloud-DlVOlEuX.js → cloud-Sfi1OGsF.js} +1 -1
  32. package/src/web/public/assets/dag-BnXCpGph.js +1 -0
  33. package/src/web/public/assets/dir-DG-o4u5h.js +3 -0
  34. package/src/web/public/assets/graph-DM-zDRdL.js +1 -0
  35. package/src/web/public/assets/{group-CQ9U4NiQ.js → group-CO1dZAIi.js} +1 -1
  36. package/src/web/public/assets/{index-DFHqf4ue.js → index-BSQ-y9mw.js} +2 -2
  37. package/src/web/public/assets/{md-history-DDQvoTFy.js → md-history-DwI1pcCg.js} +1 -1
  38. package/src/web/public/assets/{member-picker-zypl1Wuc.js → member-picker-BgVyjr7m.js} +1 -1
  39. package/src/web/public/assets/{overview-uWj8gdmN.js → overview-CdGfl5aQ.js} +1 -1
  40. package/src/web/public/assets/{progress-D2cyYcIw.js → progress-CxIXeuOg.js} +1 -1
  41. package/src/web/public/assets/{repos-lSywsxfo.js → repos-DiYt0y77.js} +1 -1
  42. package/src/web/public/assets/{route-crumb-BI31PVXo.js → route-crumb-BEAGbZBS.js} +1 -1
  43. package/src/web/public/assets/{settings-BVcCJUvg.js → settings-BIDVlTR_.js} +1 -1
  44. package/src/web/public/assets/{task-Dj77xNZX.js → task-5c2dyDzF.js} +1 -1
  45. package/src/web/public/assets/{template-slots-BjfVnHFQ.js → template-slots-B7SKP2-A.js} +1 -1
  46. package/src/web/public/assets/{templates-B9VLtDMa.js → templates-BOJCv21y.js} +1 -1
  47. package/src/web/public/assets/{upload-DvoSV9Cl.js → upload-ChrVGtCq.js} +1 -1
  48. package/src/web/public/index.html +1 -1
  49. package/src/web/public/assets/dag-B_EovnBK.js +0 -1
  50. package/src/web/public/assets/dir-DOQkmLNI.js +0 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
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
+
5
29
  ## [0.20.0] - 2026-11-04 任务编排(DAG)进面板 + 面包屑/设置面板收拾 + 改归属项目即搬家
6
30
 
7
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.20.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": {
@@ -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 */
@@ -66,13 +66,7 @@ export function applySnapshotToTopic(topic, snap) {
66
66
 
67
67
  // 边 → blockBy(显式依赖);闸门边由 blocksFollowing 表达,不写进每条 blockBy
68
68
  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)),
69
+ (snap.nodes || []).filter((n) => isGateNode(n)).map((n) => String(n.id)),
76
70
  );
77
71
 
78
72
  for (const task of tasks) {
@@ -100,8 +94,7 @@ export function applySnapshotToTopic(topic, snap) {
100
94
 
101
95
  /** @param {object} task @param {WfNode} node */
102
96
  function syncTaskFromNode(task, node) {
103
- const hasBlock = (node.plugins || []).some((p) => p.name === BUILTIN_GOALS.blockgoal);
104
- task.blocksFollowing = node.kind === 'control' || hasBlock;
97
+ task.blocksFollowing = isGateNode(node);
105
98
 
106
99
  if (node.state === 'success') {
107
100
  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
+ }
@@ -0,0 +1,207 @@
1
+ // (#54 自 service.js 拆出)等待生命周期事件流:轮询 store,从 waiting/waitingUntil 与
2
+ // 已领取数的变化里派生 waiting/reminder/resolved 事件;SSE(streamEventsSSE)与
3
+ // CLI(NDJSON)共用 watchEvents 生成器。
4
+ import { isGlobalKey, normalizeCwd } from './paths.js';
5
+ import { loadStore } from './store-io.js';
6
+ import { deadlineMsOf } from './task-view.js';
7
+ import { addEventStreamConn } from './panel-presence.js';
8
+ // ─── 等待生命周期事件流(主面板及时通知,避免用户漏过超时)────────────
9
+ //
10
+ // wait 与常驻主面板通常是两个进程,内存 EventEmitter 跨不过进程,所以事件流
11
+ // 不依赖进程内回调,而是轮询 store,从 waiting/waitingUntil 与「已领取数」的变化里
12
+ // 派生事件:waiting / reminder(剩余 30s、10s)/ resolved(delivered 或 timeout)。
13
+ // HTTP(SSE) 与 CLI(NDJSON) 共用本生成器。
14
+
15
+ const EVENT_POLL_MS = 500;
16
+ const REMINDER_SEC = [30, 10]; // 剩余 ≤30s、≤10s 各提醒一次
17
+
18
+ // 纯函数:给定剩余秒数与已提醒过的阈值,返回本次应触发的阈值(秒)。便于单测。
19
+ export function remindersDue(remainSec, firedSet) {
20
+ const out = [];
21
+ for (const sec of REMINDER_SEC) {
22
+ if (remainSec <= sec && remainSec > 0 && !firedSet.has(sec)) out.push(sec);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function consumedCount(t) {
28
+ return (Array.isArray(t.tasks) ? t.tasks : []).filter((x) => x.status === 'consumed').length;
29
+ }
30
+
31
+ /**
32
+ * 订阅等待生命周期事件(异步生成器)。
33
+ * @param {{cwd?:string,key?:string,pollMs?:number,signal?:AbortSignal}} opts
34
+ * 不传 cwd/key 则订阅全部主题(主面板总览需要)。
35
+ */
36
+ export async function* watchEvents({ cwd: rawCwd, key: rawKey, pollMs = EVENT_POLL_MS, signal } = {}) {
37
+ let onlyKey = null;
38
+ if (rawKey && String(rawKey).trim()) onlyKey = String(rawKey).trim();
39
+ else if (rawCwd && String(rawCwd).trim()) onlyKey = normalizeCwd(rawCwd).key;
40
+
41
+ const sleep = (ms) =>
42
+ new Promise((res) => {
43
+ const to = setTimeout(res, ms);
44
+ if (signal) {
45
+ signal.addEventListener(
46
+ 'abort',
47
+ () => {
48
+ clearTimeout(to);
49
+ res();
50
+ },
51
+ { once: true }
52
+ );
53
+ }
54
+ });
55
+
56
+ const state = new Map();
57
+ let first = true;
58
+ while (!(signal && signal.aborted)) {
59
+ let topics = [];
60
+ try {
61
+ const store = await loadStore();
62
+ topics = Object.values(store.topics).filter(
63
+ (t) => Array.isArray(t.tasks) && (!onlyKey || t.key === onlyKey)
64
+ );
65
+ } catch {
66
+ topics = [];
67
+ }
68
+
69
+ const now = Date.now();
70
+ for (const t of topics) {
71
+ // 截止时刻走 deadlineMsOf():优先毫秒精度的 waitingUntilMs,老数据才解析字符串。
72
+ // 之前这里自己解析 waitingUntil(秒级),短时限下截断误差会让「正在等」被判成
73
+ // 「没在等」→ 事件流永远推不出 waiting(tests/unit/localtask-sse.test.mjs 曾
74
+ // 因此有 ~4% 的 flake)。
75
+ const deadline = deadlineMsOf(t);
76
+ const isWaiting = !!t.waiting && Number.isFinite(deadline) && deadline > now;
77
+ let st = state.get(t.key);
78
+
79
+ if (isWaiting) {
80
+ // 「是不是新一轮等待」用**毫秒精度的 deadline** 判定:waitingUntil 字符串只有
81
+ // 秒级,同一秒内开始的两次等待字符串相同,靠它判会漏掉新一轮。
82
+ if (!st || st.phase !== 'waiting' || st.deadline !== deadline) {
83
+ st = {
84
+ phase: 'waiting',
85
+ waitingUntil: t.waitingUntil,
86
+ deadline,
87
+ consumed0: consumedCount(t),
88
+ reminders: new Set(),
89
+ };
90
+ state.set(t.key, st);
91
+ yield {
92
+ type: 'waiting',
93
+ key: t.key,
94
+ cwd: t.cwd || '',
95
+ name: t.name || '',
96
+ summary: t.summary || '',
97
+ isGlobal: isGlobalKey(t.key),
98
+ waitingUntil: t.waitingUntil,
99
+ remainMs: Math.max(0, deadline - now),
100
+ replay: first,
101
+ };
102
+ }
103
+ const remainSec = Math.floor((st.deadline - now) / 1000);
104
+ for (const sec of remindersDue(remainSec, st.reminders)) {
105
+ st.reminders.add(sec);
106
+ yield {
107
+ type: 'reminder',
108
+ key: t.key,
109
+ cwd: t.cwd || '',
110
+ name: t.name || '',
111
+ summary: t.summary || '',
112
+ isGlobal: isGlobalKey(t.key),
113
+ waitingUntil: t.waitingUntil,
114
+ remainSec: sec,
115
+ remainMs: Math.max(0, st.deadline - now),
116
+ label: `${sec}s`,
117
+ };
118
+ }
119
+ } else if (st && st.phase === 'waiting') {
120
+ const outcome = consumedCount(t) > st.consumed0 ? 'delivered' : 'timeout';
121
+ state.set(t.key, { phase: 'ended' });
122
+ yield { type: 'resolved', key: t.key, cwd: t.cwd || '', name: t.name || '', isGlobal: isGlobalKey(t.key), outcome };
123
+ } else if (!st) {
124
+ state.set(t.key, { phase: 'idle' });
125
+ }
126
+
127
+ // 兜底:waiting 标志还在但截止已过(wait 进程异常退出),也派生一次 timeout
128
+ if (t.waiting && (!Number.isFinite(deadline) || deadline <= now)) {
129
+ const cur = state.get(t.key);
130
+ if (!cur || cur.phase === 'waiting') {
131
+ state.set(t.key, { phase: 'ended' });
132
+ yield {
133
+ type: 'resolved',
134
+ key: t.key,
135
+ cwd: t.cwd || '',
136
+ name: t.name || '',
137
+ isGlobal: isGlobalKey(t.key),
138
+ outcome: 'timeout',
139
+ stale: true,
140
+ };
141
+ }
142
+ }
143
+ }
144
+
145
+ first = false;
146
+ await sleep(pollMs);
147
+ }
148
+ }
149
+
150
+ // HTTP 传输:SSE。注册到 sseConns 让 panelPresence 计入(wait 据此判活标签)。
151
+ // 客户端断开即中止生成器。SSE 心跳由 addEventStreamConn 统一管。
152
+ export async function streamEventsSSE(req, res, { cwd, key } = {}) {
153
+ res.writeHead(200, {
154
+ 'content-type': 'text/event-stream; charset=utf-8',
155
+ 'cache-control': 'no-store',
156
+ connection: 'keep-alive',
157
+ // 断线重连提示(浏览器 EventSource 默认 3s 重连,这里写明确一点)。
158
+ // 对齐 opencode 的 SSE 加固做法(event.ts:21-28)。
159
+ 'x-accel-buffering': 'no',
160
+ });
161
+ res.write('retry: 3000\n\n');
162
+ res.write(': connected\n\n');
163
+ // addEventStreamConn 内部已把 conn 加入 sseConns(panelPresence 据此统计活标签),
164
+ // 我们不再直接引用——心跳 / 失败剔除 / 断开清理都由它托管。
165
+ addEventStreamConn(req, res);
166
+ const ac = new AbortController();
167
+ req.on('close', () => ac.abort());
168
+ let seq = 0;
169
+ try {
170
+ for await (const ev of watchEvents({ cwd, key, signal: ac.signal })) {
171
+ // 单调 id:浏览器重连后可带着 Last-Event-ID 回来(这里只保证不重复,
172
+ // 事件本身是从 store 派生的,重放也不会串)。
173
+ seq += 1;
174
+ res.write(`id: ${seq}\nevent: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
175
+ }
176
+ } catch {
177
+ /* 客户端断开 / 瞬时读盘错误,静默结束 */
178
+ }
179
+ }
180
+
181
+ // CLI 传输:**逐行 NDJSON**(一行一个事件),与 HTTP 的 SSE 同源(都用 watchEvents)。
182
+ //
183
+ // 以前 CLI 跑 `localtask events` 必崩:那条 action 原本只做 SSE,
184
+ // 直接摸 `ctx.__raw.req` —— 而 `__raw` 只在 HTTP 传输下才注入,CLI 进程里是 undefined。
185
+ // 现在按传输方式分流,CLI 这条命令真能用(CHANGELOG 当初承诺的「CLI 等价逐行 NDJSON」)。
186
+ //
187
+ // 它是**长连**:命令会一直打印到用户 Ctrl+C(stdout 被关闭即结束)。
188
+ export async function streamEventsNDJSON({ cwd, key, signal, timeoutSec, limit } = {}) {
189
+ const ac = new AbortController();
190
+ // agent 需要能**给这条长连划个边界**:gh run watch 有 --exit-status / --interval,
191
+ // 这里给 --timeout(最长监听多少秒)与 --limit(最多几条事件)。都不给就是无限流。
192
+ const timer =
193
+ Number.isFinite(timeoutSec) && timeoutSec > 0 ? setTimeout(() => ac.abort(), timeoutSec * 1000) : null;
194
+ let n = 0;
195
+ try {
196
+ for await (const ev of watchEvents({ cwd, key, signal: signal || ac.signal })) {
197
+ process.stdout.write(`${JSON.stringify(ev)}\n`);
198
+ n += 1;
199
+ if (Number.isFinite(limit) && limit > 0 && n >= limit) break;
200
+ }
201
+ } catch {
202
+ /* Ctrl+C / 管道断开:静默收尾 */
203
+ } finally {
204
+ if (timer) clearTimeout(timer);
205
+ }
206
+ }
207
+
@@ -0,0 +1,95 @@
1
+ // (#54 自 service.js 拆出)迁移原语(task move / 云端迁移编排用):只读定位、
2
+ // 迁出 tombstone(目标端写成功后才调用)、撤销迁出、云端回插待领取首位。
3
+ import { badInput } from '../../core/errors.js';
4
+ import { normalizeCwd, resolveTaskKey } from './paths.js';
5
+ import { loadStore, saveStore, stampNow, normalizeText, topicOf, nextTaskId } from './store-io.js';
6
+ import { normalizeTask, isPending } from './task-view.js';
7
+ // ─── 迁移原语(task move 用;store 封装不外泄)─────────────────────
8
+ //
9
+ // 迁移的原子性在外层 move.js 编排(先写目标端、成功才改源端),
10
+ // 这里只提供对 store 的最小读写原语,保证清单/历史/原子写都走同一条管线。
11
+
12
+ /** 只读定位一条本机任务(不落写)。返回 {task, name, key, cwd} 或抛 notFound。 */
13
+ export async function getTopicTask({ cwd: rawCwd, key: rawKey, id } = {}) {
14
+ const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
15
+ const want = Number(id);
16
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
17
+ const store = await loadStore();
18
+ const t = store.topics[key];
19
+ const raw = t && t.tasks.find((x) => Number(x.id) === want);
20
+ if (!raw) {
21
+ const { notFound } = await import('../../core/errors.js');
22
+ throw notFound(`主题「${t ? t.name : key}」里没有 #${want} 任务`);
23
+ }
24
+ return { task: normalizeTask(raw), key, cwd: t.cwd || '', name: t.name || key };
25
+ }
26
+
27
+ /** 给本机任务打迁出标记(tombstone)。只在目标端已写成功后调用。 */
28
+ export async function markTaskMigrated({ cwd: rawCwd, key: rawKey, id, migratedTo } = {}) {
29
+ const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
30
+ const want = Number(id);
31
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
32
+ if (!migratedTo || migratedTo.side !== 'cloud') throw badInput('migratedTo 必须是 {side:"cloud", topic, targetId, at}');
33
+ const store = await loadStore();
34
+ const t = store.topics[key];
35
+ const raw = t && t.tasks.find((x) => Number(x.id) === want);
36
+ if (!raw) {
37
+ const { notFound } = await import('../../core/errors.js');
38
+ throw notFound(`主题「${t ? t.name : key}」里没有 #${want} 任务`);
39
+ }
40
+ if (raw.status === 'consumed') {
41
+ const { conflict } = await import('../../core/errors.js');
42
+ throw conflict(`主题「${t.name}」#${want} 已被领取,不能再迁移`);
43
+ }
44
+ raw.migratedTo = { side: 'cloud', topic: String(migratedTo.topic || ''), targetId: Number(migratedTo.targetId) || 0, at: String(migratedTo.at || stampNow()) };
45
+ t.updatedAt = stampNow();
46
+ await saveStore(store);
47
+ return { status: 'ok', key, cwd: t.cwd || '', name: t.name, task: normalizeTask(raw) };
48
+ }
49
+
50
+ /** 撤销迁出:清掉 migratedTo 标记,任务回到待领取首位。 */
51
+ export async function clearTaskMigrated({ cwd: rawCwd, key: rawKey, id, toTop = true } = {}) {
52
+ const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
53
+ const want = Number(id);
54
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
55
+ const store = await loadStore();
56
+ const t = store.topics[key];
57
+ const raw = t && t.tasks.find((x) => Number(x.id) === want);
58
+ if (!raw) {
59
+ const { notFound } = await import('../../core/errors.js');
60
+ throw notFound(`主题「${t ? t.name : key}」里没有 #${want} 任务`);
61
+ }
62
+ if (!raw.migratedTo) {
63
+ return { status: 'ok', restored: false, reason: 'not_migrated', key, name: t.name, task: normalizeTask(raw) };
64
+ }
65
+ delete raw.migratedTo;
66
+ // 回到待领取首位:迁出时它占着优先级位置,撤销应原样归还
67
+ if (toTop) {
68
+ const idx = t.tasks.indexOf(raw);
69
+ if (idx >= 0) t.tasks.splice(idx, 1);
70
+ t.tasks.unshift(raw);
71
+ }
72
+ t.updatedAt = stampNow();
73
+ await saveStore(store);
74
+ return { status: 'ok', restored: true, key, cwd: t.cwd || '', name: t.name, task: normalizeTask(raw) };
75
+ }
76
+
77
+ /**
78
+ * 云端 → 本机:把一条任务插到本机主题队列的**待领取首位**(不打扰 waiter——
79
+ * 迁移是「排进队列」,不是「回填给正在等的 agent」,语义必须分开)。
80
+ * 返回 {task, name, key, cwd}。
81
+ */
82
+ export async function insertTaskAtTop({ cwd: rawCwd, key: rawKey, text, from = 'cloud' } = {}) {
83
+ const body = normalizeText(text);
84
+ const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
85
+ const { cwd } = normalizeCwd(rawCwd);
86
+ const store = await loadStore();
87
+ const t = topicOf(store, key, cwd);
88
+ const task = normalizeTask({ id: nextTaskId(t.tasks), text: body, status: 'pending', createdAt: stampNow(), from });
89
+ const firstPending = t.tasks.findIndex(isPending);
90
+ if (firstPending >= 0) t.tasks.splice(firstPending, 0, task);
91
+ else t.tasks.unshift(task);
92
+ t.updatedAt = stampNow();
93
+ await saveStore(store);
94
+ return { status: 'ok', key, cwd: t.cwd || cwd, name: t.name, task };
95
+ }