nx-kv 0.1.1
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 +48 -0
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/assets/nx-kv/SKILL.md +99 -0
- package/assets/nx-kv/references/agent-workflow.md +178 -0
- package/assets/nx-kv/references/todo-commands.md +193 -0
- package/bin/cli.mjs +10 -0
- package/package.json +57 -0
- package/src/core/config.js +94 -0
- package/src/core/errors.js +81 -0
- package/src/core/frontmatter.js +65 -0
- package/src/core/fstree.js +74 -0
- package/src/core/kvapi.js +201 -0
- package/src/core/open.js +26 -0
- package/src/core/paths.js +46 -0
- package/src/core/prompt.js +60 -0
- package/src/index.js +9 -0
- package/src/modules/auth/index.js +98 -0
- package/src/modules/auth/view.jsx +78 -0
- package/src/modules/bundled/index.js +51 -0
- package/src/modules/bundled/service.js +80 -0
- package/src/modules/group/index.js +199 -0
- package/src/modules/group/view.jsx +159 -0
- package/src/modules/system/index.js +153 -0
- package/src/modules/todo/index.js +280 -0
- package/src/modules/todo/service.js +524 -0
- package/src/modules/todo/view.jsx +280 -0
- package/src/runtime/api.js +108 -0
- package/src/runtime/cli.js +299 -0
- package/src/runtime/registry.js +56 -0
- package/src/runtime/server.js +74 -0
- package/src/runtime/spec.js +147 -0
- package/src/web/frontend/App.jsx +74 -0
- package/src/web/frontend/api/client.js +39 -0
- package/src/web/frontend/components/CliHints.jsx +25 -0
- package/src/web/frontend/components/ui.jsx +197 -0
- package/src/web/frontend/index.html +13 -0
- package/src/web/frontend/main.jsx +17 -0
- package/src/web/frontend/registry.js +12 -0
- package/src/web/frontend/store.jsx +74 -0
- package/src/web/frontend/style.css +368 -0
- package/src/web/public/assets/CliHints-3H3KOoDZ.js +1 -0
- package/src/web/public/assets/index-BnWex75m.css +1 -0
- package/src/web/public/assets/index-jhTtYCb9.js +10 -0
- package/src/web/public/assets/view-CrL-DSJu.js +1 -0
- package/src/web/public/assets/view-Du7Wv_-h.js +1 -0
- package/src/web/public/assets/view-S0N5g6v1.js +2 -0
- package/src/web/public/index.html +13 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// 清单 service —— 四把 key 上的读-改-写。
|
|
2
|
+
//
|
|
3
|
+
// ─── 必须知道的四条契约(来自 Flutter 端 todo_submit_service.dart 的文件头注释)───
|
|
4
|
+
//
|
|
5
|
+
// 1. **id 分配必须扫「待办 + 冻结」**。冻结任务保留原 id,只扫待办的话,
|
|
6
|
+
// 待办清空后会从 1 重新分配,与冻结任务撞车 —— 解冻时无法按 id 定位到正确任务。
|
|
7
|
+
// 实测佐证:todo:open 为空、todo:freeze 有 id 2/10/17/23/24/28,只扫 open 会分配 1。
|
|
8
|
+
//
|
|
9
|
+
// 2. **topic 不在快捷列表时,要连 `todo:topics` 一起写**,否则它不会出现在候选里。
|
|
10
|
+
//
|
|
11
|
+
// 3. **两把 key 一起写**。后端 KV **没有事务**,所以「一起写」只能做到:
|
|
12
|
+
// 按「先写主数据、后写辅助数据」的顺序,并如实报告部分失败。
|
|
13
|
+
// 顺序是刻意的 —— open 先于 topics:万一 topics 写失败,任务本身不丢,
|
|
14
|
+
// 只是短期内不在快捷候选里;反过来则会丢失任务。
|
|
15
|
+
//
|
|
16
|
+
// 4. **写前重读**。后端没有「单条更新」接口,所有写都是整把 key 覆盖。
|
|
17
|
+
// 不重读就会把并发写(本机 CLI / App / 面板同时操作)直接覆盖掉。
|
|
18
|
+
// 接受毫秒级竞态 —— 与 Flutter 端同策略。
|
|
19
|
+
//
|
|
20
|
+
// ─── 时间格式(沿用既有数据的约定,不要统一)───
|
|
21
|
+
// createdAt / frozenAt:Dart `toIso8601String()` 风格,本地时间无时区
|
|
22
|
+
// 例 2026-08-14T19:04:56.028788
|
|
23
|
+
// doneAt :RFC3339 带偏移
|
|
24
|
+
// 例 2026-08-15T14:32:53+08:00
|
|
25
|
+
// 两种格式在同一份数据里并存是既成事实,改掉会破坏既有记录的可比性。
|
|
26
|
+
import { kvGet, kvSet, kvDelete } from '../../core/kvapi.js';
|
|
27
|
+
import { loadConfig, requireAuth, normalizeGroupId } from '../../core/config.js';
|
|
28
|
+
import { badInput, notFound, conflict } from '../../core/errors.js';
|
|
29
|
+
|
|
30
|
+
export const KEY = {
|
|
31
|
+
open: 'todo:open',
|
|
32
|
+
done: 'todo:done',
|
|
33
|
+
topics: 'todo:topics',
|
|
34
|
+
freeze: 'todo:freeze',
|
|
35
|
+
};
|
|
36
|
+
export const PROMPT_PREFIX = 'todo:prompt:';
|
|
37
|
+
export const COLD_PREFIX = 'todo:done:cold:';
|
|
38
|
+
|
|
39
|
+
const BUCKETS = ['open', 'done', 'freeze'];
|
|
40
|
+
|
|
41
|
+
// ─── 时间 ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
44
|
+
|
|
45
|
+
/** Dart toIso8601String() 风格:本地时间、微秒精度、无时区后缀。
|
|
46
|
+
* JS 的 Date 只有毫秒精度,微秒位补 000 —— 形状与既有数据一致即可,
|
|
47
|
+
* 下游从不解析这两位的实际值。 */
|
|
48
|
+
export function dartNow(d = new Date()) {
|
|
49
|
+
return (
|
|
50
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
51
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
|
|
52
|
+
`.${pad(d.getMilliseconds(), 3)}000`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** RFC3339 带本地偏移,例 2026-08-15T14:32:53+08:00 */
|
|
57
|
+
export function rfc3339Now(d = new Date()) {
|
|
58
|
+
const off = -d.getTimezoneOffset();
|
|
59
|
+
const sign = off >= 0 ? '+' : '-';
|
|
60
|
+
const abs = Math.abs(off);
|
|
61
|
+
return (
|
|
62
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
63
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
|
|
64
|
+
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── 任务模型 ──────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export function normalizeTask(t) {
|
|
71
|
+
return {
|
|
72
|
+
id: Number(t.id) || 0,
|
|
73
|
+
topic: String(t.topic ?? ''),
|
|
74
|
+
text: String(t.text ?? ''),
|
|
75
|
+
createdAt: String(t.createdAt ?? ''),
|
|
76
|
+
doneAt: String(t.doneAt ?? ''),
|
|
77
|
+
note: String(t.note ?? ''),
|
|
78
|
+
frozenAt: String(t.frozenAt ?? ''),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseTasks(raw) {
|
|
83
|
+
if (!raw || !raw.trim()) return [];
|
|
84
|
+
try {
|
|
85
|
+
const arr = JSON.parse(raw);
|
|
86
|
+
return Array.isArray(arr) ? arr.map(normalizeTask) : [];
|
|
87
|
+
} catch {
|
|
88
|
+
// 快照损坏时返回空数组而不抛 —— 读侧不该因为一条脏数据整个失效
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseTopics(raw) {
|
|
94
|
+
if (!raw || !raw.trim()) return [];
|
|
95
|
+
try {
|
|
96
|
+
const arr = JSON.parse(raw);
|
|
97
|
+
return Array.isArray(arr) ? arr.map(String).filter((s) => s.trim()) : [];
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── groupId 回落 ──────────────────────────────────────────────────
|
|
104
|
+
//
|
|
105
|
+
// 显式传入(--group)优先,否则用本机配置里的**当前工作空间**。
|
|
106
|
+
//
|
|
107
|
+
// 这一步刻意放在读写边界(下面两个函数)而不是每个 action 里:
|
|
108
|
+
// 放边界上,读与写必然用同一个 groupId;放到 action 里,一旦有人漏了一处,
|
|
109
|
+
// 就会出现「从 A 组读、往 B 组写」——而这类错不会报错,只会静默写错空间。
|
|
110
|
+
async function resolveGroup(groupId) {
|
|
111
|
+
if (groupId !== undefined && groupId !== null && groupId !== '') {
|
|
112
|
+
return normalizeGroupId(groupId);
|
|
113
|
+
}
|
|
114
|
+
const cfg = await loadConfig();
|
|
115
|
+
return normalizeGroupId(cfg.groupId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── 读 ────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
async function readKey(key, groupId) {
|
|
121
|
+
const item = await kvGet(key, await resolveGroup(groupId));
|
|
122
|
+
return item ? item.value : '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 读入全部四个 key。list/get 等只读操作走这里,一次拿齐。 */
|
|
126
|
+
export async function loadAll(groupId) {
|
|
127
|
+
await requireAuth();
|
|
128
|
+
const gid = normalizeGroupId(groupId);
|
|
129
|
+
const [open, done, freeze, topics] = await Promise.all([
|
|
130
|
+
readKey(KEY.open, gid),
|
|
131
|
+
readKey(KEY.done, gid),
|
|
132
|
+
readKey(KEY.freeze, gid),
|
|
133
|
+
readKey(KEY.topics, gid),
|
|
134
|
+
]);
|
|
135
|
+
return {
|
|
136
|
+
open: parseTasks(open),
|
|
137
|
+
done: parseTasks(done),
|
|
138
|
+
freeze: parseTasks(freeze),
|
|
139
|
+
topics: parseTopics(topics),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 待办 + 冻结的 id 最大值 + 1(契约 1) */
|
|
144
|
+
export function nextTaskId(open, freeze) {
|
|
145
|
+
let max = 0;
|
|
146
|
+
for (const t of [...open, ...freeze]) if (t.id > max) max = t.id;
|
|
147
|
+
return max + 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── 定位:id 不是唯一键 ⚠️ ────────────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// **done 里会积累同 id 的条目**:id 分配只扫 open + freeze(契约 1),
|
|
153
|
+
// 所以任务完成、open 清空之后,新任务会重新用上已被完成任务占过的 id。
|
|
154
|
+
// 实测这份数据里 done 有 68 条、其中 id=29 有 7 条。
|
|
155
|
+
//
|
|
156
|
+
// 由此推出两条硬约束,违反其一都会静默删错数据:
|
|
157
|
+
//
|
|
158
|
+
// 1. **命中多条时必须消歧,不能猜一条。** 用 `findIndex` 取第一条,
|
|
159
|
+
// 在 done 上取到的是最老的记录,而调用方往往以为自己在操作刚创建的那条。
|
|
160
|
+
// 2. **变更必须按 index 定位,禁止 `filter(t => t.id !== id)`。**
|
|
161
|
+
// 后者会把**所有**同 id 的条目一起删掉——曾因此在真实数据上误删 7 条。
|
|
162
|
+
|
|
163
|
+
/** 找出所有匹配 id 的条目(跨桶,含同桶内重复) */
|
|
164
|
+
export function locateAll(all, id) {
|
|
165
|
+
const hits = [];
|
|
166
|
+
for (const bucket of BUCKETS) {
|
|
167
|
+
all[bucket].forEach((task, index) => {
|
|
168
|
+
if (task.id === id) hits.push({ bucket, index, task });
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return hits;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 定位唯一一条。
|
|
176
|
+
*
|
|
177
|
+
* 消歧手段有两个,按需组合:
|
|
178
|
+
* `--topic <名字>` 按主题收窄(当同 id 的条目分属不同主题时够用)
|
|
179
|
+
* `--pick <n>` 按编号选择(同 id 且同主题时唯一可靠的定位方式)
|
|
180
|
+
*
|
|
181
|
+
* `--pick` 是必需的:实测这份数据里 id=29 的 7 条**全部是 qus 主题**,
|
|
182
|
+
* 光靠 --topic 永远收敛不到一条。报错时把候选项编号列出来,
|
|
183
|
+
* 调用方(人或 agent)照着选即可——**绝不替调用方猜**。
|
|
184
|
+
*/
|
|
185
|
+
export function locateUnique(all, id, { topic, pick } = {}) {
|
|
186
|
+
const allHits = locateAll(all, id);
|
|
187
|
+
const hits = topic ? allHits.filter((h) => h.task.topic === topic) : allHits;
|
|
188
|
+
|
|
189
|
+
if (!hits.length) {
|
|
190
|
+
if (allHits.length) {
|
|
191
|
+
const topics = [...new Set(allHits.map((h) => h.task.topic))].join(', ');
|
|
192
|
+
throw notFound(`id=${id} 存在,但 topic 不是 ${topic}(实际 topic: ${topics})`);
|
|
193
|
+
}
|
|
194
|
+
throw notFound(`task id=${id} 不存在(待办 / 已完成 / 冻结里都没有)`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (pick !== undefined && pick !== null) {
|
|
198
|
+
const n = Number(pick);
|
|
199
|
+
if (!Number.isInteger(n) || n < 0 || n >= hits.length) {
|
|
200
|
+
throw badInput(`--pick 必须是 0..${hits.length - 1} 之间的整数,收到: ${pick}`);
|
|
201
|
+
}
|
|
202
|
+
return hits[n];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (hits.length > 1) {
|
|
206
|
+
const list = hits
|
|
207
|
+
.map((h, i) => ` [${i}] ${h.bucket.padEnd(6)} ${h.task.topic.padEnd(5)} ${h.task.createdAt} ${h.task.text.slice(0, 50)}`)
|
|
208
|
+
.join('\n');
|
|
209
|
+
throw conflict(
|
|
210
|
+
`id=${id} 命中 ${hits.length} 条,无法确定是哪一条。用 --pick <n> 选择:\n${list}` +
|
|
211
|
+
(topic ? '' : `\n(也可先用 --topic <名字> 收窄)`),
|
|
212
|
+
{ candidates: hits.map((h) => ({ bucket: h.bucket, topic: h.task.topic, createdAt: h.task.createdAt, text: h.task.text })) }
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return hits[0];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── 写 ────────────────────────────────────────────────────────────
|
|
219
|
+
//
|
|
220
|
+
// 顺序纪律(契约 3):**主数据先写,辅助数据后写**。
|
|
221
|
+
// 部分失败时抛出的错误里带上「哪一步失败、已经写成功了什么」——
|
|
222
|
+
// 调用方(人或 agent)需要知道当前处于什么状态才能决定下一步。
|
|
223
|
+
|
|
224
|
+
async function writeKey(key, value, groupId) {
|
|
225
|
+
await kvSet({ key, value, ttl: 0, groupId: await resolveGroup(groupId) });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function writeTasks(bucketKey, tasks, groupId) {
|
|
229
|
+
await writeKey(bucketKey, JSON.stringify(tasks), groupId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function writeTopics(topics, groupId) {
|
|
233
|
+
await writeKey(KEY.topics, JSON.stringify(topics), groupId);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── 只读操作 ──────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
export async function listTodos({ topic, status, groupId } = {}) {
|
|
239
|
+
const all = await loadAll(groupId);
|
|
240
|
+
const want = status && status !== 'all' ? status : null;
|
|
241
|
+
|
|
242
|
+
// 形状恒定:三个桶的键永远都在,被 --status 排除的置 null。
|
|
243
|
+
// 这样渲染器与调用方不必为「过滤时返回另一种形状」写分支——
|
|
244
|
+
// 之前正是这个分支导致 `--status open` 什么都不打印。
|
|
245
|
+
const pick = (b) => {
|
|
246
|
+
if (want && want !== b) return null;
|
|
247
|
+
return topic ? all[b].filter((t) => t.topic === topic) : all[b];
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
topic: topic || '',
|
|
252
|
+
status: want || 'all',
|
|
253
|
+
open: pick('open'),
|
|
254
|
+
done: pick('done'),
|
|
255
|
+
freeze: pick('freeze'),
|
|
256
|
+
topics: all.topics,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function getTodo(id, { topic, pick, groupId } = {}) {
|
|
261
|
+
const all = await loadAll(groupId);
|
|
262
|
+
const hit = locateUnique(all, id, { topic, pick });
|
|
263
|
+
return { ...hit.task, bucket: hit.bucket };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── 变更操作 ──────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/** 新增任务到待办;topic 不在快捷列表时一并补上(契约 2) */
|
|
269
|
+
export async function addTodo({ topic, text, groupId }) {
|
|
270
|
+
const t = String(topic || '').trim();
|
|
271
|
+
const body = String(text || '').trim();
|
|
272
|
+
if (!t) throw badInput('--topic 必填');
|
|
273
|
+
if (!body) throw badInput('任务内容不能为空');
|
|
274
|
+
|
|
275
|
+
const all = await loadAll(groupId); // 契约 4:写前重读
|
|
276
|
+
const task = {
|
|
277
|
+
id: nextTaskId(all.open, all.freeze), // 契约 1:扫 待办 + 冻结
|
|
278
|
+
topic: t,
|
|
279
|
+
text: body,
|
|
280
|
+
createdAt: dartNow(),
|
|
281
|
+
doneAt: '',
|
|
282
|
+
note: '',
|
|
283
|
+
frozenAt: '',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const open = [...all.open, task];
|
|
287
|
+
const topicChanged = !all.topics.includes(t);
|
|
288
|
+
|
|
289
|
+
// 主数据先写(契约 3)
|
|
290
|
+
await writeTasks(KEY.open, open, groupId);
|
|
291
|
+
if (topicChanged) {
|
|
292
|
+
try {
|
|
293
|
+
await writeTopics([...all.topics, t], groupId);
|
|
294
|
+
} catch (e) {
|
|
295
|
+
throw conflict(
|
|
296
|
+
`任务已写入待办,但快捷 topic 未更新(${t}): ${e.message}`,
|
|
297
|
+
{ task, partial: true, written: [KEY.open], failed: [KEY.topics] }
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { task, topicAdded: topicChanged, id: task.id };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 编辑任务:只改传入的字段(PATCH 语义),未传的保持原值。
|
|
305
|
+
* `--match-topic` 是用来**消歧**的定位条件,与要改的 `--topic` 是两回事——
|
|
306
|
+
* 合并成一个会让「把 qus 改成 go」这种操作无从表达。 */
|
|
307
|
+
export async function updateTodo(id, { topic, text, note, matchTopic, pick, groupId } = {}) {
|
|
308
|
+
const all = await loadAll(groupId);
|
|
309
|
+
const hit = locateUnique(all, id, { topic: matchTopic, pick });
|
|
310
|
+
|
|
311
|
+
if (topic === undefined && text === undefined && note === undefined) {
|
|
312
|
+
throw badInput('至少要给出一个要改的字段(--topic / --text / --note)');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const next = { ...hit.task };
|
|
316
|
+
if (topic !== undefined) {
|
|
317
|
+
const t = String(topic).trim();
|
|
318
|
+
if (!t) throw badInput('--topic 不能为空');
|
|
319
|
+
next.topic = t;
|
|
320
|
+
}
|
|
321
|
+
if (text !== undefined) {
|
|
322
|
+
const s = String(text).trim();
|
|
323
|
+
if (!s) throw badInput('--text 不能为空');
|
|
324
|
+
next.text = s;
|
|
325
|
+
}
|
|
326
|
+
if (note !== undefined) next.note = String(note);
|
|
327
|
+
|
|
328
|
+
const bucket = [...all[hit.bucket]];
|
|
329
|
+
bucket[hit.index] = next;
|
|
330
|
+
await writeTasks(KEY[hit.bucket], bucket, groupId);
|
|
331
|
+
|
|
332
|
+
// 改了 topic 就顺带补快捷列表(与 add 同一条契约)
|
|
333
|
+
let topicAdded = false;
|
|
334
|
+
if (next.topic && !all.topics.includes(next.topic)) {
|
|
335
|
+
await writeTopics([...all.topics, next.topic], groupId);
|
|
336
|
+
topicAdded = true;
|
|
337
|
+
}
|
|
338
|
+
return { task: next, bucket: hit.bucket, topicAdded };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 删除任务。只移除**命中的那一条**(按 index),不按 id 批量删。 */
|
|
342
|
+
export async function removeTodo(id, { topic, pick, groupId } = {}) {
|
|
343
|
+
const all = await loadAll(groupId);
|
|
344
|
+
const hit = locateUnique(all, id, { topic, pick });
|
|
345
|
+
|
|
346
|
+
const bucket = [...all[hit.bucket]];
|
|
347
|
+
bucket.splice(hit.index, 1);
|
|
348
|
+
await writeTasks(KEY[hit.bucket], bucket, groupId);
|
|
349
|
+
return { removed: hit.task, bucket: hit.bucket };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 标记完成:待办 → 已完成,补 doneAt 与 note */
|
|
353
|
+
export async function doneTodo(id, { result, topic, pick, groupId } = {}) {
|
|
354
|
+
const all = await loadAll(groupId);
|
|
355
|
+
const hit = locateUnique(all, id, { topic, pick });
|
|
356
|
+
if (hit.bucket === 'done') throw conflict(`task id=${id} 已经是完成状态`);
|
|
357
|
+
if (hit.bucket === 'freeze') throw conflict(`task id=${id} 处于冻结中,先解冻再完成`);
|
|
358
|
+
|
|
359
|
+
const finished = {
|
|
360
|
+
...hit.task,
|
|
361
|
+
doneAt: rfc3339Now(),
|
|
362
|
+
note: result === undefined ? hit.task.note : String(result),
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// 按 index 移除,再追加到 done —— 不能用 filter(id !== id),那会连带删掉同 id 的其他条目
|
|
366
|
+
const from = [...all[hit.bucket]];
|
|
367
|
+
from.splice(hit.index, 1);
|
|
368
|
+
const done = [...all.done, finished];
|
|
369
|
+
|
|
370
|
+
// 顺序:先把任务从原桶移走,再追加到 done。
|
|
371
|
+
// 反过来的话,中途失败会让同一条任务同时存在于两个桶里。
|
|
372
|
+
await writeTasks(KEY[hit.bucket], from, groupId);
|
|
373
|
+
await writeTasks(KEY.done, done, groupId);
|
|
374
|
+
return { task: finished, from: hit.bucket, to: 'done' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 冻结:→ 冻结(id 保留) */
|
|
378
|
+
export async function freezeTodo(id, { topic, pick, groupId } = {}) {
|
|
379
|
+
const all = await loadAll(groupId);
|
|
380
|
+
const hit = locateUnique(all, id, { topic, pick });
|
|
381
|
+
if (hit.bucket === 'freeze') throw conflict(`task id=${id} 已经处于冻结`);
|
|
382
|
+
|
|
383
|
+
const frozen = { ...hit.task, frozenAt: dartNow() };
|
|
384
|
+
const from = [...all[hit.bucket]];
|
|
385
|
+
from.splice(hit.index, 1);
|
|
386
|
+
|
|
387
|
+
await writeTasks(KEY[hit.bucket], from, groupId);
|
|
388
|
+
await writeTasks(KEY.freeze, [...all.freeze, frozen], groupId);
|
|
389
|
+
return { task: frozen, from: hit.bucket, to: 'freeze' };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 解冻:冻结 → 待办。id 与现有待办冲突时换新 id(契约 1 的下游) */
|
|
393
|
+
export async function unfreezeTodo(id, { topic, pick, groupId } = {}) {
|
|
394
|
+
const all = await loadAll(groupId);
|
|
395
|
+
const hit = locateUnique(all, id, { topic, pick });
|
|
396
|
+
if (hit.bucket !== 'freeze') throw conflict(`task id=${id} 不在冻结区(当前在 ${hit.bucket})`);
|
|
397
|
+
|
|
398
|
+
const clashes = all.open.some((t) => t.id === id);
|
|
399
|
+
const revived = {
|
|
400
|
+
...hit.task,
|
|
401
|
+
id: clashes ? nextTaskId(all.open, all.freeze) : hit.task.id,
|
|
402
|
+
frozenAt: '',
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const from = [...all.freeze];
|
|
406
|
+
from.splice(hit.index, 1);
|
|
407
|
+
await writeTasks(KEY.freeze, from, groupId);
|
|
408
|
+
await writeTasks(KEY.open, [...all.open, revived], groupId);
|
|
409
|
+
return { task: revived, reIded: clashes, from: 'freeze', to: 'open' };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── 快捷 topic ────────────────────────────────────────────────────
|
|
413
|
+
//
|
|
414
|
+
// 后端只把 topic 存成字符串数组,没有独立接口。它是「动作集合」而非 CRUD 资源:
|
|
415
|
+
// 没有单条的读/改语义,硬凑 get/update 只会得到语义别扭的命令。
|
|
416
|
+
|
|
417
|
+
export async function listTopics({ groupId } = {}) {
|
|
418
|
+
const all = await loadAll(groupId);
|
|
419
|
+
// 顺带统计每个 topic 的任务数,光有名字看不出用量
|
|
420
|
+
const count = (arr) => arr.reduce((a, t) => ((a[t.topic] = (a[t.topic] || 0) + 1), a), {});
|
|
421
|
+
const [o, d, f] = [count(all.open), count(all.done), count(all.freeze)];
|
|
422
|
+
return all.topics.map((name) => ({
|
|
423
|
+
name,
|
|
424
|
+
open: o[name] || 0,
|
|
425
|
+
done: d[name] || 0,
|
|
426
|
+
freeze: f[name] || 0,
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function addTopic(name, { groupId } = {}) {
|
|
431
|
+
const t = String(name || '').trim();
|
|
432
|
+
if (!t) throw badInput('topic 不能为空');
|
|
433
|
+
const all = await loadAll(groupId);
|
|
434
|
+
if (all.topics.includes(t)) return { name: t, added: false, topics: all.topics };
|
|
435
|
+
const topics = [...all.topics, t];
|
|
436
|
+
await writeTopics(topics, groupId);
|
|
437
|
+
return { name: t, added: true, topics };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function removeTopic(name, { groupId } = {}) {
|
|
441
|
+
const t = String(name || '').trim();
|
|
442
|
+
const all = await loadAll(groupId);
|
|
443
|
+
const topics = all.topics.filter((x) => x !== t);
|
|
444
|
+
// 移出快捷列表不算错误(保持幂等);但如果有任务还在用它,要如实告诉调用方
|
|
445
|
+
const inUse =
|
|
446
|
+
all.open.filter((x) => x.topic === t).length +
|
|
447
|
+
all.freeze.filter((x) => x.topic === t).length;
|
|
448
|
+
if (topics.length !== all.topics.length) await writeTopics(topics, groupId);
|
|
449
|
+
return { name: t, removed: topics.length !== all.topics.length, inUse, topics };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ─── 主题提示词(纯文本,不是 JSON)────────────────────────────────
|
|
453
|
+
//
|
|
454
|
+
// `todo:prompt:<topic>` 存该主题的上下文知识。agent 拉任务时一并读走,
|
|
455
|
+
// 不必把背景重复写进每条任务文本 —— 这是「让 AI 拿任务即拿上下文」的关键。
|
|
456
|
+
|
|
457
|
+
export async function getPrompt(topic, { groupId } = {}) {
|
|
458
|
+
const t = String(topic || '').trim();
|
|
459
|
+
if (!t) throw badInput('--topic 必填');
|
|
460
|
+
const value = await readKey(PROMPT_PREFIX + t, normalizeGroupId(groupId));
|
|
461
|
+
return { topic: t, prompt: value, hasPrompt: value.length > 0 };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export async function setPrompt(topic, text, { groupId } = {}) {
|
|
465
|
+
const t = String(topic || '').trim();
|
|
466
|
+
if (!t) throw badInput('--topic 必填');
|
|
467
|
+
const body = String(text ?? '');
|
|
468
|
+
if (!body.trim()) throw badInput('提示词内容不能为空');
|
|
469
|
+
await writeKey(PROMPT_PREFIX + t, body, normalizeGroupId(groupId));
|
|
470
|
+
return { topic: t, bytes: Buffer.byteLength(body, 'utf8') };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export async function removePrompt(topic, { groupId } = {}) {
|
|
474
|
+
const t = String(topic || '').trim();
|
|
475
|
+
if (!t) throw badInput('--topic 必填');
|
|
476
|
+
const key = PROMPT_PREFIX + t;
|
|
477
|
+
// kvDelete 直接走 kvapi,不会经过 readKey/writeKey 的 groupId 回落,
|
|
478
|
+
// 所以这里必须自己解析一次——否则「删除」会打到默认组而非当前工作空间。
|
|
479
|
+
const gid = await resolveGroup(groupId);
|
|
480
|
+
const before = await readKey(key, gid);
|
|
481
|
+
if (!before) return { topic: t, removed: false };
|
|
482
|
+
await kvDelete(key, gid);
|
|
483
|
+
return { topic: t, removed: true };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── 冷归档 ────────────────────────────────────────────────────────
|
|
487
|
+
//
|
|
488
|
+
// 把已完成里较旧的条目移到 `todo:done:cold:YYYY-MM-DD`(按当天日期分片)。
|
|
489
|
+
// App 只写不查;这里保留读取能力,方便核对历史。
|
|
490
|
+
|
|
491
|
+
export async function archiveDone({ before, groupId } = {}) {
|
|
492
|
+
const all = await loadAll(groupId);
|
|
493
|
+
if (!all.done.length) return { moved: 0, coldKey: '', remaining: 0 };
|
|
494
|
+
|
|
495
|
+
const cutoff = before ? new Date(String(before)) : new Date(Date.now() - 30 * 86400_000);
|
|
496
|
+
if (Number.isNaN(cutoff.getTime())) throw badInput(`--before 不是合法日期: ${before}`);
|
|
497
|
+
|
|
498
|
+
const isOld = (t) => {
|
|
499
|
+
// doneAt 是 RFC3339(带偏移),老的 createdAt 风格也兼容
|
|
500
|
+
const ts = Date.parse(t.doneAt || t.createdAt);
|
|
501
|
+
return Number.isFinite(ts) && ts < cutoff.getTime();
|
|
502
|
+
};
|
|
503
|
+
const moved = all.done.filter(isOld);
|
|
504
|
+
if (!moved.length) return { moved: 0, coldKey: '', remaining: all.done.length };
|
|
505
|
+
|
|
506
|
+
const d = new Date();
|
|
507
|
+
const coldKey = `${COLD_PREFIX}${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
508
|
+
|
|
509
|
+
// 冷数据是追加语义:已有则合并,避免同日二次归档覆盖上一次
|
|
510
|
+
const existingRaw = await readKey(coldKey, normalizeGroupId(groupId));
|
|
511
|
+
const existing = parseTasks(existingRaw);
|
|
512
|
+
await writeKey(coldKey, JSON.stringify([...existing, ...moved]), normalizeGroupId(groupId));
|
|
513
|
+
|
|
514
|
+
const remaining = all.done.filter((t) => !isOld(t));
|
|
515
|
+
await writeTasks(KEY.done, remaining, groupId);
|
|
516
|
+
|
|
517
|
+
return { moved: moved.length, coldKey, remaining: remaining.length, total: existing.length + moved.length };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 供 groupId 默认值的统一来源(service 内多处要用)
|
|
521
|
+
export async function currentGroupId() {
|
|
522
|
+
const cfg = await loadConfig();
|
|
523
|
+
return normalizeGroupId(cfg.groupId);
|
|
524
|
+
}
|