nx-kv 0.8.2 → 0.8.4
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.
- package/CHANGELOG.md +44 -0
- package/package.json +1 -1
- package/src/modules/localtask/index.js +26 -0
- package/src/modules/localtask/service.js +291 -118
- package/src/modules/localtask/view.jsx +102 -9
- package/src/runtime/api.js +5 -0
- package/src/web/public/assets/{CliHints-DNK3TaU5.js → CliHints-D70_R4vQ.js} +1 -1
- package/src/web/public/assets/{index-Iz1qH6TC.js → index-2OK9SG2y.js} +2 -2
- package/src/web/public/assets/{view-7pNoeCnZ.js → view-C5ictDJi.js} +1 -1
- package/src/web/public/assets/view-DxwcTgBZ.js +1 -0
- package/src/web/public/assets/{view-D_gCbqXF.js → view-gzBv_kjb.js} +1 -1
- package/src/web/public/assets/{view-Dy0w1I4l.js → view-l_lnGEBC.js} +1 -1
- package/src/web/public/index.html +1 -1
- package/src/web/public/assets/view-CCkuLOoM.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
|
4
4
|
|
|
5
|
+
## [0.8.4] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **主题总览加「实时连接」探活指示灯**:1.5s 数据轮询 + EventSource 双心跳,
|
|
10
|
+
主题总览标题旁显示「实时连接正常 / 连接断开,正在重连… / 正在连接…」三态
|
|
11
|
+
(断线用红色语义提示);轮询失败不再每 1.5s 弹错误 toast,统一由指示灯提示。
|
|
12
|
+
- **主题现状 / 上一轮完成总结 / 长任务文本支持点击弹窗阅读**:在原位完整展示基础上,
|
|
13
|
+
叠加点击在居中模态框中专注阅读(长任务文本长度 >50 时才显示可点击指针);
|
|
14
|
+
支持点遮罩、按 Esc 或「关闭」按钮退出。纯前端交互、无新接口。
|
|
15
|
+
- **wait 生命周期事件流(后端 SSE)**:
|
|
16
|
+
- HTTP 为 **SSE** `GET /api/localtasks/events`(可带 `cwd`/`key` 过滤,缺省订阅全部主题,
|
|
17
|
+
首帧 `: connected`、每 15s 心跳、断线自动重连);CLI 等价为
|
|
18
|
+
`localtask events [--cwd|--key]`,逐行 NDJSON。
|
|
19
|
+
- 事件三类:`waiting`(开始等待,连接时已在等待补发 `replay` 快照)、
|
|
20
|
+
`reminder`(剩余 **30s / 10s** 各提醒一次)、`resolved`(`delivered` 被回填 /
|
|
21
|
+
`timeout` 超时;对等待标志残留的僵死状态补发 `stale:true` 的 timeout)。
|
|
22
|
+
- 后端生成器 `watchEvents` 与传输 `streamEventsSSE` 解耦,单测可独立驱动生成器。
|
|
23
|
+
- `api.js` 增 `action.stream === true` 机制:透传 req/res 给 action.run,
|
|
24
|
+
由 action 自己接管响应(SSE 等长连接用得到)。
|
|
25
|
+
|
|
26
|
+
## [0.8.3] - 2026-10-12
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **wait 统一到常驻主面板(固定端口 7877),彻底移除随机端口临时弹窗**:
|
|
31
|
+
- wait 阻塞时不再在 wait 进程内临时起 http 服务器/弹窗,而是复用 `nx-kv serve` 的
|
|
32
|
+
「本地任务」页:已在运行则直接打开并深链到当前主题
|
|
33
|
+
(`#/localtask?key=<主题>&cwd=<目录>`,前端按 key/cwd 自动选中);
|
|
34
|
+
- 主面板未运行时,wait 在后台 **detached 自动拉起** `nx-kv serve --no-open
|
|
35
|
+
--auto-exit`(独立常驻进程,健康检查通过后再开浏览器;拉起失败只降级为纯阻塞、
|
|
36
|
+
绝不报错中断);
|
|
37
|
+
- **主面板页面常驻、提交后不关闭、可连续排队**;wait 拿到任务或超时退出后 serve 依然存活,
|
|
38
|
+
回填统一由约每 0.5 秒的跨进程文件轮询取走(独立进程写入返回 `delivered:false`);
|
|
39
|
+
- `--no-open` 语义保持:不开浏览器、也不自动拉起 serve,仅阻塞等待(冒烟/CI 依赖)。
|
|
40
|
+
|
|
41
|
+
### Removed
|
|
42
|
+
|
|
43
|
+
- 删除随 wait 生命周期起停的随机端口临时弹窗服务器及其内嵌页面
|
|
44
|
+
(`src/modules/localtask/popup.js`),以及与之重复的弹窗专用 HTTP 路由;
|
|
45
|
+
实时任务统一在固定端口常驻主面板完成。
|
|
46
|
+
**行为变更**:不再有「wait 结束即关闭」的临时窗口,也不再输出随机端口地址——
|
|
47
|
+
请改用常驻面板 `http://127.0.0.1:7877/`(wait 会自动打开/拉起)。
|
|
48
|
+
|
|
5
49
|
## [0.8.2] - 2026-10-12
|
|
6
50
|
|
|
7
51
|
### Added
|
package/package.json
CHANGED
|
@@ -271,5 +271,31 @@ export default {
|
|
|
271
271
|
run: (ctx) => service.cancelWait({ cwd: ctx.cwd, key: ctx.key }),
|
|
272
272
|
render: renderCancel,
|
|
273
273
|
},
|
|
274
|
+
|
|
275
|
+
{
|
|
276
|
+
// 探测常驻面板上当前正打开的「实时任务」标签数(活动 SSE 连接数)。
|
|
277
|
+
// wait 开页前先经此端点:有活标签就复用、不再 openBrowser 新开页(避免 Edge 重复开标签);
|
|
278
|
+
// 没有活标签时才开页深链。探测失败 / 旧版无此端点时 service 保守返回 0,不阻塞 wait。
|
|
279
|
+
id: 'localtask.presence',
|
|
280
|
+
cli: ['localtask', 'presence'],
|
|
281
|
+
http: ['GET', '/api/localtasks/presence'],
|
|
282
|
+
summary: '探测主面板上当前打开的「实时任务」标签数(活动 SSE 连接数;用于 wait 不重复开页)',
|
|
283
|
+
run: () => service.panelPresence(),
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
{
|
|
287
|
+
// 等待生命周期事件流:SSE 端点(带 cwd/key 过滤),由 watchEvents 派生 waiting/reminder/resolved。
|
|
288
|
+
// action 标 stream:true → api.js 透传 req/res,run 自己接管响应不走 sendJson。
|
|
289
|
+
id: 'localtask.events',
|
|
290
|
+
cli: ['localtask', 'events'],
|
|
291
|
+
http: ['GET', '/api/localtasks/events'],
|
|
292
|
+
summary: '等待事件流:waiting 开始 / reminder 30s·10s / resolved delivered|timeout(SSE 长连接)',
|
|
293
|
+
flags: {
|
|
294
|
+
cwd: { type: 'string', hint: '工作目录过滤(与 --key 二选一)' },
|
|
295
|
+
key: { type: 'string', hint: '主题 key 过滤(与 --cwd 二选一)' },
|
|
296
|
+
},
|
|
297
|
+
stream: true,
|
|
298
|
+
run: (ctx) => service.streamEventsSSE(ctx.__raw.req, ctx.__raw.res, { cwd: ctx.cwd, key: ctx.key }),
|
|
299
|
+
},
|
|
274
300
|
],
|
|
275
301
|
};
|
|
@@ -24,11 +24,12 @@
|
|
|
24
24
|
import http from 'node:http';
|
|
25
25
|
import crypto from 'node:crypto';
|
|
26
26
|
import fsp from 'node:fs/promises';
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
27
28
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
28
|
-
import {
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { APP_DIR, DEFAULT_PORT } from '../../core/paths.js';
|
|
29
31
|
import { badInput } from '../../core/errors.js';
|
|
30
32
|
import { openBrowser } from '../../core/open.js';
|
|
31
|
-
import { renderPopupPage } from './popup.js';
|
|
32
33
|
|
|
33
34
|
export const DEFAULT_TIMEOUT_MS = 180_000; // 阻塞默认 3 分钟(推荐值)
|
|
34
35
|
const POLL_MS = 500; // 跨进程兜底轮询间隔
|
|
@@ -720,9 +721,16 @@ export async function waitForTask({
|
|
|
720
721
|
return { status: 'ok', key, cwd, name, summary, task: withTaskSuffix(queued, suffix), queued: true };
|
|
721
722
|
}
|
|
722
723
|
|
|
723
|
-
// 2. 没有 pending →
|
|
724
|
-
|
|
725
|
-
|
|
724
|
+
// 2. 没有 pending → 复用常驻主面板(不再起随机端口临时弹窗):
|
|
725
|
+
// a) 确保主面板在跑(不存在就 detached 拉起 serve --no-open --auto-exit);
|
|
726
|
+
// b) 探测活动 SSE 连接:有活标签就不开新页(避免 Edge 重复开标签);
|
|
727
|
+
// 没活标签就 openBrowser 深链到当前主题。
|
|
728
|
+
// 失败只降级为纯阻塞——绝不抛错中断 wait。
|
|
729
|
+
// --no-open 模式(冒烟/CI)不发起任何面板相关动作:不开浏览器、不拉起 serve、不开 SSE。
|
|
730
|
+
// 这样可避免在测试里 spawn 出一堆孤儿进程占着 7877 端口。
|
|
731
|
+
const panelInfo = open ? await ensureMainPanel() : { running: false, started: false, url: '' };
|
|
732
|
+
const presence = panelInfo.running ? await probePresence() : 0;
|
|
733
|
+
const deepLink = deepLinkUrl(key, cwd);
|
|
726
734
|
|
|
727
735
|
// 3. 结果承诺:两个生产者(同进程 waiter / 跨进程轮询)+ 超时
|
|
728
736
|
let resolveOutcome;
|
|
@@ -755,7 +763,7 @@ export async function waitForTask({
|
|
|
755
763
|
.catch(() => {});
|
|
756
764
|
}, POLL_MS);
|
|
757
765
|
|
|
758
|
-
//
|
|
766
|
+
// 登记「等待中」(含截止时刻与现状),面板据此显示倒计时
|
|
759
767
|
await setWaiting(key, cwd, summary, true, new Date(Date.now() + tmo));
|
|
760
768
|
|
|
761
769
|
// 登记之后再查一次关闭标记:setWaiting 刚清掉上一轮残留的标记,因此这里若有值
|
|
@@ -767,13 +775,32 @@ export async function waitForTask({
|
|
|
767
775
|
// waiter/轮询就位后再回调,保证 onReady 里立刻回填也能被接住
|
|
768
776
|
if (typeof onReady === 'function') {
|
|
769
777
|
try {
|
|
770
|
-
await onReady({ addr, key, cwd, name, summary });
|
|
778
|
+
await onReady({ addr: deepLink, key, cwd, name, summary });
|
|
771
779
|
} catch {
|
|
772
780
|
/* 钩子异常不得影响 wait */
|
|
773
781
|
}
|
|
774
782
|
}
|
|
775
783
|
|
|
776
|
-
|
|
784
|
+
// 没活标签且主面板在跑 → 开浏览器深链(让用户能在面板里选中主题并响应等待事件)。
|
|
785
|
+
// 已有活标签 / --no-open / 主面板未启动 → 都不开(避免重复标签 + 冒烟/CI 兼容)。
|
|
786
|
+
const openPage = open && presence === 0 && panelInfo.running;
|
|
787
|
+
if (openPage) openBrowser(deepLink);
|
|
788
|
+
|
|
789
|
+
// 周期复查:等待期间每 5s 查一次活动标签;万一开始时判错(页面被关),20s 冷却内重开。
|
|
790
|
+
// 不到点不开新页(节流,避免「标签被关 → 立刻开 → 立刻关」抖动)。
|
|
791
|
+
const REVIEW_MS = 5_000;
|
|
792
|
+
const REOPEN_COOLDOWN_MS = 20_000;
|
|
793
|
+
let lastReopenAt = 0;
|
|
794
|
+
const review = setInterval(async () => {
|
|
795
|
+
if (!panelInfo.running || !open) return;
|
|
796
|
+
const n = await probePresence().catch(() => 0);
|
|
797
|
+
if (n > 0) return; // 有活标签,不动
|
|
798
|
+
const now = Date.now();
|
|
799
|
+
if (now - lastReopenAt < REOPEN_COOLDOWN_MS) return; // 冷却中
|
|
800
|
+
lastReopenAt = now;
|
|
801
|
+
openBrowser(deepLink);
|
|
802
|
+
}, REVIEW_MS);
|
|
803
|
+
review.unref?.();
|
|
777
804
|
|
|
778
805
|
let result;
|
|
779
806
|
try {
|
|
@@ -787,123 +814,14 @@ export async function waitForTask({
|
|
|
787
814
|
} finally {
|
|
788
815
|
clearInterval(poll);
|
|
789
816
|
clearInterval(cancelPoll);
|
|
817
|
+
clearInterval(review);
|
|
790
818
|
clearTimeout(timer);
|
|
791
819
|
waiters.delete(key);
|
|
792
820
|
await clearWaiting(key);
|
|
793
|
-
await stopServer(server);
|
|
794
821
|
}
|
|
795
822
|
return result;
|
|
796
823
|
}
|
|
797
824
|
|
|
798
|
-
// ─── 内嵌弹窗服务器 ─────────────────────────────────────────────────
|
|
799
|
-
|
|
800
|
-
function sendJson(res, status, obj) {
|
|
801
|
-
res.writeHead(status, {
|
|
802
|
-
'content-type': 'application/json; charset=utf-8',
|
|
803
|
-
'cache-control': 'no-store',
|
|
804
|
-
});
|
|
805
|
-
res.end(JSON.stringify(obj));
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
function sendHtml(res, html) {
|
|
809
|
-
res.writeHead(200, {
|
|
810
|
-
'content-type': 'text/html; charset=utf-8',
|
|
811
|
-
'cache-control': 'no-store',
|
|
812
|
-
});
|
|
813
|
-
res.end(html);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
async function readBody(req) {
|
|
817
|
-
const chunks = [];
|
|
818
|
-
let size = 0;
|
|
819
|
-
for await (const c of req) {
|
|
820
|
-
size += c.length;
|
|
821
|
-
if (size > 1024 * 1024) throw new Error('请求体过大');
|
|
822
|
-
chunks.push(c);
|
|
823
|
-
}
|
|
824
|
-
if (!chunks.length) return {};
|
|
825
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// 与主 api.js 同一套跨站防护:弹窗服务器虽只绑 127.0.0.1,浏览器里任意页面都能
|
|
829
|
-
// 向它发请求,非本机来源一律挡。
|
|
830
|
-
function originAllowed(req) {
|
|
831
|
-
const origin = req.headers.origin;
|
|
832
|
-
if (!origin) return true;
|
|
833
|
-
try {
|
|
834
|
-
const h = new URL(origin).hostname;
|
|
835
|
-
return h === '127.0.0.1' || h === 'localhost' || h === '::1';
|
|
836
|
-
} catch {
|
|
837
|
-
return false;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
async function handlePopup(req, res, key, cwd, summary, timeoutMs) {
|
|
842
|
-
const url = new URL(req.url, 'http://127.0.0.1/');
|
|
843
|
-
const method = (req.method || 'GET').toUpperCase();
|
|
844
|
-
|
|
845
|
-
try {
|
|
846
|
-
if ((method === 'GET' || method === 'HEAD') && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
847
|
-
sendHtml(res, renderPopupPage({ key, cwd, summary, timeoutMs }));
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (url.pathname === '/api/localtasks' && method === 'GET') {
|
|
852
|
-
sendJson(res, 200, { ok: true, data: await listTasks() });
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
if (url.pathname === '/api/localtasks' && method === 'POST') {
|
|
857
|
-
if (!originAllowed(req)) {
|
|
858
|
-
sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
const body = await readBody(req);
|
|
862
|
-
sendJson(res, 200, { ok: true, data: await addTask({ cwd, text: body.text }) });
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
if (url.pathname === '/api/localtasks/item' && method === 'DELETE') {
|
|
867
|
-
if (!originAllowed(req)) {
|
|
868
|
-
sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
|
|
869
|
-
} else {
|
|
870
|
-
const body = await readBody(req);
|
|
871
|
-
sendJson(res, 200, { ok: true, data: await removeTask({ cwd, id: body.id }) });
|
|
872
|
-
}
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
sendJson(res, 404, { ok: false, error: '不存在: ' + method + ' ' + url.pathname });
|
|
877
|
-
} catch (err) {
|
|
878
|
-
const code = err && err.code === 'INVALID_INPUT' ? 400 : err && err.code === 'NOT_FOUND' ? 404 : 500;
|
|
879
|
-
sendJson(res, code, { ok: false, error: String((err && err.message) || err), code: err.code || 'INTERNAL' });
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
function startPopupServer(key, cwd, summary, timeoutMs) {
|
|
884
|
-
const server = http.createServer((req, res) => handlePopup(req, res, key, cwd, summary, timeoutMs));
|
|
885
|
-
return new Promise((resolve_, reject) => {
|
|
886
|
-
server.once('error', reject);
|
|
887
|
-
server.listen(0, '127.0.0.1', () => resolve_(server));
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
function stopServer(server) {
|
|
892
|
-
return new Promise((resolve_) => {
|
|
893
|
-
const force = setTimeout(() => {
|
|
894
|
-
try {
|
|
895
|
-
server.closeAllConnections();
|
|
896
|
-
} catch {
|
|
897
|
-
/* Node 版本不支持则忽略,close 仍会兜底 */
|
|
898
|
-
}
|
|
899
|
-
}, 800);
|
|
900
|
-
server.close(() => {
|
|
901
|
-
clearTimeout(force);
|
|
902
|
-
resolve_();
|
|
903
|
-
});
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
|
|
907
825
|
// ─── 面板空闲自释放所需的观测接口(panel-guard.js 调用)────────────
|
|
908
826
|
|
|
909
827
|
// 当前有多少主题「真的在等待」——常驻面板的空闲自释放据此否决退出:
|
|
@@ -974,3 +892,258 @@ export function addEventStreamConn(req, res) {
|
|
|
974
892
|
res.on('close', cleanup);
|
|
975
893
|
return conn;
|
|
976
894
|
}
|
|
895
|
+
|
|
896
|
+
// ─── 常驻主面板(固定端口)统一入口 ───────────────────────────────────
|
|
897
|
+
|
|
898
|
+
// 「主面板就绪」=端口上有可响应的服务。
|
|
899
|
+
// 这与 /api/ping 不同:ping 是面板壳层每 15s 主动上报「有页面开着」,
|
|
900
|
+
// 这里只问「端口上有没有人接 HTTP」——用于决定是否要拉起 serve。
|
|
901
|
+
async function probePanelReady(timeoutMs = 900) {
|
|
902
|
+
const url = `${panelBaseUrl()}/api/ping`;
|
|
903
|
+
return new Promise((resolve_) => {
|
|
904
|
+
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
905
|
+
res.resume();
|
|
906
|
+
resolve_(true);
|
|
907
|
+
});
|
|
908
|
+
req.on('timeout', () => {
|
|
909
|
+
req.destroy();
|
|
910
|
+
resolve_(false);
|
|
911
|
+
});
|
|
912
|
+
req.on('error', () => resolve_(false));
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// 「活动 SSE 连接数」= 主面板上当前正打开的「实时任务」标签数。
|
|
917
|
+
// 用于 wait 决策:已有活标签就不重开页面,让那个标签响铃/打角标/弹通知;
|
|
918
|
+
// 没有活标签时再 openBrowser 新开深链标签。
|
|
919
|
+
//
|
|
920
|
+
// 任何失败/旧版无此端点都按 0 处理(保守地开一次页),绝不因探测失败而阻塞 wait。
|
|
921
|
+
async function probePresence(timeoutMs = 900) {
|
|
922
|
+
const url = `${panelBaseUrl()}/api/localtasks/presence`;
|
|
923
|
+
return new Promise((resolve_) => {
|
|
924
|
+
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
925
|
+
let body = '';
|
|
926
|
+
res.setEncoding('utf8');
|
|
927
|
+
res.on('data', (c) => {
|
|
928
|
+
body += c;
|
|
929
|
+
});
|
|
930
|
+
res.on('end', () => {
|
|
931
|
+
try {
|
|
932
|
+
const j = JSON.parse(body);
|
|
933
|
+
const d = j && typeof j === 'object' && j.data ? j.data : j;
|
|
934
|
+
resolve_(Math.max(0, Number(d && d.clients) || 0));
|
|
935
|
+
} catch {
|
|
936
|
+
resolve_(0);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
req.on('timeout', () => {
|
|
941
|
+
req.destroy();
|
|
942
|
+
resolve_(0);
|
|
943
|
+
});
|
|
944
|
+
req.on('error', () => resolve_(0));
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function panelBaseUrl() {
|
|
949
|
+
return `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// 深链到主面板「实时任务」页并选中当前主题(key 优先,cwd 兜底)。
|
|
953
|
+
function deepLinkUrl(key, cwd) {
|
|
954
|
+
return `${panelBaseUrl()}/#/localtask?key=${encodeURIComponent(key)}&cwd=${encodeURIComponent(cwd)}`;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// 本仓库/已安装包的 CLI 入口(src/modules/localtask/ 上三级到包根)。
|
|
958
|
+
function binCliPath() {
|
|
959
|
+
return fileURLToPath(new URL('../../../bin/cli.mjs', import.meta.url));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// 确保常驻主面板可用:已在运行直接复用;否则后台 detached 拉起一个独立
|
|
963
|
+
// `nx-kv serve --no-open --auto-exit`(unref,不随 wait 退出而关闭),再轮询健康端点。
|
|
964
|
+
// 失败只返回 running:false,绝不抛错——wait 仍可阻塞等待 CLI/轮询回填。
|
|
965
|
+
//
|
|
966
|
+
// --auto-exit:这个实例是**为等用户回填而临时拉起**的,用完就该让位——面板页全部关闭、
|
|
967
|
+
// 又没有等待中的主题时自行退出(宽限期见 panel-guard.js)。下一次 wait 需要时会再拉起
|
|
968
|
+
// (冷启动 <1s)。用户手动 `nx-kv serve` 的实例不带此标志,不会自己跑掉。
|
|
969
|
+
async function ensureMainPanel() {
|
|
970
|
+
const url = panelBaseUrl() + '/';
|
|
971
|
+
if (await probePanelReady()) return { running: true, started: false, url };
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
const child = spawn(process.execPath, [binCliPath(), 'serve', '--no-open', '--auto-exit'], {
|
|
975
|
+
detached: true,
|
|
976
|
+
stdio: 'ignore',
|
|
977
|
+
windowsHide: true,
|
|
978
|
+
});
|
|
979
|
+
child.on('error', () => {
|
|
980
|
+
/* 见轮询兜底 */
|
|
981
|
+
});
|
|
982
|
+
child.unref();
|
|
983
|
+
} catch {
|
|
984
|
+
return { running: false, started: false, url };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const deadline = Date.now() + 15_000;
|
|
988
|
+
while (Date.now() < deadline) {
|
|
989
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
990
|
+
if (await probePanelReady(700)) return { running: true, started: true, url };
|
|
991
|
+
}
|
|
992
|
+
return { running: false, started: true, url };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ─── 等待生命周期事件流(主面板及时通知,避免用户漏过超时)────────────
|
|
996
|
+
//
|
|
997
|
+
// wait 与常驻主面板通常是两个进程,内存 EventEmitter 跨不过进程,所以事件流
|
|
998
|
+
// 不依赖进程内回调,而是轮询 store,从 waiting/waitingUntil 与「已领取数」的变化里
|
|
999
|
+
// 派生事件:waiting / reminder(剩余 30s、10s)/ resolved(delivered 或 timeout)。
|
|
1000
|
+
// HTTP(SSE) 与 CLI(NDJSON) 共用本生成器。
|
|
1001
|
+
|
|
1002
|
+
const EVENT_POLL_MS = 500;
|
|
1003
|
+
const REMINDER_SEC = [30, 10]; // 剩余 ≤30s、≤10s 各提醒一次
|
|
1004
|
+
|
|
1005
|
+
// 纯函数:给定剩余秒数与已提醒过的阈值,返回本次应触发的阈值(秒)。便于单测。
|
|
1006
|
+
export function remindersDue(remainSec, firedSet) {
|
|
1007
|
+
const out = [];
|
|
1008
|
+
for (const sec of REMINDER_SEC) {
|
|
1009
|
+
if (remainSec <= sec && remainSec > 0 && !firedSet.has(sec)) out.push(sec);
|
|
1010
|
+
}
|
|
1011
|
+
return out;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function consumedCount(t) {
|
|
1015
|
+
return (Array.isArray(t.tasks) ? t.tasks : []).filter((x) => x.status === 'consumed').length;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* 订阅等待生命周期事件(异步生成器)。
|
|
1020
|
+
* @param {{cwd?:string,key?:string,pollMs?:number,signal?:AbortSignal}} opts
|
|
1021
|
+
* 不传 cwd/key 则订阅全部主题(主面板总览需要)。
|
|
1022
|
+
*/
|
|
1023
|
+
export async function* watchEvents({ cwd: rawCwd, key: rawKey, pollMs = EVENT_POLL_MS, signal } = {}) {
|
|
1024
|
+
let onlyKey = null;
|
|
1025
|
+
if (rawKey && String(rawKey).trim()) onlyKey = String(rawKey).trim();
|
|
1026
|
+
else if (rawCwd && String(rawCwd).trim()) onlyKey = normalizeCwd(rawCwd).key;
|
|
1027
|
+
|
|
1028
|
+
const sleep = (ms) =>
|
|
1029
|
+
new Promise((res) => {
|
|
1030
|
+
const to = setTimeout(res, ms);
|
|
1031
|
+
if (signal) {
|
|
1032
|
+
signal.addEventListener(
|
|
1033
|
+
'abort',
|
|
1034
|
+
() => {
|
|
1035
|
+
clearTimeout(to);
|
|
1036
|
+
res();
|
|
1037
|
+
},
|
|
1038
|
+
{ once: true }
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
const state = new Map();
|
|
1044
|
+
let first = true;
|
|
1045
|
+
while (!(signal && signal.aborted)) {
|
|
1046
|
+
let topics = [];
|
|
1047
|
+
try {
|
|
1048
|
+
const store = await loadStore();
|
|
1049
|
+
topics = Object.values(store.topics).filter(
|
|
1050
|
+
(t) => Array.isArray(t.tasks) && (!onlyKey || t.key === onlyKey)
|
|
1051
|
+
);
|
|
1052
|
+
} catch {
|
|
1053
|
+
topics = [];
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const now = Date.now();
|
|
1057
|
+
for (const t of topics) {
|
|
1058
|
+
const deadline = parseStampMs(t.waitingUntil);
|
|
1059
|
+
const isWaiting = !!t.waiting && Number.isFinite(deadline) && deadline > now;
|
|
1060
|
+
let st = state.get(t.key);
|
|
1061
|
+
|
|
1062
|
+
if (isWaiting) {
|
|
1063
|
+
if (!st || st.phase !== 'waiting' || st.waitingUntil !== t.waitingUntil) {
|
|
1064
|
+
st = {
|
|
1065
|
+
phase: 'waiting',
|
|
1066
|
+
waitingUntil: t.waitingUntil,
|
|
1067
|
+
deadline,
|
|
1068
|
+
consumed0: consumedCount(t),
|
|
1069
|
+
reminders: new Set(),
|
|
1070
|
+
};
|
|
1071
|
+
state.set(t.key, st);
|
|
1072
|
+
yield {
|
|
1073
|
+
type: 'waiting',
|
|
1074
|
+
key: t.key,
|
|
1075
|
+
cwd: t.cwd || '',
|
|
1076
|
+
name: t.name || '',
|
|
1077
|
+
summary: t.summary || '',
|
|
1078
|
+
waitingUntil: t.waitingUntil,
|
|
1079
|
+
remainMs: Math.max(0, deadline - now),
|
|
1080
|
+
replay: first,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
const remainSec = Math.floor((st.deadline - now) / 1000);
|
|
1084
|
+
for (const sec of remindersDue(remainSec, st.reminders)) {
|
|
1085
|
+
st.reminders.add(sec);
|
|
1086
|
+
yield {
|
|
1087
|
+
type: 'reminder',
|
|
1088
|
+
key: t.key,
|
|
1089
|
+
cwd: t.cwd || '',
|
|
1090
|
+
name: t.name || '',
|
|
1091
|
+
summary: t.summary || '',
|
|
1092
|
+
waitingUntil: t.waitingUntil,
|
|
1093
|
+
remainSec: sec,
|
|
1094
|
+
remainMs: Math.max(0, st.deadline - now),
|
|
1095
|
+
label: `${sec}s`,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
} else if (st && st.phase === 'waiting') {
|
|
1099
|
+
const outcome = consumedCount(t) > st.consumed0 ? 'delivered' : 'timeout';
|
|
1100
|
+
state.set(t.key, { phase: 'ended' });
|
|
1101
|
+
yield { type: 'resolved', key: t.key, cwd: t.cwd || '', name: t.name || '', outcome };
|
|
1102
|
+
} else if (!st) {
|
|
1103
|
+
state.set(t.key, { phase: 'idle' });
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// 兜底:waiting 标志还在但截止已过(wait 进程异常退出),也派生一次 timeout
|
|
1107
|
+
if (t.waiting && (!Number.isFinite(deadline) || deadline <= now)) {
|
|
1108
|
+
const cur = state.get(t.key);
|
|
1109
|
+
if (!cur || cur.phase === 'waiting') {
|
|
1110
|
+
state.set(t.key, { phase: 'ended' });
|
|
1111
|
+
yield {
|
|
1112
|
+
type: 'resolved',
|
|
1113
|
+
key: t.key,
|
|
1114
|
+
cwd: t.cwd || '',
|
|
1115
|
+
name: t.name || '',
|
|
1116
|
+
outcome: 'timeout',
|
|
1117
|
+
stale: true,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
first = false;
|
|
1124
|
+
await sleep(pollMs);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// HTTP 传输:SSE。注册到 sseConns 让 panelPresence 计入(wait 据此判活标签)。
|
|
1129
|
+
// 客户端断开即中止生成器。SSE 心跳由 addEventStreamConn 统一管。
|
|
1130
|
+
export async function streamEventsSSE(req, res, { cwd, key } = {}) {
|
|
1131
|
+
res.writeHead(200, {
|
|
1132
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1133
|
+
'cache-control': 'no-store',
|
|
1134
|
+
connection: 'keep-alive',
|
|
1135
|
+
});
|
|
1136
|
+
res.write(': connected\n\n');
|
|
1137
|
+
// addEventStreamConn 内部已把 conn 加入 sseConns(panelPresence 据此统计活标签),
|
|
1138
|
+
// 我们不再直接引用——心跳 / 失败剔除 / 断开清理都由它托管。
|
|
1139
|
+
addEventStreamConn(req, res);
|
|
1140
|
+
const ac = new AbortController();
|
|
1141
|
+
req.on('close', () => ac.abort());
|
|
1142
|
+
try {
|
|
1143
|
+
for await (const ev of watchEvents({ cwd, key, signal: ac.signal })) {
|
|
1144
|
+
res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
1145
|
+
}
|
|
1146
|
+
} catch {
|
|
1147
|
+
/* 客户端断开 / 瞬时读盘错误,静默结束 */
|
|
1148
|
+
}
|
|
1149
|
+
}
|