llm-api-gateway-cli 1.0.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/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/lib/taskstore.js
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务持久化(模式五)
|
|
3
|
+
*
|
|
4
|
+
* 任务记录原本存在浏览器的 localStorage 里,但一次任务可能产生十几条工具输出、
|
|
5
|
+
* 每条都带文件正文,localStorage 那 ~5MB 根本不够,只能一路截断降级。
|
|
6
|
+
* 改成落到磁盘后这些限制就不存在了,而且换浏览器、清缓存都不会丢。
|
|
7
|
+
*
|
|
8
|
+
* 目录布局:
|
|
9
|
+
* <store>/index.json 任务索引(只存元信息,列表用,很小)
|
|
10
|
+
* <store>/tasks/<id>.json 单条任务的完整数据
|
|
11
|
+
*
|
|
12
|
+
* 索引与数据分开是为了列表页不必把每条任务的正文都读出来;索引丢了也能靠扫描
|
|
13
|
+
* tasks/ 重建,所以不会出现「索引坏了数据就全没了」。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
copyFileSync,
|
|
25
|
+
unlinkSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
statSync,
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
// 模式的唯一真相在 agent 那边,这里只用来校验落盘字段,避免两处各写一份清单
|
|
30
|
+
import { MODES } from './agent.js';
|
|
31
|
+
|
|
32
|
+
export const TTL_DAYS = 15; // 滑动 15 天
|
|
33
|
+
export const TTL_MS = TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
34
|
+
export const MAX_TASKS = 20; // 最多保留 20 条
|
|
35
|
+
export const MAX_MESSAGES = 2000; // 单条任务的消息数上限
|
|
36
|
+
export const MAX_STEPS = 200; // 单条消息里的工具调用数上限
|
|
37
|
+
|
|
38
|
+
/** 任务 id 必须是服务端生成的 UUID 形状 —— 这是防目录穿越的第一道闸 */
|
|
39
|
+
const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
40
|
+
|
|
41
|
+
export function isSafeId(id) {
|
|
42
|
+
return typeof id === 'string' && ID_RE.test(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 目录比较用:去掉尾部分隔符、Windows 上大小写不敏感 */
|
|
46
|
+
const normDir = (d) => (typeof d === 'string' && d ? d.replace(/[\\/]+$/, '').toLowerCase() : '');
|
|
47
|
+
|
|
48
|
+
/* ---------- 存储目录探测 ---------- */
|
|
49
|
+
|
|
50
|
+
/** 数据根目录名:统一放用户主目录下的这一个点目录 */
|
|
51
|
+
export const DATA_DIR_NAME = '.llm-api-gateway-cli';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 数据根目录:**用户主目录**下的 `.llm-api-gateway-cli`,各平台一致。
|
|
55
|
+
*
|
|
56
|
+
* 以前按平台惯例分散在 `%LOCALAPPDATA%` / `~/Library/Application Support` / `$XDG_DATA_HOME`,
|
|
57
|
+
* 外面还挂一个「脚本目录下的 `.tasks`」当便携兜底 —— 结果是同一台机器上数据可能出现在两三个
|
|
58
|
+
* 地方,用户根本不知道去哪儿找;而 `.tasks` 落在项目目录里还会弄脏仓库(打包时都得专门排除)。
|
|
59
|
+
* 现在统一成主目录里的一个点目录,跟 `.npm` / `.config` 这些做邻居:找得到、删得掉、拷得走。
|
|
60
|
+
*
|
|
61
|
+
* 想整根换地方用 `LLM_GATEWAY_DATA_DIR`;只想换任务目录用 `--store` / `store.dir`。
|
|
62
|
+
*/
|
|
63
|
+
export function defaultStoreRoot(env = process.env, home = os.homedir()) {
|
|
64
|
+
const custom = typeof env.LLM_GATEWAY_DATA_DIR === 'string' ? env.LLM_GATEWAY_DATA_DIR.trim() : '';
|
|
65
|
+
if (custom) return path.resolve(custom);
|
|
66
|
+
return path.join(home, DATA_DIR_NAME);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 平台惯例的数据目录(旧版本的落点之一) */
|
|
70
|
+
function legacyPlatformRoot(env = process.env, home = os.homedir()) {
|
|
71
|
+
if (process.platform === 'win32') {
|
|
72
|
+
return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'llm-api-gateway-cli');
|
|
73
|
+
}
|
|
74
|
+
if (process.platform === 'darwin') {
|
|
75
|
+
return path.join(home, 'Library', 'Application Support', 'llm-api-gateway-cli');
|
|
76
|
+
}
|
|
77
|
+
return path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'llm-api-gateway-cli');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 数一个存储目录的索引里记了几条任务;不存在/坏文件都算 0(只用来排序,不该把启动搞挂) */
|
|
81
|
+
function indexedTaskCount(storeDir) {
|
|
82
|
+
try {
|
|
83
|
+
const data = JSON.parse(readFileSync(path.join(storeDir, 'index.json'), 'utf8'));
|
|
84
|
+
return Array.isArray(data?.tasks) ? data.tasks.length : 0;
|
|
85
|
+
} catch {
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 数一个存储目录里实际有几个任务文件 */
|
|
91
|
+
function taskFileCount(storeDir) {
|
|
92
|
+
try {
|
|
93
|
+
return readdirSync(path.join(storeDir, 'tasks')).filter((f) => f.endsWith('.json')).length;
|
|
94
|
+
} catch {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 旧版本可能用过的地方 —— **只在搬家时读,不再写入**。kind 决定它对应新根里的什么位置:
|
|
101
|
+
* 'root' 平台数据目录(当年是整根:tasks/ sessions/ config.json 都在里面)
|
|
102
|
+
* 'store' 脚本目录下的 `.tasks`(它本身就是一个存储目录)
|
|
103
|
+
* 'sessions' 脚本目录下的 `.sessions`
|
|
104
|
+
*/
|
|
105
|
+
export function legacyLocations(scriptDir, dataRoot, env = process.env, home = os.homedir()) {
|
|
106
|
+
const out = [];
|
|
107
|
+
const push = (from, to, kind) => {
|
|
108
|
+
if (!from || path.resolve(from) === path.resolve(to)) return;
|
|
109
|
+
if (out.some((x) => path.resolve(x.from) === path.resolve(from))) return;
|
|
110
|
+
out.push({ from, to, kind });
|
|
111
|
+
};
|
|
112
|
+
push(legacyPlatformRoot(env, home), dataRoot, 'root');
|
|
113
|
+
if (scriptDir) {
|
|
114
|
+
push(path.join(scriptDir, '.tasks'), path.join(dataRoot, 'tasks'), 'store');
|
|
115
|
+
push(path.join(scriptDir, '.sessions'), path.join(dataRoot, 'sessions'), 'sessions');
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 某个旧位置里的「任务存储目录」在哪;纯会话位置没有任务目录 */
|
|
121
|
+
function taskStoreDirOf(loc) {
|
|
122
|
+
if (loc.kind === 'root') return path.join(loc.from, 'tasks');
|
|
123
|
+
if (loc.kind === 'store') return loc.from;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 旧版本可能用过的**任务存储目录**(主目录不可写时的保命通道,有数据的排前面) */
|
|
128
|
+
export function legacyStoreDirs(scriptDir, dataRoot, env = process.env, home = os.homedir()) {
|
|
129
|
+
return legacyLocations(scriptDir, dataRoot, env, home)
|
|
130
|
+
.map(taskStoreDirOf)
|
|
131
|
+
.filter((d) => d && path.resolve(d) !== path.resolve(dataRoot, 'tasks'))
|
|
132
|
+
.filter((d, i, a) => a.indexOf(d) === i)
|
|
133
|
+
.sort((a, b) => indexedTaskCount(b) - indexedTaskCount(a));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 真写一个探针文件再删掉,比只看权限位可靠(Windows 上权限位基本没用) */
|
|
137
|
+
export function isWritable(dir) {
|
|
138
|
+
const probe = path.join(dir, `.write-probe-${process.pid}`);
|
|
139
|
+
try {
|
|
140
|
+
mkdirSync(dir, { recursive: true });
|
|
141
|
+
writeFileSync(probe, 'ok');
|
|
142
|
+
unlinkSync(probe);
|
|
143
|
+
return true;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 递归复制,**已存在的目标文件一律不覆盖**(搬家的口径:新位置先来的算数) */
|
|
150
|
+
function copyTreeIfMissing(from, to) {
|
|
151
|
+
const stat = { copied: 0, failed: 0 };
|
|
152
|
+
let entries;
|
|
153
|
+
try {
|
|
154
|
+
entries = readdirSync(from, { withFileTypes: true });
|
|
155
|
+
} catch {
|
|
156
|
+
return stat;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
mkdirSync(to, { recursive: true });
|
|
160
|
+
} catch {
|
|
161
|
+
return { copied: 0, failed: 1 };
|
|
162
|
+
}
|
|
163
|
+
for (const e of entries) {
|
|
164
|
+
// 探针与临时文件不是数据,别搬过去
|
|
165
|
+
if (e.name.startsWith('.write-probe-') || e.name.endsWith('.tmp')) continue;
|
|
166
|
+
const src = path.join(from, e.name);
|
|
167
|
+
const dst = path.join(to, e.name);
|
|
168
|
+
if (e.isDirectory()) {
|
|
169
|
+
const sub = copyTreeIfMissing(src, dst);
|
|
170
|
+
stat.copied += sub.copied;
|
|
171
|
+
stat.failed += sub.failed;
|
|
172
|
+
} else if (e.isFile()) {
|
|
173
|
+
try {
|
|
174
|
+
if (existsSync(dst)) continue;
|
|
175
|
+
copyFileSync(src, dst);
|
|
176
|
+
stat.copied++;
|
|
177
|
+
} catch {
|
|
178
|
+
// 单个文件搬不动(被杀软锁住之类)不该毁掉整个启动,记下来继续
|
|
179
|
+
stat.failed++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return stat;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 搬过一次就留个记号,之后不再搬 —— 否则用户在新位置删掉的任务会被旧目录「复活」 */
|
|
187
|
+
const MIGRATION_MARK = '.legacy-migrated.json';
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 把旧位置的数据**复制**到新的数据根目录。
|
|
191
|
+
*
|
|
192
|
+
* 为什么是复制而不是移动:搬家途中失败(权限、占用)不能让用户两头都没有 —— 复制成功
|
|
193
|
+
* 新位置就是完整的一份,旧目录爱留不留都由用户自己决定(横幅里会写明)。
|
|
194
|
+
* 幂等:不覆盖已存在的目标文件,且成功后留记号文件,所以第二次跑什么也不做。
|
|
195
|
+
*/
|
|
196
|
+
export function migrateLegacyData(dataRoot, { scriptDir = '', env = process.env, home = os.homedir() } = {}) {
|
|
197
|
+
const markFile = path.join(dataRoot, MIGRATION_MARK);
|
|
198
|
+
if (existsSync(markFile)) return [];
|
|
199
|
+
|
|
200
|
+
const locs = legacyLocations(scriptDir, dataRoot, env, home).filter((l) => existsSync(l.from));
|
|
201
|
+
if (!locs.length) return [];
|
|
202
|
+
if (!isWritable(dataRoot)) return [];
|
|
203
|
+
|
|
204
|
+
// 谁的任务多谁先搬:旧的平台数据目录里常常只剩一个**空索引**,真正的历史在别处
|
|
205
|
+
// (比如脚本目录下的 .tasks)。按固定顺序硬搬的话,空索引会先占住 index.json,
|
|
206
|
+
// 把有数据的挡在外面 —— 用户看到的就是「任务一条都没了」。所以按数据量排序。
|
|
207
|
+
locs.sort((a, b) => {
|
|
208
|
+
const da = taskStoreDirOf(a);
|
|
209
|
+
const db = taskStoreDirOf(b);
|
|
210
|
+
return (db ? indexedTaskCount(db) : -1) - (da ? indexedTaskCount(da) : -1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const moved = [];
|
|
214
|
+
let failed = 0;
|
|
215
|
+
for (const loc of locs) {
|
|
216
|
+
const stat = copyTreeIfMissing(loc.from, loc.to);
|
|
217
|
+
failed += stat.failed;
|
|
218
|
+
if (stat.copied > 0) moved.push({ from: loc.from, to: loc.to, files: stat.copied });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 从多个来源合并过来之后,索引可能只列了其中一部分任务(也可能反过来,列着几个已经不在
|
|
222
|
+
// 磁盘上的)。索引里全是能从任务文件推出来的元信息,所以只要对不上就删掉,让它按 tasks/
|
|
223
|
+
// 重建 —— 免得出现「文件明明在、侧边栏却不显示」,或者点进去 404 的幽灵条目。
|
|
224
|
+
const storeDir = path.join(dataRoot, 'tasks');
|
|
225
|
+
if (taskFileCount(storeDir) !== indexedTaskCount(storeDir)) {
|
|
226
|
+
try {
|
|
227
|
+
unlinkSync(path.join(storeDir, 'index.json'));
|
|
228
|
+
} catch {
|
|
229
|
+
/* 删不掉就按索引读,最多少显示几条,不影响数据本身 */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!failed && moved.length) {
|
|
234
|
+
try {
|
|
235
|
+
writeFileSync(markFile, `${JSON.stringify({ at: new Date().toISOString(), moved }, null, 2)}\n`, 'utf8');
|
|
236
|
+
} catch {
|
|
237
|
+
/* 记号写不上就下次再搬一遍;「不覆盖」保证不会重复计数 */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return moved;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 挑一个能写的任务存储目录。候选顺序:
|
|
245
|
+
* 1. 用户显式指定的 --store(指定了就必须能写,否则直接报错,不偷偷换地方)
|
|
246
|
+
* 2. 用户主目录下的 `.llm-api-gateway-cli/tasks`(新家;顺手把旧位置的数据搬过来)
|
|
247
|
+
* 3. 旧位置里**有数据**且能写的那个(主目录不可写时的保命通道,见下)
|
|
248
|
+
* 4. 系统临时目录(最后兜底)
|
|
249
|
+
* 返回 { dir, source, warning, notice, moved }:warning 是「没按预期落位」,
|
|
250
|
+
* notice 是「搬了家」这类正常但要让人知道的事。
|
|
251
|
+
*
|
|
252
|
+
* 特别注意 2 → 3:主目录可不可写会随环境变(受限沙箱里不可写、普通终端里可写)。
|
|
253
|
+
* 所以当主目录用不了、而旧位置里明明有历史任务时,继续用旧位置并说清楚 —— 宁可
|
|
254
|
+
* 让数据留在原地,也不能让用户看见一个空的任务列表。
|
|
255
|
+
*/
|
|
256
|
+
export function resolveStoreDir(explicit, scriptDir, env = process.env, home = os.homedir()) {
|
|
257
|
+
if (explicit) {
|
|
258
|
+
const dir = path.resolve(explicit);
|
|
259
|
+
if (!isWritable(dir)) throw new Error(`指定的存储目录不可写:${dir}`);
|
|
260
|
+
return { dir, source: 'explicit', warning: null, notice: null, moved: [] };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const dataRoot = defaultStoreRoot(env, home);
|
|
264
|
+
const preferred = path.join(dataRoot, 'tasks');
|
|
265
|
+
|
|
266
|
+
if (isWritable(preferred)) {
|
|
267
|
+
const moved = migrateLegacyData(dataRoot, { scriptDir, env, home });
|
|
268
|
+
const notice = moved.length
|
|
269
|
+
? `已把旧位置的数据搬到 ${dataRoot}:${moved.map((m) => `${m.from}(${m.files} 个文件)`).join('、')};确认无误后旧目录可以自己删掉。`
|
|
270
|
+
: null;
|
|
271
|
+
return { dir: preferred, source: 'user-data', warning: null, notice, moved };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 主目录不可写:先看旧位置里有没有数据,有就继续用那儿(总比给一个空列表强)
|
|
275
|
+
for (const candidate of legacyStoreDirs(scriptDir, dataRoot, env, home)) {
|
|
276
|
+
// 索引没了、但 tasks/ 里还躺着任务文件的也算「有数据」—— 那种情况索引是可以重建的
|
|
277
|
+
const hasData = existsSync(path.join(candidate, 'index.json')) || taskFileCount(candidate) > 0;
|
|
278
|
+
if (!hasData) continue;
|
|
279
|
+
if (!isWritable(candidate)) continue;
|
|
280
|
+
return {
|
|
281
|
+
dir: candidate,
|
|
282
|
+
source: 'legacy',
|
|
283
|
+
warning: `用户主目录下的存储目录不可写(${preferred}),继续使用旧位置:${candidate}。想固定下来请用 --store 指定。`,
|
|
284
|
+
notice: null,
|
|
285
|
+
moved: [],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const tmp = path.join(os.tmpdir(), 'llm-api-gateway-cli-tasks');
|
|
290
|
+
if (isWritable(tmp)) {
|
|
291
|
+
return {
|
|
292
|
+
dir: tmp,
|
|
293
|
+
source: 'temp',
|
|
294
|
+
warning: `主目录与旧位置都不可写,已改用临时目录(重启后可能被系统清理):${tmp}`,
|
|
295
|
+
notice: null,
|
|
296
|
+
moved: [],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
throw new Error(`找不到可写的存储目录,试过:${preferred} / ${legacyStoreDirs(scriptDir, dataRoot, env, home).join(' / ')} / ${tmp}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ---------- 数据清洗 ---------- */
|
|
304
|
+
|
|
305
|
+
/** 从消息里生成标题(服务端也算一遍,保证不依赖前端) */
|
|
306
|
+
export function titleFromMessages(messages) {
|
|
307
|
+
const first = (Array.isArray(messages) ? messages : []).find((m) => m?.role === 'user' && m.content);
|
|
308
|
+
if (!first) return '新任务';
|
|
309
|
+
const t = String(first.content).replace(/\s+/g, ' ').trim();
|
|
310
|
+
if (!t) return '新任务';
|
|
311
|
+
return t.length > 24 ? `${t.slice(0, 24)}…` : t;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 已完成几轮回答(有 assistant 消息就算「已完成」),用于侧边栏状态 */
|
|
315
|
+
function countReplies(messages) {
|
|
316
|
+
return (Array.isArray(messages) ? messages : []).filter((m) => m?.role === 'assistant').length;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 两份**已清洗**的消息是不是一模一样。
|
|
321
|
+
* 两边都过了 sanitizeMessages,字段与顺序一致,所以逐条比 JSON 就够;
|
|
322
|
+
* 逐条比也避免把几十 MB 的历史拼成一个巨大的字符串。
|
|
323
|
+
*/
|
|
324
|
+
function sameMessages(a, b) {
|
|
325
|
+
if (a.length !== b.length) return false;
|
|
326
|
+
for (let i = 0; i < a.length; i++) {
|
|
327
|
+
if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) return false;
|
|
328
|
+
}
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function sanitizeStep(s) {
|
|
333
|
+
if (!s || typeof s !== 'object') return null;
|
|
334
|
+
return {
|
|
335
|
+
id: typeof s.id === 'string' ? s.id.slice(0, 120) : '',
|
|
336
|
+
name: typeof s.name === 'string' ? s.name.slice(0, 60) : '',
|
|
337
|
+
label: typeof s.label === 'string' ? s.label.slice(0, 300) : '',
|
|
338
|
+
status: ['ok', 'fail', 'running'].includes(s.status) ? s.status : 'ok',
|
|
339
|
+
summary: typeof s.summary === 'string' ? s.summary.slice(0, 1000) : '',
|
|
340
|
+
content: typeof s.content === 'string' ? s.content : undefined,
|
|
341
|
+
// 调用参数也留着(`id` 就是模型的 tool_call_id):任务记录因此始终能还原出
|
|
342
|
+
// 原生 tool_calls —— 会话文件被清掉、或换个 store 目录时,还能拿它把上下文接回去,
|
|
343
|
+
// 不至于退化成「把工具记录折成一段文本」那种有损形态。
|
|
344
|
+
args: typeof s.args === 'string' ? s.args.slice(0, 4000) : undefined,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function sanitizePending(p) {
|
|
349
|
+
if (!p || typeof p !== 'object' || typeof p.runId !== 'string') return null;
|
|
350
|
+
return {
|
|
351
|
+
runId: p.runId.slice(0, 120),
|
|
352
|
+
callId: typeof p.callId === 'string' ? p.callId.slice(0, 120) : '',
|
|
353
|
+
name: typeof p.name === 'string' ? p.name.slice(0, 60) : '',
|
|
354
|
+
args: typeof p.args === 'string' ? p.args : '',
|
|
355
|
+
preview: typeof p.preview === 'string' ? p.preview.slice(0, 4000) : '',
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 只接受认识的字段,避免把页面传来的任意对象原样写盘 */
|
|
360
|
+
function sanitizeMessage(m) {
|
|
361
|
+
if (!m || typeof m !== 'object') return null;
|
|
362
|
+
const content = typeof m.content === 'string' ? m.content : '';
|
|
363
|
+
const reasoning = typeof m.reasoning === 'string' ? m.reasoning : '';
|
|
364
|
+
const steps = Array.isArray(m.steps) ? m.steps.slice(0, MAX_STEPS).map(sanitizeStep).filter(Boolean) : [];
|
|
365
|
+
const pending = sanitizePending(m.pending);
|
|
366
|
+
// 提示与「继续执行」入口也要落盘:撞到上限后刷新页面,不该既没了说明也没了按钮
|
|
367
|
+
const notices = Array.isArray(m.notices)
|
|
368
|
+
? m.notices.filter((t) => typeof t === 'string' && t).slice(0, 10).map((t) => t.slice(0, 500))
|
|
369
|
+
: [];
|
|
370
|
+
const canResume = m.canResume === true;
|
|
371
|
+
if (!content && !reasoning && !steps.length && !notices.length) return null;
|
|
372
|
+
return { role: m.role === 'assistant' ? 'assistant' : 'user', content, reasoning, steps, pending, notices, canResume };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function sanitizeMessages(input) {
|
|
376
|
+
if (!Array.isArray(input)) return [];
|
|
377
|
+
return input.slice(-MAX_MESSAGES).map(sanitizeMessage).filter(Boolean);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* ---------- 存储实现 ---------- */
|
|
381
|
+
|
|
382
|
+
function readJsonFile(file, fallback = null) {
|
|
383
|
+
try {
|
|
384
|
+
if (!existsSync(file)) return fallback;
|
|
385
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
386
|
+
} catch {
|
|
387
|
+
return fallback;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 同步睡一会儿(rename 重试用) */
|
|
392
|
+
function sleepSync(ms) {
|
|
393
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 先写 .tmp 再 rename —— 进程被杀也不会留下半截 JSON。
|
|
398
|
+
*
|
|
399
|
+
* Windows 上 rename 覆盖已存在的目标会偶发 EPERM(杀软或索引器短暂占用文件句柄),
|
|
400
|
+
* 短时间连续写索引时尤其容易撞上,所以重试几次;实在不行退回直接覆盖写,
|
|
401
|
+
* 宁可牺牲这一轮的原子性,也不能让保存整个失败。
|
|
402
|
+
*/
|
|
403
|
+
function writeJsonAtomic(file, value) {
|
|
404
|
+
const tmp = `${file}.tmp`;
|
|
405
|
+
const text = JSON.stringify(value, null, 2);
|
|
406
|
+
writeFileSync(tmp, text, 'utf8');
|
|
407
|
+
for (let i = 0; i < 5; i++) {
|
|
408
|
+
try {
|
|
409
|
+
renameSync(tmp, file);
|
|
410
|
+
return;
|
|
411
|
+
} catch (e) {
|
|
412
|
+
if (e.code !== 'EPERM' && e.code !== 'EACCES' && e.code !== 'EBUSY') throw e;
|
|
413
|
+
sleepSync(5 * (i + 1));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
writeFileSync(file, text, 'utf8');
|
|
418
|
+
} finally {
|
|
419
|
+
try {
|
|
420
|
+
unlinkSync(tmp);
|
|
421
|
+
} catch {
|
|
422
|
+
/* 忽略 */
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export class TaskStore {
|
|
428
|
+
constructor(dir) {
|
|
429
|
+
this.dir = dir;
|
|
430
|
+
this.tasksDir = path.join(dir, 'tasks');
|
|
431
|
+
this.indexFile = path.join(dir, 'index.json');
|
|
432
|
+
this.tasks = [];
|
|
433
|
+
this.warnings = [];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** 建目录 + 载入索引;索引缺失或损坏时扫描 tasks/ 重建 */
|
|
437
|
+
init() {
|
|
438
|
+
mkdirSync(this.tasksDir, { recursive: true });
|
|
439
|
+
// readJsonFile 对「文件不存在」和「文件损坏」都返回 null,这里要分开对待:
|
|
440
|
+
// 前者是首次使用(正常),后者是数据受损(必须告诉用户,不能静默吞掉)
|
|
441
|
+
const existed = existsSync(this.indexFile);
|
|
442
|
+
const raw = readJsonFile(this.indexFile, null);
|
|
443
|
+
if (raw && Array.isArray(raw.tasks)) {
|
|
444
|
+
this.tasks = raw.tasks.map((t) => this.#normalizeMeta(t)).filter(Boolean);
|
|
445
|
+
this.#backfillReplies(raw.tasks);
|
|
446
|
+
} else {
|
|
447
|
+
if (existed) this.warnings.push('index.json 无法解析(可能上次写入被中断),已按 tasks/ 目录重建索引');
|
|
448
|
+
this.tasks = this.#scan();
|
|
449
|
+
this.#writeIndex();
|
|
450
|
+
}
|
|
451
|
+
return this;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 老索引里没有 replies 字段(这个字段是后加的),#normalizeMeta 会把它补成 0,
|
|
456
|
+
* 那样已有的、明明回答过的任务会被显示成「未开始」。这里按需读一次任务文件补上,
|
|
457
|
+
* 读不到就维持 0 —— 只是少一个标记,不影响任务本身。
|
|
458
|
+
*/
|
|
459
|
+
#backfillReplies(rawTasks) {
|
|
460
|
+
let changed = false;
|
|
461
|
+
for (const raw of rawTasks) {
|
|
462
|
+
if (!raw || Number.isInteger(raw.replies)) continue;
|
|
463
|
+
const meta = this.#meta(raw.id);
|
|
464
|
+
if (!meta) continue;
|
|
465
|
+
const data = readJsonFile(this.#taskFile(meta.id), null);
|
|
466
|
+
if (!data) continue;
|
|
467
|
+
const n = countReplies(data.messages);
|
|
468
|
+
if (n !== meta.replies) {
|
|
469
|
+
meta.replies = n;
|
|
470
|
+
changed = true;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (changed) this.#writeIndex();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 从任务文件里扫出元信息(索引丢了才发现,所以尽量容错) */
|
|
477
|
+
#scan() {
|
|
478
|
+
const found = [];
|
|
479
|
+
let files = [];
|
|
480
|
+
try {
|
|
481
|
+
files = readdirSync(this.tasksDir).filter((f) => f.endsWith('.json'));
|
|
482
|
+
} catch {
|
|
483
|
+
return found;
|
|
484
|
+
}
|
|
485
|
+
for (const f of files) {
|
|
486
|
+
const id = f.slice(0, -'.json'.length);
|
|
487
|
+
if (!isSafeId(id)) continue;
|
|
488
|
+
const data = readJsonFile(path.join(this.tasksDir, f), null);
|
|
489
|
+
if (!data) {
|
|
490
|
+
this.warnings.push(`任务文件损坏已跳过:${f}`);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const meta = this.#normalizeMeta({ ...data, id });
|
|
494
|
+
if (meta) found.push(meta);
|
|
495
|
+
}
|
|
496
|
+
return found;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#normalizeMeta(t) {
|
|
500
|
+
if (!t || !isSafeId(t.id)) return null;
|
|
501
|
+
const created = Number(t.createdAt);
|
|
502
|
+
const updated = Number(t.updatedAt);
|
|
503
|
+
const base = Number.isFinite(created) ? created : Date.now();
|
|
504
|
+
return {
|
|
505
|
+
id: t.id,
|
|
506
|
+
title: typeof t.title === 'string' && t.title.trim() ? t.title.trim().slice(0, 60) : '未命名任务',
|
|
507
|
+
workDir: typeof t.workDir === 'string' && t.workDir ? t.workDir : null,
|
|
508
|
+
// 审批模式跟着任务走:切回某个任务时还是当初用的那个模式
|
|
509
|
+
mode: MODES.includes(t.mode) ? t.mode : 'manual',
|
|
510
|
+
createdAt: base,
|
|
511
|
+
updatedAt: Number.isFinite(updated) ? updated : base,
|
|
512
|
+
renamed: Boolean(t.renamed),
|
|
513
|
+
// 侧边栏据此显示「已完成 / 未开始」;只记数量,不把正文拖进索引
|
|
514
|
+
replies: Number.isInteger(t.replies) && t.replies > 0 ? t.replies : 0,
|
|
515
|
+
// 派生关系:形成任务树。只保证形状合法,父任务是否还在由渲染端容错
|
|
516
|
+
// (父任务被删掉时,这条会自然退回顶层,而不是从列表里消失)
|
|
517
|
+
parentId: typeof t.parentId === 'string' && isSafeId(t.parentId) && t.parentId !== t.id ? t.parentId : null,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#writeIndex() {
|
|
522
|
+
writeJsonAtomic(this.indexFile, {
|
|
523
|
+
version: 3,
|
|
524
|
+
updatedAt: Date.now(),
|
|
525
|
+
tasks: this.tasks,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#meta(id) {
|
|
530
|
+
return this.tasks.find((t) => t.id === id) || null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* 父任务必须真实存在、且与子任务在同一个工作目录下。
|
|
535
|
+
* 不满足就退回顶层 —— 宁可少一层层级,也不要留下指向虚空(或跨目录)的引用,
|
|
536
|
+
* 否则删掉父任务后列表里会出现「谁也找不到归属」的孤儿行。
|
|
537
|
+
*/
|
|
538
|
+
#resolveParent(parentId, workDir) {
|
|
539
|
+
if (!isSafeId(parentId)) return null;
|
|
540
|
+
const parent = this.#meta(parentId);
|
|
541
|
+
if (!parent) return null;
|
|
542
|
+
return normDir(parent.workDir) === normDir(workDir) ? parentId : null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#taskFile(id) {
|
|
546
|
+
// id 已过 isSafeId,这里再 resolve 一次做双保险
|
|
547
|
+
const file = path.join(this.tasksDir, `${id}.json`);
|
|
548
|
+
if (path.dirname(path.resolve(file)) !== path.resolve(this.tasksDir)) {
|
|
549
|
+
throw Object.assign(new Error('任务 id 非法'), { status: 400 });
|
|
550
|
+
}
|
|
551
|
+
return file;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
list() {
|
|
555
|
+
return [...this.tasks].sort((a, b) => b.updatedAt - a.updatedAt).map((t) => ({ ...t }));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 读一条任务的消息体。`ok` 表示文件真的读出来了 —— 文件丢了或坏了时
|
|
560
|
+
* 调用方(save)要知道「盘上其实没有这份数据」,不能把它当成「内容一样」。
|
|
561
|
+
*/
|
|
562
|
+
#read(id) {
|
|
563
|
+
const data = readJsonFile(this.#taskFile(id), null);
|
|
564
|
+
if (!data) return { ok: false, messages: [] };
|
|
565
|
+
return { ok: true, messages: sanitizeMessages(data.messages) };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
get(id) {
|
|
569
|
+
if (!isSafeId(id)) throw Object.assign(new Error('任务 id 非法'), { status: 400 });
|
|
570
|
+
const meta = this.#meta(id);
|
|
571
|
+
if (!meta) return null;
|
|
572
|
+
return { ...meta, messages: this.#read(id).messages };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** 新建一条任务,id 由服务端生成;parentId 非空时派生到某条已有任务之下(任务树) */
|
|
576
|
+
create({ id, title, workDir, mode, messages, parentId }) {
|
|
577
|
+
const now = Date.now();
|
|
578
|
+
const clean = sanitizeMessages(messages);
|
|
579
|
+
// 没显式给标题时从首条用户消息取,否则会先落成「未命名任务」再等下次保存才改名
|
|
580
|
+
const meta = this.#normalizeMeta({
|
|
581
|
+
id,
|
|
582
|
+
title: typeof title === 'string' && title.trim() ? title.trim() : titleFromMessages(clean),
|
|
583
|
+
workDir,
|
|
584
|
+
mode,
|
|
585
|
+
createdAt: now,
|
|
586
|
+
updatedAt: now,
|
|
587
|
+
renamed: false,
|
|
588
|
+
replies: countReplies(clean),
|
|
589
|
+
parentId,
|
|
590
|
+
});
|
|
591
|
+
if (!meta) throw Object.assign(new Error('任务 id 非法'), { status: 400 });
|
|
592
|
+
if (this.#meta(meta.id)) throw Object.assign(new Error('任务已存在'), { status: 409 });
|
|
593
|
+
// 父任务必须在同目录下真实存在,否则当顶层任务
|
|
594
|
+
meta.parentId = this.#resolveParent(meta.parentId, meta.workDir);
|
|
595
|
+
this.#put(meta, clean);
|
|
596
|
+
this.tasks.unshift(meta);
|
|
597
|
+
this.prune();
|
|
598
|
+
this.#writeIndex();
|
|
599
|
+
return { ...meta, messages: clean };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 覆盖保存一条任务;title 只在没被用户改过名时才自动更新。
|
|
604
|
+
*
|
|
605
|
+
* **什么都没变的保存不算一次「更新」**:updatedAt 不动,文件也不重写。
|
|
606
|
+
* 页面在切任务、切模式、点目录时都会顺手保存一次,如果每次都把时间戳刷新,
|
|
607
|
+
* 侧边栏那个「N 分钟前」就会因为「点了下标题」变成「刚刚」—— 看着像发生了什么事,
|
|
608
|
+
* 其实什么都没发生。判断依据是这条记录到底变没变(标题 / 目录 / 模式 / 改名标记 / 回答数 / 消息)。
|
|
609
|
+
*/
|
|
610
|
+
save(id, { title, workDir, mode, messages, renamed }) {
|
|
611
|
+
const meta = this.#meta(id);
|
|
612
|
+
if (!meta) return null;
|
|
613
|
+
const clean = sanitizeMessages(messages);
|
|
614
|
+
|
|
615
|
+
// 先算出「这次要变成什么样」,再和盘上的逐项比
|
|
616
|
+
const next = {
|
|
617
|
+
workDir: typeof workDir === 'string' && workDir ? workDir : meta.workDir,
|
|
618
|
+
mode: MODES.includes(mode) ? mode : meta.mode,
|
|
619
|
+
renamed: meta.renamed || renamed === true,
|
|
620
|
+
title: meta.title,
|
|
621
|
+
replies: countReplies(clean),
|
|
622
|
+
};
|
|
623
|
+
if (typeof title === 'string' && title.trim() && next.renamed) next.title = title.trim().slice(0, 60);
|
|
624
|
+
else if (!next.renamed && clean.length) next.title = titleFromMessages(clean);
|
|
625
|
+
|
|
626
|
+
const stored = this.#read(id);
|
|
627
|
+
const unchanged =
|
|
628
|
+
stored.ok &&
|
|
629
|
+
next.title === meta.title &&
|
|
630
|
+
next.workDir === meta.workDir &&
|
|
631
|
+
next.mode === meta.mode &&
|
|
632
|
+
next.renamed === meta.renamed &&
|
|
633
|
+
next.replies === meta.replies &&
|
|
634
|
+
sameMessages(stored.messages, clean);
|
|
635
|
+
// 一样就到此为止:不写文件、不写索引、updatedAt 保持原值(盘上文件丢了/坏了则照常补写)
|
|
636
|
+
if (unchanged) return { ...meta, messages: stored.messages };
|
|
637
|
+
|
|
638
|
+
meta.title = next.title;
|
|
639
|
+
meta.workDir = next.workDir;
|
|
640
|
+
meta.mode = next.mode;
|
|
641
|
+
meta.renamed = next.renamed;
|
|
642
|
+
meta.replies = next.replies;
|
|
643
|
+
meta.updatedAt = Date.now();
|
|
644
|
+
this.#put(meta, clean);
|
|
645
|
+
this.#writeIndex();
|
|
646
|
+
return { ...meta, messages: clean };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
#put(meta, messages) {
|
|
650
|
+
writeJsonAtomic(this.#taskFile(meta.id), {
|
|
651
|
+
version: 2,
|
|
652
|
+
...meta,
|
|
653
|
+
messages: sanitizeMessages(messages),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
remove(id) {
|
|
658
|
+
if (!isSafeId(id)) throw Object.assign(new Error('任务 id 非法'), { status: 400 });
|
|
659
|
+
const before = this.tasks.length;
|
|
660
|
+
this.tasks = this.tasks.filter((t) => t.id !== id);
|
|
661
|
+
try {
|
|
662
|
+
unlinkSync(this.#taskFile(id));
|
|
663
|
+
} catch {
|
|
664
|
+
/* 文件可能已经不在了 */
|
|
665
|
+
}
|
|
666
|
+
if (this.tasks.length === before) return false;
|
|
667
|
+
// 父任务没了,子任务上的 parentId 就成了悬空引用。渲染端虽然有容错(悬空当顶层),
|
|
668
|
+
// 但盘上的数据自己也该是自洽的 —— 这里把子任务升到顶层并写回。
|
|
669
|
+
this.#orphanChildren([id]);
|
|
670
|
+
this.#writeIndex();
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* 把「父任务已不在」的子任务升到顶层并落盘,返回被处理的 id。
|
|
676
|
+
* parentIds 是这一轮消失的父任务;调用方可能是手动删除(remove)或自动清理(prune)。
|
|
677
|
+
* 只改 parentId、不动 updatedAt:删的是别人,不该让幸存任务的「N 分钟前」跳一下。
|
|
678
|
+
* 任务文件读不出来(损坏 / 丢失)时就只改索引里的元信息,不凭空造一个空任务文件。
|
|
679
|
+
*/
|
|
680
|
+
#orphanChildren(parentIds) {
|
|
681
|
+
const gone = new Set(parentIds);
|
|
682
|
+
const ids = [];
|
|
683
|
+
for (const kid of this.tasks) {
|
|
684
|
+
if (!kid.parentId || !gone.has(kid.parentId)) continue;
|
|
685
|
+
kid.parentId = null;
|
|
686
|
+
ids.push(kid.id);
|
|
687
|
+
const stored = this.#read(kid.id);
|
|
688
|
+
if (stored.ok) this.#put(kid, stored.messages);
|
|
689
|
+
}
|
|
690
|
+
return ids;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** 滑动 15 天 + 最多 20 条;返回被清掉的 id */
|
|
694
|
+
prune(now = Date.now()) {
|
|
695
|
+
const removed = [];
|
|
696
|
+
const kept = [];
|
|
697
|
+
for (const t of this.tasks) {
|
|
698
|
+
if (now - t.updatedAt > TTL_MS) removed.push(t);
|
|
699
|
+
else kept.push(t);
|
|
700
|
+
}
|
|
701
|
+
kept.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
702
|
+
removed.push(...kept.splice(MAX_TASKS));
|
|
703
|
+
for (const t of removed) {
|
|
704
|
+
try {
|
|
705
|
+
unlinkSync(this.#taskFile(t.id));
|
|
706
|
+
} catch {
|
|
707
|
+
/* 忽略 */
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
this.tasks = kept;
|
|
711
|
+
if (removed.length) {
|
|
712
|
+
// 被清掉的可能正好是某个任务的父任务(父任务通常更老,先到期):同样不能留下悬空引用
|
|
713
|
+
this.#orphanChildren(removed.map((t) => t.id));
|
|
714
|
+
this.#writeIndex();
|
|
715
|
+
}
|
|
716
|
+
return removed.map((t) => t.id);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
stats() {
|
|
720
|
+
let bytes = 0;
|
|
721
|
+
for (const t of this.tasks) {
|
|
722
|
+
try {
|
|
723
|
+
bytes += statSync(this.#taskFile(t.id)).size;
|
|
724
|
+
} catch {
|
|
725
|
+
/* 忽略 */
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const oldest = this.tasks.length
|
|
729
|
+
? this.tasks.reduce((a, b) => (a.updatedAt < b.updatedAt ? a : b))
|
|
730
|
+
: null;
|
|
731
|
+
return {
|
|
732
|
+
dir: this.dir,
|
|
733
|
+
taskCount: this.tasks.length,
|
|
734
|
+
maxTasks: MAX_TASKS,
|
|
735
|
+
ttlDays: TTL_MS / 86400000,
|
|
736
|
+
bytes,
|
|
737
|
+
oldestExpiresAt: oldest ? oldest.updatedAt + TTL_MS : null,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
}
|