@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
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// 读取路径零扫盘(列表 / 任务区 / 路径解析)—— 可执行证明。
|
|
2
|
+
//
|
|
3
|
+
// PI_CODING_AGENT_DIR 指向一个空目录(连 sessions/ 都没有),session_meta 里塞几行,
|
|
4
|
+
// 断言所有读取路径仍返回正确数据(含 lastReply)。任何「还得摸盘才能给出答案」的读取
|
|
5
|
+
// 路径在这套环境里都会退化成空/缺字段,于是这条测试就是「列表加载完全不扫盘」的证明。
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import test, { after, beforeEach } from "node:test";
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import { createJiti } from "jiti";
|
|
13
|
+
|
|
14
|
+
// 空 agentDir:没有 sessions/、没有会话文件。必须在 import 前设好(getAgentDir 每次读 env)。
|
|
15
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-noscan-empty-"));
|
|
16
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
17
|
+
after(() => rmSync(agentDir, { recursive: true, force: true }));
|
|
18
|
+
|
|
19
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
|
|
20
|
+
const { getDb, setDbForTesting } = await jiti.import("./sqlite-db.ts");
|
|
21
|
+
const reader = await jiti.import("./session-reader.ts");
|
|
22
|
+
const scanner = await jiti.import("./session-scanner.ts");
|
|
23
|
+
const { loadTaskSessionsPage, loadProjectSessions, loadAllSessionIndex, loadSessionSummariesByIds, resolveSessionPath, resolveSessionIdByPath } = reader;
|
|
24
|
+
|
|
25
|
+
const PROJ = "/tmp/noscan-proj";
|
|
26
|
+
|
|
27
|
+
function freshDb() {
|
|
28
|
+
setDbForTesting(new DatabaseSync(":memory:"));
|
|
29
|
+
// 路径缓存是 globalThis 级别的,跨用例会串味 —— 每个用例都清空。
|
|
30
|
+
globalThis.__piSessionPathCache = new Map();
|
|
31
|
+
globalThis.__piPathToSessionIdCache = new Map();
|
|
32
|
+
// 索引扫描器视为已 ready:读取路径不该触发扫描。
|
|
33
|
+
globalThis.__piSessionIndexScanner = { timer: undefined, firstScanDone: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => freshDb());
|
|
37
|
+
|
|
38
|
+
/** 直接往库里塞会话行(不经过扫描器——磁盘上什么都没有)。 */
|
|
39
|
+
function seedRow({
|
|
40
|
+
id,
|
|
41
|
+
taskId = null,
|
|
42
|
+
parentId = null,
|
|
43
|
+
cwd = PROJ,
|
|
44
|
+
projectKey = PROJ,
|
|
45
|
+
path = `/nonexistent/${id}.jsonl`,
|
|
46
|
+
title = null,
|
|
47
|
+
firstMessage = "首条消息",
|
|
48
|
+
lastReply = null,
|
|
49
|
+
modified = 1000,
|
|
50
|
+
updated = 1000,
|
|
51
|
+
pinned = 0,
|
|
52
|
+
}) {
|
|
53
|
+
getDb()
|
|
54
|
+
.prepare(
|
|
55
|
+
`INSERT INTO session_meta
|
|
56
|
+
(session_id, task_id, updated, pinned, path, cwd, project_key, title, first_message, parent_id, created, modified, last_reply)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
58
|
+
)
|
|
59
|
+
.run(id, taskId, updated, pinned, path, cwd, projectKey, title, firstMessage, parentId, modified, modified, lastReply);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("列表零扫盘:sessions 目录不存在,聊天列表仍返回库内会话(含 lastReply)", async () => {
|
|
63
|
+
seedRow({ id: "s1", lastReply: "最后一条回复", modified: 200 });
|
|
64
|
+
seedRow({ id: "s2", modified: 100, firstMessage: null });
|
|
65
|
+
|
|
66
|
+
const list = await loadProjectSessions(PROJ);
|
|
67
|
+
assert.deepEqual(list.map((s) => s.id), ["s1", "s2"], "modified 降序");
|
|
68
|
+
assert.equal(list[0].lastReply, "最后一条回复", "lastReply 来自库(旧实现读文件尾)");
|
|
69
|
+
assert.equal(list[1].lastReply, "", "未回填/无回复 → 空串");
|
|
70
|
+
assert.equal(list[1].firstMessage, "(no messages)", "库里没标题且不读文件头");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("列表零扫盘:first_message 为空也不读文件、不回写库(读取路径不写库)", async () => {
|
|
74
|
+
// 文件真实存在且有首条消息:旧实现会读它并把结果写回 meta。
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-noscan-lazy-"));
|
|
76
|
+
try {
|
|
77
|
+
const file = join(dir, "2026-01-01T00-00-00-000Z_a.jsonl");
|
|
78
|
+
writeFileSync(file, [
|
|
79
|
+
`{"type":"session","version":3,"id":"a","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${PROJ}"}`,
|
|
80
|
+
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"文件里的首条"}}',
|
|
81
|
+
].join("\n") + "\n");
|
|
82
|
+
seedRow({ id: "a", path: file, firstMessage: null });
|
|
83
|
+
|
|
84
|
+
const list = await loadProjectSessions(PROJ);
|
|
85
|
+
assert.equal(list[0].firstMessage, "(no messages)", "列表不读文件头补标题");
|
|
86
|
+
const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='a'").get();
|
|
87
|
+
assert.equal(row.first_message, null, "列表 GET 不回写库(回填改由扫描器/事件链路负责)");
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("全量索引零扫盘:loadAllSessionIndex 同样带 lastReply,任务会话也在内", async () => {
|
|
94
|
+
seedRow({ id: "chat", lastReply: "聊了两句", modified: 300 });
|
|
95
|
+
seedRow({ id: "tasked", taskId: "task-1", lastReply: "任务回复", modified: 200 });
|
|
96
|
+
|
|
97
|
+
const all = await loadAllSessionIndex();
|
|
98
|
+
assert.deepEqual(all.map((s) => s.id), ["chat", "tasked"]);
|
|
99
|
+
assert.deepEqual(all.map((s) => s.lastReply), ["聊了两句", "任务回复"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("列表不再有跨请求结果缓存:新增行立刻可见(旧实现缓存 5 分钟)", async () => {
|
|
103
|
+
seedRow({ id: "first", modified: 100 });
|
|
104
|
+
assert.deepEqual((await loadAllSessionIndex()).map((s) => s.id), ["first"]);
|
|
105
|
+
|
|
106
|
+
seedRow({ id: "second", modified: 200 });
|
|
107
|
+
assert.deepEqual((await loadAllSessionIndex()).map((s) => s.id), ["second", "first"], "每次调用都查库");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("resolveSessionPath 先查库 path,找不到文件才按名兜底,绝不触发全量扫盘", async () => {
|
|
111
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-noscan-path-"));
|
|
112
|
+
try {
|
|
113
|
+
// 文件名与「<ts>_<id>.jsonl」命名约定无关 → 名字兜底永远找不到它,
|
|
114
|
+
// 命中只能来自库里的 path(旧实现会退化成全量头尾扫并返回 null)。
|
|
115
|
+
const file = join(dir, "sess-1.jsonl");
|
|
116
|
+
writeFileSync(file, `{"type":"session","version":3,"id":"sess-1","cwd":"${PROJ}"}\n`);
|
|
117
|
+
seedRow({ id: "sess-1", path: file });
|
|
118
|
+
|
|
119
|
+
assert.equal(await resolveSessionPath("sess-1"), file);
|
|
120
|
+
assert.equal(await resolveSessionPath("no-such-session"), null, "查不到就是 null,不扫盘兜底");
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("resolveSessionIdByPath 查库(含路径归一化差异)", async () => {
|
|
127
|
+
seedRow({ id: "sess-9", path: "/tmp/noscan-path/2026-01-01T00-00-00-000Z_sess-9.jsonl" });
|
|
128
|
+
|
|
129
|
+
assert.equal(
|
|
130
|
+
await resolveSessionIdByPath("/tmp/noscan-path/2026-01-01T00-00-00-000Z_sess-9.jsonl"),
|
|
131
|
+
"sess-9",
|
|
132
|
+
"精确命中",
|
|
133
|
+
);
|
|
134
|
+
assert.equal(
|
|
135
|
+
await resolveSessionIdByPath("/tmp/noscan-path/./2026-01-01T00-00-00-000Z_sess-9.jsonl"),
|
|
136
|
+
"sess-9",
|
|
137
|
+
"归一化差异也命中(旧实现靠全量扫盘填缓存)",
|
|
138
|
+
);
|
|
139
|
+
assert.equal(await resolveSessionIdByPath("/tmp/elsewhere/x.jsonl"), undefined);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("任务区分页:根/子树全部来自 session_meta.parent_id,不 readdir 也不读 header", async () => {
|
|
143
|
+
// 任务成员:两个根(R2 置顶、R),R 的 fork 链 R → C1 → C2(文件都不存在)。
|
|
144
|
+
seedRow({ id: "R", taskId: "task-1", modified: 300, firstMessage: "任务根", lastReply: "根的最后回复" });
|
|
145
|
+
seedRow({ id: "C1", taskId: "task-1", parentId: "R", modified: 200, firstMessage: "fork 一" });
|
|
146
|
+
seedRow({ id: "C2", taskId: "task-1", parentId: "C1", modified: 100, firstMessage: "fork 二" });
|
|
147
|
+
seedRow({ id: "R2", taskId: "task-1", modified: 150, pinned: 1, firstMessage: "置顶根" });
|
|
148
|
+
// 非任务会话 → 不进任务区
|
|
149
|
+
seedRow({ id: "chat", modified: 400, firstMessage: "聊天区" });
|
|
150
|
+
|
|
151
|
+
const page = await loadTaskSessionsPage("task-1", 0, 5);
|
|
152
|
+
assert.deepEqual(page.sessions.map((s) => s.id).sort(), ["C1", "C2", "R", "R2"], "子树随根");
|
|
153
|
+
assert.equal(page.rootTotal, 2, "根数 = 两个(R、R2),fork 不算根");
|
|
154
|
+
assert.equal(page.sessionTotal, 4, "任务下全部节点数");
|
|
155
|
+
assert.deepEqual(page.pinnedSessionIds, ["R2"]);
|
|
156
|
+
const byId = Object.fromEntries(page.sessions.map((s) => [s.id, s]));
|
|
157
|
+
assert.equal(byId.C1.firstMessage, "fork 一", "子会话详情同样来自库里(与聊天列表同源)");
|
|
158
|
+
assert.equal(byId.R.lastReply, "根的最后回复");
|
|
159
|
+
assert.ok(!page.sessions.some((s) => s.id === "chat"), "非任务会话不下发");
|
|
160
|
+
|
|
161
|
+
// offset/limit 只切非置顶根;置顶根永远在页里
|
|
162
|
+
const limited = await loadTaskSessionsPage("task-1", 0, 1);
|
|
163
|
+
assert.deepEqual(limited.sessions.map((s) => s.id).sort(), ["C1", "C2", "R", "R2"], "limit=1 时第一个非置顶根=R(R2 置顶)");
|
|
164
|
+
const secondPage = await loadTaskSessionsPage("task-1", 1, 1);
|
|
165
|
+
assert.deepEqual(secondPage.sessions.map((s) => s.id), ["R2"], "offset=1 → 非置顶根已切完,只剩置顶根");
|
|
166
|
+
assert.equal(secondPage.rootTotal, 2, "rootTotal 与 offset 无关");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("任务区根集合重定义:父不在本任务成员集合内 → 该节点是根(父已删/父属别处)", async () => {
|
|
170
|
+
seedRow({ id: "orphan", taskId: "task-2", parentId: "gone", modified: 100, firstMessage: "父已不在任务" });
|
|
171
|
+
seedRow({ id: "elsewhere", taskId: null, parentId: "orphan", modified: 50 });
|
|
172
|
+
|
|
173
|
+
const page = await loadTaskSessionsPage("task-2", 0, 5);
|
|
174
|
+
assert.deepEqual(page.sessions.map((s) => s.id), ["orphan"], "父不在任务成员集合 → orphan 是根");
|
|
175
|
+
assert.equal(page.rootTotal, 1);
|
|
176
|
+
assert.equal(page.sessionTotal, 1, "非成员子会话不进任务区(归属由整棵子树存)");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("看板摘要零扫盘:loadSessionSummariesByIds 的 title/lastReply/modified 全取库", async () => {
|
|
180
|
+
seedRow({ id: "a", title: "改过的名字", lastReply: "最后一条回复", modified: 500 });
|
|
181
|
+
seedRow({ id: "b", firstMessage: "首条", lastReply: "", modified: 400 });
|
|
182
|
+
|
|
183
|
+
const sessions = await loadSessionSummariesByIds(["b", "a", "missing-id"]);
|
|
184
|
+
assert.deepEqual(sessions.map((s) => s.id), ["b", "a"], "不存在的 id 跳过;顺序随入参(已命中项)");
|
|
185
|
+
const byId = Object.fromEntries(sessions.map((s) => [s.id, s]));
|
|
186
|
+
assert.equal(byId.a.name, "改过的名字", "title 取库,不读文件尾 session_info");
|
|
187
|
+
assert.equal(byId.a.lastReply, "最后一条回复");
|
|
188
|
+
assert.equal(new Date(byId.a.modified).getTime(), 500, "modified 取库,不 stat 文件");
|
|
189
|
+
assert.equal(byId.b.name, undefined);
|
|
190
|
+
assert.equal(byId.b.lastReply, "");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("库内无行的 id(外部工具刚建、尚未入索引)→ 文件名兜底 + 文件头尾读", async () => {
|
|
194
|
+
const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
195
|
+
const fallbackAgentDir = mkdtempSync(join(tmpdir(), "pi-noscan-fallback-"));
|
|
196
|
+
const projSessionsDir = join(fallbackAgentDir, "sessions", "--tmp-proj--");
|
|
197
|
+
mkdirSync(projSessionsDir, { recursive: true });
|
|
198
|
+
try {
|
|
199
|
+
// 库内无行 → 只能靠磁盘:按「<ts>_<id>.jsonl」找到文件并读头尾。
|
|
200
|
+
// 库优先的断言(上面的用例)不依赖文件;这一条守住唯一的文件兜底口子。
|
|
201
|
+
// 父文件也放盘中(且库内也无行)→ parentSessionId 应经库反查不到 → undefined;
|
|
202
|
+
// 另建一个有库行的父 → parentSessionId 应是对应会话 id(与主路径字段一致)。
|
|
203
|
+
const parentFile = join(projSessionsDir, "2026-01-01T00-00-00-000Z_fallbackparent.jsonl");
|
|
204
|
+
writeFileSync(parentFile, `{"type":"session","version":3,"id":"fallbackparent","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${PROJ}"}\n`);
|
|
205
|
+
writeFileSync(join(projSessionsDir, "2026-01-02T00-00-00-000Z_outside.jsonl"), [
|
|
206
|
+
`{"type":"session","version":3,"id":"outside","timestamp":"2026-01-02T00:00:00.000Z","cwd":"${PROJ}","parentSession":"${parentFile}"}`,
|
|
207
|
+
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"外部工具建的"}}',
|
|
208
|
+
'{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":[{"type":"text","text":"文件里的最后回复"}]}}',
|
|
209
|
+
].join("\n") + "\n");
|
|
210
|
+
process.env.PI_CODING_AGENT_DIR = fallbackAgentDir;
|
|
211
|
+
globalThis.__piSessionPathCache = new Map();
|
|
212
|
+
globalThis.__piPathToSessionIdCache = new Map();
|
|
213
|
+
|
|
214
|
+
// 父在库里有行(扫描器已索引)→ 兜底路径要给出会话 id,而不是磁盘路径。
|
|
215
|
+
seedRow({ id: "fallbackparent", path: parentFile, modified: 900 });
|
|
216
|
+
|
|
217
|
+
const sessions = await loadSessionSummariesByIds(["outside"]);
|
|
218
|
+
assert.equal(sessions.length, 1, "库内无行但磁盘有文件 → 兜底读得到");
|
|
219
|
+
assert.equal(sessions[0].firstMessage, "外部工具建的");
|
|
220
|
+
assert.equal(sessions[0].lastReply, "文件里的最后回复");
|
|
221
|
+
assert.equal(sessions[0].parentSessionId, "fallbackparent", "兜底摘要也带 parentSessionId(会话 id,与主路径一致)");
|
|
222
|
+
} finally {
|
|
223
|
+
process.env.PI_CODING_AGENT_DIR = prevAgentDir;
|
|
224
|
+
rmSync(fallbackAgentDir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("任务区冷启动闸门:未就绪时 loadTaskSessionsPage 先跑首轮扫描(子会话被发现并继承归属)", async () => {
|
|
229
|
+
const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
230
|
+
const gateAgentDir = mkdtempSync(join(tmpdir(), "pi-noscan-gate-"));
|
|
231
|
+
const projSessionsDir = join(gateAgentDir, "sessions", "--tmp-noscan-proj--");
|
|
232
|
+
mkdirSync(projSessionsDir, { recursive: true });
|
|
233
|
+
try {
|
|
234
|
+
// 父会话:磁盘有文件、库里已有行且已归属任务。
|
|
235
|
+
const parentFile = join(projSessionsDir, "2026-01-01T00-00-00-000Z_gateparent.jsonl");
|
|
236
|
+
writeFileSync(parentFile, `{"type":"session","version":3,"id":"gateparent","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${PROJ}"}\n`);
|
|
237
|
+
// 子会话(fork):磁盘上有文件(header.parentSession 指向父),库里还没有行。
|
|
238
|
+
// 闸门没跑首轮扫描 → 任务页只有父;跑过 → 子会话建行并继承任务归属(归属收敛)。
|
|
239
|
+
const childFile = join(projSessionsDir, "2026-01-02T00-00-00-000Z_gatechild.jsonl");
|
|
240
|
+
writeFileSync(childFile, [
|
|
241
|
+
`{"type":"session","version":3,"id":"gatechild","timestamp":"2026-01-02T00:00:00.000Z","cwd":"${PROJ}","parentSession":"${parentFile}"}`,
|
|
242
|
+
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"fork"}}',
|
|
243
|
+
].join("\n") + "\n");
|
|
244
|
+
process.env.PI_CODING_AGENT_DIR = gateAgentDir;
|
|
245
|
+
globalThis.__piSessionPathCache = new Map();
|
|
246
|
+
globalThis.__piPathToSessionIdCache = new Map();
|
|
247
|
+
// 冷启动:没有扫描器状态(未就绪)——闸门必须自己跑首轮
|
|
248
|
+
globalThis.__piSessionIndexScanner = undefined;
|
|
249
|
+
seedRow({ id: "gateparent", taskId: "task-gate", path: parentFile, modified: 1000 });
|
|
250
|
+
|
|
251
|
+
const page = await loadTaskSessionsPage("task-gate", 0, 5);
|
|
252
|
+
assert.deepEqual(
|
|
253
|
+
page.sessions.map((s) => s.id).sort(),
|
|
254
|
+
["gatechild", "gateparent"],
|
|
255
|
+
"首轮扫描发现的子会话已进入任务页",
|
|
256
|
+
);
|
|
257
|
+
assert.equal(page.rootTotal, 1, "gatechild 随父入任务 → 不是根");
|
|
258
|
+
assert.equal(page.sessionTotal, 2);
|
|
259
|
+
assert.equal(globalThis.__piSessionIndexScanner?.firstScanDone, true, "闸门确实跑完首轮扫描并置 ready");
|
|
260
|
+
const row = getDb().prepare("SELECT task_id, parent_id FROM session_meta WHERE session_id='gatechild'").get();
|
|
261
|
+
assert.equal(row.task_id, "task-gate", "归属收敛:子会话继承任务");
|
|
262
|
+
assert.equal(row.parent_id, "gateparent");
|
|
263
|
+
} finally {
|
|
264
|
+
process.env.PI_CODING_AGENT_DIR = prevAgentDir;
|
|
265
|
+
globalThis.__piSessionIndexScanner = { timer: undefined, firstScanDone: true };
|
|
266
|
+
rmSync(gateAgentDir, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("任务区冷启动闸门故障(真分支):无扫描器 → 自己跑的那一轮真的失败,闸门不穿透——仍返回库内已有行", async (t) => {
|
|
271
|
+
// 为什么换成这套写法(旧用例分支不可达):旧用例 stub 的是 firstScanPromise 的
|
|
272
|
+
// rejection,而生产里 firstScanPromise 由 startSessionIndexScanner 的
|
|
273
|
+
// `.then(...).catch(...)` 链产生(session-index-scanner.ts:371-379、393-396)——
|
|
274
|
+
// catch 把失败吃掉并返回 { ok: false },**结构上永不 reject**,所以
|
|
275
|
+
// `await scanner.firstScanPromise` 那条 rejection 路径不可达(旧用例只证明
|
|
276
|
+
// 「stub 的 rejection 会穿透」)。
|
|
277
|
+
// 真正可达的抛出点:无扫描器状态时 ensureSessionIndexReady 直接
|
|
278
|
+
// `await runSessionIndexScan(sessionsDir)`(同文件 361 行,没有 try/catch)——
|
|
279
|
+
// 那一轮扫描抛什么就穿透什么。
|
|
280
|
+
// 让「这一轮」真的失败:给 session_meta 挂 BEFORE DELETE 触发器 RAISE(ABORT)
|
|
281
|
+
// ——真实的 SQLite 写入错误(等价于「注入一个写时会抛错的库」)。扫描器的删除分支
|
|
282
|
+
// 撞上它就整轮失败,而 SELECT 不受影响,于是能区分验证:闸门 catch 生效后
|
|
283
|
+
// 「继续查库」返回的是**库内已有的行**,而不是空页。
|
|
284
|
+
seedRow({ id: "member", taskId: "task-boom", modified: 500 });
|
|
285
|
+
getDb()
|
|
286
|
+
.prepare("INSERT INTO tasks (id, project_key, name, created, updated) VALUES ('task-boom', ?, 'T', 1, 1)")
|
|
287
|
+
.run(PROJ);
|
|
288
|
+
getDb().exec(
|
|
289
|
+
"CREATE TRIGGER boom_block_delete BEFORE DELETE ON session_meta BEGIN SELECT RAISE(ABORT, '会话索引写入故障(测试注入)'); END",
|
|
290
|
+
);
|
|
291
|
+
// 无扫描器状态 → ensureSessionIndexReady 走「自己跑一轮」,而不是复用某个 promise。
|
|
292
|
+
globalThis.__piSessionIndexScanner = undefined;
|
|
293
|
+
const logs = [];
|
|
294
|
+
const original = console.error;
|
|
295
|
+
console.error = (...args) => logs.push(args.map(String).join(" "));
|
|
296
|
+
t.after(() => { console.error = original; });
|
|
297
|
+
|
|
298
|
+
const page = await loadTaskSessionsPage("task-boom", 0, 5);
|
|
299
|
+
|
|
300
|
+
assert.equal(logs.length, 1, "闸门失败可见(console.error)");
|
|
301
|
+
assert.match(logs[0], /任务区索引就绪检查失败/);
|
|
302
|
+
assert.match(logs[0], /会话索引写入故障(测试注入)/, "失败确实来自闸门自己跑的那轮扫描(真分支)");
|
|
303
|
+
assert.deepEqual(page.sessions.map((s) => s.id), ["member"], "库里有行 + 扫描失败 → 照常返回库内行(不是空页 / 500)");
|
|
304
|
+
assert.equal(page.rootTotal, 1);
|
|
305
|
+
assert.equal(page.sessionTotal, 1);
|
|
306
|
+
assert.equal(globalThis.__piSessionIndexScanner, undefined, "首轮失败不置 ready(下次 ensure 再试)");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("任务区分页:modified 相同的根分页顺序确定(ORDER BY modified DESC, rowid)", async () => {
|
|
310
|
+
seedRow({ id: "first", taskId: "task-1", modified: 500 });
|
|
311
|
+
seedRow({ id: "second", taskId: "task-1", modified: 500 });
|
|
312
|
+
|
|
313
|
+
// 同 modified 时按建行顺序(rowid)切页,且重复调用结果一致——否则「加载更多」
|
|
314
|
+
// 会在两页间反复拿到同一条/丢一条。
|
|
315
|
+
const p1 = await loadTaskSessionsPage("task-1", 0, 1);
|
|
316
|
+
const p2 = await loadTaskSessionsPage("task-1", 1, 1);
|
|
317
|
+
assert.deepEqual(p1.sessions.map((s) => s.id), ["first"]);
|
|
318
|
+
assert.deepEqual(p2.sessions.map((s) => s.id), ["second"]);
|
|
319
|
+
assert.deepEqual((await loadTaskSessionsPage("task-1", 0, 1)).sessions.map((s) => s.id), ["first"]);
|
|
320
|
+
assert.equal(p1.rootTotal, 2);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("库不可用 + 无扫描器:闸门与查库同时失败 → 空页降级(两条降级路径并存,互不掩盖)", async (t) => {
|
|
324
|
+
// 与上一条互补:上一条「库好好的、只有扫描失败」→ 返回库内行;这一条
|
|
325
|
+
// 「库也坏了、扫描也跑不了」→ 闸门先抛(catch 记日志)+ 查库再抛(catch 返回空页)。
|
|
326
|
+
// 两条路径各自独立生效,谁也不靠对方兜底。
|
|
327
|
+
seedRow({ id: "R", taskId: "task-1" });
|
|
328
|
+
globalThis.__piSessionIndexScanner = undefined;
|
|
329
|
+
getDb().close(); // getDb() 仍返回同一个已关闭实例 → 扫描与查库都抛
|
|
330
|
+
const logs = [];
|
|
331
|
+
const original = console.error;
|
|
332
|
+
console.error = (...args) => logs.push(args.map(String).join(" "));
|
|
333
|
+
t.after(() => { console.error = original; });
|
|
334
|
+
|
|
335
|
+
assert.deepEqual(await loadTaskSessionsPage("task-1", 0, 5), {
|
|
336
|
+
sessions: [],
|
|
337
|
+
rootTotal: 0,
|
|
338
|
+
sessionTotal: 0,
|
|
339
|
+
pinnedSessionIds: [],
|
|
340
|
+
});
|
|
341
|
+
assert.equal(logs.length, 1, "闸门失败仍然可见(一条 console.error),但绝不穿透成 500");
|
|
342
|
+
assert.match(logs[0], /任务区索引就绪检查失败/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("库不可用(连接已关)→ 任务分页/列表降级为空,不抛错(否则 /api/tasks、/api/sessions 全 500)", async () => {
|
|
346
|
+
seedRow({ id: "R", taskId: "task-1" });
|
|
347
|
+
seedRow({ id: "chat" });
|
|
348
|
+
getDb().close(); // getDb() 仍返回同一个已关闭实例 → prepare 抛错
|
|
349
|
+
|
|
350
|
+
assert.deepEqual(await loadTaskSessionsPage("task-1", 0, 5), {
|
|
351
|
+
sessions: [],
|
|
352
|
+
rootTotal: 0,
|
|
353
|
+
sessionTotal: 0,
|
|
354
|
+
pinnedSessionIds: [],
|
|
355
|
+
});
|
|
356
|
+
assert.deepEqual(await loadProjectSessions(PROJ), []);
|
|
357
|
+
assert.deepEqual(await loadAllSessionIndex(), []);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("零扫盘是结构性保证:读取模块不再暴露全量扫盘/文件回填入口", async () => {
|
|
361
|
+
for (const gone of ["listAllSessions", "buildTaskSessionIndex", "fillFirstMessageFromFile", "loadAllSessions"]) {
|
|
362
|
+
assert.equal(typeof reader[gone], "undefined", `session-reader 不应再导出 ${gone}`);
|
|
363
|
+
}
|
|
364
|
+
for (const gone of ["scanSessionFiles", "sessionScanner"]) {
|
|
365
|
+
assert.equal(typeof scanner[gone], "undefined", `session-scanner 不应再导出 ${gone}`);
|
|
366
|
+
}
|
|
367
|
+
// 保留:扫描器/事件链路/单文件详情仍要用的入口
|
|
368
|
+
for (const kept of ["scanSessionFileMeta", "scanOneSessionHead", "scanOneSessionFile", "readSessionTail"]) {
|
|
369
|
+
assert.equal(typeof scanner[kept], "function", `${kept} 必须保留`);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
@@ -77,7 +77,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
77
77
|
import { tmpdir } from "node:os";
|
|
78
78
|
import { join } from "node:path";
|
|
79
79
|
|
|
80
|
-
test("loadSessionSummariesByIds
|
|
80
|
+
test("loadSessionSummariesByIds:title/lastReply 取库(不读文件)", async () => {
|
|
81
81
|
freshDb();
|
|
82
82
|
markIndexReady();
|
|
83
83
|
const root = mkdtempSync(join(tmpdir(), "pi-sum-"));
|
|
@@ -85,67 +85,52 @@ test("loadSessionSummariesByIds:按 id 点查摘要(title/lastReply/自定
|
|
|
85
85
|
mkdirSync(projDir, { recursive: true });
|
|
86
86
|
try {
|
|
87
87
|
const cwd = "/home/u/p";
|
|
88
|
-
//
|
|
88
|
+
// 磁盘上有文件,但库行存在 → 库优先(title/lastReply/modified 全部不摸盘)
|
|
89
89
|
const namedPath = join(projDir, "2026-01-01T00-00-00-000Z_named.jsonl");
|
|
90
90
|
writeFileSync(namedPath, [
|
|
91
91
|
`{"type":"session","version":3,"id":"named","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${cwd}"}`,
|
|
92
92
|
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"首条"}}',
|
|
93
|
-
'{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":[{"type":"text","text":"
|
|
94
|
-
'{"type":"session_info","id":"s1","parentId":"a1","name":"
|
|
93
|
+
'{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":[{"type":"text","text":"文件里的回复"}]}}',
|
|
94
|
+
'{"type":"session_info","id":"s1","parentId":"a1","name":"文件里的自定义标题"}',
|
|
95
95
|
].join("\n") + "\n");
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"无名字会话"}}',
|
|
101
|
-
'{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":[{"type":"text","text":"回复2"}]}}',
|
|
102
|
-
].join("\n") + "\n");
|
|
103
|
-
// meta 索引行(path 指向文件)
|
|
104
|
-
seedSession({ id: "named", cwd, projectKey: cwd, path: namedPath, firstMessage: "首条" });
|
|
105
|
-
seedSession({ id: "plain", cwd, projectKey: cwd, path: plainPath, firstMessage: "无名字会话" });
|
|
106
|
-
seedSession({ id: "ghost", cwd, projectKey: cwd, path: "/no/such/file.jsonl", firstMessage: "x" });
|
|
96
|
+
seedSession({ id: "named", cwd, projectKey: cwd, path: namedPath, title: "库里的标题", firstMessage: "首条" });
|
|
97
|
+
getDb().prepare("UPDATE session_meta SET last_reply = ? WHERE session_id = 'named'").run("库里的回复");
|
|
98
|
+
// 库内无行的 id(外部工具刚建)走文件兜底——那条路径需要 agentDir 隔离,
|
|
99
|
+
// 已在 session-reader.noscan.test.mjs 里用空 agentDir 单独覆盖。
|
|
107
100
|
|
|
108
101
|
const { loadSessionSummariesByIds } = await jiti.import("./session-reader.ts");
|
|
109
|
-
const sessions = await loadSessionSummariesByIds(["named", "
|
|
102
|
+
const sessions = await loadSessionSummariesByIds(["named", "missing-id"]);
|
|
110
103
|
const byId = Object.fromEntries(sessions.map((s) => [s.id, s]));
|
|
111
|
-
assert.equal(sessions.length,
|
|
112
|
-
assert.equal(byId.named.name, "
|
|
113
|
-
assert.equal(byId.named.lastReply, "
|
|
114
|
-
assert.equal(byId.plain.name, undefined, "无自定义名");
|
|
115
|
-
assert.equal(byId.plain.lastReply, "回复2");
|
|
104
|
+
assert.equal(sessions.length, 1, "missing-id 跳过");
|
|
105
|
+
assert.equal(byId.named.name, "库里的标题", "title 取库(不读文件尾 session_info)");
|
|
106
|
+
assert.equal(byId.named.lastReply, "库里的回复", "last_reply 取库(不读文件尾)");
|
|
116
107
|
assert.equal(byId.named.cwd, cwd);
|
|
117
108
|
} finally {
|
|
118
109
|
rmSync(root, { recursive: true, force: true });
|
|
119
110
|
}
|
|
120
111
|
});
|
|
121
112
|
|
|
122
|
-
test("
|
|
113
|
+
test("loadTaskSessionsPage:任务列表标题与聊天同源(session_meta,不依赖文件存在)", async () => {
|
|
123
114
|
freshDb();
|
|
124
115
|
markIndexReady();
|
|
125
|
-
//
|
|
126
|
-
// 与聊天列表 loadProjectSessions 同源(旧实现 scanOneSessionFile 读文件 → 全跳过)。
|
|
116
|
+
// 文件都不存在(磁盘扫描快照外/已删):标题/最后回复必须仍能从 session_meta 读出,
|
|
117
|
+
// 与聊天列表 loadProjectSessions 同源(旧实现 buildTaskSessionIndex 扫盘 + scanOneSessionFile 读文件 → 全跳过)。
|
|
127
118
|
seedSession({ id: "a", cwd: "/home/u/p", projectKey: "/home/u/p", taskId: "task-1", firstMessage: "任务首条", path: "/no/such/a.jsonl", modified: 300 });
|
|
128
119
|
seedSession({ id: "b", cwd: "/home/u/p", projectKey: "/home/u/p", taskId: "task-1", firstMessage: "另一条", path: "/no/such/b.jsonl", modified: 200 });
|
|
129
120
|
// 非任务会话 → 不返回
|
|
130
121
|
seedSession({ id: "c", cwd: "/home/u/p", projectKey: "/home/u/p", firstMessage: "游离", path: "/no/such/c.jsonl", modified: 100 });
|
|
131
122
|
|
|
132
|
-
const {
|
|
133
|
-
const
|
|
134
|
-
metaById: new Map([
|
|
135
|
-
["a", { path: "/no/such/a.jsonl", id: "a", modified: new Date(300) }],
|
|
136
|
-
["b", { path: "/no/such/b.jsonl", id: "b", modified: new Date(200) }],
|
|
137
|
-
]),
|
|
138
|
-
childrenOf: new Map(),
|
|
139
|
-
};
|
|
140
|
-
const res = await loadTaskSessionsPageWithIndex("task-1", index, 0, 5);
|
|
123
|
+
const { loadTaskSessionsPage } = await jiti.import("./session-reader.ts");
|
|
124
|
+
const res = await loadTaskSessionsPage("task-1", 0, 5);
|
|
141
125
|
assert.equal(res.sessions.length, 2, "文件不存在但 meta 有行 → 仍返回(与聊天列表同源)");
|
|
142
|
-
assert.equal(res.
|
|
143
|
-
|
|
144
|
-
assert.equal(
|
|
145
|
-
assert.equal(
|
|
126
|
+
assert.equal(res.rootTotal, 2, "两个根(库内无父子关系)");
|
|
127
|
+
const byId = Object.fromEntries(res.sessions.map((s) => [s.id, s]));
|
|
128
|
+
assert.equal(byId.a.firstMessage, "任务首条", "标题来自 session_meta.first_message");
|
|
129
|
+
assert.equal(byId.b.firstMessage, "另一条");
|
|
130
|
+
assert.equal(byId.a.name, undefined);
|
|
146
131
|
});
|
|
147
132
|
|
|
148
|
-
test("loadProjectSessions:first_message 空且文件有首条消息 →
|
|
133
|
+
test("loadProjectSessions:first_message 空且文件有首条消息 → 仍不读文件(回填已挪出读取路径)", async () => {
|
|
149
134
|
freshDb();
|
|
150
135
|
markIndexReady();
|
|
151
136
|
const root = mkdtempSync(join(tmpdir(), "pi-lazy-"));
|
|
@@ -153,33 +138,26 @@ test("loadProjectSessions:first_message 空且文件有首条消息 → lazy
|
|
|
153
138
|
mkdirSync(projDir, { recursive: true });
|
|
154
139
|
try {
|
|
155
140
|
const cwd = "/home/u/p";
|
|
156
|
-
// 有消息但 meta 未同步(persist
|
|
141
|
+
// 有消息但 meta 未同步(persist 建行冻结场景):旧实现列表 GET 会读文件头并回写库,
|
|
142
|
+
// 现在读取路径不摸盘——回填由扫描器(index 列)/事件链路负责,列表只呈现库里的事实。
|
|
157
143
|
const file = join(projDir, "2026-01-01T00-00-00-000Z_a.jsonl");
|
|
158
144
|
writeFileSync(file, [
|
|
159
145
|
`{"type":"session","version":3,"id":"a","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${cwd}"}`,
|
|
160
146
|
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"lazy 首条"}}',
|
|
161
147
|
].join("\n") + "\n");
|
|
162
148
|
seedSession({ id: "a", cwd, projectKey: cwd, path: file, firstMessage: null });
|
|
163
|
-
// 真空会话:文件无消息 → 保持 no messages
|
|
164
|
-
const emptyFile = join(projDir, "2026-01-02T00-00-00-000Z_b.jsonl");
|
|
165
|
-
writeFileSync(emptyFile, [
|
|
166
|
-
`{"type":"session","version":3,"id":"b","timestamp":"2026-01-02T00:00:00.000Z","cwd":"${cwd}"}`,
|
|
167
|
-
].join("\n") + "\n");
|
|
168
|
-
seedSession({ id: "b", cwd, projectKey: cwd, path: emptyFile, firstMessage: null });
|
|
169
149
|
|
|
170
150
|
const { loadProjectSessions } = await jiti.import("./session-reader.ts");
|
|
171
151
|
const sessions = await loadProjectSessions(cwd);
|
|
172
|
-
|
|
173
|
-
assert.equal(byId.a.firstMessage, "lazy 首条", "lazy 读文件补标题");
|
|
174
|
-
assert.equal(byId.b.firstMessage, "(no messages)", "真空会话保持 no messages");
|
|
152
|
+
assert.equal(sessions[0].firstMessage, "(no messages)", "列表不读文件头");
|
|
175
153
|
const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='a'").get();
|
|
176
|
-
assert.equal(row.first_message,
|
|
154
|
+
assert.equal(row.first_message, null, "列表 GET 不回写库");
|
|
177
155
|
} finally {
|
|
178
156
|
rmSync(root, { recursive: true, force: true });
|
|
179
157
|
}
|
|
180
158
|
});
|
|
181
159
|
|
|
182
|
-
test("loadSessionDetailsFromMeta
|
|
160
|
+
test("loadSessionDetailsFromMeta:任务列表同样不读文件(标题来自库,不 lazy 回写)", async () => {
|
|
183
161
|
freshDb();
|
|
184
162
|
markIndexReady();
|
|
185
163
|
const root = mkdtempSync(join(tmpdir(), "pi-lazy-task-"));
|
|
@@ -197,9 +175,9 @@ test("loadSessionDetailsFromMeta:任务列表同样 lazy 补标题并写回",
|
|
|
197
175
|
const { loadSessionDetailsFromMeta } = await jiti.import("./session-reader.ts");
|
|
198
176
|
const sessions = await loadSessionDetailsFromMeta(["a"]);
|
|
199
177
|
assert.equal(sessions.length, 1);
|
|
200
|
-
assert.equal(sessions[0].firstMessage, "
|
|
178
|
+
assert.equal(sessions[0].firstMessage, "(no messages)", "任务列表也不读文件");
|
|
201
179
|
const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='a'").get();
|
|
202
|
-
assert.equal(row.first_message,
|
|
180
|
+
assert.equal(row.first_message, null, "不回写");
|
|
203
181
|
} finally {
|
|
204
182
|
rmSync(root, { recursive: true, force: true });
|
|
205
183
|
}
|
|
@@ -8,24 +8,13 @@ import { createJiti } from "jiti";
|
|
|
8
8
|
const jiti = createJiti(import.meta.url);
|
|
9
9
|
const { sessionPathKey } = await jiti.import("./session-path.ts");
|
|
10
10
|
const {
|
|
11
|
-
listAllSessions,
|
|
12
11
|
mergeSessionLists,
|
|
13
12
|
buildSessionContext,
|
|
14
13
|
cacheSessionPath,
|
|
15
|
-
invalidateSessionListCache,
|
|
16
14
|
invalidateSessionPathCache,
|
|
17
15
|
readSessionHeader,
|
|
18
16
|
resolveSessionIdByPath,
|
|
19
17
|
} = await jiti.import("./session-reader.ts");
|
|
20
|
-
const { SessionManager } = await jiti.import("@earendil-works/pi-coding-agent");
|
|
21
|
-
const scannerMod = await jiti.import("./session-scanner.ts");
|
|
22
|
-
|
|
23
|
-
function resetSessionListState() {
|
|
24
|
-
globalThis.__piSessionListCache = undefined;
|
|
25
|
-
globalThis.__piSessionListPromise = undefined;
|
|
26
|
-
globalThis.__piSessionListPromiseGeneration = undefined;
|
|
27
|
-
globalThis.__piSessionListGeneration = 0;
|
|
28
|
-
}
|
|
29
18
|
|
|
30
19
|
function userEntry(id, parentId, content, timestamp = "2026-01-01T00:00:00.000Z") {
|
|
31
20
|
return {
|
|
@@ -359,60 +348,8 @@ test("keeps forward and reverse session path caches in sync", async () => {
|
|
|
359
348
|
assert.equal(globalThis.__piPathToSessionIdCache?.has(sessionPathKey(filePath)), false);
|
|
360
349
|
});
|
|
361
350
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
let scans = 0;
|
|
365
|
-
scannerMod.sessionScanner.scan = async () => {
|
|
366
|
-
scans += 1;
|
|
367
|
-
return [];
|
|
368
|
-
};
|
|
369
|
-
resetSessionListState();
|
|
370
|
-
t.after(() => {
|
|
371
|
-
scannerMod.sessionScanner.scan = originalScan;
|
|
372
|
-
resetSessionListState();
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
await listAllSessions({ force: true });
|
|
376
|
-
await listAllSessions();
|
|
377
|
-
assert.equal(scans, 1);
|
|
378
|
-
|
|
379
|
-
await listAllSessions({ force: true });
|
|
380
|
-
assert.equal(scans, 2);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
test("a scan invalidated in flight retries before returning to its caller", async (t) => {
|
|
384
|
-
const originalScan = scannerMod.sessionScanner.scan;
|
|
385
|
-
let scans = 0;
|
|
386
|
-
let releaseFirstScan;
|
|
387
|
-
let markFirstScanStarted;
|
|
388
|
-
const firstScanStarted = new Promise((resolve) => {
|
|
389
|
-
markFirstScanStarted = resolve;
|
|
390
|
-
});
|
|
391
|
-
const firstScanGate = new Promise((resolve) => {
|
|
392
|
-
releaseFirstScan = resolve;
|
|
393
|
-
});
|
|
394
|
-
scannerMod.sessionScanner.scan = async () => {
|
|
395
|
-
scans += 1;
|
|
396
|
-
if (scans === 1) {
|
|
397
|
-
markFirstScanStarted();
|
|
398
|
-
await firstScanGate;
|
|
399
|
-
}
|
|
400
|
-
return [];
|
|
401
|
-
};
|
|
402
|
-
resetSessionListState();
|
|
403
|
-
t.after(() => {
|
|
404
|
-
scannerMod.sessionScanner.scan = originalScan;
|
|
405
|
-
resetSessionListState();
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
const listing = listAllSessions({ force: true });
|
|
409
|
-
await firstScanStarted;
|
|
410
|
-
invalidateSessionListCache();
|
|
411
|
-
releaseFirstScan();
|
|
412
|
-
await listing;
|
|
413
|
-
|
|
414
|
-
assert.equal(scans, 2);
|
|
415
|
-
});
|
|
351
|
+
// 列表缓存(5 分钟 TTL + 在途 promise 合并)连同 listAllSessions 一起删了:读取
|
|
352
|
+
// = 每次一次单表查询,「新行立刻可见」的等价断言在 session-reader.noscan.test.mjs。
|
|
416
353
|
|
|
417
354
|
test("disk sessions replace runtime snapshots with the same id", () => {
|
|
418
355
|
const base = {
|