@zhushanwen/pi-session-reader 0.13.0 → 0.13.2

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.
@@ -1,265 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { tmpdir } from 'node:os'
3
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
4
- import { join, dirname } from 'node:path'
5
- import { DatabaseSync } from 'node:sqlite'
6
-
7
- import { buildFamilyIndex, resolveFamily, buildZcodeSubagentsByRoot } from '../core/family.js'
8
- import type { Entry, ZcodeFamilyNode } from '../core/family.js'
9
- import { listZcodeManifests } from '../discovery/zcode-manifest.js'
10
-
11
- // ============================================================
12
- // family zcode 分支(design session-reader-shared-core §3.3 D5-1 / §4 场景 2)。
13
- //
14
- // 判据(两项、不开库):锚可解析(枚举谓词已过)∧ 库文件存在(stat)→ cleanedUp=false;
15
- // 缺一 → true(GC 语义,不报错不抛异常)。刻意不含「库内 session 查询」第三项——
16
- // 静息态 CANTOPEN 会误判活会话 + 列表视图不开库;反向断言(库文件在但库内 session
17
- // 行被删 → 仍 false)即「列表视图不开库」的分工声明断言。
18
- //
19
- // 与 pi 判据不同构(D5-1 显式声明):pi = fileStats JSONL 存在性;zcode 节点无
20
- // JSONL 文件,按「库可达」判。pi 侧既有 fixture 行为零变化由 family.test.ts 全绿检查
21
- // (本文件零改动 family.test.ts——更强的零涟漪证据)。
22
- // ============================================================
23
-
24
- const ROOT = 'root-session-1'
25
-
26
- let dir: string
27
-
28
- beforeEach(() => {
29
- dir = mkdtempSync(join(tmpdir(), 'family-zcode-test-'))
30
- })
31
- afterEach(() => {
32
- rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
33
- })
34
-
35
- function header(id: string): Entry {
36
- return { type: 'session', id, parentId: null, cwd: '/proj' }
37
- }
38
-
39
- /** zcode 节点(core 层判据输入,与 ZcodeManifestRecord 结构兼容) */
40
- function zcodeNode(opts: Partial<ZcodeFamilyNode> & { id: string }): ZcodeFamilyNode {
41
- return {
42
- rootSessionId: ROOT,
43
- slug: 'worker',
44
- dbPath: join(dir, 'db.sqlite'),
45
- dbFileExists: false,
46
- ...opts,
47
- }
48
- }
49
-
50
- /** 在存储树分区 <root>/<rootSessionId>/subagents/records/ 下写 zcode manifest(F21 磁盘真实形态:无 sessionFile 键;[session-store-layout-inversion D6/单新根] 读侧已换存储树) */
51
- function writeZcodeManifest(
52
- agentDir: string,
53
- saId: string,
54
- sessionRef: Record<string, string>,
55
- ): void {
56
- const recordsDir = join(dirname(agentDir), 'ext-sessions', ROOT, 'subagents', 'records')
57
- mkdirSync(recordsDir, { recursive: true })
58
- writeFileSync(
59
- join(recordsDir, `${saId}.json`),
60
- JSON.stringify({
61
- id: saId,
62
- rootSessionId: ROOT,
63
- agentName: 'worker',
64
- status: 'running',
65
- engine: 'zcode',
66
- engineHandle: { sessionRef, poolKey: 'shared' },
67
- }),
68
- 'utf8',
69
- )
70
- }
71
-
72
- /** 造一个真实 sqlite 库(session/message/part 三级表先例形态)并插入 sess 行 */
73
- function createFixtureDb(dbPath: string, sessionId: string): void {
74
- const db = new DatabaseSync(dbPath)
75
- try {
76
- db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT)')
77
- db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, sequence INTEGER)')
78
- db.exec('CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT)')
79
- db.prepare('INSERT INTO session (id, title) VALUES (?, ?)').run(sessionId, 'fixture')
80
- } finally {
81
- db.close()
82
- }
83
- }
84
-
85
- describe('core:buildZcodeSubagentsByRoot / buildFamilyIndex 的 zcode 分支(D5-1 两态)', () => {
86
- it('库文件存在 → cleanedUp=false;库文件缺席 → cleanedUp=true(缺一即 true,不抛错)', () => {
87
- const index = buildFamilyIndex([header(ROOT)], [], new Map(), [
88
- zcodeNode({ id: 'sa-alive', dbFileExists: true }),
89
- zcodeNode({ id: 'sa-gone', dbFileExists: false }),
90
- ])
91
- const family = resolveFamily(ROOT, index)
92
-
93
- const alive = family.subagents.find((s) => s.sessionId === 'sa-alive')
94
- const gone = family.subagents.find((s) => s.sessionId === 'sa-gone')
95
- expect(alive?.cleanedUp).toBe(false)
96
- expect(gone?.cleanedUp).toBe(true)
97
- })
98
-
99
- it('zcode 节点按 rootSessionId 挂载,隔代场景与 pi 同链合并(resolveFamily 消费同一 subagentsByRoot)', () => {
100
- const FORK = 'fork-session-1'
101
- const headers = [header(ROOT), { ...header(FORK), parentSession: `/s/_${ROOT}.jsonl` }]
102
- const index = buildFamilyIndex(headers, [], new Map(), [
103
- zcodeNode({ id: 'sa-deep', rootSessionId: FORK }),
104
- ])
105
- // Q1 隔代:zcode subagent 挂在 fork 子代下,从 ROOT resolve 仍可见
106
- expect(resolveFamily(ROOT, index).subagents.map((s) => s.sessionId)).toContain('sa-deep')
107
- })
108
-
109
- it('不传 zcodeNodes / 传空数组 → pi 行为不变(零涟漪等价)', () => {
110
- const piIdent: Entry = {
111
- type: 'custom',
112
- id: 'sa-pi',
113
- parentId: null,
114
- customType: 'subagent-identity',
115
- data: { rootSessionId: ROOT, slug: 'pi-sub' },
116
- }
117
- const stats = new Map([[ROOT, { mtime: 1, size: 2 }], ['sa-pi', { mtime: 3, size: 4 }]])
118
- const without = buildFamilyIndex([header(ROOT)], [piIdent], stats)
119
- const withEmpty = buildFamilyIndex([header(ROOT)], [piIdent], stats, [])
120
- const a = resolveFamily(ROOT, without)
121
- const b = resolveFamily(ROOT, withEmpty)
122
- expect(b.subagents).toEqual(a.subagents)
123
- expect(b.subagents[0]?.cleanedUp).toBe(false) // pi 判据:fileStats 命中
124
- })
125
-
126
- it('zcode 节点与 pi 孤儿节点并存:判据互不污染(不同构声明断言)', () => {
127
- // pi 孤儿:identity 在但 fileStats 不含 → cleanedUp=true(JSONL GC)
128
- const piOrphan: Entry = {
129
- type: 'custom',
130
- id: 'sa-pi-orphan',
131
- parentId: null,
132
- customType: 'subagent-identity',
133
- data: { rootSessionId: ROOT, slug: 'orphan' },
134
- }
135
- const index = buildFamilyIndex([header(ROOT)], [piOrphan], new Map(), [
136
- zcodeNode({ id: 'sa-zcode-gone', dbFileExists: false }),
137
- ])
138
- const subs = resolveFamily(ROOT, index).subagents
139
- // 两节点都 cleanedUp=true,但判据来源不同:pi 走 fileStats,zcode 走 dbFileExists
140
- expect(subs.find((s) => s.sessionId === 'sa-pi-orphan')?.cleanedUp).toBe(true)
141
- expect(subs.find((s) => s.sessionId === 'sa-zcode-gone')?.cleanedUp).toBe(true)
142
- })
143
-
144
- it('SubagentRef 字段映射:富字段透传、sessionFile 不设、mtime/sizeBytes 占位 0', () => {
145
- const byRoot = buildZcodeSubagentsByRoot([
146
- zcodeNode({
147
- id: 'sa-rich',
148
- slug: 'rich-slug',
149
- agentName: 'explorer',
150
- task: 'do research',
151
- model: 'glm-5.3',
152
- status: 'closed',
153
- dbFileExists: true,
154
- }),
155
- ])
156
- const ref = byRoot.get(ROOT)?.[0]
157
- expect(ref?.slug).toBe('rich-slug')
158
- expect(ref?.agentName).toBe('explorer')
159
- expect(ref?.task).toBe('do research')
160
- expect(ref?.model).toBe('glm-5.3')
161
- expect(ref?.status).toBe('closed')
162
- expect(ref?.sessionFile).toBeUndefined() // zcode 无 session.jsonl
163
- expect(ref?.mtime).toBe(0)
164
- expect(ref?.sizeBytes).toBe(0)
165
- })
166
- })
167
-
168
- describe('集成:listZcodeManifests → buildFamilyIndex → resolveFamily(场景 2 两态 + 反向断言)', () => {
169
- it('库可达态(dbPath 指向真实 fixture 库)→ cleanedUp=false', async () => {
170
- // agentDir 两层化(<mkdtemp>/agent):存储树推导 = dirname(agentDir)/ext-sessions,
171
- // 单层直接当 agentDir 会把扫描根泄到 tmpdir 公共层(跨测试污染——先例 reaper 同修)
172
- const agentDir = join(dir, 'agent')
173
- mkdirSync(agentDir, { recursive: true })
174
- const dbPath = join(dir, 'isolated.sqlite')
175
- createFixtureDb(dbPath, 'sess_live')
176
- writeZcodeManifest(agentDir, 'sa-live', { sessionId: 'sess_live', dbPath })
177
-
178
- const nodes = await listZcodeManifests(agentDir)
179
- expect(nodes).toHaveLength(1)
180
- expect(nodes[0]?.dbFileExists).toBe(true)
181
-
182
- const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
183
- const sub = family.subagents.find((s) => s.sessionId === 'sa-live')
184
- expect(sub?.cleanedUp).toBe(false)
185
- })
186
-
187
- it('库不存在态(dbPath 指向不存在路径)→ cleanedUp=true(GC 语义,不报错)', async () => {
188
- const agentDir = join(dir, 'agent')
189
- mkdirSync(agentDir, { recursive: true })
190
- writeZcodeManifest(agentDir, 'sa-gone', {
191
- sessionId: 'sess_gone',
192
- dbPath: join(dir, 'subagents', 'proj-a', 'missing.sqlite'),
193
- })
194
-
195
- const nodes = await listZcodeManifests(agentDir)
196
- expect(nodes[0]?.dbFileExists).toBe(false)
197
-
198
- const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
199
- expect(family.subagents.find((s) => s.sessionId === 'sa-gone')?.cleanedUp).toBe(true)
200
- })
201
-
202
- it('反向断言:库文件在但库内 session 行被删 → 仍 cleanedUp=false(列表视图不开库的分工声明)', async () => {
203
- // 分工(D5-1):库内 session 已被 zcode GC 的情形由 read 路径 zcode_session_not_found
204
- // 权威承接;family 只 stat 文件存在性。本用例删行留文件,断言 family 不受库内容影响。
205
- const agentDir = join(dir, 'agent')
206
- mkdirSync(agentDir, { recursive: true })
207
- const dbPath = join(dir, 'gc.sqlite')
208
- createFixtureDb(dbPath, 'sess_victim')
209
- writeZcodeManifest(agentDir, 'sa-gced', { sessionId: 'sess_victim', dbPath })
210
-
211
- // 前置核实:fixture 库里确实有该 session 行(反向断言的非平凡性前提)
212
- {
213
- const db = new DatabaseSync(dbPath)
214
- try {
215
- const row = db.prepare('SELECT COUNT(*) AS c FROM session WHERE id = ?').get('sess_victim') as
216
- | { c: number }
217
- | undefined
218
- expect(row?.c).toBe(1)
219
- } finally {
220
- db.close()
221
- }
222
- }
223
-
224
- // 删库内 session 行(含 message/part 级联),db 文件保留
225
- {
226
- const db = new DatabaseSync(dbPath)
227
- try {
228
- db.prepare('DELETE FROM part WHERE session_id = ?').run('sess_victim')
229
- db.prepare('DELETE FROM message WHERE session_id = ?').run('sess_victim')
230
- db.prepare('DELETE FROM session WHERE id = ?').run('sess_victim')
231
- } finally {
232
- db.close()
233
- }
234
- }
235
-
236
- const nodes = await listZcodeManifests(agentDir)
237
- expect(nodes[0]?.dbFileExists).toBe(true) // 只 stat:文件在即 true,不看内容
238
-
239
- const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
240
- expect(family.subagents.find((s) => s.sessionId === 'sa-gced')?.cleanedUp).toBe(false)
241
- })
242
-
243
- it('坏 manifest 与缺库节点混排 → 好节点照常输出(单节点不可达不炸整屏)', async () => {
244
- const dbPath = join(dir, 'ok.sqlite')
245
- createFixtureDb(dbPath, 'sess_ok')
246
- const agentDir = join(dir, 'agent')
247
- mkdirSync(agentDir, { recursive: true })
248
- writeZcodeManifest(agentDir, 'sa-ok', { sessionId: 'sess_ok', dbPath })
249
- // 缺库节点:合法 zcode 形态(dbFileExists=false,不抛错),不剔除出列表
250
- writeZcodeManifest(agentDir, 'sa-broken-manifest', { sessionId: 'sess_x', dbPath: '/nonexistent/x.sqlite' })
251
- // 坏 JSON manifest:枚举跳过(不中断)
252
- writeFileSync(
253
- join(dirname(agentDir), 'ext-sessions', ROOT, 'subagents', 'records', 'sa-corrupt.json'),
254
- '{{{broken',
255
- 'utf8',
256
- )
257
-
258
- const nodes = await listZcodeManifests(agentDir)
259
- expect(nodes.map((n) => n.id).sort()).toEqual(['sa-broken-manifest', 'sa-ok'])
260
-
261
- const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
262
- expect(family.subagents.find((s) => s.sessionId === 'sa-ok')?.cleanedUp).toBe(false)
263
- expect(family.subagents.find((s) => s.sessionId === 'sa-broken-manifest')?.cleanedUp).toBe(true)
264
- })
265
- })