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
@@ -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,98 @@
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, bumpWorkflowRevision } 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
+ bumpWorkflowRevision(t);
47
+ await saveStore(store);
48
+ return { status: 'ok', key, cwd: t.cwd || '', name: t.name, task: normalizeTask(raw) };
49
+ }
50
+
51
+ /** 撤销迁出:清掉 migratedTo 标记,任务回到待领取首位。 */
52
+ export async function clearTaskMigrated({ cwd: rawCwd, key: rawKey, id, toTop = true } = {}) {
53
+ const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
54
+ const want = Number(id);
55
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
56
+ const store = await loadStore();
57
+ const t = store.topics[key];
58
+ const raw = t && t.tasks.find((x) => Number(x.id) === want);
59
+ if (!raw) {
60
+ const { notFound } = await import('../../core/errors.js');
61
+ throw notFound(`主题「${t ? t.name : key}」里没有 #${want} 任务`);
62
+ }
63
+ if (!raw.migratedTo) {
64
+ return { status: 'ok', restored: false, reason: 'not_migrated', key, name: t.name, task: normalizeTask(raw) };
65
+ }
66
+ delete raw.migratedTo;
67
+ // 回到待领取首位:迁出时它占着优先级位置,撤销应原样归还
68
+ if (toTop) {
69
+ const idx = t.tasks.indexOf(raw);
70
+ if (idx >= 0) t.tasks.splice(idx, 1);
71
+ t.tasks.unshift(raw);
72
+ }
73
+ t.updatedAt = stampNow();
74
+ bumpWorkflowRevision(t);
75
+ await saveStore(store);
76
+ return { status: 'ok', restored: true, key, cwd: t.cwd || '', name: t.name, task: normalizeTask(raw) };
77
+ }
78
+
79
+ /**
80
+ * 云端 → 本机:把一条任务插到本机主题队列的**待领取首位**(不打扰 waiter——
81
+ * 迁移是「排进队列」,不是「回填给正在等的 agent」,语义必须分开)。
82
+ * 返回 {task, name, key, cwd}。
83
+ */
84
+ export async function insertTaskAtTop({ cwd: rawCwd, key: rawKey, text, from = 'cloud' } = {}) {
85
+ const body = normalizeText(text);
86
+ const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
87
+ const { cwd } = normalizeCwd(rawCwd);
88
+ const store = await loadStore();
89
+ const t = topicOf(store, key, cwd);
90
+ const task = normalizeTask({ id: nextTaskId(t.tasks), text: body, status: 'pending', createdAt: stampNow(), from });
91
+ const firstPending = t.tasks.findIndex(isPending);
92
+ if (firstPending >= 0) t.tasks.splice(firstPending, 0, task);
93
+ else t.tasks.unshift(task);
94
+ t.updatedAt = stampNow();
95
+ bumpWorkflowRevision(t);
96
+ await saveStore(store);
97
+ return { status: 'ok', key, cwd: t.cwd || cwd, name: t.name, task };
98
+ }
@@ -1,4 +1,6 @@
1
1
  import { assertPluginSpec } from '../../../core/workflow/plugin-registry.js';
2
+ import { isGateNode } from '../../../core/workflow/types.js';
3
+ import { wouldCycleAfterLink } from '../../../shared/graph.js';
2
4
 
3
5
  /** @typedef {import('../../../core/workflow/types.js').WorkflowSnapshot} WorkflowSnapshot */
4
6
  /** @typedef {import('../../../core/workflow/types.js').WfNode} WfNode */
@@ -32,7 +34,7 @@ export function assertBind(node, spec, target) {
32
34
  return normalized;
33
35
  }
34
36
  if (!node) throw new Error('node required for node-level bind');
35
- if (node.kind === 'control' && normalized.scope !== 'control') {
37
+ if (isGateNode(node) && normalized.scope !== 'control') {
36
38
  throw new Error('control node may only bind control-scope plugins');
37
39
  }
38
40
  // task + control 插件:允许,由 mutator 升级 kind→control(现网闸门常从普通任务打 --gate)
@@ -40,23 +42,12 @@ export function assertBind(node, spec, target) {
40
42
  }
41
43
 
42
44
  export function wouldCreateCycle(snap, from, to) {
43
- // DFS:从 to 能否走到 from
45
+ // 加边 from→to 成环 ⟺ to ⇝ from(沿快照正向边)——实现下沉 src/shared/graph.js
44
46
  const adj = new Map();
45
47
  for (const e of snap.edges || []) {
46
48
  const a = String(e.from);
47
49
  if (!adj.has(a)) adj.set(a, []);
48
50
  adj.get(a).push(String(e.to));
49
51
  }
50
- const start = String(to);
51
- const target = String(from);
52
- const stack = [start];
53
- const seen = new Set();
54
- while (stack.length) {
55
- const cur = stack.pop();
56
- if (cur === target) return true;
57
- if (seen.has(cur)) continue;
58
- seen.add(cur);
59
- for (const n of adj.get(cur) || []) stack.push(n);
60
- }
61
- return false;
52
+ return wouldCycleAfterLink((n) => adj.get(n) || [], from, to);
62
53
  }
@@ -0,0 +1,322 @@
1
+ // (#54 自 service.js 拆出)面板 presence 观测与注册表:SSE 连接计数(含心跳剔除)、
2
+ // 常驻主面板发现(APP_DIR/panels.json 登记)、逐端口活性探测、窗口重开节流判据
3
+ // shouldReopenWindow(panel-guard 与 tasktool/panel-escort 用)。
4
+ import http from 'node:http';
5
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { APP_DIR, DEFAULT_PORT } from '../../core/paths.js';
8
+ import { loadStore } from './store-io.js';
9
+ import { isLiveWaiting } from './task-view.js';
10
+ // ─── 面板空闲自释放所需的观测接口(panel-guard.js 调用)────────────
11
+
12
+ // 当前有多少主题「真的在等待」——常驻面板的空闲自释放据此否决退出:
13
+ // 有 agent 正阻塞等人时,面板是唯一提醒来源,绝不能在此时释放。
14
+ //
15
+ // waiting 标志由 wait 进程写入、结束复位,跨进程可见;但 wait 被强杀(SIGKILL)时
16
+ // 标志会残留。判据统一走 isLiveWaiting(含「截止时刻已过 = 不算等待」这道闸)。
17
+ export async function countWaitingTopics() {
18
+ const store = await loadStore();
19
+ const now = Date.now();
20
+ let n = 0;
21
+ for (const t of Object.values(store.topics)) {
22
+ if (isLiveWaiting(t, now)) n += 1;
23
+ }
24
+ return n;
25
+ }
26
+ // 活动 SSE 面板连接(只在常驻 serve 进程内有意义)。每条记录最近一次成功写入的时间,
27
+ // wait 据此判断是否真有「活着的」面板标签:心跳写失败(标签已关、TCP 残留)会立刻剔除,
28
+ // 超过新鲜窗口没成功写过的连接不计入——避免页面关了却误判在线、整轮零提醒也不开页。
29
+ //
30
+ // 本期只交付「存在性」接口:panel-guard 只要 clients 数字。SSE 事件流是更上层能力,
31
+ // 留到下个 release 再叠加;这里只保留最小数据结构,streamEventsSSE 接入时直接复用。
32
+ const SSE_HEARTBEAT_MS = 15000;
33
+ const SSE_LIVE_WINDOW_MS = 30000;
34
+ const sseConns = new Set();
35
+
36
+ // 最近一次**任意**面板请求的时间。
37
+ // SSE 只挂在「本地任务」页,用户切到别的页 SSE 就断了 → 面板明明开着却报 0。
38
+ // 但 App 壳每 15s 打一次 /api/ping(任何页都打)→ 用它兜底才知道"面板还开着"。
39
+ let lastPanelRequestAt = 0;
40
+
41
+ /** serve 每收到一个面板请求就调一次(供 presence 与空闲判定用)。 */
42
+ export function notePanelRequest() {
43
+ lastPanelRequestAt = Date.now();
44
+ }
45
+
46
+ export function panelPresence() {
47
+ const now = Date.now();
48
+ let clients = 0;
49
+ for (const conn of sseConns) {
50
+ if (now - conn.lastOkWrite <= SSE_LIVE_WINDOW_MS) clients += 1;
51
+ }
52
+ return {
53
+ clients,
54
+ total: sseConns.size,
55
+ // 面板最近有没有请求过(切到别的页时 SSE 断了,但面板还开着)
56
+ panelActive: now - lastPanelRequestAt <= SSE_LIVE_WINDOW_MS,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * HTTP 面板用的 presence:在进程内计数(SSE + panelActive)之外,再补
62
+ * 「等待中主题数」——App 壳据此在「本地任务」tab 上打角标,用户停在别的页
63
+ * 也知道有 agent 在等回填(wait 因此不必再重开标签)。
64
+ */
65
+ export async function httpPanelPresence() {
66
+ const waiting = await countWaitingTopics().catch(() => 0);
67
+ return { ...panelPresence(), waiting };
68
+ }
69
+
70
+ // 给 runtime/api.js 用的最小 SSE 注册表:每次「/api/localtasks/events」连接时
71
+ // 调一下 addEventStreamConn(req, res),把连接纳入 panelPresence 统计。
72
+ export function addEventStreamConn(req, res) {
73
+ const conn = { lastOkWrite: Date.now() };
74
+ sseConns.add(conn);
75
+ const beat = setInterval(() => {
76
+ try {
77
+ res.write(`: ping ${Date.now()}\n\n`);
78
+ conn.lastOkWrite = Date.now();
79
+ } catch {
80
+ clearInterval(beat);
81
+ sseConns.delete(conn);
82
+ }
83
+ }, SSE_HEARTBEAT_MS);
84
+ beat.unref?.();
85
+ const cleanup = () => {
86
+ clearInterval(beat);
87
+ sseConns.delete(conn);
88
+ };
89
+ req.on('close', cleanup);
90
+ req.on('error', cleanup);
91
+ res.on('close', cleanup);
92
+ return conn;
93
+ }
94
+ // ─── 常驻主面板(固定端口)统一入口 ───────────────────────────────────
95
+
96
+ // 「某端口上有没有可响应的面板」:GET /api/ping。
97
+ // 这与 panelActive 不同:ping 在这里只问「端口上有没有人接 HTTP」,
98
+ // 用于决定是否复用现成面板 / 拉起 serve。
99
+ function pingPort(port, timeoutMs = 700) {
100
+ return new Promise((resolve_) => {
101
+ const req = http.get(`http://127.0.0.1:${port}/api/ping`, { timeout: timeoutMs }, (res) => {
102
+ res.resume();
103
+ resolve_(true);
104
+ });
105
+ req.on('timeout', () => {
106
+ req.destroy();
107
+ resolve_(false);
108
+ });
109
+ req.on('error', () => resolve_(false));
110
+ });
111
+ }
112
+
113
+ // 候选端口:默认端口优先,再叠加 panels.json 里登记的端口
114
+ // (`serve --port` 或默认端口被占自动顺延出来的面板)。
115
+ function candidatePanelPorts() {
116
+ return [...new Set([DEFAULT_PORT, ...readAllPanels().map((p) => Number(p.port))])].filter(
117
+ (p) => Number(p) > 0
118
+ );
119
+ }
120
+
121
+ /**
122
+ * 在给定端口列表里找一个正在响应的面板,返回第一个(按列表顺序);都没有 → null。
123
+ * 纯候选集版本,便于单测注入端口(默认端口上可能正跑着真实面板)。
124
+ */
125
+ export async function findRunningAmongPorts(ports, timeoutMs = 700) {
126
+ const list = [...new Set((Array.isArray(ports) ? ports : []).map(Number))].filter((p) => p > 0);
127
+ const probed = await Promise.all(
128
+ list.map(async (port) => ({ port, up: await pingPort(port, timeoutMs) }))
129
+ );
130
+ const hit = probed.find((r) => r.up);
131
+ return hit ? hit.port : null;
132
+ }
133
+
134
+ /**
135
+ * 在所有候选端口上找一个正在响应的面板,返回其端口(默认端口优先);都没有 → null。
136
+ * 多端口发现是「wait 不再重复开页面」的关键一环。
137
+ */
138
+ export async function findRunningPanelPort(timeoutMs = 700) {
139
+ return findRunningAmongPorts(candidatePanelPorts(), timeoutMs);
140
+ }
141
+
142
+ // 探主面板的 presence 端点。返回 {clients,total};面板没在跑 / 探测失败 → null。
143
+ //
144
+ // 注意:**CLI 进程自己查自己的进程内计数恒为 0**(SSE 连接只存在于 serve 进程里),
145
+ // 所以 CLI 的 presence 必须去问「常驻主面板」——这正是本函数存在的理由。
146
+ async function fetchPanelPresence(timeoutMs = 900, port = DEFAULT_PORT) {
147
+ const url = `http://127.0.0.1:${port}/api/localtasks/presence`;
148
+ return new Promise((resolve_) => {
149
+ const req = http.get(url, { timeout: timeoutMs }, (res) => {
150
+ let body = '';
151
+ res.setEncoding('utf8');
152
+ res.on('data', (c) => {
153
+ body += c;
154
+ });
155
+ res.on('end', () => {
156
+ try {
157
+ const j = JSON.parse(body);
158
+ const d = j && typeof j === 'object' && j.data ? j.data : j;
159
+ resolve_({
160
+ clients: Math.max(0, Number(d && d.clients) || 0),
161
+ total: Math.max(0, Number(d && d.total) || 0),
162
+ panelActive: !!(d && d.panelActive),
163
+ });
164
+ } catch {
165
+ resolve_(null);
166
+ }
167
+ });
168
+ });
169
+ req.on('timeout', () => {
170
+ req.destroy();
171
+ resolve_(null);
172
+ });
173
+ req.on('error', () => resolve_(null));
174
+ });
175
+ }
176
+
177
+ /**
178
+ * wait 用的活性探测,返回 0 / 1。
179
+ *
180
+ * SSE 活标签(用户停在「本地任务」页)**或** 面板最近有请求(用户停在任意页,
181
+ * App 壳每 15s 打 /api/ping 兜底)都算「面板开着」→ 1;都没有 → 0。
182
+ * 旧口径只数 SSE:用户一切到别的页(SSE 单视图挂载,切页即断)wait 就误判
183
+ * 「没开」,于是重复开标签——这正是本次修复的根因。
184
+ *
185
+ * 任何探测失败都按 0 处理(保守地开一次页),绝不因探测失败而阻塞 wait。
186
+ */
187
+ /**
188
+ * 纯判据:一次 presence 快照 → 0 / 1。
189
+ * SSE 活标签或面板最近有请求(停在任意页)都算「面板开着」。
190
+ */
191
+ export function effectivePresenceFrom(q) {
192
+ if (!q) return 0;
193
+ const clients = Math.max(0, Number(q.clients) || 0);
194
+ return clients > 0 || q.panelActive ? 1 : 0;
195
+ }
196
+
197
+ export async function probeEffectivePresence(timeoutMs = 900) {
198
+ const q = await queryPanelPresence(timeoutMs).catch(() => null);
199
+ return effectivePresenceFrom(q);
200
+ }
201
+
202
+ /**
203
+ * 面板活跃度(对外):**逐个问所有跑着面板的端口**再求和。
204
+ *
205
+ * 两个真缺口(结构审计 + 真实 Chrome 复核):
206
+ * 1. 只问默认端口(7877):别的端口上的面板(`serve --port` 或端口被占自动顺延)
207
+ * 完全看不见 → wait 会以为「没人开着面板」而多开一个窗口。
208
+ * serve 起来时把自己登记进 `APP_DIR/panels.json`,这里逐个问。
209
+ * 2. SSE 只挂在「本地任务」页,用户切到别的页 SSE 就断了 → `panelPresence()`
210
+ * 报 0,但面板明明开着。这里额外用「最近有没有面板请求」兜底。
211
+ *
212
+ * 登记文件放在 APP_DIR(不是 store),**不碰 store schema**。
213
+ */
214
+ export async function queryPanelPresence(timeoutMs = 900) {
215
+ const registered = readPanelRegistry();
216
+ const ports = [...new Set([DEFAULT_PORT, ...registered.map((p) => p.port)])];
217
+ const results = await Promise.all(
218
+ ports.map(async (port) => {
219
+ const r = await fetchPanelPresence(timeoutMs, port);
220
+ return r ? { port, ...r, reachable: true } : { port, clients: 0, total: 0, reachable: false };
221
+ })
222
+ );
223
+ // 清掉探不到的面板登记(被强杀的进程来不及注销自己,登记会越积越多)
224
+ const dead = registered.filter((p) => !results.some((r) => r.port === p.port && r.reachable));
225
+ if (dead.length) {
226
+ try {
227
+ writePanels(registered.filter((p) => !dead.includes(p)));
228
+ } catch {
229
+ /* ignore */
230
+ }
231
+ }
232
+ const alive = results.filter((r) => r.reachable);
233
+ if (!alive.length) {
234
+ // 一个面板都联系不上 → 退回本进程内计数(并说明来源)
235
+ return { ...panelPresence(), source: 'self', panels: results };
236
+ }
237
+ return {
238
+ clients: alive.reduce((n, r) => n + r.clients, 0),
239
+ total: alive.reduce((n, r) => n + r.total, 0),
240
+ // 面板「最近有请求」也算活着:用户可能停在别的页(SSE 已断)但面板开着
241
+ panelActive: alive.some((r) => r.panelActive),
242
+ source: 'panel',
243
+ panels: results,
244
+ };
245
+ }
246
+
247
+ // ─── 面板注册表(哪些端口上跑着面板)──────────────────────────────────
248
+ //
249
+ // serve 启动时登记 {port, pid},退出时注销。presence 逐个探测这些端口。
250
+ // 死掉的登记不碍事:探测不到就跳过(还能顺手清掉)。
251
+ const PANEL_REGISTRY_FILE = () => join(APP_DIR, 'panels.json');
252
+
253
+ function readAllPanels() {
254
+ try {
255
+ const raw = readFileSync(PANEL_REGISTRY_FILE(), 'utf8');
256
+ const data = JSON.parse(raw);
257
+ const list = Array.isArray(data && data.panels) ? data.panels : [];
258
+ return list.filter((p) => p && Number(p.port) > 0);
259
+ } catch {
260
+ return [];
261
+ }
262
+ }
263
+
264
+ function writePanels(list) {
265
+ try {
266
+ const f = PANEL_REGISTRY_FILE();
267
+ // 目录可能还不存在(全新机器 / CI runner:第一次跑就是干净的用户目录)。
268
+ // 旧实现只 writeFileSync,ENOENT 被下面的 catch 静默吞掉——registerPanel
269
+ // 表面上成功、实际一条没落盘,queryPanelPresence 因此永远发现不了非默认端口面板。
270
+ mkdirSync(dirname(f), { recursive: true });
271
+ writeFileSync(f, JSON.stringify({ panels: list }, null, 2), 'utf8');
272
+ } catch {
273
+ /* 登记失败不影响面板本身 */
274
+ }
275
+ }
276
+
277
+ /** serve 起来时登记自己(同一端口重复登记即覆盖)。 */
278
+ export function registerPanel(port) {
279
+ const list = readAllPanels().filter((p) => Number(p.port) !== Number(port));
280
+ list.push({ port: Number(port), pid: process.pid, at: Date.now() });
281
+ writePanels(list);
282
+ return list;
283
+ }
284
+
285
+ /** serve 退出时注销自己。 */
286
+ export function unregisterPanel(port) {
287
+ writePanels(readAllPanels().filter((p) => Number(p.port) !== Number(port)));
288
+ }
289
+
290
+ /** 读面板注册表(对外只读)。 */
291
+ export function readPanelRegistry() {
292
+ return readAllPanels();
293
+ }
294
+
295
+ // 复查节奏与重开上限(REVIEW_MS 已随 wait 编排迁往 tasktool/wait.js)。
296
+ const REOPEN_COOLDOWN_MS = 20_000;
297
+ // 一次 wait 最多开 3 个窗口:够救「手滑关掉」,又不至于在浏览器拦截脚本时刷屏。
298
+ const MAX_WINDOW_OPENS = 3;
299
+
300
+ /**
301
+ * 复查时要不要重开窗口。四条判据,任一不满足就不开:
302
+ * 1. 有活标签(presence > 0)→ 用户在看着,别打扰;
303
+ * 2. 已经开够次数 → 停止(浏览器拦了脚本、或用户就是不想看,开再多也没用);
304
+ * 3. 距上一次开页还在冷却期内 → 等一等(页面加载 + 建立 SSE 需要几秒);
305
+ * 4. 其余情况才重开(标签被关了 / 第一次没开成)。
306
+ *
307
+ * 抽成纯函数的原因:这段逻辑的 bug 不报错、只表现为「窗口越开越多」,
308
+ * 必须能被单测钉住。
309
+ */
310
+ export function shouldReopenWindow({
311
+ presence,
312
+ now,
313
+ lastOpenAt,
314
+ opens,
315
+ cooldownMs = REOPEN_COOLDOWN_MS,
316
+ maxOpens = MAX_WINDOW_OPENS,
317
+ } = {}) {
318
+ if (Number(presence) > 0) return false;
319
+ if (Number(opens) >= maxOpens) return false;
320
+ if (Number(lastOpenAt) > 0 && Number(now) - Number(lastOpenAt) < cooldownMs) return false;
321
+ return true;
322
+ }