nx-kv 0.3.0 → 0.3.2
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 +27 -0
- package/package.json +1 -1
- package/src/modules/sessiontask/index.js +53 -34
- package/src/modules/sessiontask/popup.js +36 -12
- package/src/modules/sessiontask/service.js +259 -137
- package/src/modules/sessiontask/view.jsx +89 -34
- package/src/web/public/assets/{CliHints-CDKMmZbP.js → CliHints-uQDPykkl.js} +1 -1
- package/src/web/public/assets/{index-DW8t26MP.js → index-C3LDUTKW.js} +2 -2
- package/src/web/public/assets/{view-CqGxh_0X.js → view-1sDSMusO.js} +1 -1
- package/src/web/public/assets/view-BgQvKmWZ.js +1 -0
- package/src/web/public/assets/{view-CdtYjY9W.js → view-CZkcHw_6.js} +1 -1
- package/src/web/public/assets/{view-C_qjdm2x.js → view-UqnZYH9n.js} +1 -1
- package/src/web/public/index.html +1 -1
- package/src/web/public/assets/view-CLDx6X3W.js +0 -1
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
// 实时任务输入 service ——
|
|
1
|
+
// 实时任务输入 service —— 按「工作目录(cwd)」划分的主题(topic)+ 主题任务队列 +
|
|
2
|
+
// 阻塞等待 + 内嵌弹窗。
|
|
2
3
|
//
|
|
3
4
|
// 解决的问题:agent 在一个对话里需要用户继续给任务时,不必结束本轮、等用户去
|
|
4
|
-
// 原始输入框打字,而是把 `nx-kv sessiontask wait --summary "
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// 原始输入框打字,而是把 `nx-kv sessiontask wait --cwd "<工作目录>" --summary "<当前现状>"`
|
|
6
|
+
// 当作 **本轮最后一个动作**调用——命令阻塞、自动打开浏览器弹窗;用户在弹窗/面板回填,
|
|
7
|
+
// 命令解除阻塞、把新任务作为结果返回给 agent,agent 接着干。
|
|
7
8
|
//
|
|
8
9
|
// 关键设计:
|
|
9
|
-
// 1.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// 2.
|
|
13
|
-
//
|
|
14
|
-
// 3.
|
|
10
|
+
// 1. **工作目录即主题,主题拥有队列。** wait 传入工作目录(默认进程 cwd),归一化后
|
|
11
|
+
// 作为主题 key;同一目录的多次 wait(即便现状措辞不同)共用同一个任务队列,
|
|
12
|
+
// 不同目录 → 不同主题 → 队列互不串。任务可在一个主题队列里逐条堆积,agent 按序领取。
|
|
13
|
+
// 2. **现状总结只作状态展示。** --summary 是 agent 当前状态(人读),每次 wait 刷新,
|
|
14
|
+
// 不再参与队列分桶;用户据此一眼看清 agent 做到哪、卡在哪。
|
|
15
|
+
// 3. **纯本地协调,不依赖 KV 后端登录态。** 数据落本机文件,弹窗由本进程临时起的
|
|
16
|
+
// http 服务器直出,未登录也能用。
|
|
17
|
+
// 4. **排队语义。** 用户可提前回填:主题已有 pending 时 wait 立即取走、不开窗;
|
|
15
18
|
// 没有 pending 才开窗阻塞。
|
|
16
|
-
//
|
|
19
|
+
// 5. **超时是业务结果不是错误。** 默认阻塞 2 分钟;超时返回 {status:'timeout'}、
|
|
17
20
|
// exit 0,agent 据此安静收尾结束本轮,绝不抛异常。
|
|
18
21
|
//
|
|
19
22
|
// 跨进程:弹窗与 wait 同进程时靠内存 waiter 即时唤醒;任务由别的进程写入(如常驻
|
|
@@ -21,15 +24,18 @@
|
|
|
21
24
|
import http from 'node:http';
|
|
22
25
|
import crypto from 'node:crypto';
|
|
23
26
|
import fsp from 'node:fs/promises';
|
|
24
|
-
import { dirname, join } from 'node:path';
|
|
27
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
25
28
|
import { APP_DIR } from '../../core/paths.js';
|
|
26
29
|
import { badInput } from '../../core/errors.js';
|
|
27
30
|
import { openBrowser } from '../../core/open.js';
|
|
28
31
|
import { renderPopupPage } from './popup.js';
|
|
29
32
|
|
|
30
|
-
export const DEFAULT_TIMEOUT_MS =
|
|
33
|
+
export const DEFAULT_TIMEOUT_MS = 180_000; // 阻塞默认 3 分钟(推荐值)
|
|
31
34
|
const POLL_MS = 500; // 跨进程兜底轮询间隔
|
|
32
|
-
const
|
|
35
|
+
export const DEFAULT_TIMEOUT_SEC = 180; // 推荐阻塞时长(3 分钟)
|
|
36
|
+
export const MAX_TIMEOUT_SEC = 600; // 阻塞时长上限(10 分钟)
|
|
37
|
+
export const MAX_SUMMARY = 2000; // 现状(状态)长度上限(原 200,已放宽)
|
|
38
|
+
export const MAX_TEXT = 20_000; // 单条任务内容长度上限
|
|
33
39
|
|
|
34
40
|
// ─── 存储位置:可环境变量覆盖(测试隔离) ───────────────────────────
|
|
35
41
|
|
|
@@ -37,19 +43,55 @@ export function storePath() {
|
|
|
37
43
|
return process.env.NX_KV_SESSIONTASK_STORE || join(APP_DIR, 'sessiontasks.json');
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
// ───
|
|
46
|
+
// ─── 工作目录 → 主题 ───────────────────────────────────────────────
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
/**
|
|
49
|
+
* 归一化工作目录并解析主题。
|
|
50
|
+
* @param {string} [raw] 目录路径;缺省/空 → 进程当前 cwd
|
|
51
|
+
* @returns {{cwd:string, key:string}}
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeCwd(raw) {
|
|
54
|
+
const trimmed = raw && String(raw).trim() ? String(raw).trim() : '';
|
|
55
|
+
let cwd;
|
|
56
|
+
try {
|
|
57
|
+
cwd = resolve(trimmed || process.cwd());
|
|
58
|
+
} catch {
|
|
59
|
+
cwd = process.cwd();
|
|
60
|
+
}
|
|
61
|
+
const name = basename(cwd);
|
|
62
|
+
return { cwd, name, key: topicKeyFor(name) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 主题以「目录名」标识:在名为 br_ct 的目录里执行 CLI → 自动领取 br_ct 主题队列的任务;
|
|
66
|
+
// 同名目录共用同一队列。Windows 折叠目录名大小写。
|
|
67
|
+
export function topicKeyFor(name) {
|
|
68
|
+
const fold = process.platform === 'win32' ? String(name).toLowerCase() : String(name);
|
|
69
|
+
return crypto.createHash('sha1').update(fold, 'utf8').digest('hex').slice(0, 16);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── 现状(状态文本) ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
// 归一化:折叠所有连续空白为单空格、去首尾。wait 必填;add 可选(仅刷新状态)。
|
|
75
|
+
export function normalizeSummary(raw, { optional = false } = {}) {
|
|
44
76
|
const s = String(raw ?? '').replace(/\s+/g, ' ').trim();
|
|
45
|
-
if (!s)
|
|
46
|
-
|
|
77
|
+
if (!s) {
|
|
78
|
+
if (optional) return '';
|
|
79
|
+
throw badInput('缺少 --summary "<当前现状总结>"(agent 当前状态,展示给用户)');
|
|
80
|
+
}
|
|
81
|
+
if (s.length > MAX_SUMMARY) {
|
|
82
|
+
throw badInput(`--summary 过长(≤${MAX_SUMMARY} 字,当前 ${s.length} 字)`);
|
|
83
|
+
}
|
|
47
84
|
return s;
|
|
48
85
|
}
|
|
49
86
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
87
|
+
// 任务正文:必填、限长
|
|
88
|
+
function normalizeText(raw) {
|
|
89
|
+
const body = String(raw ?? '').trim();
|
|
90
|
+
if (!body) throw badInput('任务内容不能为空');
|
|
91
|
+
if (body.length > MAX_TEXT) {
|
|
92
|
+
throw badInput(`任务内容过长(≤${MAX_TEXT} 字,当前 ${body.length} 字)`);
|
|
93
|
+
}
|
|
94
|
+
return body;
|
|
53
95
|
}
|
|
54
96
|
|
|
55
97
|
// ─── 时间 ───────────────────────────────────────────────────────────
|
|
@@ -63,39 +105,67 @@ export function stampNow(d = new Date()) {
|
|
|
63
105
|
);
|
|
64
106
|
}
|
|
65
107
|
|
|
66
|
-
// ─── 存储读写(原子写 + 损坏容忍 + v1 迁移)
|
|
108
|
+
// ─── 存储读写(原子写 + 损坏容忍 + v1/v2 迁移) ───────────────────
|
|
67
109
|
|
|
68
110
|
function emptyStore() {
|
|
69
|
-
return { version:
|
|
111
|
+
return { version: 4, settings: { timeoutSec: DEFAULT_TIMEOUT_SEC }, topics: {} };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 归一化超时秒数:非法/缺省 → 推荐值;超上限截断为 10 分钟
|
|
115
|
+
export function clampTimeoutSec(sec) {
|
|
116
|
+
let n = Number(sec);
|
|
117
|
+
if (!Number.isFinite(n) || n <= 0) n = DEFAULT_TIMEOUT_SEC;
|
|
118
|
+
n = Math.floor(n);
|
|
119
|
+
if (n > MAX_TIMEOUT_SEC) n = MAX_TIMEOUT_SEC;
|
|
120
|
+
return n;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 旧的等待点(v2 slot / v1 session)转主题:旧 key 来自现状摘要、无 cwd 信息,
|
|
124
|
+
// 故保留原 key,用现状摘要截一段作主题名、cwd 留空。
|
|
125
|
+
function legacyTopic(key, b) {
|
|
126
|
+
const summary = (b && b.summary) || key;
|
|
127
|
+
return {
|
|
128
|
+
key,
|
|
129
|
+
cwd: '',
|
|
130
|
+
name: summary.length > 40 ? summary.slice(0, 40) + '…' : summary,
|
|
131
|
+
summary,
|
|
132
|
+
createdAt: (b && b.createdAt) || stampNow(),
|
|
133
|
+
updatedAt: (b && b.updatedAt) || stampNow(),
|
|
134
|
+
waiting: !!(b && b.waiting),
|
|
135
|
+
waitingUntil: (b && b.waitingUntil) || '',
|
|
136
|
+
tasks: b && Array.isArray(b.tasks) ? b.tasks : [],
|
|
137
|
+
};
|
|
70
138
|
}
|
|
71
139
|
|
|
72
140
|
async function loadStore() {
|
|
141
|
+
let topics = null;
|
|
142
|
+
let settings = null;
|
|
73
143
|
try {
|
|
74
144
|
const raw = await fsp.readFile(storePath(), 'utf8');
|
|
75
145
|
const data = JSON.parse(raw);
|
|
76
146
|
if (data && typeof data === 'object') {
|
|
77
|
-
if (data.
|
|
78
|
-
|
|
79
|
-
if (data.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
waiting: false,
|
|
88
|
-
waitingUntil: '',
|
|
89
|
-
tasks: b && Array.isArray(b.tasks) ? b.tasks : [],
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
return { version: 2, slots };
|
|
147
|
+
if (data.topics && typeof data.topics === 'object') {
|
|
148
|
+
topics = data.topics;
|
|
149
|
+
} else if (data.slots && typeof data.slots === 'object') {
|
|
150
|
+
// v2(slots 桶)→ topics
|
|
151
|
+
topics = {};
|
|
152
|
+
for (const [k, b] of Object.entries(data.slots)) topics[k] = legacyTopic(k, b);
|
|
153
|
+
} else if (data.sessions && typeof data.sessions === 'object') {
|
|
154
|
+
// v1(sessions 桶)→ topics
|
|
155
|
+
topics = {};
|
|
156
|
+
for (const [k, b] of Object.entries(data.sessions)) topics[k] = legacyTopic(k, b);
|
|
93
157
|
}
|
|
158
|
+
if (data.settings && typeof data.settings === 'object') settings = data.settings;
|
|
94
159
|
}
|
|
95
160
|
} catch {
|
|
96
161
|
/* 损坏/缺失 → 空 store */
|
|
97
162
|
}
|
|
98
|
-
return emptyStore();
|
|
163
|
+
if (!topics) return emptyStore();
|
|
164
|
+
return {
|
|
165
|
+
version: 4,
|
|
166
|
+
settings: { timeoutSec: clampTimeoutSec(settings && settings.timeoutSec) },
|
|
167
|
+
topics,
|
|
168
|
+
};
|
|
99
169
|
}
|
|
100
170
|
|
|
101
171
|
async function saveStore(data) {
|
|
@@ -106,23 +176,28 @@ async function saveStore(data) {
|
|
|
106
176
|
await fsp.rename(tmp, p);
|
|
107
177
|
}
|
|
108
178
|
|
|
109
|
-
//
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
179
|
+
// 取(必要时新建)某个主题
|
|
180
|
+
function topicOf(store, key, cwd, name = '') {
|
|
181
|
+
const nm = name || (cwd ? basename(cwd) : '') || key;
|
|
182
|
+
let t = store.topics[key];
|
|
183
|
+
if (!t || !Array.isArray(t.tasks)) {
|
|
184
|
+
t = {
|
|
114
185
|
key,
|
|
115
|
-
|
|
186
|
+
cwd: cwd || '',
|
|
187
|
+
name: nm,
|
|
116
188
|
createdAt: stampNow(),
|
|
117
189
|
updatedAt: stampNow(),
|
|
118
190
|
waiting: false,
|
|
119
191
|
waitingUntil: '',
|
|
192
|
+
summary: '',
|
|
120
193
|
tasks: [],
|
|
121
194
|
};
|
|
122
|
-
store.
|
|
195
|
+
store.topics[key] = t;
|
|
123
196
|
}
|
|
124
|
-
if (
|
|
125
|
-
|
|
197
|
+
if (cwd) t.cwd = cwd;
|
|
198
|
+
if (name) t.name = name;
|
|
199
|
+
else if (cwd) t.name = basename(cwd) || t.name;
|
|
200
|
+
return t;
|
|
126
201
|
}
|
|
127
202
|
|
|
128
203
|
function nextTaskId(tasks) {
|
|
@@ -144,16 +219,18 @@ function normalizeTask(t) {
|
|
|
144
219
|
};
|
|
145
220
|
}
|
|
146
221
|
|
|
147
|
-
function
|
|
148
|
-
const tasks =
|
|
222
|
+
function topicView(t) {
|
|
223
|
+
const tasks = t.tasks.map(normalizeTask);
|
|
149
224
|
return {
|
|
150
|
-
key:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
225
|
+
key: t.key,
|
|
226
|
+
cwd: t.cwd || '',
|
|
227
|
+
name: t.name || '',
|
|
228
|
+
summary: t.summary || '',
|
|
229
|
+
waiting: !!t.waiting,
|
|
230
|
+
waitingUntil: t.waitingUntil || '',
|
|
231
|
+
createdAt: t.createdAt,
|
|
232
|
+
updatedAt: t.updatedAt,
|
|
233
|
+
pending: tasks.filter((x) => x.status !== 'consumed').length,
|
|
157
234
|
total: tasks.length,
|
|
158
235
|
tasks,
|
|
159
236
|
};
|
|
@@ -169,149 +246,194 @@ function markConsumed(task) {
|
|
|
169
246
|
return task;
|
|
170
247
|
}
|
|
171
248
|
|
|
172
|
-
//
|
|
249
|
+
// 取走该主题第一条 pending;没有则返回 null
|
|
173
250
|
export async function consumeFirstPending(key) {
|
|
174
251
|
const store = await loadStore();
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
177
|
-
const task =
|
|
252
|
+
const t = store.topics[key];
|
|
253
|
+
if (!t) return null;
|
|
254
|
+
const task = t.tasks.find((x) => x.status !== 'consumed');
|
|
178
255
|
if (!task) return null;
|
|
179
256
|
markConsumed(task);
|
|
180
|
-
|
|
257
|
+
t.updatedAt = stampNow();
|
|
181
258
|
await saveStore(store);
|
|
182
259
|
return normalizeTask(task);
|
|
183
260
|
}
|
|
184
261
|
|
|
185
|
-
// 在 store
|
|
186
|
-
async function setWaiting(key, summary, waiting, deadline) {
|
|
262
|
+
// 在 store 里把某主题置为等待/结束,并刷新现状
|
|
263
|
+
async function setWaiting(key, cwd, summary, waiting, deadline) {
|
|
187
264
|
const store = await loadStore();
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
265
|
+
const t = topicOf(store, key, cwd);
|
|
266
|
+
t.waiting = waiting;
|
|
267
|
+
t.waitingUntil = waiting && deadline ? stampNow(deadline) : '';
|
|
268
|
+
if (summary) t.summary = summary;
|
|
269
|
+
t.updatedAt = stampNow();
|
|
192
270
|
await saveStore(store);
|
|
193
271
|
}
|
|
194
272
|
|
|
195
273
|
// ─── CRUD ───────────────────────────────────────────────────────────
|
|
196
274
|
|
|
197
|
-
|
|
275
|
+
function settingsView(s) {
|
|
276
|
+
return {
|
|
277
|
+
timeoutSec: clampTimeoutSec(s && s.timeoutSec),
|
|
278
|
+
recommendedSec: DEFAULT_TIMEOUT_SEC,
|
|
279
|
+
maxSec: MAX_TIMEOUT_SEC,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 总览:返回全部主题(面板据此分组展示与回填)+ 超时设置,按最近更新排序
|
|
198
284
|
export async function listTasks() {
|
|
199
285
|
const store = await loadStore();
|
|
200
|
-
const
|
|
201
|
-
.map(
|
|
286
|
+
const topics = Object.values(store.topics)
|
|
287
|
+
.map(topicView)
|
|
202
288
|
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
203
|
-
return {
|
|
289
|
+
return { topics, waiting: topics.filter((t) => t.waiting).length, settings: settingsView(store.settings) };
|
|
204
290
|
}
|
|
205
291
|
|
|
206
|
-
export async function
|
|
207
|
-
const
|
|
208
|
-
|
|
292
|
+
export async function getSettings() {
|
|
293
|
+
const store = await loadStore();
|
|
294
|
+
return settingsView(store.settings);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 配置等待超时(页面可调;推荐 180=3 分钟,最大 600=10 分钟,超上限自动截断)
|
|
298
|
+
export async function updateSettings({ timeoutSec } = {}) {
|
|
299
|
+
const n = Number(timeoutSec);
|
|
300
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
301
|
+
throw badInput(`超时时间需为 1-${MAX_TIMEOUT_SEC} 秒(推荐 ${DEFAULT_TIMEOUT_SEC})`);
|
|
302
|
+
}
|
|
303
|
+
const store = await loadStore();
|
|
304
|
+
store.settings = { timeoutSec: clampTimeoutSec(n) };
|
|
305
|
+
await saveStore(store);
|
|
306
|
+
return settingsView(store.settings);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function getTask({ cwd: rawCwd, id } = {}) {
|
|
310
|
+
const { key, cwd } = normalizeCwd(rawCwd);
|
|
209
311
|
const want = Number(id);
|
|
210
312
|
if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
|
|
211
313
|
const store = await loadStore();
|
|
212
|
-
const
|
|
213
|
-
const task =
|
|
314
|
+
const t = store.topics[key];
|
|
315
|
+
const task = t && t.tasks.find((x) => Number(x.id) === want);
|
|
214
316
|
if (!task) {
|
|
215
317
|
const { notFound } = await import('../../core/errors.js');
|
|
216
|
-
throw notFound(
|
|
318
|
+
throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
|
|
217
319
|
}
|
|
218
320
|
return normalizeTask(task);
|
|
219
321
|
}
|
|
220
322
|
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
323
|
+
/**
|
|
324
|
+
* 向某主题(工作目录)的队列回填任务。
|
|
325
|
+
* 若本进程正有 wait 在等该主题 → 直接投递并唤醒;否则入队堆积。
|
|
326
|
+
*/
|
|
327
|
+
export async function addTask({ cwd: rawCwd, summary: rawSummary, text } = {}) {
|
|
328
|
+
const { key, cwd } = normalizeCwd(rawCwd);
|
|
329
|
+
const summary = normalizeSummary(rawSummary, { optional: true });
|
|
330
|
+
const body = normalizeText(text);
|
|
227
331
|
|
|
228
332
|
const store = await loadStore();
|
|
229
|
-
const
|
|
230
|
-
const task = normalizeTask({ id: nextTaskId(
|
|
231
|
-
|
|
232
|
-
|
|
333
|
+
const t = topicOf(store, key, cwd);
|
|
334
|
+
const task = normalizeTask({ id: nextTaskId(t.tasks), text: body, status: 'pending', createdAt: stampNow() });
|
|
335
|
+
t.tasks.push(task);
|
|
336
|
+
t.updatedAt = stampNow();
|
|
337
|
+
if (summary) t.summary = summary;
|
|
233
338
|
|
|
234
339
|
const waiter = waiters.get(key);
|
|
235
340
|
const delivered = !!waiter;
|
|
236
341
|
if (waiter) {
|
|
237
342
|
markConsumed(task);
|
|
238
343
|
waiters.delete(key);
|
|
239
|
-
|
|
240
|
-
|
|
344
|
+
t.waiting = false;
|
|
345
|
+
t.waitingUntil = '';
|
|
241
346
|
}
|
|
242
347
|
|
|
243
348
|
await saveStore(store); // 先落盘再唤醒:wait 返回时数据必然一致
|
|
244
349
|
if (waiter) waiter.resolve(normalizeTask(task));
|
|
245
350
|
|
|
246
|
-
return {
|
|
351
|
+
return {
|
|
352
|
+
status: 'ok',
|
|
353
|
+
key,
|
|
354
|
+
cwd,
|
|
355
|
+
name: t.name,
|
|
356
|
+
summary: t.summary,
|
|
357
|
+
task: normalizeTask(task),
|
|
358
|
+
delivered,
|
|
359
|
+
queued: !delivered,
|
|
360
|
+
};
|
|
247
361
|
}
|
|
248
362
|
|
|
249
|
-
export async function updateTask({
|
|
250
|
-
const
|
|
251
|
-
const key = keyForSummary(summary);
|
|
363
|
+
export async function updateTask({ cwd: rawCwd, id, text } = {}) {
|
|
364
|
+
const { key, cwd } = normalizeCwd(rawCwd);
|
|
252
365
|
const want = Number(id);
|
|
253
366
|
if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
|
|
254
367
|
const body = String(text ?? '').trim();
|
|
255
368
|
if (!body) throw badInput('--text 不能为空');
|
|
369
|
+
if (body.length > MAX_TEXT) throw badInput(`任务内容过长(≤${MAX_TEXT} 字)`);
|
|
256
370
|
|
|
257
371
|
const store = await loadStore();
|
|
258
|
-
const
|
|
259
|
-
const task =
|
|
372
|
+
const t = store.topics[key];
|
|
373
|
+
const task = t && t.tasks.find((x) => Number(x.id) === want);
|
|
260
374
|
if (!task) {
|
|
261
375
|
const { notFound } = await import('../../core/errors.js');
|
|
262
|
-
throw notFound(
|
|
376
|
+
throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
|
|
263
377
|
}
|
|
264
378
|
task.text = body;
|
|
265
|
-
|
|
379
|
+
t.updatedAt = stampNow();
|
|
266
380
|
await saveStore(store);
|
|
267
|
-
return { status: 'ok', key,
|
|
381
|
+
return { status: 'ok', key, cwd, name: t.name, task: normalizeTask(task) };
|
|
268
382
|
}
|
|
269
383
|
|
|
270
|
-
export async function removeTask({
|
|
271
|
-
const
|
|
272
|
-
const key = keyForSummary(summary);
|
|
384
|
+
export async function removeTask({ cwd: rawCwd, id } = {}) {
|
|
385
|
+
const { key, cwd } = normalizeCwd(rawCwd);
|
|
273
386
|
const want = Number(id);
|
|
274
387
|
if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
|
|
275
388
|
|
|
276
389
|
const store = await loadStore();
|
|
277
|
-
const
|
|
278
|
-
const index =
|
|
390
|
+
const t = store.topics[key];
|
|
391
|
+
const index = t ? t.tasks.findIndex((x) => Number(x.id) === want) : -1;
|
|
279
392
|
if (index < 0) {
|
|
280
393
|
const { notFound } = await import('../../core/errors.js');
|
|
281
|
-
throw notFound(
|
|
394
|
+
throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
|
|
282
395
|
}
|
|
283
|
-
const [removed] =
|
|
284
|
-
|
|
396
|
+
const [removed] = t.tasks.splice(index, 1);
|
|
397
|
+
t.updatedAt = stampNow();
|
|
285
398
|
await saveStore(store);
|
|
286
|
-
return { status: 'ok', key,
|
|
399
|
+
return { status: 'ok', key, cwd, name: t.name, removed: normalizeTask(removed) };
|
|
287
400
|
}
|
|
288
401
|
|
|
289
402
|
// ─── 阻塞等待:核心入口 ─────────────────────────────────────────────
|
|
290
403
|
|
|
291
404
|
/**
|
|
292
|
-
*
|
|
293
|
-
* @param {string}
|
|
294
|
-
* @param {
|
|
295
|
-
* @param {
|
|
296
|
-
* @param {
|
|
405
|
+
* 针对某主题(工作目录)等待用户回填下一条任务。
|
|
406
|
+
* @param {string} [cwd] 工作目录(主题按目录名识别);缺省 → 进程当前 cwd
|
|
407
|
+
* @param {string} summary 当前现状(必填,状态展示)
|
|
408
|
+
* @param {number} [timeoutSec] 阻塞上限(秒);缺省 → 页面配置(推荐 180,最大 600)
|
|
409
|
+
* @param {number} [timeoutMs] 兼容旧参数(毫秒)
|
|
410
|
+
* @param {boolean} [open] 是否自动打开浏览器弹窗(测试可关)
|
|
411
|
+
* @param {function} [onReady] 服务器就绪回调 {addr,key,cwd,name,summary}(测试/集成钩子)
|
|
297
412
|
*/
|
|
298
413
|
export async function waitForTask({
|
|
414
|
+
cwd: rawCwd,
|
|
299
415
|
summary: rawSummary,
|
|
300
|
-
|
|
416
|
+
timeoutSec,
|
|
417
|
+
timeoutMs,
|
|
301
418
|
open = true,
|
|
302
419
|
onReady,
|
|
303
420
|
} = {}) {
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
421
|
+
const { key, cwd, name } = normalizeCwd(rawCwd);
|
|
422
|
+
const summary = normalizeSummary(rawSummary); // 现状必填
|
|
423
|
+
|
|
424
|
+
// 超时:显式 --timeout(秒/毫秒)优先;否则用页面配置;统一夹到 1-600
|
|
425
|
+
let sec;
|
|
426
|
+
if (timeoutSec !== undefined && timeoutSec !== null) sec = timeoutSec;
|
|
427
|
+
else if (timeoutMs !== undefined && timeoutMs !== null) sec = Number(timeoutMs) / 1000;
|
|
428
|
+
else sec = (await loadStore()).settings.timeoutSec;
|
|
429
|
+
const tmo = clampTimeoutSec(sec) * 1000;
|
|
308
430
|
|
|
309
|
-
// 1.
|
|
431
|
+
// 1. 主题队列已有 pending → 立即取走,不开窗、不阻塞
|
|
310
432
|
const queued = await consumeFirstPending(key);
|
|
311
|
-
if (queued) return { status: 'ok', key, summary, task: queued, queued: true };
|
|
433
|
+
if (queued) return { status: 'ok', key, cwd, name, summary, task: queued, queued: true };
|
|
312
434
|
|
|
313
435
|
// 2. 没有 pending → 起内嵌弹窗服务器
|
|
314
|
-
const server = await startPopupServer(key, summary, tmo);
|
|
436
|
+
const server = await startPopupServer(key, cwd, summary, tmo);
|
|
315
437
|
const addr = `http://127.0.0.1:${server.address().port}/`;
|
|
316
438
|
|
|
317
439
|
// 3. 结果承诺:两个生产者(同进程 waiter / 跨进程轮询)+ 超时
|
|
@@ -325,8 +447,8 @@ export async function waitForTask({
|
|
|
325
447
|
|
|
326
448
|
const poll = setInterval(() => {
|
|
327
449
|
consumeFirstPending(key)
|
|
328
|
-
.then((
|
|
329
|
-
if (
|
|
450
|
+
.then((task) => {
|
|
451
|
+
if (task) resolveOutcome({ kind: 'task', task, delivered: false });
|
|
330
452
|
})
|
|
331
453
|
.catch(() => {
|
|
332
454
|
/* 瞬时读写错误,下一轮再试 */
|
|
@@ -335,13 +457,13 @@ export async function waitForTask({
|
|
|
335
457
|
|
|
336
458
|
const timer = setTimeout(() => resolveOutcome({ kind: 'timeout' }), tmo);
|
|
337
459
|
|
|
338
|
-
//
|
|
339
|
-
await setWaiting(key, summary, true, new Date(Date.now() + tmo));
|
|
460
|
+
// 登记「等待中」(含截止时刻与现状),面板/弹窗据此显示倒计时
|
|
461
|
+
await setWaiting(key, cwd, summary, true, new Date(Date.now() + tmo));
|
|
340
462
|
|
|
341
463
|
// waiter/轮询就位后再回调,保证 onReady 里立刻回填也能被接住
|
|
342
464
|
if (typeof onReady === 'function') {
|
|
343
465
|
try {
|
|
344
|
-
await onReady({ addr, key, summary });
|
|
466
|
+
await onReady({ addr, key, cwd, name, summary });
|
|
345
467
|
} catch {
|
|
346
468
|
/* 钩子异常不得影响 wait */
|
|
347
469
|
}
|
|
@@ -354,13 +476,13 @@ export async function waitForTask({
|
|
|
354
476
|
const r = await outcome;
|
|
355
477
|
result =
|
|
356
478
|
r.kind === 'timeout'
|
|
357
|
-
? { status: 'timeout', key, summary, timeoutMs: tmo }
|
|
358
|
-
: { status: 'ok', key, summary, task: r.task, delivered: r.delivered, queued: false };
|
|
479
|
+
? { status: 'timeout', key, cwd, summary, timeoutMs: tmo }
|
|
480
|
+
: { status: 'ok', key, cwd, name, summary, task: r.task, delivered: r.delivered, queued: false };
|
|
359
481
|
} finally {
|
|
360
482
|
clearInterval(poll);
|
|
361
483
|
clearTimeout(timer);
|
|
362
484
|
waiters.delete(key);
|
|
363
|
-
await setWaiting(key,
|
|
485
|
+
await setWaiting(key, cwd, '', false);
|
|
364
486
|
await stopServer(server);
|
|
365
487
|
}
|
|
366
488
|
return result;
|
|
@@ -409,13 +531,13 @@ function originAllowed(req) {
|
|
|
409
531
|
}
|
|
410
532
|
}
|
|
411
533
|
|
|
412
|
-
async function handlePopup(req, res, key, summary, timeoutMs) {
|
|
534
|
+
async function handlePopup(req, res, key, cwd, summary, timeoutMs) {
|
|
413
535
|
const url = new URL(req.url, 'http://127.0.0.1/');
|
|
414
536
|
const method = (req.method || 'GET').toUpperCase();
|
|
415
537
|
|
|
416
538
|
try {
|
|
417
539
|
if ((method === 'GET' || method === 'HEAD') && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
418
|
-
sendHtml(res, renderPopupPage({ key, summary, timeoutMs }));
|
|
540
|
+
sendHtml(res, renderPopupPage({ key, cwd, summary, timeoutMs }));
|
|
419
541
|
return;
|
|
420
542
|
}
|
|
421
543
|
|
|
@@ -430,7 +552,7 @@ async function handlePopup(req, res, key, summary, timeoutMs) {
|
|
|
430
552
|
return;
|
|
431
553
|
}
|
|
432
554
|
const body = await readBody(req);
|
|
433
|
-
sendJson(res, 200, { ok: true, data: await addTask({
|
|
555
|
+
sendJson(res, 200, { ok: true, data: await addTask({ cwd, text: body.text }) });
|
|
434
556
|
return;
|
|
435
557
|
}
|
|
436
558
|
|
|
@@ -439,7 +561,7 @@ async function handlePopup(req, res, key, summary, timeoutMs) {
|
|
|
439
561
|
sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
|
|
440
562
|
} else {
|
|
441
563
|
const body = await readBody(req);
|
|
442
|
-
sendJson(res, 200, { ok: true, data: await removeTask({
|
|
564
|
+
sendJson(res, 200, { ok: true, data: await removeTask({ cwd, id: body.id }) });
|
|
443
565
|
}
|
|
444
566
|
return;
|
|
445
567
|
}
|
|
@@ -451,8 +573,8 @@ async function handlePopup(req, res, key, summary, timeoutMs) {
|
|
|
451
573
|
}
|
|
452
574
|
}
|
|
453
575
|
|
|
454
|
-
function startPopupServer(key, summary, timeoutMs) {
|
|
455
|
-
const server = http.createServer((req, res) => handlePopup(req, res, key, summary, timeoutMs));
|
|
576
|
+
function startPopupServer(key, cwd, summary, timeoutMs) {
|
|
577
|
+
const server = http.createServer((req, res) => handlePopup(req, res, key, cwd, summary, timeoutMs));
|
|
456
578
|
return new Promise((resolve_, reject) => {
|
|
457
579
|
server.once('error', reject);
|
|
458
580
|
server.listen(0, '127.0.0.1', () => resolve_(server));
|