nx-kv 0.5.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.7.0] - 2026-10-09
6
+
7
+ ### Added
8
+
9
+ - **支持删除整个主题与批量删除**:
10
+ - 「实时任务」页主题卡片右下角新增「删除主题」,一次删除该主题及其全部任务;
11
+ - 卡片左上角可勾选(总览标题处支持「全选」),顶部「批量删除 (N)」一键删除多个主题。
12
+ - 新增 CLI/HTTP:
13
+ - `nx-kv sessiontask topic remove --cwd "<目录>"`(或 `--key`)/ `DELETE /api/sessiontasks/topic`;
14
+ - `nx-kv sessiontask topic remove-many --keys "<k,k>"`(也可 `--cwds` 或 `--all`)
15
+ / `POST /api/sessiontasks/topics/remove`。
16
+ - 安全保护:等待中的主题默认不删(避免阻塞的 agent 干等到超时),界面二次确认、
17
+ 命令加 `--force` 才连等待中一起删;批量返回 `{count,removed,skipped}`,skipped
18
+ 列出被跳过的「等待中 / 不存在」主题。
19
+ - 新增删除/批量删除单测与冒烟断言。
20
+
21
+ ## [0.6.0] - 2026-10-08
22
+
23
+ ### Added
24
+
25
+ - **待领取任务队列支持拖拽重排序优先级**:
26
+ - 「实时任务」页队列的待领取行可按住拖拽(行首 ⠿ 手柄),拖动时半透明、目标行
27
+ 显示上/下落点指示线,松开即调整领取顺序;表格反转显示,落点已正确映射到数组真实顺序。
28
+ - 同时提供「提前 / 置后」按钮作为拖拽之外的兜底(窄屏/触屏友好),边界自动禁用。
29
+ - 新增 `nx-kv sessiontask reorder --id <编号> (--beforeId <编号> | --afterId <编号>) [--cwd]`
30
+ 与 HTTP `PATCH /api/sessiontasks/reorder`;只在待领取任务之间换位,已领取(历史)任务
31
+ 固定不可移动;非法输入(同时给/都不给/移到自己/移动已领取)一律显式报错。
32
+ - 新增 reorder 逻辑单测;全套 51 项全绿。
33
+
5
34
  ## [0.5.0] - 2026-10-08
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -81,8 +81,9 @@ nx-kv sessiontask wait --summary "<当前现状总结>" --json
81
81
  新等待点。弹窗/面板顶部直接显示现状,用户不读对话也能快速了解、点选等待点
82
82
  **分别回填(分散答复)**,也可提前排队;等待点非空时 wait 立即取走、不开窗。
83
83
  纯本机协调、存本地文件,**不需登录后端**。其余命令:
84
- `sessiontask list / add / get / update / remove`(均按 `--summary` 定位),
85
- 面板上对应「实时任务」页。
84
+ `sessiontask list / add / get / update / remove`,以及主题级
85
+ `sessiontask topic remove`(删整个主题)与 `sessiontask topic remove-many`
86
+ (批量删除,`--keys/--cwds/--all`;等待中需 `--force`),面板上对应「实时任务」页。
86
87
 
87
88
  配套 skill:
88
89
 
@@ -77,9 +77,15 @@ nx-kv sessiontask list # 等待点总览
77
77
  nx-kv sessiontask add "<回填内容>" --summary "<现状>" # 针对现状排队/投递
78
78
  nx-kv sessiontask get --id <n> --summary "<现状>" # 查看单条
79
79
  nx-kv sessiontask update --id <n> --text "<新内容>" --summary "<现状>"
80
- nx-kv sessiontask remove --id <n> --summary "<现状>" # 删除
80
+ nx-kv sessiontask remove --id <n> --summary "<现状>" # 删除单条任务
81
+ nx-kv sessiontask topic remove --cwd "<工作目录>" # 删除整个主题(等待中需 --force)
82
+ nx-kv sessiontask topic remove-many --keys "<key,key>" # 批量删除主题(或 --cwds "<dir,dir>",或 --all)
81
83
  ```
82
84
 
85
+ > 删除主题:面板「主题总览」每张卡有「删除主题」,顶部勾选后可「批量删除」。
86
+ > 等待中的主题默认不删(避免让阻塞的 agent 干等),确认要删会二次提示并以
87
+ > `--force` 强删、agent 随后超时收尾。
88
+
83
89
  常驻面板(`nx-kv serve`)的「实时任务」页就是等待点总览,可直接点选回填;
84
90
  wait 同样能取走(跨进程由轮询兜底)。
85
91
 
@@ -103,7 +103,23 @@ wait 必带一句当前现状总结,它被归一化(折叠首尾/连续空
103
103
  各现状下,而不是挤在一个队列。
104
104
  - agent 用对应现状调 wait 时只取该等待点的任务,互不串。
105
105
 
106
- ## 七、常见错误与避坑
106
+ ## 七、删除主题与批量删除
107
+
108
+ 主题(工作目录)可整体删除,不必逐条删任务:
109
+
110
+ - **单个删除**:
111
+ - 面板:主题卡片右下角「删除主题」;
112
+ - CLI:`nx-kv sessiontask topic remove --cwd "<工作目录>"`(或 `--key <key>`)。
113
+ - **批量删除**:
114
+ - 面板:勾选卡片左上角(或总览标题处「全选」)→「批量删除 (N)」;
115
+ - CLI:`nx-kv sessiontask topic remove-many --keys "<key,key>"`,也可
116
+ `--cwds "<dir,dir>"`,或 `--all` 清空。
117
+ - **安全保护**:等待中的主题默认不删(删了阻塞的 agent 只能等到超时);界面会
118
+ 二次确认,命令需加 `--force` 才连等待中一起删,agent 随后超时收尾。
119
+ - **返回**:单个 `{status:"removed",topic}`;批量 `{count,removed,skipped}`,
120
+ skipped 列出被跳过的「等待中 / 不存在」主题。
121
+
122
+ ## 八、常见错误与避坑
107
123
 
108
124
  | 错误做法 | 后果 | 正确做法 |
109
125
  | --- | --- | --- |
@@ -117,7 +133,7 @@ wait 必带一句当前现状总结,它被归一化(折叠首尾/连续空
117
133
  | 同时开多个 wait(同一等待点) | 只有一个收到回填,另一个等到超时 | 同一等待点同一时刻只调一个 wait |
118
134
  | 没用 --json | 输出人读文本,宿主难以可靠解析 | agent 调用一律加 `--json` |
119
135
 
120
- ## 八、与常驻面板配合 / 排障
136
+ ## 九、与常驻面板配合 / 排障
121
137
 
122
138
  - 已 `nx-kv serve` 打开面板时,直接在「等待点总览」点选现状回填;正在阻塞的
123
139
  wait 通过轮询(约每 0.5 秒)取走,不依赖弹窗本身。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "nx-kv — KV 清单管理中枢 + 对话内实时任务接力:serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 领取任务与阻塞接收用户输入",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,6 +60,21 @@ function renderWait(r) {
60
60
  );
61
61
  }
62
62
 
63
+ // 批量删除的人读输出:删除清单 + 跳过清单(等待中/不存在)
64
+ function renderRemoveMany(d) {
65
+ const lines = [d.all ? `清空:删除 ${d.count} 个主题` : `已删除 ${d.count} 个主题`];
66
+ for (const r of d.removed) lines.push(` - ${r.name}${r.cwd ? ` ${r.cwd}` : ''}`);
67
+ if (d.skipped.length) {
68
+ lines.push(`跳过 ${d.skipped.length} 个:`);
69
+ for (const s of d.skipped) {
70
+ const why = s.reason === 'waiting' ? '等待中' : '不存在';
71
+ lines.push(` - [${why}] ${s.name || s.key}`);
72
+ }
73
+ }
74
+ if (!d.removed.length && !d.skipped.length) lines.push('(没有匹配的主题)');
75
+ return lines.join('\n');
76
+ }
77
+
63
78
  export default {
64
79
  id: 'sessiontask',
65
80
  title: '实时任务(= Web「实时任务」页)',
@@ -121,6 +136,37 @@ export default {
121
136
  render: (d) => `已删除主题「${d.name}」#${d.removed.id}: ${d.removed.text}`,
122
137
  },
123
138
 
139
+ // ─── 主题删除(单个 / 批量)──────────────────────────────
140
+ {
141
+ id: 'sessiontask.topic.remove',
142
+ cli: ['sessiontask', 'topic', 'remove'],
143
+ http: ['DELETE', '/api/sessiontasks/topic'],
144
+ summary: '删除整个主题(含全部任务;按 --cwd 或 --key,等待中需 --force)',
145
+ flags: {
146
+ cwd: { type: 'string', hint: '工作目录(与 --key 二选一)' },
147
+ key: { type: 'string', hint: '主题 key(与 --cwd 二选一)' },
148
+ force: { type: 'boolean', hint: '连等待中的主题也删(agent 将超时收尾)' },
149
+ },
150
+ run: (ctx) => service.removeTopic({ cwd: ctx.cwd, key: ctx.key, force: ctx.force }),
151
+ render: (d) =>
152
+ `已删除主题「${d.name}」(待领取/共 ${d.topic.pending}/${d.topic.total} 条任务一并删除)`,
153
+ },
154
+ {
155
+ id: 'sessiontask.topic.removeMany',
156
+ cli: ['sessiontask', 'topic', 'remove-many'],
157
+ http: ['POST', '/api/sessiontasks/topics/remove'],
158
+ summary: '批量删除主题(--keys/--cwds 逗号分隔,或 --all;等待中默认跳过,--force 连等待中删)',
159
+ flags: {
160
+ keys: { type: 'array', hint: '主题 key,逗号分隔' },
161
+ cwds: { type: 'array', hint: '工作目录,逗号分隔' },
162
+ all: { type: 'boolean', hint: '清空全部主题(等待中仍跳过,除非 --force)' },
163
+ force: { type: 'boolean', hint: '连等待中的主题一起删' },
164
+ },
165
+ run: (ctx) =>
166
+ service.removeTopics({ keys: ctx.keys, cwds: ctx.cwds, all: ctx.all, force: ctx.force }),
167
+ render: renderRemoveMany,
168
+ },
169
+
124
170
  {
125
171
  id: 'sessiontask.config',
126
172
  cli: ['sessiontask', 'config'],
@@ -148,6 +194,25 @@ export default {
148
194
  },
149
195
  },
150
196
 
197
+ {
198
+ id: 'sessiontask.reorder',
199
+ cli: ['sessiontask', 'reorder'],
200
+ http: ['PATCH', '/api/sessiontasks/reorder'],
201
+ summary: '调整待领取任务优先级(--id <编号> 移到 --before-id <编号> 前或 --after-id <编号> 后)',
202
+ flags: {
203
+ id: { type: 'number', required: true, hint: '要移动的任务编号' },
204
+ beforeId: { type: 'number', hint: '插到该编号之前(与 afterId 二选一)' },
205
+ afterId: { type: 'number', hint: '插到该编号之后(与 beforeId 二选一)' },
206
+ cwd: CWD,
207
+ },
208
+ run: (ctx) =>
209
+ service.reorderTasks({ id: ctx.id, beforeId: ctx.beforeId, afterId: ctx.afterId, cwd: ctx.cwd }),
210
+ render: (d) => {
211
+ const order = d.topic.tasks.filter((x) => x.status !== 'consumed').map((x) => `#${x.id}`);
212
+ return `主题「${d.name}」待领取顺序:${order.join(' -> ') || '(无待领取)'}`;
213
+ },
214
+ },
215
+
151
216
  // ─── 阻塞等待(纯 CLI,agent 本轮最后一步)────────────────
152
217
  {
153
218
  id: 'sessiontask.wait',
@@ -274,6 +274,18 @@ async function setWaiting(key, cwd, summary, waiting, deadline) {
274
274
  await saveStore(store);
275
275
  }
276
276
 
277
+ // wait 结束复位:**仅当主题仍存在**时清等待态,绝不重建——主题可能已被 force 删除,
278
+ // 重建会让「删除主题」失效(曾在超时清理路径上把删掉的主题又建回来)。
279
+ async function clearWaiting(key) {
280
+ const store = await loadStore();
281
+ const t = store.topics[key];
282
+ if (!t) return;
283
+ t.waiting = false;
284
+ t.waitingUntil = '';
285
+ t.updatedAt = stampNow();
286
+ await saveStore(store);
287
+ }
288
+
277
289
  // ─── CRUD ───────────────────────────────────────────────────────────
278
290
 
279
291
  function settingsView(s) {
@@ -430,6 +442,125 @@ export async function removeTask({ cwd: rawCwd, id } = {}) {
430
442
  return { status: 'ok', key, cwd, name: t.name, removed: normalizeTask(removed) };
431
443
  }
432
444
 
445
+ // ─── 主题删除(单个 / 批量) ───────────────────────────────────────
446
+
447
+ // 定位主题 key:显式 --key 优先,否则按工作目录(目录名)推导
448
+ function resolveTopicKey({ key: rawKey, cwd: rawCwd }) {
449
+ if (rawKey && String(rawKey).trim()) return String(rawKey).trim();
450
+ return normalizeCwd(rawCwd).key;
451
+ }
452
+
453
+ // 删除整个主题(连同其全部任务)。默认拒绝删除「等待中」主题——那会让正阻塞的
454
+ // agent 只能等到超时;force=true 才强删(agent 随后超时收尾)。
455
+ export async function removeTopic({ cwd: rawCwd, key: rawKey, force = false } = {}) {
456
+ if (!rawKey && !rawCwd) {
457
+ throw badInput('需要 --cwd "<工作目录>" 或 --key "<主题key>"');
458
+ }
459
+ const key = resolveTopicKey({ key: rawKey, cwd: rawCwd });
460
+ const store = await loadStore();
461
+ const t = store.topics[key];
462
+ if (!t) {
463
+ const { notFound } = await import('../../core/errors.js');
464
+ throw notFound(`主题不存在(key ${key})`);
465
+ }
466
+ if (t.waiting && !force) {
467
+ throw badInput('主题正在等待中,不能删除(确认要删请加 --force,agent 将超时收尾)');
468
+ }
469
+ const view = topicView(t);
470
+ delete store.topics[key];
471
+ await saveStore(store);
472
+ return { status: 'removed', key, name: t.name, cwd: t.cwd, topic: view };
473
+ }
474
+
475
+ // 批量删除主题:keys / cwds(数组,可并用),或 all=true 清空全部。
476
+ // 默认把「等待中」「不存在」计入 skipped 而不删;force=true 连等待中一起删。
477
+ export async function removeTopics({ keys, cwds, all = false, force = false } = {}) {
478
+ const hasList = (a) => Array.isArray(a) && a.some((x) => String(x).trim());
479
+ if (!all && !hasList(keys) && !hasList(cwds)) {
480
+ throw badInput('需要 --keys "<key,key>" 或 --cwds "<目录,目录>",或 --all');
481
+ }
482
+
483
+ const store = await loadStore();
484
+ let targets;
485
+ if (all) {
486
+ targets = Object.keys(store.topics);
487
+ } else {
488
+ const list = [];
489
+ if (Array.isArray(keys)) list.push(...keys.map((x) => String(x).trim()).filter(Boolean));
490
+ if (Array.isArray(cwds)) {
491
+ for (const c of cwds) if (String(c).trim()) list.push(normalizeCwd(c).key);
492
+ }
493
+ targets = [...new Set(list)];
494
+ }
495
+
496
+ const removed = [];
497
+ const skipped = [];
498
+ for (const key of targets) {
499
+ const t = store.topics[key];
500
+ if (!t) {
501
+ skipped.push({ key, reason: 'not_found' });
502
+ continue;
503
+ }
504
+ if (t.waiting && !force) {
505
+ skipped.push({ key, name: t.name, cwd: t.cwd, reason: 'waiting' });
506
+ continue;
507
+ }
508
+ removed.push({ key, name: t.name, cwd: t.cwd });
509
+ delete store.topics[key];
510
+ }
511
+ if (removed.length) await saveStore(store);
512
+ return { status: 'ok', all: !!all, count: removed.length, removed, skipped };
513
+ }
514
+
515
+ // 调整**待领取**任务的优先级:把 #id 移动到 #beforeId 之前或 #afterId 之后。
516
+ // 领取顺序 = tasks 数组里 pending 子序列的先后(consumeFirstPending 取第一条 pending),
517
+ // 故只在 pending 之间换位;已领取(历史)任务固定、不可移动。
518
+ export async function reorderTasks({ cwd: rawCwd, id, beforeId, afterId } = {}) {
519
+ const { key, cwd } = normalizeCwd(rawCwd);
520
+ const dragId = Number(id);
521
+ const hasBefore = beforeId !== undefined && beforeId !== null;
522
+ const hasAfter = afterId !== undefined && afterId !== null;
523
+
524
+ if (!Number.isFinite(dragId) || dragId <= 0) {
525
+ throw badInput('reorder 缺少 --id <要移动的任务编号>');
526
+ }
527
+ if (hasBefore === hasAfter) {
528
+ throw badInput('reorder 需且仅需 --before <编号> 或 --after <编号> 之一');
529
+ }
530
+ const targetId = Number(hasBefore ? beforeId : afterId);
531
+ if (!Number.isFinite(targetId) || targetId <= 0) {
532
+ throw badInput('reorder 的目标任务编号非法');
533
+ }
534
+ if (targetId === dragId) throw badInput('不能把任务移动到它自己旁边');
535
+
536
+ const store = await loadStore();
537
+ const t = store.topics[key];
538
+ if (!t) {
539
+ const { notFound } = await import('../../core/errors.js');
540
+ throw notFound(`主题「${cwd}」不存在`);
541
+ }
542
+ const drag = t.tasks.find((x) => Number(x.id) === dragId);
543
+ const target = t.tasks.find((x) => Number(x.id) === targetId);
544
+ if (!drag || !target) {
545
+ const { notFound } = await import('../../core/errors.js');
546
+ throw notFound(
547
+ `主题「${t.name}」里找不到任务 #${drag ? targetId : dragId}`
548
+ );
549
+ }
550
+ if (drag.status === 'consumed' || target.status === 'consumed') {
551
+ throw badInput('只有「待领取」任务可以调整优先级(已领取任务是固定历史)');
552
+ }
553
+
554
+ const from = t.tasks.indexOf(drag);
555
+ t.tasks.splice(from, 1);
556
+ const ti = t.tasks.indexOf(target); // 删除 drag 后目标下标自动修正
557
+ t.tasks.splice(hasBefore ? ti : ti + 1, 0, drag);
558
+ t.updatedAt = stampNow();
559
+
560
+ await saveStore(store);
561
+ return { status: 'ok', key, cwd, name: t.name, topic: topicView(t) };
562
+ }
563
+
433
564
  // ─── 阻塞等待:核心入口 ─────────────────────────────────────────────
434
565
 
435
566
  /**
@@ -513,7 +644,7 @@ export async function waitForTask({
513
644
  clearInterval(poll);
514
645
  clearTimeout(timer);
515
646
  waiters.delete(key);
516
- await setWaiting(key, cwd, '', false);
647
+ await clearWaiting(key);
517
648
  await stopServer(server);
518
649
  }
519
650
  return result;