@baique/pi-web-sky 0.1.56 → 0.1.57
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +5 -5
- package/.next/build-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +2 -2
- package/.next/required-server-files.js +3 -2
- package/.next/required-server-files.json +3 -2
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +3 -3
- package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/bash-output/route.js +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/running/route.js +1 -1
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/board-assets/[...path]/route.js +1 -1
- package/.next/server/app/api/board-assets/route.js +1 -1
- package/.next/server/app/api/boards/[id]/add-session/route.js +1 -1
- package/.next/server/app/api/boards/[id]/reconcile/route.js +1 -1
- package/.next/server/app/api/boards/[id]/route.js +1 -1
- package/.next/server/app/api/boards/reorder/route.js +1 -1
- package/.next/server/app/api/boards/route.js +1 -1
- package/.next/server/app/api/cwd/validate/route.js +1 -1
- package/.next/server/app/api/default-cwd/route.js +1 -1
- package/.next/server/app/api/file-index/route.js +1 -1
- package/.next/server/app/api/files/[...path]/route.js +2 -2
- package/.next/server/app/api/mcp/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js +1 -1
- package/.next/server/app/api/search/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/auto-name/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/export/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/minimap/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/summary/route.js +1 -1
- package/.next/server/app/api/skills/install/route.js +1 -1
- package/.next/server/app/api/subagents/[id]/route.js +1 -1
- package/.next/server/app/api/subagents/profiles/route.js +1 -1
- package/.next/server/app/api/task-card-questions/[id]/answer/route.js +1 -1
- package/.next/server/app/api/task-card-questions/route.js +1 -1
- package/.next/server/app/api/task-cards/[id]/route.js +1 -1
- package/.next/server/app/api/task-cards/route.js +1 -1
- package/.next/server/app/api/task-cards/unbind/route.js +1 -1
- package/.next/server/app/api/task-scheduler/status/route.js +1 -1
- package/.next/server/app/api/tasks/[id]/assign-session/route.js +2 -12
- package/.next/server/app/api/tasks/[id]/board/route.js +1 -1
- package/.next/server/app/api/tasks/[id]/route.js +1 -1
- package/.next/server/app/api/tasks/reorder/route.js +2 -12
- package/.next/server/app/api/tasks/route.js +2 -12
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +5 -5
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
- package/.next/server/app/index.segments/_full.segment.rsc +5 -5
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +10 -10
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +5 -5
- package/.next/server/chunks/1937.js +14 -13
- package/.next/server/chunks/2524.js +2 -2
- package/.next/server/chunks/3500.js +1 -1
- package/.next/server/chunks/402.js +21 -10
- package/.next/server/chunks/4457.js +3 -13
- package/.next/server/chunks/542.js +8 -8
- package/.next/server/chunks/5794.js +1 -1
- package/.next/server/chunks/5940.js +1 -1
- package/.next/server/chunks/6429.js +4 -4
- package/.next/server/chunks/6525.js +18 -7
- package/.next/server/chunks/6583.js +12 -11
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/752.bafff304e6b00987.js +1 -0
- package/.next/static/chunks/8088.39866dc5f1d15868.js +1 -0
- package/.next/static/chunks/app/{layout-87087b955e0c9507.js → layout-fae2b226b3c78448.js} +1 -1
- package/.next/static/chunks/app/page-d5af3bd535589daf.js +256 -0
- package/.next/static/chunks/{webpack-4de8e8dd9f2f3173.js → webpack-1ce35e1249cfd931.js} +1 -1
- package/.next/static/css/{c6d32b6031181be7.css → c7d127e07b1a786c.css} +1 -1
- package/.next/trace +8 -7
- package/.next/trace-build +1 -1
- package/lib/board-reconcile.test.mjs +26 -8
- package/lib/board-reconcile.ts +3 -1
- package/lib/board-store.test.mjs +2 -2
- package/lib/ime-guard.test.mjs +52 -0
- package/lib/ime-guard.ts +54 -0
- package/lib/rpc-manager.activity.test.mjs +223 -0
- package/lib/rpc-manager.test.mjs +22 -0
- package/lib/rpc-manager.title.test.mjs +152 -0
- package/lib/rpc-manager.ts +95 -8
- package/lib/session-activity.test.mjs +123 -0
- package/lib/session-activity.ts +77 -0
- package/lib/session-delete.test.mjs +45 -41
- package/lib/session-delete.ts +8 -34
- package/lib/session-index-scanner.test.mjs +364 -2
- package/lib/session-index-scanner.ts +162 -57
- package/lib/session-reader.noscan.test.mjs +371 -0
- package/lib/session-reader.project.test.mjs +30 -52
- package/lib/session-reader.test.mjs +2 -65
- package/lib/session-reader.ts +177 -213
- package/lib/session-scanner.test.mjs +151 -38
- package/lib/session-scanner.ts +140 -61
- package/lib/session-search.ts +6 -5
- package/lib/sqlite-db.test.mjs +26 -0
- package/lib/sqlite-db.ts +10 -1
- package/lib/subagent-runtime.ts +42 -0
- package/lib/subagent-session-title.test.mjs +120 -0
- package/lib/task-scheduler.test.mjs +27 -0
- package/lib/task-scheduler.ts +8 -1
- package/lib/task-store.activity.test.mjs +141 -0
- package/lib/task-store.meta-row.test.mjs +25 -5
- package/lib/task-store.partial-row.test.mjs +198 -0
- package/lib/task-store.subtree.test.mjs +172 -0
- package/lib/task-store.test.mjs +46 -20
- package/lib/task-store.ts +344 -68
- package/next.config.ts +3 -1
- package/package.json +1 -1
- package/.next/static/chunks/752.adce8bc3ebbaf4b6.js +0 -1
- package/.next/static/chunks/8088.0cad1ff2e09fd8ff.js +0 -1
- package/.next/static/chunks/app/page-efca6bf51a5f8ccb.js +0 -256
- /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_buildManifest.js +0 -0
- /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_ssgManifest.js +0 -0
- /package/lib/{subagent-isolation.test.mjs → worktree-isolation.test.mjs} +0 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import { createJiti } from "jiti";
|
|
7
7
|
|
|
8
8
|
const jiti = createJiti(import.meta.url);
|
|
9
|
-
const {
|
|
9
|
+
const { scanOneSessionFile, readSessionTail } = await jiti.import("./session-scanner.ts");
|
|
10
|
+
|
|
11
|
+
/** 单文件详情扫描(旧 scanSessionFiles 的全量入口已删:读取路径不再扫盘)。 */
|
|
12
|
+
function scanFile(file) {
|
|
13
|
+
return scanOneSessionFile(file);
|
|
14
|
+
}
|
|
10
15
|
|
|
11
16
|
function makeRoot() {
|
|
12
17
|
const root = mkdtempSync(join(tmpdir(), "pi-scan-"));
|
|
@@ -40,7 +45,7 @@ test("scanSessionFileMeta:阶段一只解析文件名 id+mtime,不读内容"
|
|
|
40
45
|
}
|
|
41
46
|
});
|
|
42
47
|
|
|
43
|
-
test("
|
|
48
|
+
test("scanOneSessionFile:读取 header + 首条用户消息,忽略正文", async () => {
|
|
44
49
|
const { root, projDir } = makeRoot();
|
|
45
50
|
try {
|
|
46
51
|
const file = join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl");
|
|
@@ -52,9 +57,8 @@ test("scanSessionFiles:读取 header + 首条用户消息,忽略正文", asy
|
|
|
52
57
|
'{"type":"message","id":"u2","parentId":"a1","message":{"role":"user","content":"再帮我优化一下"}}',
|
|
53
58
|
].join("\n") + "\n");
|
|
54
59
|
|
|
55
|
-
const
|
|
56
|
-
assert.
|
|
57
|
-
const s = result[0];
|
|
60
|
+
const s = scanFile(file);
|
|
61
|
+
assert.ok(s, "会话文件可扫描");
|
|
58
62
|
assert.equal(s.id, "aaa");
|
|
59
63
|
assert.equal(s.cwd, "/home/wa/project/demo");
|
|
60
64
|
assert.equal(s.firstMessage, "你好,请帮我看看这个项目");
|
|
@@ -67,7 +71,7 @@ test("scanSessionFiles:读取 header + 首条用户消息,忽略正文", asy
|
|
|
67
71
|
}
|
|
68
72
|
});
|
|
69
73
|
|
|
70
|
-
test("
|
|
74
|
+
test("scanOneSessionFile:扫描读取最后一个 session_info 的自定义名", async () => {
|
|
71
75
|
const { root, projDir } = makeRoot();
|
|
72
76
|
try {
|
|
73
77
|
const file = join(projDir, "2026-01-01T00-00-00-000Z_bbb.jsonl");
|
|
@@ -78,16 +82,14 @@ test("scanSessionFiles:扫描读取最后一个 session_info 的自定义名",
|
|
|
78
82
|
'{"type":"session_info","id":"s2","parentId":"a1","name":"最终名字","timestamp":"2026-01-01T00:00:06.000Z"}',
|
|
79
83
|
].join("\n") + "\n");
|
|
80
84
|
|
|
81
|
-
const result = await scanSessionFiles(root);
|
|
82
|
-
assert.equal(result.length, 1);
|
|
83
85
|
// 扫描直接读名字(反向分块),取最后一个 session_info。
|
|
84
|
-
assert.equal(
|
|
86
|
+
assert.equal(scanFile(file).name, "最终名字");
|
|
85
87
|
} finally {
|
|
86
88
|
rmSync(root, { recursive: true, force: true });
|
|
87
89
|
}
|
|
88
90
|
});
|
|
89
91
|
|
|
90
|
-
test("
|
|
92
|
+
test("scanOneSessionFile:改名后大量新消息把 session_info 推远也能读到(旧 bug 场景)", async () => {
|
|
91
93
|
const { root, projDir } = makeRoot();
|
|
92
94
|
try {
|
|
93
95
|
const file = join(projDir, "2026-01-01T00-00-00-000Z_far.jsonl");
|
|
@@ -101,27 +103,27 @@ test("scanSessionFiles:改名后大量新消息把 session_info 推远也能
|
|
|
101
103
|
}
|
|
102
104
|
writeFileSync(file, lines.join("\n") + "\n");
|
|
103
105
|
|
|
104
|
-
|
|
105
|
-
assert.equal(result.length, 1);
|
|
106
|
-
assert.equal(result[0].name, "深藏的名字");
|
|
106
|
+
assert.equal(scanFile(file).name, "深藏的名字");
|
|
107
107
|
} finally {
|
|
108
108
|
rmSync(root, { recursive: true, force: true });
|
|
109
109
|
}
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
test("
|
|
112
|
+
test("scanOneSessionFile:非会话文件(首行不是 session)与空文件跳过", async () => {
|
|
113
113
|
const { root, projDir } = makeRoot();
|
|
114
114
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
const junk = join(projDir, "junk.jsonl");
|
|
116
|
+
const empty = join(projDir, "empty.jsonl");
|
|
117
|
+
writeFileSync(junk, '{"type":"message",...}\n');
|
|
118
|
+
writeFileSync(empty, "");
|
|
119
|
+
assert.equal(scanFile(junk), null, "首行不是 session → null");
|
|
120
|
+
assert.equal(scanFile(empty), null, "空文件 → null");
|
|
119
121
|
} finally {
|
|
120
122
|
rmSync(root, { recursive: true, force: true });
|
|
121
123
|
}
|
|
122
124
|
});
|
|
123
125
|
|
|
124
|
-
test("
|
|
126
|
+
test("scanOneSessionFile:超大正文不影响扫描,只取首部简述", async () => {
|
|
125
127
|
const { root, projDir } = makeRoot();
|
|
126
128
|
try {
|
|
127
129
|
const file = join(projDir, "2026-01-01T00-00-00-000Z_ccc.jsonl");
|
|
@@ -135,45 +137,156 @@ test("scanSessionFiles:超大正文不影响扫描,只取首部简述", asyn
|
|
|
135
137
|
].join("\n") + "\n");
|
|
136
138
|
|
|
137
139
|
const t0 = Date.now();
|
|
138
|
-
const
|
|
140
|
+
const scanned = scanFile(file);
|
|
139
141
|
const elapsed = Date.now() - t0;
|
|
140
|
-
assert.equal(
|
|
141
|
-
assert.equal(
|
|
142
|
-
assert.equal(result[0].firstMessage, "帮我写一篇文章");
|
|
142
|
+
assert.equal(scanned.id, "ccc");
|
|
143
|
+
assert.equal(scanned.firstMessage, "帮我写一篇文章");
|
|
143
144
|
assert.ok(elapsed < 2000, `大文件扫描应远快于读全量(实际 ${elapsed}ms)`);
|
|
144
145
|
} finally {
|
|
145
146
|
rmSync(root, { recursive: true, force: true });
|
|
146
147
|
}
|
|
147
148
|
});
|
|
148
149
|
|
|
149
|
-
test("
|
|
150
|
+
test("readSessionTail:文件尾一次反向分块同时拿自定义名与最后一条 assistant 回复", async () => {
|
|
150
151
|
const { root, projDir } = makeRoot();
|
|
151
152
|
try {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
153
|
+
const file = join(projDir, "2026-01-01T00-00-00-000Z_tail.jsonl");
|
|
154
|
+
writeFileSync(file, [
|
|
155
|
+
'{"type":"session","version":3,"id":"tail","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/home/wa/project/demo"}',
|
|
156
|
+
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"问题"}}',
|
|
157
|
+
'{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":[{"type":"text","text":"第一条回复"}]}}',
|
|
158
|
+
'{"type":"session_info","id":"s1","parentId":"a1","name":"我的会话"}',
|
|
159
|
+
'{"type":"message","id":"a2","parentId":"s1","message":{"role":"assistant","content":[{"type":"text","text":"最后一条回复"}]}}',
|
|
160
|
+
].join("\n") + "\n");
|
|
161
|
+
|
|
162
|
+
assert.deepEqual(readSessionTail(file), { name: "我的会话", lastReply: "最后一条回复" });
|
|
163
|
+
|
|
164
|
+
// 只有用户消息(没有回复)→ lastReply 为空串(调用方据此写 '',与「读不出来」区分)
|
|
165
|
+
const noReply = join(projDir, "2026-01-02T00-00-00-000Z_noreply.jsonl");
|
|
166
|
+
writeFileSync(noReply, [
|
|
167
|
+
'{"type":"session","version":3,"id":"noreply","timestamp":"2026-01-02T00:00:00.000Z","cwd":"/home/wa/project/demo"}',
|
|
168
|
+
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"只有提问"}}',
|
|
169
|
+
].join("\n") + "\n");
|
|
170
|
+
assert.deepEqual(readSessionTail(noReply), { name: undefined, lastReply: "" });
|
|
171
|
+
|
|
172
|
+
// 文件不存在/为空 → null(调用方保持「未回填」,下一轮再试)
|
|
173
|
+
assert.equal(readSessionTail(join(projDir, "missing.jsonl")), null);
|
|
174
|
+
const empty = join(projDir, "empty-tail.jsonl");
|
|
175
|
+
writeFileSync(empty, "");
|
|
176
|
+
assert.equal(readSessionTail(empty), null);
|
|
177
|
+
} finally {
|
|
178
|
+
rmSync(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("readSessionTail 永不抛错:stat 能过但 open 失败(EACCES)/路径是目录 → null", async (t) => {
|
|
183
|
+
const { root, projDir } = makeRoot();
|
|
184
|
+
try {
|
|
185
|
+
// 路径是目录:stat 成功、不是文件 → null(不需要 open)
|
|
186
|
+
const dir = join(projDir, "a-directory.jsonl");
|
|
187
|
+
mkdirSync(dir, { recursive: true });
|
|
188
|
+
assert.equal(readSessionTail(dir), null, "目录不当会话文件");
|
|
189
|
+
|
|
190
|
+
if (process.getuid?.() === 0) {
|
|
191
|
+
t.diagnostic("以 root 运行:文件权限不生效,跳过「stat 可过但 open 失败」用例");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// stat 能过、openSync 抛 EACCES:readSessionTail 必须吞掉(它是回填循环里的逐行调用,
|
|
195
|
+
// 抛出去会中止整轮扫描)。
|
|
196
|
+
const noRead = join(projDir, "2026-01-03T00-00-00-000Z_noread.jsonl");
|
|
197
|
+
writeFileSync(noRead, '{"type":"session","version":3,"id":"noread","cwd":"/x"}\n');
|
|
198
|
+
chmodSync(noRead, 0o000);
|
|
199
|
+
try {
|
|
200
|
+
assert.equal(readSessionTail(noRead), null, "不可读文件 → null,不抛");
|
|
201
|
+
assert.ok(statSync(noRead).size > 0, "stat 本身仍能过(确实是 stat 通过/open 失败)");
|
|
202
|
+
} finally {
|
|
203
|
+
chmodSync(noRead, 0o644);
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
rmSync(root, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
158
209
|
|
|
159
|
-
|
|
160
|
-
|
|
210
|
+
test("scanOneSessionFile / scanOneSessionHead 永不抛错:stat 能过但 open 失败(EACCES)→ null", async (t) => {
|
|
211
|
+
const { root, projDir } = makeRoot();
|
|
212
|
+
try {
|
|
213
|
+
const mod = await jiti.import("./session-scanner.ts");
|
|
214
|
+
// 目录路径:stat 成功但 isFile() 为 false → null(不用 open)
|
|
215
|
+
const dir = join(projDir, "2026-01-04T00-00-00-000Z_a-dir.jsonl");
|
|
216
|
+
mkdirSync(dir, { recursive: true });
|
|
217
|
+
assert.equal(mod.scanOneSessionFile(dir), null, "目录不当会话文件");
|
|
218
|
+
assert.equal(mod.scanOneSessionHead(dir), null);
|
|
219
|
+
|
|
220
|
+
if (process.getuid?.() === 0) {
|
|
221
|
+
t.diagnostic("以 root 运行:文件权限不生效,跳过「stat 可过但 open 失败」用例");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// stat 能过、openSync 抛 EACCES:详情读取路径不能把异常抛给调用方
|
|
225
|
+
// (/api/sessions/summary 的「库内无行」回退——旧 bug 会让接口 500)。
|
|
226
|
+
const noRead = join(projDir, "2026-01-03T00-00-00-000Z_noread.jsonl");
|
|
227
|
+
writeFileSync(noRead, '{"type":"session","version":3,"id":"noread","cwd":"/x"}\n');
|
|
228
|
+
chmodSync(noRead, 0o000);
|
|
229
|
+
try {
|
|
230
|
+
assert.ok(statSync(noRead).size > 0, "stat 本身仍能过(确实是 stat 通过/open 失败)");
|
|
231
|
+
assert.equal(mod.scanOneSessionFile(noRead), null, "详情读取:open 失败 → null,不抛");
|
|
232
|
+
assert.equal(mod.scanOneSessionHead(noRead), null, "头部读取:open 失败 → null,不抛");
|
|
233
|
+
} finally {
|
|
234
|
+
chmodSync(noRead, 0o644);
|
|
235
|
+
}
|
|
161
236
|
} finally {
|
|
162
237
|
rmSync(root, { recursive: true, force: true });
|
|
163
238
|
}
|
|
164
239
|
});
|
|
165
240
|
|
|
166
|
-
test("
|
|
241
|
+
test("scanOneSessionFile:缺失 timestamp 的 header 用 mtime 兜底", async () => {
|
|
167
242
|
const { root, projDir } = makeRoot();
|
|
168
243
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
assert.
|
|
172
|
-
assert.ok(Number.isFinite(result[0].created.getTime()), "created 不可为 Invalid Date");
|
|
244
|
+
const file = join(projDir, "2026-01-01T00-00-00-000Z_noTs.jsonl");
|
|
245
|
+
writeFileSync(file, '{"type":"session","version":3,"id":"noTs","cwd":"/home/wa/project/demo"}\n');
|
|
246
|
+
assert.ok(Number.isFinite(scanFile(file).created.getTime()), "created 不可为 Invalid Date");
|
|
173
247
|
} finally {
|
|
174
248
|
rmSync(root, { recursive: true, force: true });
|
|
175
249
|
}
|
|
176
250
|
});
|
|
251
|
+
test("scanSessionFileMeta:递归进子目录(forks/、<session>/<run>/run-0/),隐藏目录不扫", async () => {
|
|
252
|
+
const { root, projDir } = makeRoot();
|
|
253
|
+
try {
|
|
254
|
+
const sessionDir = join(projDir, "2026-01-01T00-00-00-000Z_aaa");
|
|
255
|
+
const forksDir = join(sessionDir, "forks");
|
|
256
|
+
const runDir = join(sessionDir, "run-1", "run-0");
|
|
257
|
+
const hiddenDir = join(projDir, ".hidden");
|
|
258
|
+
mkdirSync(forksDir, { recursive: true });
|
|
259
|
+
mkdirSync(runDir, { recursive: true });
|
|
260
|
+
mkdirSync(hiddenDir, { recursive: true });
|
|
261
|
+
writeFileSync(join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl"), '{"type":"session","version":3,"id":"aaa","cwd":"/x"}\n');
|
|
262
|
+
writeFileSync(join(forksDir, "2026-01-01T00-10-00-000Z_fork1.jsonl"), '{"type":"session","version":3,"id":"fork1","cwd":"/x"}\n');
|
|
263
|
+
writeFileSync(join(runDir, "session.jsonl"), '{"type":"session","version":3,"id":"run1","cwd":"/x"}\n');
|
|
264
|
+
writeFileSync(join(hiddenDir, "2026-01-01T00-00-00-000Z_hidden.jsonl"), '{"type":"session","version":3,"id":"hidden","cwd":"/x"}\n');
|
|
265
|
+
|
|
266
|
+
const mod = await jiti.import("./session-scanner.ts");
|
|
267
|
+
const result = await mod.scanSessionFileMeta(root);
|
|
268
|
+
assert.deepEqual(result.map((m) => m.id).sort(), ["", "aaa", "fork1"], "三个会话文件(含子目录);隐藏目录不算");
|
|
269
|
+
const nameless = result.find((m) => m.path.endsWith("session.jsonl"));
|
|
270
|
+
assert.equal(nameless.id, "", "没有 `<timestamp>_<id>` 前缀的名字返回空串提示,id 交给 header");
|
|
271
|
+
} finally {
|
|
272
|
+
rmSync(root, { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("scanSessionFileMeta:超深目录不扫(深度上限护住每轮扫描成本)", async () => {
|
|
277
|
+
const { root, projDir } = makeRoot();
|
|
278
|
+
try {
|
|
279
|
+
let deep = projDir;
|
|
280
|
+
for (let i = 0; i < 10; i += 1) deep = join(deep, `d${i}`);
|
|
281
|
+
mkdirSync(deep, { recursive: true });
|
|
282
|
+
writeFileSync(join(deep, "2026-01-01T00-00-00-000Z_deep.jsonl"), '{"type":"session","version":3,"id":"deep"}\n');
|
|
283
|
+
const mod = await jiti.import("./session-scanner.ts");
|
|
284
|
+
assert.deepEqual(await mod.scanSessionFileMeta(root), [], "超出深度上限的目录不进入扫描");
|
|
285
|
+
} finally {
|
|
286
|
+
rmSync(root, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
177
290
|
test("scanOneSessionHead:只读 header + 首条消息,不含自定义名/lastReply", async () => {
|
|
178
291
|
const { root, projDir } = makeRoot();
|
|
179
292
|
try {
|
package/lib/session-scanner.ts
CHANGED
|
@@ -35,7 +35,8 @@ export interface SessionScanResult {
|
|
|
35
35
|
const HEAD_INITIAL_BYTES = 4 * 1024;
|
|
36
36
|
const HEAD_MAX_BYTES = 48 * 1024;
|
|
37
37
|
// 列表里的首条消息只做简述展示(sidebar 也只截取前 50 字符)。
|
|
38
|
-
|
|
38
|
+
// 导出给事件链路(rpc-manager 回填 first_message)复用同一上限。
|
|
39
|
+
export const FIRST_MESSAGE_PREVIEW_LENGTH = 300;
|
|
39
40
|
|
|
40
41
|
function parseLine(line: string): unknown {
|
|
41
42
|
if (!line.trim()) return null;
|
|
@@ -63,10 +64,19 @@ function extractTextContent(message: { content?: unknown }): string {
|
|
|
63
64
|
* 首行必须是 session 头,否则视为非本应用会话文件。
|
|
64
65
|
*/
|
|
65
66
|
function scanHead(path: string, size: number): { header: HeaderEntry; firstMessage: string } | null {
|
|
66
|
-
let
|
|
67
|
-
let text = "";
|
|
68
|
-
const fd = openSync(path, "r");
|
|
67
|
+
let fd: number;
|
|
69
68
|
try {
|
|
69
|
+
fd = openSync(path, "r");
|
|
70
|
+
} catch {
|
|
71
|
+
// stat 通过但 open 失败(EACCES / 被并发换成目录等)→ 当作「读不出来」。
|
|
72
|
+
// 调用方(scanOneSessionHead / scanOneSessionFile)把 null 往下传,不向上抛:
|
|
73
|
+
// 详情读取路径是 /api/sessions/summary 的「库内无行」回退,一个打不开的文件
|
|
74
|
+
// 不该让整个接口 500。
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
let readLen = Math.min(HEAD_INITIAL_BYTES, size);
|
|
79
|
+
let text = "";
|
|
70
80
|
let header: HeaderEntry | null = null;
|
|
71
81
|
let firstMessage = "";
|
|
72
82
|
|
|
@@ -109,8 +119,16 @@ function scanHead(path: string, size: number): { header: HeaderEntry; firstMessa
|
|
|
109
119
|
if (readLen <= 0) break;
|
|
110
120
|
}
|
|
111
121
|
return { header, firstMessage };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// readSync 中途失败(EIO / 文件被重写 / 块大小异常)同样降级为「读不出来」。
|
|
124
|
+
console.warn(`[pi-web] 会话头部读取失败 path=${path}:`, error instanceof Error ? error.message : String(error));
|
|
125
|
+
return null;
|
|
112
126
|
} finally {
|
|
113
|
-
|
|
127
|
+
try {
|
|
128
|
+
closeSync(fd);
|
|
129
|
+
} catch {
|
|
130
|
+
// 已关闭/句柄失效:不影响返回值
|
|
131
|
+
}
|
|
114
132
|
}
|
|
115
133
|
}
|
|
116
134
|
|
|
@@ -152,14 +170,22 @@ export function scanOneSessionHead(path: string): { path: string; id: string; cw
|
|
|
152
170
|
|
|
153
171
|
/** 读取文件尾部:最后一个 session_info 的自定义名 + 最后一条 assistant 回复。
|
|
154
172
|
* 同一趟反向分块里同时取两样:名字(显式清空 → undefined)、
|
|
155
|
-
* lastReply(最后一个 text 块,完整不截断)。返回 { name, lastReply }
|
|
173
|
+
* lastReply(最后一个 text 块,完整不截断)。返回 { name, lastReply };
|
|
174
|
+
* open/read 任何异常 → **null**(读不出来),与「读了但没有回复」的
|
|
175
|
+
* `{ name: undefined, lastReply: "" }` 区分——调用方据此决定要不要回填。
|
|
176
|
+
* 本函数永不抛错:scanOneSessionFile 是详情读取路径,一个打不开的文件不该让整个接口 500。 */
|
|
156
177
|
function scanTail(
|
|
157
178
|
path: string,
|
|
158
179
|
size: number,
|
|
159
|
-
): { name: string | undefined; lastReply: string } {
|
|
160
|
-
const
|
|
161
|
-
if (size <= 0) return
|
|
162
|
-
|
|
180
|
+
): { name: string | undefined; lastReply: string } | null {
|
|
181
|
+
const empty: { name: string | undefined; lastReply: string } = { name: undefined, lastReply: "" };
|
|
182
|
+
if (size <= 0) return empty;
|
|
183
|
+
let fd: number;
|
|
184
|
+
try {
|
|
185
|
+
fd = openSync(path, "r");
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
163
189
|
try {
|
|
164
190
|
let offset = size;
|
|
165
191
|
for (let block = 0; block < NAME_MAX_BLOCKS && offset > 0; block++) {
|
|
@@ -172,30 +198,58 @@ function scanTail(
|
|
|
172
198
|
// 块首可能截断一行(前一行的后半),跳过第一行;块尾对齐 offset 完整。
|
|
173
199
|
// 从后往前找本块内最后一个 session_info 与最后一条 assistant 回复。
|
|
174
200
|
for (let i = lines.length - 1; i >= 1; i--) {
|
|
175
|
-
if (
|
|
201
|
+
if (empty.name !== undefined && empty.lastReply) continue;
|
|
176
202
|
const entry = parseLine(lines[i]) as
|
|
177
203
|
| { type?: string; name?: unknown; message?: { role?: unknown; content?: unknown } }
|
|
178
204
|
| null;
|
|
179
205
|
if (!entry) continue;
|
|
180
|
-
if (
|
|
206
|
+
if (empty.name === undefined && entry.type === "session_info" && typeof entry.name === "string") {
|
|
181
207
|
const name = entry.name.trim();
|
|
182
|
-
if (name)
|
|
183
|
-
} else if (!
|
|
208
|
+
if (name) empty.name = name; // 显式清空 → undefined
|
|
209
|
+
} else if (!empty.lastReply && entry.type === "message" && entry.message?.role === "assistant") {
|
|
184
210
|
const reply = extractTextContent(entry.message).trim();
|
|
185
211
|
// 不截断:收合卡完整展示最后一条消息(跨 64KB 块的超大回复仍可能缺块首,极端场景)
|
|
186
|
-
if (reply)
|
|
212
|
+
if (reply) empty.lastReply = reply;
|
|
187
213
|
}
|
|
188
|
-
if (
|
|
214
|
+
if (empty.name !== undefined && empty.lastReply) break; // 两个都拿到,收工
|
|
189
215
|
}
|
|
190
216
|
offset = start;
|
|
191
217
|
}
|
|
192
|
-
return
|
|
218
|
+
return empty;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.warn(`[pi-web] 会话尾部读取失败 path=${path}:`, error instanceof Error ? error.message : String(error));
|
|
221
|
+
return null;
|
|
193
222
|
} finally {
|
|
194
|
-
|
|
223
|
+
try {
|
|
224
|
+
closeSync(fd);
|
|
225
|
+
} catch {
|
|
226
|
+
// 已关闭/句柄失效:不影响返回值
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 单文件尾部轻量读:自定义名 + 最后一条 assistant 回复(反向分块,命中即停)。
|
|
232
|
+
* 这个函数**永不抛错**:文件不存在 / 不可读(EACCES)/ 打开后又读失败(EIO、文件被重写)
|
|
233
|
+
* / 是目录 / 为空 → null。调用方区分「读不出来」(null,保持未回填、下轮再试)与
|
|
234
|
+
* 「读了但没有回复」(lastReply 为空串)。
|
|
235
|
+
* 为什么必须吞掉 open/read 的错:扫描器的 last_reply 回填逐行调用它,一个 EACCES
|
|
236
|
+
* 逃出去就会中止整轮扫描;查询无 ORDER BY,同一行每轮都被重新命中 → 它后面的行
|
|
237
|
+
* 永远得不到回填(冷启动时还会把异常抛到 ensureSessionIndexReady → /api/sessions 500)。
|
|
238
|
+
* 这是全库唯一的尾部反向分块实现——不要再写第二份。 */
|
|
239
|
+
export function readSessionTail(path: string): { name?: string; lastReply: string } | null {
|
|
240
|
+
try {
|
|
241
|
+
const stat = statSync(path);
|
|
242
|
+
if (!stat.isFile() || stat.size === 0) return null;
|
|
243
|
+
// scanTail 自身不再抛错:open/read 失败返回 null(与这里「读不出来」的语义直接对齐)。
|
|
244
|
+
return scanTail(path, stat.size);
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
195
247
|
}
|
|
196
248
|
}
|
|
197
249
|
|
|
198
|
-
/** 单个会话文件详情(阶段二按需读取:只对当前页/置顶子集调用)。
|
|
250
|
+
/** 单个会话文件详情(阶段二按需读取:只对当前页/置顶子集调用)。
|
|
251
|
+
* 本函数永不抛错:stat / 头部 / 尾部任一读取失败都降级为 null 或空字段——
|
|
252
|
+
* 调用方是 /api/sessions/summary 的「库内无行」回退,一个打不开的文件不该 500。 */
|
|
199
253
|
export function scanOneSessionFile(path: string): SessionScanResult | null {
|
|
200
254
|
let stat;
|
|
201
255
|
try {
|
|
@@ -214,7 +268,9 @@ export function scanOneSessionFile(path: string): SessionScanResult | null {
|
|
|
214
268
|
const created = new Date(typeof header.timestamp === "string" ? header.timestamp : "");
|
|
215
269
|
if (Number.isNaN(created.getTime())) created.setTime(stat.mtime.getTime());
|
|
216
270
|
|
|
217
|
-
|
|
271
|
+
// 尾部读不出来(open/read 失败)→ 空结果:详情仍可展示(名字/最后回复为空),
|
|
272
|
+
// 不像 readSessionTail 那样返回 null(那里要靠 null 区分「下轮再回填」)。
|
|
273
|
+
const tail = scanTail(path, stat.size) ?? { name: undefined, lastReply: "" };
|
|
218
274
|
|
|
219
275
|
return {
|
|
220
276
|
path,
|
|
@@ -229,28 +285,73 @@ export function scanOneSessionFile(path: string): SessionScanResult | null {
|
|
|
229
285
|
};
|
|
230
286
|
}
|
|
231
287
|
|
|
232
|
-
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
288
|
+
/* 全量详情扫描(scanSessionFiles / sessionScanner.scan)已删除:读取路径全部改查
|
|
289
|
+
* session_meta,扫描器只需阶段一元数据(scanSessionFileMeta)+ 单文件详情
|
|
290
|
+
* (scanOneSessionFile)。保留全量版本会让「列表=扫盘」的旧路径随时被重新接上。 */
|
|
291
|
+
|
|
292
|
+
/** 阶段一元数据:目录项 + stat 即可,不读内容。`id` 是「文件名提示」
|
|
293
|
+
* (可能为空串——如 `session.jsonl`,此时 id 由 header 决定)。 */
|
|
294
|
+
export interface SessionFileMeta {
|
|
295
|
+
path: string;
|
|
296
|
+
id: string;
|
|
297
|
+
modified: Date;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 扫描深度上限(项目目录 = 1 层):`<project>/<session>/<runId>/run-0/session.jsonl`
|
|
301
|
+
* 只到第 4 层,6 层留余量;没有上限的话一个异常目录树能把每轮扫描拖成深递归。 */
|
|
302
|
+
const MAX_SCAN_DEPTH = 6;
|
|
303
|
+
|
|
304
|
+
/** 会话文件候选 id:`<...>_<id>.jsonl` 取末段(本应用的命名约定,见
|
|
305
|
+
* session-reader 的 findSessionPathByName);`session.jsonl` 这类没有
|
|
306
|
+
* `<timestamp>_<id>` 形状的名字返回 ""——不猜 id,交给 header 决定。 */
|
|
307
|
+
function idHintFromName(name: string): string {
|
|
308
|
+
const parts = name.slice(0, -".jsonl".length).split("_");
|
|
309
|
+
if (parts.length < 2) return "";
|
|
310
|
+
const last = parts[parts.length - 1];
|
|
311
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(last) && !/^[0-9]+$/.test(last) ? last : "";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 递归收集目录下的 jsonl(深度优先,串行)。
|
|
315
|
+
* 隐藏目录跳过(.git 等不可能是会话目录);符号链接目录不跟(防环);
|
|
316
|
+
* 单个目录不可读 → 跳过,不影响其它目录。 */
|
|
317
|
+
async function walkJsonl(dir: string, depth: number, out: SessionFileMeta[]): Promise<void> {
|
|
318
|
+
let entries;
|
|
319
|
+
try {
|
|
320
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
321
|
+
} catch {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
for (const entry of entries) {
|
|
325
|
+
if (entry.name.startsWith(".")) continue;
|
|
326
|
+
const full = joinPath(dir, entry.name);
|
|
327
|
+
if (entry.isDirectory()) {
|
|
328
|
+
if (depth < MAX_SCAN_DEPTH) await walkJsonl(full, depth + 1, out);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (!entry.name.endsWith(".jsonl")) continue;
|
|
332
|
+
let stat;
|
|
333
|
+
try {
|
|
334
|
+
stat = statSync(full);
|
|
335
|
+
} catch {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!stat.isFile() || stat.size === 0) continue;
|
|
339
|
+
out.push({ path: full, id: idHintFromName(entry.name), modified: stat.mtime });
|
|
243
340
|
}
|
|
244
|
-
results.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
245
|
-
return results;
|
|
246
341
|
}
|
|
247
342
|
|
|
248
343
|
/** 阶段一元数据:只读目录项 + stat,不碰文件内容。
|
|
249
344
|
* 会话文件命名 `<timestamp>_<id>.jsonl`,id 直接从文件名解析(与 header 一致,
|
|
250
345
|
* 见 scanOneSessionFile 校验);mtime 即最后活动时间(侧栏排序键)。
|
|
251
|
-
* 两阶段设计:这里全量便宜拿到 id+mtime
|
|
252
|
-
*
|
|
253
|
-
|
|
346
|
+
* 两阶段设计:这里全量便宜拿到 id+mtime 后,扫描器按 path 匹配建行/收敛,
|
|
347
|
+
* 读取路径(列表/任务区/看板摘要)则完全不再读文件。
|
|
348
|
+
*
|
|
349
|
+
* 递归扫描:外部 pi 系工具会在项目目录下造子目录布局
|
|
350
|
+
* (`<session>/forks/<ts>_<id>.jsonl`、`<session>/<runId>/run-0/session.jsonl`、
|
|
351
|
+
* `subagent-artifacts/*.jsonl`)。只做一层 readdir 会让这些会话永远不进库
|
|
352
|
+
* (列表里等于不存在)。这里向下递归到 MAX_SCAN_DEPTH,id 仍是「文件名提示」
|
|
353
|
+
* (可能为空串,由 header 决定,见 idHintFromName)。 */
|
|
354
|
+
export async function scanSessionFileMeta(sessionsDir?: string): Promise<SessionFileMeta[]> {
|
|
254
355
|
const root = sessionsDir ?? joinPath(getAgentDir(), "sessions");
|
|
255
356
|
let projectDirs: string[];
|
|
256
357
|
try {
|
|
@@ -262,33 +363,11 @@ export async function scanSessionFileMeta(sessionsDir?: string): Promise<Array<{
|
|
|
262
363
|
return [];
|
|
263
364
|
}
|
|
264
365
|
|
|
265
|
-
const metas:
|
|
366
|
+
const metas: SessionFileMeta[] = [];
|
|
266
367
|
for (const dir of projectDirs) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
names = await readdir(dir);
|
|
270
|
-
} catch {
|
|
271
|
-
continue; // 单目录不可读则跳过,不影响其它目录。
|
|
272
|
-
}
|
|
273
|
-
for (const name of names) {
|
|
274
|
-
if (!name.endsWith(".jsonl")) continue;
|
|
275
|
-
const path = joinPath(dir, name);
|
|
276
|
-
let stat;
|
|
277
|
-
try {
|
|
278
|
-
stat = statSync(path);
|
|
279
|
-
} catch {
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
if (!stat.isFile() || stat.size === 0) continue;
|
|
283
|
-
// 文件名 `..._<id>.jsonl`:下划线后到 .jsonl 之间即 session id。
|
|
284
|
-
const id = name.slice(0, -6).split("_").pop() ?? "";
|
|
285
|
-
if (!id) continue;
|
|
286
|
-
metas.push({ path, id, modified: stat.mtime });
|
|
287
|
-
}
|
|
368
|
+
// 项目目录自身算第 1 层:`<project>/<session>/<runId>/run-0/session.jsonl` 到第 4 层。
|
|
369
|
+
await walkJsonl(dir, 1, metas);
|
|
288
370
|
}
|
|
289
371
|
metas.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
290
372
|
return metas;
|
|
291
373
|
}
|
|
292
|
-
|
|
293
|
-
// 以对象形式暴露扫描入口,测试可替换实现(ESM 的模块绑定本身只读)。
|
|
294
|
-
export const sessionScanner = { scan: scanSessionFiles };
|
package/lib/session-search.ts
CHANGED
|
@@ -152,8 +152,8 @@ export async function ensureSearchIndex(
|
|
|
152
152
|
): Promise<{ indexing: boolean }> {
|
|
153
153
|
let sessions = sessionsOverride;
|
|
154
154
|
if (!sessions) {
|
|
155
|
-
const {
|
|
156
|
-
sessions = await
|
|
155
|
+
const { loadAllSessionIndex } = await import("./session-reader");
|
|
156
|
+
sessions = await loadAllSessionIndex();
|
|
157
157
|
}
|
|
158
158
|
const db = getDb();
|
|
159
159
|
|
|
@@ -221,7 +221,7 @@ export async function searchSessions(
|
|
|
221
221
|
): Promise<{ indexing: boolean; results: SearchResult[] }> {
|
|
222
222
|
const query = q.trim();
|
|
223
223
|
if (!query) return { indexing: false, results: [] };
|
|
224
|
-
const sessions = sessionsOverride ?? (await
|
|
224
|
+
const sessions = sessionsOverride ?? (await loadIndexedSessions());
|
|
225
225
|
const indexed = await ensureSearchIndex(sessions);
|
|
226
226
|
|
|
227
227
|
const cap = Math.min(Math.max(limit, 1), 50);
|
|
@@ -282,9 +282,10 @@ export async function searchSessions(
|
|
|
282
282
|
return { indexing: indexed.indexing, results };
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
|
|
285
|
+
/** 搜索基线:session_meta 全表索引(与 /api/sessions 无参同源,纯库查询)。 */
|
|
286
|
+
async function loadIndexedSessions(): Promise<SessionInfo[]> {
|
|
286
287
|
// 基线吃 session_meta 索引(与 /api/sessions 无参同源):列表重构后磁盘扫描
|
|
287
|
-
//
|
|
288
|
+
// 已不是权威数据源,读取路径也不再扫盘。索引 modified 由事件链路/扫描器维护,
|
|
288
289
|
// 搜索索引重建随之滞后可接受(搜索非实时)。
|
|
289
290
|
const { loadAllSessionIndex } = await import("./session-reader");
|
|
290
291
|
return loadAllSessionIndex();
|
package/lib/sqlite-db.test.mjs
CHANGED
|
@@ -189,3 +189,29 @@ test("v10 database upgrades to v11 (session_meta index columns + project index)"
|
|
|
189
189
|
assert.equal(row.task_id, "task-1");
|
|
190
190
|
assert.equal(row.pinned, 1);
|
|
191
191
|
});
|
|
192
|
+
|
|
193
|
+
test("schema v12:老库升级后 session_meta 有 last_reply 列,新库直接具备", () => {
|
|
194
|
+
const old = new DatabaseSync(":memory:");
|
|
195
|
+
old.exec("PRAGMA user_version = 11");
|
|
196
|
+
old.exec(
|
|
197
|
+
"CREATE TABLE session_meta (session_id TEXT PRIMARY KEY, task_id TEXT, updated INTEGER NOT NULL, pinned INTEGER NOT NULL DEFAULT 0, path TEXT, cwd TEXT, project_key TEXT, title TEXT, first_message TEXT, parent_id TEXT, created INTEGER, modified INTEGER)",
|
|
198
|
+
);
|
|
199
|
+
old.prepare(
|
|
200
|
+
"INSERT INTO session_meta (session_id, updated, modified) VALUES ('s-old', 1, 2)",
|
|
201
|
+
).run();
|
|
202
|
+
|
|
203
|
+
initSchema(old);
|
|
204
|
+
|
|
205
|
+
const cols = old.prepare("PRAGMA table_info(session_meta)").all().map((c) => c.name);
|
|
206
|
+
assert.ok(cols.includes("last_reply"), "v12 adds session_meta.last_reply");
|
|
207
|
+
assert.equal(old.prepare("PRAGMA user_version").get().user_version, 12);
|
|
208
|
+
// 老行保留,新列默认 NULL("还没有最后一条消息")
|
|
209
|
+
const kept = old.prepare("SELECT session_id, last_reply FROM session_meta WHERE session_id='s-old'").get();
|
|
210
|
+
assert.equal(kept.session_id, "s-old");
|
|
211
|
+
assert.equal(kept.last_reply, null);
|
|
212
|
+
// 新库第一次建表就该有该列(老库升级与新库同构)
|
|
213
|
+
const fresh = memoryDb();
|
|
214
|
+
const freshCols = fresh.prepare("PRAGMA table_info(session_meta)").all().map((c) => c.name);
|
|
215
|
+
assert.ok(freshCols.includes("last_reply"));
|
|
216
|
+
assert.equal(fresh.prepare("PRAGMA user_version").get().user_version, 12);
|
|
217
|
+
});
|
package/lib/sqlite-db.ts
CHANGED
|
@@ -93,7 +93,7 @@ export function initSchema(db: DatabaseSync): void {
|
|
|
93
93
|
* 老库打开时自动按序补齐缺失的迁移(每个迁移一个事务,成功后推进版本号),
|
|
94
94
|
* 新库建表后从 v0 一路迁到 SCHEMA_VERSION。重复打开不再执行已完成的迁移。
|
|
95
95
|
*/
|
|
96
|
-
export const SCHEMA_VERSION =
|
|
96
|
+
export const SCHEMA_VERSION = 12;
|
|
97
97
|
|
|
98
98
|
interface Migration {
|
|
99
99
|
version: number;
|
|
@@ -219,6 +219,15 @@ const MIGRATIONS: Migration[] = [
|
|
|
219
219
|
"CREATE INDEX IF NOT EXISTS idx_meta_project_modified ON session_meta(project_key, modified DESC);",
|
|
220
220
|
],
|
|
221
221
|
},
|
|
222
|
+
{
|
|
223
|
+
version: 12,
|
|
224
|
+
name: "session_meta.last_reply(最后一条消息入库,列表零扫盘)",
|
|
225
|
+
statements: [
|
|
226
|
+
// 「最后一条消息」原先靠读取期读文件尾(滞后且每次列表都要摸盘)。改由事件
|
|
227
|
+
// 链路在一轮循环结束时写入(agent_settled,含取消),读取路径不再读文件。
|
|
228
|
+
"ALTER TABLE session_meta ADD COLUMN last_reply TEXT;",
|
|
229
|
+
],
|
|
230
|
+
},
|
|
222
231
|
];
|
|
223
232
|
|
|
224
233
|
function migrate(db: DatabaseSync): void {
|