c0de-agent 1.11.0 → 1.12.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/README.md +24 -5
- package/dist/cli/commands/chat.js +87 -1
- package/dist/cli/commands/config.js +17 -3
- package/dist/cli/commands/sessions.js +62 -3
- package/dist/cli/index.js +26 -7
- package/dist/cli/modes/print.js +9 -2
- package/dist/core/compact.d.ts +2 -0
- package/dist/core/compact.js +1 -0
- package/dist/core/config.d.ts +11 -2
- package/dist/core/config.js +73 -6
- package/dist/core/loop/compaction.d.ts +2 -1
- package/dist/core/loop/compaction.js +19 -6
- package/dist/core/loop/stream-collect.js +9 -1
- package/dist/core/loop/subagent.js +23 -5
- package/dist/core/redact.d.ts +11 -0
- package/dist/core/redact.js +49 -0
- package/dist/core/slash.js +93 -51
- package/dist/core/title.js +7 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/db/schema.d.ts +17 -0
- package/dist/db/schema.js +3 -1
- package/dist/kanban/index.d.ts +1 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +5 -1
- package/dist/kanban/store.js +76 -2
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/provider.js +1 -1
- package/dist/llm/retry.d.ts +5 -2
- package/dist/llm/retry.js +6 -5
- package/dist/llm/routing.d.ts +24 -1
- package/dist/llm/routing.js +29 -1
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +14 -0
- package/dist/project/project.js +77 -1
- package/dist/server/agent-manager.d.ts +10 -1
- package/dist/server/agent-manager.js +14 -1
- package/dist/server/auth-manager.d.ts +11 -5
- package/dist/server/auth-manager.js +39 -20
- package/dist/server/context.js +3 -0
- package/dist/server/permission/interactive.d.ts +6 -3
- package/dist/server/permission/interactive.js +20 -4
- package/dist/server/permission/store.d.ts +12 -2
- package/dist/server/permission/store.js +31 -3
- package/dist/server/registry-config.d.ts +14 -0
- package/dist/server/registry-config.js +44 -0
- package/dist/server/routes/auth.js +32 -2
- package/dist/server/routes/chat.d.ts +12 -0
- package/dist/server/routes/chat.js +216 -38
- package/dist/server/routes/commands.d.ts +4 -2
- package/dist/server/routes/commands.js +10 -3
- package/dist/server/routes/config.js +103 -23
- package/dist/server/routes/files.js +22 -4
- package/dist/server/routes/kanban.js +68 -6
- package/dist/server/routes/permissions.d.ts +0 -4
- package/dist/server/routes/permissions.js +133 -7
- package/dist/server/routes/project.js +59 -10
- package/dist/server/routes/provider.js +48 -3
- package/dist/server/routes/session.js +298 -22
- package/dist/server/routes/update.d.ts +11 -5
- package/dist/server/routes/update.js +88 -28
- package/dist/server/server.d.ts +4 -12
- package/dist/server/server.js +198 -102
- package/dist/server/types.d.ts +7 -0
- package/dist/session/archive.d.ts +3 -1
- package/dist/session/archive.js +13 -1
- package/dist/session/branch.js +20 -2
- package/dist/session/import.d.ts +44 -0
- package/dist/session/import.js +83 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/jobs.d.ts +12 -0
- package/dist/session/jobs.js +53 -0
- package/dist/session/message.d.ts +7 -1
- package/dist/session/message.js +21 -1
- package/dist/session/session.d.ts +128 -11
- package/dist/session/session.js +461 -30
- package/dist/session/snapshot.d.ts +5 -2
- package/dist/session/snapshot.js +10 -2
- package/dist/session/types.d.ts +11 -1
- package/dist/shared/types/agent.d.ts +11 -0
- package/dist/shared/types/config.d.ts +5 -1
- package/dist/shared/types/kanban.d.ts +13 -0
- package/dist/shared/types/message.d.ts +9 -0
- package/dist/tools/websearch/index.js +6 -3
- package/dist/update/index.d.ts +3 -3
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +13 -2
- package/dist/update/ipc.js +36 -2
- package/dist/update/snapshot.d.ts +1 -0
- package/dist/update/snapshot.js +2 -0
- package/dist/update/updater.d.ts +15 -1
- package/dist/update/updater.js +45 -17
- package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
- package/drizzle/meta/0008_snapshot.json +875 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +41 -42
package/dist/session/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { and, eq, gt, inArray, isNull, lt, ne, or } from 'drizzle-orm';
|
|
2
|
-
import {
|
|
1
|
+
import { and, eq, gt, ilike, inArray, isNull, lt, ne, notExists, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { alias } from 'drizzle-orm/pg-core';
|
|
3
|
+
import { sessionEntries, sessions } from '../db/schema.js';
|
|
3
4
|
import { generateId } from '../shared/index.js';
|
|
4
5
|
/** Convert a DB row (with Date timestamps) to the shared Session type (with number timestamps). */
|
|
5
6
|
export function rowToSession(row) {
|
|
@@ -26,7 +27,9 @@ export function rowToSession(row) {
|
|
|
26
27
|
};
|
|
27
28
|
}
|
|
28
29
|
/** Create a new root session. */
|
|
29
|
-
async function createSession(handle, title, projectId, agentType, source
|
|
30
|
+
async function createSession(handle, title, projectId, agentType, source,
|
|
31
|
+
/** P1 会话树治理:子 agent 会话挂到父会话(树内嵌套 + 删除级联)。 */
|
|
32
|
+
parentId) {
|
|
30
33
|
const [row] = await handle.db
|
|
31
34
|
.insert(sessions)
|
|
32
35
|
.values({
|
|
@@ -34,6 +37,7 @@ async function createSession(handle, title, projectId, agentType, source) {
|
|
|
34
37
|
projectId: projectId ?? null,
|
|
35
38
|
agentType: agentType ?? null,
|
|
36
39
|
source: source ?? null,
|
|
40
|
+
parentId: parentId ?? null,
|
|
37
41
|
})
|
|
38
42
|
.returning();
|
|
39
43
|
if (!row)
|
|
@@ -58,82 +62,386 @@ async function listAllSessions(handle) {
|
|
|
58
62
|
const rows = await handle.db.select().from(sessions).where(isNull(sessions.deletedAt));
|
|
59
63
|
return rows.map(rowToSession);
|
|
60
64
|
}
|
|
61
|
-
/** List soft-deleted sessions (回收站)
|
|
62
|
-
|
|
65
|
+
/** List soft-deleted sessions (回收站);projectId 提供时仅列出归属该项目的会话
|
|
66
|
+
* (P1-7:回收站此前全库共享,项目 A 的界面能清空项目 B 的删除会话)。 */
|
|
67
|
+
async function listDeletedSessions(handle, projectId) {
|
|
63
68
|
const rows = await handle.db
|
|
64
69
|
.select()
|
|
65
70
|
.from(sessions)
|
|
66
|
-
.where(
|
|
71
|
+
.where(projectId
|
|
72
|
+
? and(gt(sessions.deletedAt, new Date(0)), eq(sessions.projectId, projectId))
|
|
73
|
+
: gt(sessions.deletedAt, new Date(0)));
|
|
74
|
+
return rows.map(rowToSession);
|
|
75
|
+
}
|
|
76
|
+
/** 列出未归属任何项目的已软删除会话(孤儿)。删除项目时 FK set null 使会话失去 projectId,
|
|
77
|
+
* 在任何项目的回收站视图都不可见(F1),需专门的全局视图暴露以便恢复/归巢。 */
|
|
78
|
+
async function listOrphanDeletedSessions(handle) {
|
|
79
|
+
const rows = await handle.db
|
|
80
|
+
.select()
|
|
81
|
+
.from(sessions)
|
|
82
|
+
.where(and(gt(sessions.deletedAt, new Date(0)), isNull(sessions.projectId)));
|
|
67
83
|
return rows.map(rowToSession);
|
|
68
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* 标记回收站条目「已被用户看到」,记录 metadata.trashSeenAt = now(仅首次,不重置)。
|
|
87
|
+
* 回收站保留期自首次看到起算(见 purgeDeletedSessions)。软删除时会清除旧标记,
|
|
88
|
+
* 恢复后再次删除须重新看到回收站才重新起算——避免「未重新看到就被过早清空」。
|
|
89
|
+
* scope.orphan=true 时仅标记未归属项目的孤儿会话(与 listOrphanDeletedSessions 对齐);
|
|
90
|
+
* 孤儿标记仅在用户展开「未归属项目」分组时由前端显式调用(POST /deleted/orphans/seen),
|
|
91
|
+
* 避免打开任意项目回收站连带启动无关孤儿条目的倒计时。
|
|
92
|
+
*/
|
|
93
|
+
async function touchTrashSeen(handle, scope = {}) {
|
|
94
|
+
const where = scope.orphan
|
|
95
|
+
? and(gt(sessions.deletedAt, new Date(0)), isNull(sessions.projectId))
|
|
96
|
+
: scope.projectId
|
|
97
|
+
? and(gt(sessions.deletedAt, new Date(0)), eq(sessions.projectId, scope.projectId))
|
|
98
|
+
: gt(sessions.deletedAt, new Date(0));
|
|
99
|
+
const rows = await handle.db
|
|
100
|
+
.select({ id: sessions.id, metadata: sessions.metadata })
|
|
101
|
+
.from(sessions)
|
|
102
|
+
.where(where);
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
let touched = 0;
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
const meta = (row.metadata ?? {});
|
|
107
|
+
// A3:只标记首次看到,不随每次打开重置——否则「剩余天数」虚标,
|
|
108
|
+
// 经常打开回收站的用户条目永不清理,保留期形同虚设。
|
|
109
|
+
if (typeof meta.trashSeenAt === 'number')
|
|
110
|
+
continue;
|
|
111
|
+
await handle.db
|
|
112
|
+
.update(sessions)
|
|
113
|
+
.set({ metadata: { ...meta, trashSeenAt: now } })
|
|
114
|
+
.where(eq(sessions.id, row.id));
|
|
115
|
+
touched += 1;
|
|
116
|
+
}
|
|
117
|
+
return touched;
|
|
118
|
+
}
|
|
69
119
|
/**
|
|
70
120
|
* 软删除会话(级联其所有 fork 后代)。设置 deletedAt = now;
|
|
71
|
-
*
|
|
121
|
+
* 60 天后由 purgeDeletedSessions 物理清除(到期先标记、宽限 7 天)。
|
|
122
|
+
* A3:同时清除 trashSeenAt/purgePendingAt——恢复后重删的会话必须重新被
|
|
123
|
+
* 用户看到才重新起算,宽限期标记不跨删除周期生效。
|
|
72
124
|
* 会话不存在或已在回收站 → 返回 false(调用方按 404 处理)。
|
|
73
125
|
*/
|
|
74
126
|
async function softDeleteSession(handle, id) {
|
|
75
127
|
const [row] = await handle.db
|
|
76
|
-
.select({ id: sessions.id, deletedAt: sessions.deletedAt })
|
|
128
|
+
.select({ id: sessions.id, deletedAt: sessions.deletedAt, metadata: sessions.metadata })
|
|
77
129
|
.from(sessions)
|
|
78
130
|
.where(eq(sessions.id, id));
|
|
79
131
|
if (!row || row.deletedAt)
|
|
80
132
|
return false;
|
|
81
133
|
const ids = new Set([id]);
|
|
134
|
+
const metas = new Map([[id, row.metadata]]);
|
|
82
135
|
let frontier = [id];
|
|
83
136
|
while (frontier.length > 0) {
|
|
84
137
|
// 用 parentId 过滤:收集下一层子会话
|
|
85
138
|
const children = await handle.db
|
|
86
|
-
.select({ id: sessions.id })
|
|
139
|
+
.select({ id: sessions.id, metadata: sessions.metadata })
|
|
87
140
|
.from(sessions)
|
|
88
141
|
.where(and(isNull(sessions.deletedAt), inArray(sessions.parentId, frontier)));
|
|
89
142
|
frontier = children.map((r) => r.id).filter((cid) => !ids.has(cid));
|
|
90
|
-
for (const r of children)
|
|
143
|
+
for (const r of children) {
|
|
91
144
|
ids.add(r.id);
|
|
145
|
+
metas.set(r.id, r.metadata);
|
|
146
|
+
}
|
|
92
147
|
}
|
|
93
148
|
const now = new Date();
|
|
149
|
+
// 同一次删除级联共享同一批次号:恢复时仅还原同批次后代,避免把早先
|
|
150
|
+
// 被用户单独删除的分支一并复活(删除/恢复级联的语义不对称缺陷)。
|
|
151
|
+
const batchId = generateId();
|
|
94
152
|
for (const sid of ids) {
|
|
95
|
-
await handle.db
|
|
153
|
+
await handle.db
|
|
154
|
+
.update(sessions)
|
|
155
|
+
.set({
|
|
156
|
+
deletedAt: now,
|
|
157
|
+
deletedBatchId: batchId,
|
|
158
|
+
metadata: clearTrashMarks((metas.get(sid) ?? {})),
|
|
159
|
+
})
|
|
160
|
+
.where(eq(sessions.id, sid));
|
|
96
161
|
}
|
|
97
162
|
return true;
|
|
98
163
|
}
|
|
99
164
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
165
|
+
* A3:清除回收站倒计时/宽限期标记(软删除与项目删除共用)。
|
|
166
|
+
* 恢复后重删的会话须重新被看到才重新起算;宽限期标记不跨删除周期生效。
|
|
167
|
+
* 无标记时原样返回,避免无谓的 jsonb 覆写。
|
|
103
168
|
*/
|
|
104
|
-
|
|
169
|
+
function clearTrashMarks(meta) {
|
|
170
|
+
if (typeof meta.trashSeenAt !== 'number' && typeof meta.purgePendingAt !== 'number')
|
|
171
|
+
return meta;
|
|
172
|
+
const { trashSeenAt: _seen, purgePendingAt: _purge, ...rest } = meta;
|
|
173
|
+
return rest;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 把会话归属到指定项目(P1-2 孤儿会话补救):设置 projectId 与 worktreePath。
|
|
177
|
+
* 会话不存在返回 false。
|
|
178
|
+
*/
|
|
179
|
+
async function rebindSession(handle, id, project) {
|
|
105
180
|
const [row] = await handle.db.select().from(sessions).where(eq(sessions.id, id));
|
|
106
|
-
if (!row
|
|
181
|
+
if (!row)
|
|
107
182
|
return false;
|
|
183
|
+
await handle.db
|
|
184
|
+
.update(sessions)
|
|
185
|
+
.set({ projectId: project.id, worktreePath: project.worktree })
|
|
186
|
+
.where(eq(sessions.id, id));
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
async function restoreSessionCore(handle, id, opts = {}) {
|
|
190
|
+
const includeDescendants = opts.includeDescendants !== false;
|
|
191
|
+
const [row] = await handle.db.select().from(sessions).where(eq(sessions.id, id));
|
|
192
|
+
if (!row?.deletedAt)
|
|
193
|
+
return {
|
|
194
|
+
restored: false,
|
|
195
|
+
restoredAncestorCount: 0,
|
|
196
|
+
crossedBatchAncestor: false,
|
|
197
|
+
leftBehindDescendantCount: 0,
|
|
198
|
+
};
|
|
199
|
+
const targetBatch = row.deletedBatchId;
|
|
108
200
|
const ids = new Set([id]);
|
|
201
|
+
// 后代 BFS:仅还原属于同一删除批次的后代(删除时父+后代共享 deletedBatchId)。
|
|
202
|
+
// 若只按「已删除」收集,会把早先被用户单独删除的分支一并复活(F2 不对称缺陷)。
|
|
203
|
+
if (includeDescendants) {
|
|
204
|
+
let frontier = [id];
|
|
205
|
+
while (frontier.length > 0) {
|
|
206
|
+
const children = await handle.db
|
|
207
|
+
.select({ id: sessions.id })
|
|
208
|
+
.from(sessions)
|
|
209
|
+
.where(and(gt(sessions.deletedAt, new Date(0)), inArray(sessions.parentId, frontier), row.deletedBatchId
|
|
210
|
+
? eq(sessions.deletedBatchId, row.deletedBatchId)
|
|
211
|
+
: isNull(sessions.deletedBatchId)));
|
|
212
|
+
frontier = children.map((r) => r.id).filter((cid) => !ids.has(cid));
|
|
213
|
+
for (const r of children)
|
|
214
|
+
ids.add(r.id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// A2:统计所有已删后代中未随本次恢复的数量(批次不同 → 滞留回收站)。
|
|
218
|
+
let leftBehindDescendantCount = 0;
|
|
219
|
+
if (includeDescendants) {
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
let frontier = [id];
|
|
222
|
+
while (frontier.length > 0) {
|
|
223
|
+
const children = await handle.db
|
|
224
|
+
.select({ id: sessions.id })
|
|
225
|
+
.from(sessions)
|
|
226
|
+
.where(and(gt(sessions.deletedAt, new Date(0)), inArray(sessions.parentId, frontier)));
|
|
227
|
+
frontier = [];
|
|
228
|
+
for (const ch of children) {
|
|
229
|
+
if (seen.has(ch.id))
|
|
230
|
+
continue;
|
|
231
|
+
seen.add(ch.id);
|
|
232
|
+
frontier.push(ch.id);
|
|
233
|
+
if (!ids.has(ch.id))
|
|
234
|
+
leftBehindDescendantCount += 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// 祖先链(仅还原其中已软删除的节点):为保证恢复节点在会话树可达,
|
|
239
|
+
// 已删除的祖先无论批次均需一并还原。记录还原的祖先数量与是否跨越删除批次,
|
|
240
|
+
// 供前端提示「为保持会话树完整,同时还原了 N 个父会话」。
|
|
241
|
+
let restoredAncestorCount = 0;
|
|
242
|
+
let crossedBatchAncestor = false;
|
|
109
243
|
let parentId = row.parentId;
|
|
110
244
|
while (parentId) {
|
|
111
245
|
const [parent] = await handle.db.select().from(sessions).where(eq(sessions.id, parentId));
|
|
112
246
|
if (!parent)
|
|
113
247
|
break;
|
|
114
|
-
if (parent.deletedAt)
|
|
248
|
+
if (parent.deletedAt && !ids.has(parent.id)) {
|
|
115
249
|
ids.add(parent.id);
|
|
250
|
+
restoredAncestorCount += 1;
|
|
251
|
+
if (parent.deletedBatchId !== targetBatch)
|
|
252
|
+
crossedBatchAncestor = true;
|
|
253
|
+
}
|
|
116
254
|
parentId = parent.parentId;
|
|
117
255
|
}
|
|
118
256
|
for (const sid of ids) {
|
|
119
|
-
await handle.db
|
|
257
|
+
await handle.db
|
|
258
|
+
.update(sessions)
|
|
259
|
+
.set({ deletedAt: null, deletedBatchId: null })
|
|
260
|
+
.where(eq(sessions.id, sid));
|
|
120
261
|
}
|
|
121
|
-
return
|
|
262
|
+
return {
|
|
263
|
+
restored: true,
|
|
264
|
+
restoredAncestorCount,
|
|
265
|
+
crossedBatchAncestor,
|
|
266
|
+
leftBehindDescendantCount,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async function restoreSession(handle, id, opts = {}) {
|
|
270
|
+
return (await restoreSessionCore(handle, id, opts)).restored;
|
|
122
271
|
}
|
|
123
272
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
273
|
+
* P2-4:回收站保留期 60 天(原 30 天),自「用户首次在回收站看到该条目」
|
|
274
|
+
* (metadata.trashSeenAt)起算,而非删除时间(墙钟)——否则用户删除后长期不开服务、
|
|
275
|
+
* 重启即被静默物理清除,从未见过任何倒计时。临时会话(CLI print / workflow)保留期
|
|
276
|
+
* 仍为 30 天,见 purgeTemporarySessions。
|
|
277
|
+
*/
|
|
278
|
+
export const TRASH_RETENTION_MS = 60 * 24 * 60 * 60 * 1000;
|
|
279
|
+
/**
|
|
280
|
+
* A3:到期标记 → 物理清除的宽限期(默认 7 天)。
|
|
281
|
+
* 条目到期后先写 metadata.purgePendingAt,UI 显示「即将清除」并可恢复;
|
|
282
|
+
* 宽限期满才物理清除——杜绝「到期即静默物理清空」的数据丢失死角。
|
|
283
|
+
*/
|
|
284
|
+
export const TRASH_PURGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
285
|
+
/**
|
|
286
|
+
* 回收站两阶段清理(启动时与每日定时调用):
|
|
287
|
+
* 阶段一:trashSeenAt 超过保留期且未标记 → 标记 purgePendingAt(进入宽限期,可恢复)。
|
|
288
|
+
* 阶段二:purgePendingAt 早于宽限截止 → 物理清除(子会话先于父,自引用 FK 要求)。
|
|
289
|
+
* 未被看到过(trashSeenAt 缺失)或恢复后重删、尚未重新看到的会话永不进入任一阶段。
|
|
290
|
+
*/
|
|
291
|
+
async function purgeDeletedSessions(handle, retentionMs = TRASH_RETENTION_MS, graceMs = TRASH_PURGE_GRACE_MS) {
|
|
292
|
+
const retentionCutoff = Date.now() - retentionMs;
|
|
293
|
+
const graceCutoff = Date.now() - graceMs;
|
|
294
|
+
const rows = await handle.db
|
|
295
|
+
.select({
|
|
296
|
+
id: sessions.id,
|
|
297
|
+
parentId: sessions.parentId,
|
|
298
|
+
deletedAt: sessions.deletedAt,
|
|
299
|
+
metadata: sessions.metadata,
|
|
300
|
+
})
|
|
301
|
+
.from(sessions)
|
|
302
|
+
.where(gt(sessions.deletedAt, new Date(0)));
|
|
303
|
+
// —— 阶段一:到期标记(先宽限,不直接清除)——
|
|
304
|
+
let marked = 0;
|
|
305
|
+
for (const r of rows) {
|
|
306
|
+
if (!r.deletedAt)
|
|
307
|
+
continue;
|
|
308
|
+
const meta = (r.metadata ?? {});
|
|
309
|
+
const seen = meta.trashSeenAt;
|
|
310
|
+
const deletedMs = r.deletedAt.getTime();
|
|
311
|
+
// 保留期自「首次看到」起算:seen 缺失或早于最近一次删除 → 未重新看到,不标记。
|
|
312
|
+
if (typeof seen !== 'number' || seen <= deletedMs || seen >= retentionCutoff)
|
|
313
|
+
continue;
|
|
314
|
+
if (typeof meta.purgePendingAt === 'number')
|
|
315
|
+
continue;
|
|
316
|
+
await handle.db
|
|
317
|
+
.update(sessions)
|
|
318
|
+
.set({ metadata: { ...meta, purgePendingAt: Date.now() } })
|
|
319
|
+
.where(eq(sessions.id, r.id));
|
|
320
|
+
marked += 1;
|
|
321
|
+
}
|
|
322
|
+
// —— 阶段二:宽限期满物理清除 ——
|
|
323
|
+
const pending = rows.filter((r) => {
|
|
324
|
+
if (!r.deletedAt)
|
|
325
|
+
return false;
|
|
326
|
+
const meta = (r.metadata ?? {});
|
|
327
|
+
const p = meta.purgePendingAt;
|
|
328
|
+
return typeof p === 'number' && p < graceCutoff;
|
|
329
|
+
});
|
|
330
|
+
let deleted = 0;
|
|
331
|
+
if (pending.length > 0) {
|
|
332
|
+
// 拓扑序:无子会话的先删
|
|
333
|
+
const remaining = new Set(pending.map((r) => r.id));
|
|
334
|
+
while (remaining.size > 0) {
|
|
335
|
+
const hasChildParent = new Set(pending.filter((r) => r.parentId && remaining.has(r.parentId)).map((r) => r.parentId));
|
|
336
|
+
const leaves = pending
|
|
337
|
+
.filter((r) => remaining.has(r.id) && !hasChildParent.has(r.id))
|
|
338
|
+
.map((r) => r.id);
|
|
339
|
+
if (leaves.length === 0) {
|
|
340
|
+
// 循环引用兜底:强制按 id 逐个删除(自引用环数据异常场景)
|
|
341
|
+
for (const id of Array.from(remaining)) {
|
|
342
|
+
await handle.db.delete(sessions).where(eq(sessions.id, id));
|
|
343
|
+
remaining.delete(id);
|
|
344
|
+
deleted += 1;
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
for (const id of leaves) {
|
|
349
|
+
await handle.db.delete(sessions).where(eq(sessions.id, id));
|
|
350
|
+
remaining.delete(id);
|
|
351
|
+
deleted += 1;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { marked, deleted };
|
|
356
|
+
}
|
|
357
|
+
/** C4:空会话 GC 保留期(7 天)。仅覆盖「从未写入任何消息/工具条目」的会话。 */
|
|
358
|
+
const EMPTY_SESSION_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
359
|
+
/**
|
|
360
|
+
* C4:清理长期空置的 web 会话(无任何条目、无子会话、未删除、创建早于保留期)。
|
|
361
|
+
* 覆盖「新建会话后未发言」的残留:首条消息失败清理依赖前端 JS 执行,
|
|
362
|
+
* 崩溃/断电/离线时必然残留空「New Session」行。CLI 临时会话由 purgeTemporarySessions 负责。
|
|
127
363
|
*/
|
|
128
|
-
async function
|
|
364
|
+
async function purgeEmptySessions(handle, retentionMs = EMPTY_SESSION_RETENTION_MS) {
|
|
129
365
|
const cutoff = new Date(Date.now() - retentionMs);
|
|
366
|
+
const children = alias(sessions, 'empty_children');
|
|
367
|
+
const result = await handle.db
|
|
368
|
+
.delete(sessions)
|
|
369
|
+
.where(and(lt(sessions.createdAt, cutoff), isNull(sessions.deletedAt), or(isNull(sessions.source), eq(sessions.source, 'web')), notExists(handle.db
|
|
370
|
+
.select({ one: sql `1` })
|
|
371
|
+
.from(sessionEntries)
|
|
372
|
+
.where(eq(sessionEntries.sessionId, sessions.id))), notExists(handle.db
|
|
373
|
+
.select({ one: sql `1` })
|
|
374
|
+
.from(children)
|
|
375
|
+
.where(eq(children.parentId, sessions.id)))))
|
|
376
|
+
.returning({ id: sessions.id });
|
|
377
|
+
return result.length;
|
|
378
|
+
}
|
|
379
|
+
/** Update a session's title. */
|
|
380
|
+
async function updateSessionTitle(handle, id, title) {
|
|
381
|
+
await handle.db.update(sessions).set({ title, updatedAt: new Date() }).where(eq(sessions.id, id));
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* 彻底删除某个回收站会话及其全部后代(含未软删除的 fork 后代,防御数据异常)。
|
|
385
|
+
* 子会话先于父会话删除(自引用 FK RESTRICT 要求);entries/archives 经 FK cascade 清理。
|
|
386
|
+
* 返回删除数量。会话不存在或不在回收站 → 返回 0。
|
|
387
|
+
*/
|
|
388
|
+
async function permanentlyDeleteSession(handle, id) {
|
|
389
|
+
const [row] = await handle.db
|
|
390
|
+
.select({ id: sessions.id, deletedAt: sessions.deletedAt })
|
|
391
|
+
.from(sessions)
|
|
392
|
+
.where(eq(sessions.id, id));
|
|
393
|
+
if (!row?.deletedAt)
|
|
394
|
+
return 0;
|
|
395
|
+
const all = await handle.db
|
|
396
|
+
.select({ id: sessions.id, parentId: sessions.parentId })
|
|
397
|
+
.from(sessions);
|
|
398
|
+
const ids = new Set([id]);
|
|
399
|
+
let frontier = [id];
|
|
400
|
+
while (frontier.length > 0) {
|
|
401
|
+
const children = all
|
|
402
|
+
.filter((r) => r.parentId && frontier.includes(r.parentId))
|
|
403
|
+
.map((r) => r.id)
|
|
404
|
+
.filter((cid) => !ids.has(cid));
|
|
405
|
+
for (const cid of children)
|
|
406
|
+
ids.add(cid);
|
|
407
|
+
frontier = children;
|
|
408
|
+
}
|
|
409
|
+
// 拓扑序:无子会话的先删(与 purgeDeletedSessions 同策略)
|
|
410
|
+
const remaining = new Set(ids);
|
|
411
|
+
let deleted = 0;
|
|
412
|
+
while (remaining.size > 0) {
|
|
413
|
+
const hasChildParent = new Set(Array.from(remaining).filter((rid) => {
|
|
414
|
+
const r = all.find((x) => x.id === rid);
|
|
415
|
+
return r?.parentId && remaining.has(r.parentId);
|
|
416
|
+
}));
|
|
417
|
+
const leaves = Array.from(remaining).filter((rid) => !hasChildParent.has(rid));
|
|
418
|
+
if (leaves.length === 0) {
|
|
419
|
+
for (const rid of Array.from(remaining)) {
|
|
420
|
+
await handle.db.delete(sessions).where(eq(sessions.id, rid));
|
|
421
|
+
remaining.delete(rid);
|
|
422
|
+
deleted += 1;
|
|
423
|
+
}
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
for (const rid of leaves) {
|
|
427
|
+
await handle.db.delete(sessions).where(eq(sessions.id, rid));
|
|
428
|
+
remaining.delete(rid);
|
|
429
|
+
deleted += 1;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return deleted;
|
|
433
|
+
}
|
|
434
|
+
/** 清空回收站:物理删除所有已软删除会话(子先于父)。返回删除数量。
|
|
435
|
+
* projectId 提供时仅清空该项目(P1-7)。 */
|
|
436
|
+
async function emptyTrash(handle, projectId) {
|
|
130
437
|
const rows = await handle.db
|
|
131
438
|
.select({ id: sessions.id, parentId: sessions.parentId })
|
|
132
439
|
.from(sessions)
|
|
133
|
-
.where(
|
|
440
|
+
.where(projectId
|
|
441
|
+
? and(gt(sessions.deletedAt, new Date(0)), eq(sessions.projectId, projectId))
|
|
442
|
+
: gt(sessions.deletedAt, new Date(0)));
|
|
134
443
|
if (rows.length === 0)
|
|
135
444
|
return 0;
|
|
136
|
-
// 拓扑序:无子会话的先删
|
|
137
445
|
const remaining = new Set(rows.map((r) => r.id));
|
|
138
446
|
let deleted = 0;
|
|
139
447
|
while (remaining.size > 0) {
|
|
@@ -142,7 +450,6 @@ async function purgeDeletedSessions(handle, retentionMs = 30 * 24 * 60 * 60 * 10
|
|
|
142
450
|
.filter((r) => remaining.has(r.id) && !hasChildParent.has(r.id))
|
|
143
451
|
.map((r) => r.id);
|
|
144
452
|
if (leaves.length === 0) {
|
|
145
|
-
// 循环引用兜底:强制按 id 逐个删除(自引用环数据异常场景)
|
|
146
453
|
for (const id of Array.from(remaining)) {
|
|
147
454
|
await handle.db.delete(sessions).where(eq(sessions.id, id));
|
|
148
455
|
remaining.delete(id);
|
|
@@ -158,9 +465,78 @@ async function purgeDeletedSessions(handle, retentionMs = 30 * 24 * 60 * 60 * 10
|
|
|
158
465
|
}
|
|
159
466
|
return deleted;
|
|
160
467
|
}
|
|
161
|
-
/**
|
|
162
|
-
|
|
163
|
-
|
|
468
|
+
/**
|
|
469
|
+
* 清理过期临时会话(P2 → P1 收紧:仅清理显式标记的临时会话)。
|
|
470
|
+
* - agentType='print':CLI 一次性问答(c0de chat,非 --continue)创建的会话。
|
|
471
|
+
* - agentType='workflow':工作流运行产生的会话。
|
|
472
|
+
* 普通 CLI 会话(ACP)与 --continue 续接的会话(续接时已升级)永不清理——
|
|
473
|
+
* 此前按 source='cli' 全删会把用户显式续接的历史静默物理删除。
|
|
474
|
+
* 保留期默认 30 天;子条目经 FK cascade 一并删除。
|
|
475
|
+
* 返回清除数量。启动时与每日定时调用。
|
|
476
|
+
*/
|
|
477
|
+
async function purgeTemporarySessions(handle, retentionMs = 30 * 24 * 60 * 60 * 1000) {
|
|
478
|
+
const cutoff = new Date(Date.now() - retentionMs);
|
|
479
|
+
const all = await handle.db
|
|
480
|
+
.select({
|
|
481
|
+
id: sessions.id,
|
|
482
|
+
parentId: sessions.parentId,
|
|
483
|
+
agentType: sessions.agentType,
|
|
484
|
+
updatedAt: sessions.updatedAt,
|
|
485
|
+
})
|
|
486
|
+
.from(sessions);
|
|
487
|
+
const roots = all.filter((r) => (r.agentType === 'print' || r.agentType === 'workflow') &&
|
|
488
|
+
r.updatedAt != null &&
|
|
489
|
+
r.updatedAt.getTime() < cutoff.getTime());
|
|
490
|
+
if (roots.length === 0)
|
|
491
|
+
return 0;
|
|
492
|
+
// 临时会话可能派发过子 agent(其子会话挂 parentId)。父会话直接删除会撞自引用
|
|
493
|
+
// FK(RESTRICT),且子会话失去父后成为游离节点——故连同全部后代一起物理清除,
|
|
494
|
+
// 并按拓扑序「子先于父」删除(与 emptyTrash / purgeDeletedSessions 同一策略)。
|
|
495
|
+
const childrenOf = new Map();
|
|
496
|
+
for (const r of all) {
|
|
497
|
+
if (r.parentId) {
|
|
498
|
+
const list = childrenOf.get(r.parentId) ?? [];
|
|
499
|
+
list.push(r.id);
|
|
500
|
+
childrenOf.set(r.parentId, list);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const ids = new Set();
|
|
504
|
+
const collect = (sid) => {
|
|
505
|
+
if (ids.has(sid))
|
|
506
|
+
return;
|
|
507
|
+
ids.add(sid);
|
|
508
|
+
for (const c of childrenOf.get(sid) ?? [])
|
|
509
|
+
collect(c);
|
|
510
|
+
};
|
|
511
|
+
for (const r of roots)
|
|
512
|
+
collect(r.id);
|
|
513
|
+
const remaining = new Set(ids);
|
|
514
|
+
let deleted = 0;
|
|
515
|
+
while (remaining.size > 0) {
|
|
516
|
+
const leaves = Array.from(remaining).filter((sid) => !(childrenOf.get(sid) ?? []).some((c) => remaining.has(c)));
|
|
517
|
+
if (leaves.length === 0) {
|
|
518
|
+
// 循环引用兜底:强制逐个删除(数据异常场景)
|
|
519
|
+
for (const sid of Array.from(remaining)) {
|
|
520
|
+
await handle.db.delete(sessions).where(eq(sessions.id, sid));
|
|
521
|
+
remaining.delete(sid);
|
|
522
|
+
deleted += 1;
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
for (const sid of leaves) {
|
|
527
|
+
await handle.db.delete(sessions).where(eq(sessions.id, sid));
|
|
528
|
+
remaining.delete(sid);
|
|
529
|
+
deleted += 1;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return deleted;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* 把临时会话升级为持久会话(--continue 续接时调用):
|
|
536
|
+
* 清除 print 标记,30 天临时清理不再触及。
|
|
537
|
+
*/
|
|
538
|
+
async function upgradeTemporarySession(handle, id) {
|
|
539
|
+
await handle.db.update(sessions).set({ agentType: null }).where(eq(sessions.id, id));
|
|
164
540
|
}
|
|
165
541
|
/** Bump updatedAt to now (used after appending messages). */
|
|
166
542
|
async function touchSession(handle, id) {
|
|
@@ -271,4 +647,59 @@ async function listSessionsByProject(handle, projectId) {
|
|
|
271
647
|
.where(and(eq(sessions.projectId, projectId), isNull(sessions.deletedAt), or(isNull(sessions.source), ne(sessions.source, 'cli'))));
|
|
272
648
|
return rows.map(rowToSession);
|
|
273
649
|
}
|
|
274
|
-
|
|
650
|
+
/**
|
|
651
|
+
* 跨会话搜索(P2-6):标题 + 消息内容子串匹配。
|
|
652
|
+
* 默认仅搜索未软删除的非 CLI 会话(与 Web 会话树一致);projectId 提供时限定项目。
|
|
653
|
+
* opts.includeDeleted=true 时搜索回收站(P3:回收站无搜索,删后找内容只能逐行翻)。
|
|
654
|
+
* 返回 { session, matchedBy: 'title' | 'content' }。
|
|
655
|
+
*/
|
|
656
|
+
async function searchSessions(handle, query, projectId, opts = {}) {
|
|
657
|
+
const needle = query.trim();
|
|
658
|
+
if (!needle)
|
|
659
|
+
return [];
|
|
660
|
+
const pattern = `%${needle.replace(/[%_\\]/g, '\\$&')}%`;
|
|
661
|
+
const baseWhere = and(opts.includeDeleted ? gt(sessions.deletedAt, new Date(0)) : isNull(sessions.deletedAt),
|
|
662
|
+
// 回收站搜索与回收站列表(listDeletedSessions)一致:不过滤 source。
|
|
663
|
+
...(opts.includeDeleted ? [] : [or(isNull(sessions.source), ne(sessions.source, 'cli'))]), ...(projectId ? [eq(sessions.projectId, projectId)] : []));
|
|
664
|
+
// 标题命中(ILIKE 转义后按字面子串)
|
|
665
|
+
const byTitle = await handle.db
|
|
666
|
+
.select()
|
|
667
|
+
.from(sessions)
|
|
668
|
+
.where(and(baseWhere, ilike(sessions.title, pattern)));
|
|
669
|
+
const byContent = await handle.db
|
|
670
|
+
.selectDistinctOn([sessions.id], {
|
|
671
|
+
id: sessions.id,
|
|
672
|
+
title: sessions.title,
|
|
673
|
+
parentId: sessions.parentId,
|
|
674
|
+
projectId: sessions.projectId,
|
|
675
|
+
branchPoint: sessions.branchPoint,
|
|
676
|
+
metadata: sessions.metadata,
|
|
677
|
+
agentType: sessions.agentType,
|
|
678
|
+
worktreePath: sessions.worktreePath,
|
|
679
|
+
source: sessions.source,
|
|
680
|
+
deletedAt: sessions.deletedAt,
|
|
681
|
+
deletedBatchId: sessions.deletedBatchId,
|
|
682
|
+
createdAt: sessions.createdAt,
|
|
683
|
+
updatedAt: sessions.updatedAt,
|
|
684
|
+
})
|
|
685
|
+
.from(sessionEntries)
|
|
686
|
+
.innerJoin(sessions, eq(sessionEntries.sessionId, sessions.id))
|
|
687
|
+
.where(and(baseWhere, eq(sessionEntries.tag, 'message'), sql `${sessionEntries.content}::text ILIKE ${pattern}`))
|
|
688
|
+
.orderBy(sessions.id, sessions.updatedAt);
|
|
689
|
+
const result = [];
|
|
690
|
+
const seen = new Set();
|
|
691
|
+
for (const row of byTitle) {
|
|
692
|
+
const s = rowToSession(row);
|
|
693
|
+
result.push({ session: s, matchedBy: 'title' });
|
|
694
|
+
seen.add(s.id);
|
|
695
|
+
}
|
|
696
|
+
for (const row of byContent) {
|
|
697
|
+
const s = rowToSession(row);
|
|
698
|
+
if (!seen.has(s.id)) {
|
|
699
|
+
result.push({ session: s, matchedBy: 'content' });
|
|
700
|
+
seen.add(s.id);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return result;
|
|
704
|
+
}
|
|
705
|
+
export { clearTrashMarks, createSession, emptyTrash, getSession, listAllSessions, listDeletedSessions, listOrphanDeletedSessions, listSessions, listSessionsByProject, permanentlyDeleteSession, purgeDeletedSessions, purgeEmptySessions, purgeTemporarySessions, rebindSession, restoreSession, restoreSessionCore, searchSessions, softDeleteSession, touchLastOpened, touchSession, touchTrashSeen, updateSessionLastRun, updateSessionTitle, upgradeTemporarySession, };
|
|
@@ -12,6 +12,9 @@ declare function getFileSnapshots(handle: DB, sessionId: string): Promise<FileSn
|
|
|
12
12
|
declare function getLatestFileSnapshot(handle: DB, sessionId: string, filePath: string): Promise<FileSnapshot | null>;
|
|
13
13
|
/** Quick check: return cached file content if a snapshot exists, else null. */
|
|
14
14
|
declare function checkFileSnapshot(handle: DB, sessionId: string, filePath: string): Promise<string | null>;
|
|
15
|
-
/** 把源会话每个文件的最新快照复制到目标会话(fork 分支用,P1-4)。
|
|
16
|
-
|
|
15
|
+
/** 把源会话每个文件的最新快照复制到目标会话(fork 分支用,P1-4)。
|
|
16
|
+
* C3:beforeMs 提供时只复制创建时间 ≤ 分支点的快照版本——从较早消息点 fork 时,
|
|
17
|
+
* 分支点之后更新的快照反映的是「未来」文件状态,复制会污染分支上下文;
|
|
18
|
+
* 无合格版本的文件夹跳过(宁可缺快照由磁盘重读,也不注入错误内容)。 */
|
|
19
|
+
declare function copyFileSnapshots(handle: DB, fromSessionId: string, toSessionId: string, beforeMs?: number): Promise<void>;
|
|
17
20
|
export { checkFileSnapshot, copyFileSnapshots, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, };
|
package/dist/session/snapshot.js
CHANGED
|
@@ -73,14 +73,22 @@ async function checkFileSnapshot(handle, sessionId, filePath) {
|
|
|
73
73
|
const snapshot = await getLatestFileSnapshot(handle, sessionId, filePath);
|
|
74
74
|
return snapshot?.content ?? null;
|
|
75
75
|
}
|
|
76
|
-
/** 把源会话每个文件的最新快照复制到目标会话(fork 分支用,P1-4)。
|
|
77
|
-
|
|
76
|
+
/** 把源会话每个文件的最新快照复制到目标会话(fork 分支用,P1-4)。
|
|
77
|
+
* C3:beforeMs 提供时只复制创建时间 ≤ 分支点的快照版本——从较早消息点 fork 时,
|
|
78
|
+
* 分支点之后更新的快照反映的是「未来」文件状态,复制会污染分支上下文;
|
|
79
|
+
* 无合格版本的文件夹跳过(宁可缺快照由磁盘重读,也不注入错误内容)。 */
|
|
80
|
+
async function copyFileSnapshots(handle, fromSessionId, toSessionId, beforeMs) {
|
|
78
81
|
const rows = await handle.db
|
|
79
82
|
.select()
|
|
80
83
|
.from(fileSnapshots)
|
|
81
84
|
.where(eq(fileSnapshots.sessionId, fromSessionId));
|
|
82
85
|
const latest = new Map();
|
|
83
86
|
for (const r of rows) {
|
|
87
|
+
if (beforeMs !== undefined) {
|
|
88
|
+
const created = r.createdAt instanceof Date ? r.createdAt.getTime() : new Date(r.createdAt).getTime();
|
|
89
|
+
if (created > beforeMs)
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
84
92
|
const prev = latest.get(r.filePath);
|
|
85
93
|
if (!prev || (r.version ?? 0) > (prev.version ?? 0))
|
|
86
94
|
latest.set(r.filePath, r);
|
package/dist/session/types.d.ts
CHANGED
|
@@ -91,10 +91,20 @@ type ArchiveRef = {
|
|
|
91
91
|
type: 'archive' | 'squash';
|
|
92
92
|
id: string;
|
|
93
93
|
};
|
|
94
|
+
/** 会话级用量汇总(P0 审查:列表视图此前无法回答「会话花了多少 token/钱」)。
|
|
95
|
+
* 从 metadata.segments 聚合,零额外查询。 */
|
|
96
|
+
type SessionUsage = {
|
|
97
|
+
inputTokens: number;
|
|
98
|
+
outputTokens: number;
|
|
99
|
+
cacheRead: number;
|
|
100
|
+
cost: number;
|
|
101
|
+
calls: number;
|
|
102
|
+
};
|
|
94
103
|
/** A node in the session tree. */
|
|
95
104
|
type SessionTreeNode = {
|
|
96
105
|
session: Session;
|
|
97
106
|
children: SessionTreeNode[];
|
|
107
|
+
usage: SessionUsage;
|
|
98
108
|
};
|
|
99
109
|
/** A decoded compaction archive row. */
|
|
100
110
|
type CompactionArchive = {
|
|
@@ -121,4 +131,4 @@ type FileSnapshot = {
|
|
|
121
131
|
mtimeMs?: number;
|
|
122
132
|
createdAt: number;
|
|
123
133
|
};
|
|
124
|
-
export type { ArchiveRef, BranchSummaryEntry, CompactionArchive, CompactionConfig, CompactionEntry, CompactionResult, FileSnapshot, HotFile, MessageInput, SessionEntry, SessionTreeNode, SquashConfig, SquashEntry, SteeringEntry, Summarizer, };
|
|
134
|
+
export type { ArchiveRef, BranchSummaryEntry, CompactionArchive, CompactionConfig, CompactionEntry, CompactionResult, FileSnapshot, HotFile, MessageInput, SessionEntry, SessionTreeNode, SessionUsage, SquashConfig, SquashEntry, SteeringEntry, Summarizer, };
|
|
@@ -155,6 +155,13 @@ type AgentEvent = {
|
|
|
155
155
|
toolCallId: string;
|
|
156
156
|
tool: string;
|
|
157
157
|
input: unknown;
|
|
158
|
+
}
|
|
159
|
+
/** P0 双层超时:提示后仍无响应,pending 已兜底自动拒绝(run 继续)——前端须清理弹窗状态。 */
|
|
160
|
+
| {
|
|
161
|
+
_tag: 'permission_expired';
|
|
162
|
+
toolCallId: string;
|
|
163
|
+
tool: string;
|
|
164
|
+
input: unknown;
|
|
158
165
|
} | {
|
|
159
166
|
_tag: 'error';
|
|
160
167
|
error: AgentError;
|
|
@@ -202,6 +209,10 @@ type AgentEvent = {
|
|
|
202
209
|
status: string;
|
|
203
210
|
}[];
|
|
204
211
|
}[];
|
|
212
|
+
}
|
|
213
|
+
/** SSE 心跳(服务端防前端 90s 静默看门狗误杀长任务);前端无 UI 变化,直接忽略。 */
|
|
214
|
+
| {
|
|
215
|
+
_tag: 'heartbeat';
|
|
205
216
|
} | {
|
|
206
217
|
_tag: 'done';
|
|
207
218
|
};
|