@zhushanwen/pi-session-reader 0.6.1 → 0.8.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 +100 -0
- package/package.json +6 -3
- package/src/__tests__/entry-anchor.test.ts +370 -0
- package/src/__tests__/execution-tree.test.ts +12 -12
- package/src/__tests__/family-zcode.test.ts +255 -0
- package/src/__tests__/family.test.ts +1 -1
- package/src/__tests__/find.test.ts +32 -49
- package/src/__tests__/fixtures.ts +15 -0
- package/src/__tests__/parser.test.ts +37 -13
- package/src/__tests__/render.test.ts +22 -19
- package/src/__tests__/result.test.ts +2 -2
- package/src/__tests__/roots.test.ts +32 -34
- package/src/__tests__/subagents.test.ts +131 -108
- package/src/__tests__/tool-handler.test.ts +506 -153
- package/src/__tests__/toolcall.test.ts +1 -1
- package/src/__tests__/tree.test.ts +1 -1
- package/src/__tests__/turns.test.ts +21 -20
- package/src/__tests__/workflow.test.ts +319 -23
- package/src/__tests__/zcode-manifest.test.ts +361 -0
- package/src/__tests__/zcode-routing.test.ts +751 -0
- package/src/__tests__/zcode-whitelist.test.ts +119 -0
- package/src/core/execution-tree.ts +3 -1
- package/src/core/family.ts +80 -1
- package/src/core/render.ts +1 -1
- package/src/core/toolcall.ts +2 -2
- package/src/core/tree.ts +1 -1
- package/src/core/turns.ts +1 -1
- package/src/core/workflow.ts +196 -6
- package/src/discovery/entry-anchor.ts +139 -0
- package/src/discovery/roots.ts +1 -2
- package/src/discovery/session-header.ts +30 -69
- package/src/discovery/subagents.ts +9 -2
- package/src/discovery/whitelist.ts +87 -0
- package/src/discovery/workflows.ts +232 -22
- package/src/discovery/zcode-manifest.ts +277 -0
- package/src/index.ts +7 -0
- package/src/result-action.ts +14 -5
- package/src/search-across.ts +1 -1
- package/src/tool-format.ts +455 -0
- package/src/tool-handler.ts +400 -344
- package/src/zcode-anchor-classify.ts +74 -0
- package/src/__tests__/real-data.ts +0 -96
- package/src/core/parser.ts +0 -167
|
@@ -0,0 +1,119 @@
|
|
|
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 { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
assertZcodeDbPathAllowed,
|
|
9
|
+
zcodeDbAllowlistFor,
|
|
10
|
+
} from '../discovery/whitelist.js'
|
|
11
|
+
|
|
12
|
+
// ============================================================
|
|
13
|
+
// P-whitelist(design session-reader-shared-core §3.3 D5 / §3.4 检查顺序 / 附录 A):
|
|
14
|
+
// 派生白名单在 taiji dataDir 布局下精确命中隔离库。
|
|
15
|
+
//
|
|
16
|
+
// 布局(mkdtemp 自建自删):root/agent = agentDir(dirname = dataDir root),
|
|
17
|
+
// root/engines/zcode/session-db/db.sqlite = 隔离库。测试内手拼路径段是**测试对
|
|
18
|
+
// 派生正确性的唯一验证手段**(生产 src 禁手拼,段单源 = SDK ZCODE_ISOLATED_DB_
|
|
19
|
+
// SEGMENTS 经 zcode-session-source zcodeImportDbAllowlist 引用)。
|
|
20
|
+
//
|
|
21
|
+
// 检查顺序铁律断言:存在性(不存在 → unreadable)先于路径(集合外 → forbidden)——
|
|
22
|
+
// 集合外且不存在的路径必须报 unreadable 而非 forbidden(场景 5 ④ 顺序断言的单元锚)。
|
|
23
|
+
// ============================================================
|
|
24
|
+
|
|
25
|
+
const ISOLATED_DB_SEGMENTS = ['engines', 'zcode', 'session-db', 'db.sqlite']
|
|
26
|
+
|
|
27
|
+
let root: string
|
|
28
|
+
let agentDir: string
|
|
29
|
+
let isolatedDbPath: string
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
root = mkdtempSync(join(tmpdir(), 'zcode-whitelist-test-'))
|
|
33
|
+
agentDir = join(root, 'agent')
|
|
34
|
+
mkdirSync(agentDir, { recursive: true })
|
|
35
|
+
isolatedDbPath = join(root, ...ISOLATED_DB_SEGMENTS)
|
|
36
|
+
})
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('zcodeDbAllowlistFor:tmp dataDir 布局精确命中隔离库(P-whitelist)', () => {
|
|
42
|
+
it('第一项 = zcodeIsolatedDbPath(dirname(agentDir)),集合共两项(隔离库 + 宿主库)', () => {
|
|
43
|
+
const allow = zcodeDbAllowlistFor(agentDir)
|
|
44
|
+
expect(allow).toHaveLength(2)
|
|
45
|
+
// 精确命中:手拼段与派生结果逐字节相等(同源常量 + 同一 join 派生式的行为锚)
|
|
46
|
+
expect(allow[0]).toBe(isolatedDbPath)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('realpath 对齐:agentDir 经 symlink 到达(macOS /var → /private/var 形态)仍命中', () => {
|
|
50
|
+
// tmpdir 本身在 macOS 即 symlink 链(/var → /private/var):mkdtemp 产物 realpath
|
|
51
|
+
// 后 ≠ 字面串。允许集合与 dbPath 同经 realpath 归一(whitelist.ts realOf),
|
|
52
|
+
// 两侧一致归一后比对 = 命中。isolatedDbPath 真实存在才有 realpath 归一输入。
|
|
53
|
+
mkdirSync(join(root, ...ISOLATED_DB_SEGMENTS.slice(0, -1)), { recursive: true })
|
|
54
|
+
writeFileSync(isolatedDbPath, '', 'utf8')
|
|
55
|
+
expect(() => assertZcodeDbPathAllowed(isolatedDbPath, agentDir)).not.toThrow()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('纯 pi 宿主边界(D5):agentDir 无对应 dataDir 布局时集合外路径照常拒绝', () => {
|
|
59
|
+
const outside = join(root, 'somewhere-else', 'db.sqlite')
|
|
60
|
+
mkdirSync(join(root, 'somewhere-else'), { recursive: true })
|
|
61
|
+
writeFileSync(outside, '', 'utf8')
|
|
62
|
+
expect(() => assertZcodeDbPathAllowed(outside, agentDir)).toThrow('zcode_db_path_forbidden')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('assertZcodeDbPathAllowed:检查顺序三段递进(存在性先于路径)', () => {
|
|
67
|
+
it('第 1 段存在性:白名单内路径但文件不存在 → zcode_db_unreadable(不误报 forbidden)', () => {
|
|
68
|
+
expect(() => assertZcodeDbPathAllowed(isolatedDbPath, agentDir)).toThrow(
|
|
69
|
+
'zcode_db_unreadable',
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('第 1 段先于第 2 段:集合外且文件不存在的路径 → 仍是 unreadable(不是 forbidden)', () => {
|
|
74
|
+
const outsideMissing = join(root, 'nowhere', 'evil.sqlite')
|
|
75
|
+
expect(() => assertZcodeDbPathAllowed(outsideMissing, agentDir)).toThrow(
|
|
76
|
+
'zcode_db_unreadable',
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('第 2 段路径:存在但集合外 → zcode_db_path_forbidden(安全面错误语义更重)', () => {
|
|
81
|
+
const outside = join(root, 'evil.sqlite')
|
|
82
|
+
writeFileSync(outside, '', 'utf8')
|
|
83
|
+
try {
|
|
84
|
+
assertZcodeDbPathAllowed(outside, agentDir)
|
|
85
|
+
expect.unreachable('should have thrown')
|
|
86
|
+
} catch (e) {
|
|
87
|
+
const msg = (e as Error).message
|
|
88
|
+
expect(msg).toContain('zcode_db_path_forbidden')
|
|
89
|
+
expect(msg).toContain(outside)
|
|
90
|
+
expect(msg).toContain('👉')
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('通过闸(集合内且存在)→ 不抛错(开库由调用方进行)', () => {
|
|
95
|
+
mkdirSync(join(root, ...ISOLATED_DB_SEGMENTS.slice(0, -1)), { recursive: true })
|
|
96
|
+
writeFileSync(isolatedDbPath, '', 'utf8')
|
|
97
|
+
expect(() => assertZcodeDbPathAllowed(isolatedDbPath, agentDir)).not.toThrow()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('unreadable 指引含动态推导的隔离库路径与 -wal 告诫(§3.4 文案)', () => {
|
|
101
|
+
try {
|
|
102
|
+
assertZcodeDbPathAllowed(isolatedDbPath, agentDir)
|
|
103
|
+
expect.unreachable('should have thrown')
|
|
104
|
+
} catch (e) {
|
|
105
|
+
const msg = (e as Error).message
|
|
106
|
+
expect(msg).toContain(isolatedDbPath)
|
|
107
|
+
expect(msg).toContain('-wal')
|
|
108
|
+
expect(msg).toContain('👉')
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('宿主库第二项 = join(homedir(), SDK 宿主段)(存量兼容锚点,不在本梯队触碰)', () => {
|
|
113
|
+
const allow = zcodeDbAllowlistFor(agentDir)
|
|
114
|
+
// 第二项是 homedir 派生的宿主库(真实 HOME 下的 .zcode/cli/db/db.sqlite 形态)——
|
|
115
|
+
// 测试不触碰真实文件,只断言其位于集合且 ≠ 隔离库项
|
|
116
|
+
expect(allow[1]).not.toBe(allow[0])
|
|
117
|
+
expect(allow[1].startsWith(homedir())).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -437,7 +437,9 @@ export async function buildExecutionTree(
|
|
|
437
437
|
sessionId: rootSessionId,
|
|
438
438
|
// MF-1:main root 携带 sessionFile(调用方传入),否则 attachWorkflowChildren 对无
|
|
439
439
|
// sessionFile 节点直接 return,main 自身发起的 workflow run 永远进不了执行树。
|
|
440
|
-
|
|
440
|
+
// falsy 归一为 undefined:调用方的「路径不可得」哨兵是空串(zcode family 路由
|
|
441
|
+
// 反查未命中),root 节点 details 不携带 sessionFile:"" 这类非路径占位值。
|
|
442
|
+
sessionFile: mainSessionFile || undefined,
|
|
441
443
|
depth: 0,
|
|
442
444
|
rootSessionId,
|
|
443
445
|
children: [],
|
package/src/core/family.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Entry } from '
|
|
1
|
+
import type { Entry } from '@zhushanwen/session-core'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 家族索引与解析(design §3.3 D-7)。
|
|
@@ -78,6 +78,74 @@ export interface FamilyIndex {
|
|
|
78
78
|
fileStats: Map<string, { mtime: number; size: number }>
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// ---- zcode 节点(design session-reader-shared-core §3.3 D5-1)----
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* zcode subagent 节点的判据输入视图(discovery 层 zcode-manifest.ts 枚举产出,
|
|
85
|
+
* core 层零 IO 消费)。扁平化:dbPath 取自 manifest engineHandle.sessionRef.dbPath
|
|
86
|
+
* (zcode 锚),dbFileExists 由 discovery 枚举时 statSync 预计算——stat 只查存在性
|
|
87
|
+
* **不开库**(D5-1:静息态 CANTOPEN 会把活会话误判已 GC,且列表视图每节点开库的
|
|
88
|
+
* 成本不该付;库内 session 已被 zcode GC 的情形由 read 路径 zcode_session_not_found
|
|
89
|
+
* 权威承接,列表视图保持廉价是本判据的设计核心)。
|
|
90
|
+
*/
|
|
91
|
+
export interface ZcodeFamilyNode {
|
|
92
|
+
/** record id(= sa-id,manifest 文件名 stem)。作 SubagentRef.sessionId(pi 孤儿链同款:entry.id = manifest.id) */
|
|
93
|
+
id: string
|
|
94
|
+
/** subagent 的发起 session id(subagentsByRoot 挂载键,与 pi identity.data.rootSessionId 同语义) */
|
|
95
|
+
rootSessionId: string
|
|
96
|
+
/** slug 标签(discovery 组装时已兜底 m.slug ?? m.agentName ?? '',与 pi 孤儿链同款) */
|
|
97
|
+
slug: string
|
|
98
|
+
/** agent 类型名 */
|
|
99
|
+
agentName?: string
|
|
100
|
+
task?: string
|
|
101
|
+
model?: string
|
|
102
|
+
status?: string
|
|
103
|
+
/** zcode 锚的库路径(engineHandle.sessionRef.dbPath;枚举谓词已保证非空 string) */
|
|
104
|
+
dbPath: string
|
|
105
|
+
/** 库文件存在性(discovery 枚举时 statSync 预计算;D5-1 第二判据的唯一输入) */
|
|
106
|
+
dbFileExists: boolean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* zcode 节点 → subagentsByRoot(D5-1 判据落地)。
|
|
111
|
+
*
|
|
112
|
+
* 判据(两项、不开库):**锚可解析 ∧ 库文件存在 → cleanedUp=false;缺一 → true**。
|
|
113
|
+
* 「锚可解析」在枚举层已构造性成立(listZcodeManifests 谓词 = engine==='zcode' ∧
|
|
114
|
+
* sessionRef 双键齐,未通过的记录不产出)——core 层只需消费 dbFileExists。
|
|
115
|
+
*
|
|
116
|
+
* 与 pi 节点**刻意不同构**(D5-1 显式声明):pi 判据 = fileStats JSONL 文件存在性
|
|
117
|
+
* (buildSubagentsByRoot 的 !fileStats.has(ident.id));zcode 节点住在 SQLite 库里、
|
|
118
|
+
* 没有 JSONL 文件,「文件在不在」对它是无效判据。二者不互相回归。
|
|
119
|
+
*/
|
|
120
|
+
export function buildZcodeSubagentsByRoot(
|
|
121
|
+
nodes: readonly ZcodeFamilyNode[],
|
|
122
|
+
): Map<string, SubagentRef[]> {
|
|
123
|
+
const byRoot = new Map<string, SubagentRef[]>()
|
|
124
|
+
for (const n of nodes) {
|
|
125
|
+
const ref: SubagentRef = {
|
|
126
|
+
// zcode 节点无 subagent session 文件,sessionId 用 record id(pi 孤儿链同款占位)
|
|
127
|
+
sessionId: n.id,
|
|
128
|
+
rootSessionId: n.rootSessionId,
|
|
129
|
+
slug: n.slug,
|
|
130
|
+
fileName: '', // 占位空串(family 路径不回填,与 pi 一致)
|
|
131
|
+
mtime: 0, // 无 JSONL 文件可 stat,恒 0(占位约定,见文件头注释)
|
|
132
|
+
sizeBytes: 0,
|
|
133
|
+
cwd: '',
|
|
134
|
+
// D5-1:锚可解析(枚举谓词已过)∧ 库文件存在 → false;缺一 → true(GC 语义)
|
|
135
|
+
cleanedUp: !n.dbFileExists,
|
|
136
|
+
task: n.task,
|
|
137
|
+
agentName: n.agentName,
|
|
138
|
+
model: n.model,
|
|
139
|
+
status: n.status,
|
|
140
|
+
// sessionFile 不设:zcode 无 session.jsonl(pi 孤儿保留 GC 路径,zcode 无路径可保留)
|
|
141
|
+
}
|
|
142
|
+
const list = byRoot.get(n.rootSessionId)
|
|
143
|
+
if (list) list.push(ref)
|
|
144
|
+
else byRoot.set(n.rootSessionId, [ref])
|
|
145
|
+
}
|
|
146
|
+
return byRoot
|
|
147
|
+
}
|
|
148
|
+
|
|
81
149
|
// ---- 类型守卫:从 unknown 的 entry.data 提取 subagent identity 字段 ----
|
|
82
150
|
|
|
83
151
|
/**
|
|
@@ -225,6 +293,8 @@ function buildSubagentsByRoot(
|
|
|
225
293
|
* - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
|
|
226
294
|
* - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
|
|
227
295
|
* - fileStats 原样存入 index,供 cleanedUp 判断
|
|
296
|
+
* - zcodeNodes(可选,U6)→ zcode subagent 节点并入 subagentsByRoot(D5-1 判据,
|
|
297
|
+
* 与 pi 判据不同构;不传 = pi 行为逐字节不变——零涟漪)
|
|
228
298
|
*
|
|
229
299
|
* 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
|
|
230
300
|
* 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
|
|
@@ -233,10 +303,19 @@ export function buildFamilyIndex(
|
|
|
233
303
|
headers: Entry[],
|
|
234
304
|
subagentIdentities: Entry[],
|
|
235
305
|
fileStats: Map<string, { mtime: number; size: number }>,
|
|
306
|
+
zcodeNodes?: readonly ZcodeFamilyNode[],
|
|
236
307
|
): FamilyIndex {
|
|
237
308
|
const byId = buildById(headers, fileStats)
|
|
238
309
|
const childrenOf = buildChildrenOf(headers, byId)
|
|
239
310
|
const subagentsByRoot = buildSubagentsByRoot(subagentIdentities, fileStats)
|
|
311
|
+
if (zcodeNodes !== undefined && zcodeNodes.length > 0) {
|
|
312
|
+
const zcodeByRoot = buildZcodeSubagentsByRoot(zcodeNodes)
|
|
313
|
+
for (const [rootId, refs] of zcodeByRoot) {
|
|
314
|
+
const existing = subagentsByRoot.get(rootId)
|
|
315
|
+
if (existing) existing.push(...refs)
|
|
316
|
+
else subagentsByRoot.set(rootId, refs)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
240
319
|
return { byId, childrenOf, subagentsByRoot, fileStats }
|
|
241
320
|
}
|
|
242
321
|
|
package/src/core/render.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Entry } from '
|
|
1
|
+
import type { Entry } from '@zhushanwen/session-core'
|
|
2
2
|
import { BYTES_PER_KB } from './units.js'
|
|
3
3
|
import { extractToolCalls, formatToolCallSummary, type ToolCallInfo } from './toolcall.js'
|
|
4
4
|
import type { Turn } from './turns.js'
|
package/src/core/toolcall.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* message.toolCalls 顶层字段从未存在——v1 render.ts 读 toolCalls 恒返 [] 是 O1 要修的 bug。
|
|
7
7
|
* - arguments 始终是 object(10 工具 100%);string 形态做 JSON.parse 兜底(失败返 {}),防御历史/异类实现。
|
|
8
8
|
* - toolResult.message 自带 toolName + toolCallId(515/515,全部匹配 toolCall.id),
|
|
9
|
-
*
|
|
9
|
+
* Entry 接口(session-core 基座)已 additive 透出这两个字段,O2/O3 据此精确关联取参数。
|
|
10
10
|
*/
|
|
11
|
-
import type { Entry } from '
|
|
11
|
+
import type { Entry } from '@zhushanwen/session-core'
|
|
12
12
|
import { BYTES_PER_KB } from './units.js'
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
package/src/core/tree.ts
CHANGED
package/src/core/turns.ts
CHANGED
package/src/core/workflow.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// ============================================================
|
|
2
|
-
// workflow 概览解析与渲染(w5
|
|
2
|
+
// workflow 概览解析与渲染(w5 新增 → [D16③] record 流换源)
|
|
3
3
|
// ============================================================
|
|
4
4
|
//
|
|
5
|
-
// 本文件消费 discovery/workflows.ts 的 readRunSnapshot
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
5
|
+
// 本文件消费 discovery/workflows.ts 的 readRunSnapshot(v1/旧指针档——返 unknown
|
|
6
|
+
// 原始快照对象,NEW/OLD 双格式分支)与 record 流文本([D16③] v2 档——journalPath
|
|
7
|
+
// 锚点语义重定义为 record 流路径后的新数据源),把 unknown/事件流行类型化为
|
|
8
|
+
// WorkflowOverview,再渲染为人类可读文本。零 IO:parseRunSnapshot /
|
|
9
|
+
// parseRunRecordStream / renderWorkflowOverview 喂 mock 即可单测(w5
|
|
10
|
+
// TC-wf-core-pure-logic,对齐 session-reader core/* 纯逻辑约定)。
|
|
9
11
|
//
|
|
10
12
|
// 不 import @zhushanwen/pi-subagent-workflow 的 RunSnapshot 类型——跨包类型耦合会使上游
|
|
11
13
|
// 升版连带编译期影响本扩展;且上游类型只描述 NEW,OLD 仍需自处理(TC-wf-snapshot-version-union)。
|
|
@@ -72,7 +74,7 @@ export interface WorkflowOverview {
|
|
|
72
74
|
runId: string
|
|
73
75
|
/** WorkflowRef.stateFile 透传 */
|
|
74
76
|
stateFile: string
|
|
75
|
-
/** NEW state.status / OLD 顶层 status */
|
|
77
|
+
/** NEW state.status / OLD 顶层 status / record 流投影([D2] 三态:running | interrupted | done) */
|
|
76
78
|
status: string
|
|
77
79
|
/** details 格式标记(随 details.runs 透传给程序化消费方,文本渲染不读)。v2 读取面形状与 v1 一致(pi-subagent-workflow 8.x 一次性生命周期) */
|
|
78
80
|
version: 'wf-run-v1' | 'wf-run-v2' | 'legacy'
|
|
@@ -319,3 +321,191 @@ export function renderWorkflowOverview(overview: WorkflowOverview): string {
|
|
|
319
321
|
if (overview.error) lines.push(`error: ${overview.error}`)
|
|
320
322
|
return lines.join('\n')
|
|
321
323
|
}
|
|
324
|
+
|
|
325
|
+
// ---- parseRunRecordStream([D16③] record 事件流 → WorkflowOverview)----
|
|
326
|
+
|
|
327
|
+
/** record 事件流逐行宽容解析后的最小行视图(type 判别 + 载荷按需读)。 */
|
|
328
|
+
interface RecordStreamLine {
|
|
329
|
+
type: unknown
|
|
330
|
+
ts?: unknown
|
|
331
|
+
[key: string]: unknown
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 流解析产物:run 装配面(runId 透传 + stateFile=record 流路径)。 */
|
|
335
|
+
interface RecordStreamFold {
|
|
336
|
+
workflowName?: string
|
|
337
|
+
startedAt?: number
|
|
338
|
+
interrupted?: { errorCode?: string; reason?: string; ts: number }
|
|
339
|
+
runSettled?: { outcome?: string; reason?: string; ts: number }
|
|
340
|
+
budgetUsedTokens?: number
|
|
341
|
+
calls: Map<number, RecordStreamCallFold>
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
interface RecordStreamCallFold {
|
|
345
|
+
index: number
|
|
346
|
+
agentName?: string
|
|
347
|
+
phase?: string
|
|
348
|
+
settledOutcome?: string
|
|
349
|
+
durationMs?: number
|
|
350
|
+
sessionFile?: string
|
|
351
|
+
sessionId?: string
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parseRecordStreamLine(line: string): RecordStreamLine | undefined {
|
|
355
|
+
const trimmed = line.trim()
|
|
356
|
+
if (trimmed === '') return undefined
|
|
357
|
+
try {
|
|
358
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
359
|
+
if (typeof parsed === 'object' && parsed !== null) return parsed as RecordStreamLine
|
|
360
|
+
return undefined
|
|
361
|
+
} catch {
|
|
362
|
+
return undefined // 坏行(截断行)跳过
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* record 事件流 → WorkflowOverview([D16③] 概览链换源;[D2]/[D3]/[D4] 词表):
|
|
368
|
+
* - run-created:workflowName + startedAt(ISO 换算);
|
|
369
|
+
* - agent-started:call 行骨架(agentName / phase / 首见生效);
|
|
370
|
+
* - agent-settled:call 终局(outcome/durationMs + result 全文的 sessionFile/
|
|
371
|
+
* sessionId——家族链跳转入口);
|
|
372
|
+
* - run-interrupted:[D2] 中断转移 → status 'interrupted'(非终局);
|
|
373
|
+
* - run-resumed:[D2] 复活转移 → 清中断标记(status 回 running——lifecycle fold
|
|
374
|
+
* 投影语义:run-resumed 是 interrupted → running 的转移证据,跳过会使 resume
|
|
375
|
+
* 续跑期间的概览停在 interrupted 直至终局);
|
|
376
|
+
* - run-settled:终局帧 → status 'done' + outcome(done/failed/cancelled/time_limited);
|
|
377
|
+
* - 其余(phase-started / phase-settled):跳过(状态机转移不进概览文本——phase 分组展示
|
|
378
|
+
* 归 format 域,U3 接线)。
|
|
379
|
+
*
|
|
380
|
+
* 空流/无终局帧 → status 'running'(活跃 run 概览不退化为 skipped——红线)。
|
|
381
|
+
*/
|
|
382
|
+
/** [parseRunRecordStream 拆分] 单行 fold 归并(rec 已过 parseRecordStreamLine 判形)。 */
|
|
383
|
+
function applyRecordStreamLine(fold: RecordStreamFold, rec: RecordStreamLine): void {
|
|
384
|
+
if (rec.type === 'run-created') {
|
|
385
|
+
fold.workflowName = typeof rec.workflowName === 'string' ? rec.workflowName : undefined
|
|
386
|
+
fold.startedAt = typeof rec.ts === 'number' ? rec.ts : undefined
|
|
387
|
+
} else if (rec.type === 'agent-started' && typeof rec.taskIndex === 'number') {
|
|
388
|
+
applyAgentStartedToFold(fold, rec)
|
|
389
|
+
} else if (rec.type === 'agent-settled' && typeof rec.taskIndex === 'number') {
|
|
390
|
+
applyAgentSettledToFold(fold, rec)
|
|
391
|
+
} else if (rec.type === 'run-interrupted' || rec.type === 'run-resumed' || rec.type === 'run-settled') {
|
|
392
|
+
applyRunTransitionLine(fold, rec)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** [applyRecordStreamLine 拆分] run 级转移帧归并(interrupted 置位 / resumed 清除 / settled 落终局)。 */
|
|
397
|
+
function applyRunTransitionLine(fold: RecordStreamFold, rec: RecordStreamLine): void {
|
|
398
|
+
if (rec.type === 'run-interrupted') {
|
|
399
|
+
fold.interrupted = {
|
|
400
|
+
errorCode: typeof rec.errorCode === 'string' ? rec.errorCode : undefined,
|
|
401
|
+
reason: typeof rec.reason === 'string' ? rec.reason : undefined,
|
|
402
|
+
ts: typeof rec.ts === 'number' ? rec.ts : 0,
|
|
403
|
+
}
|
|
404
|
+
} else if (rec.type === 'run-resumed') {
|
|
405
|
+
fold.interrupted = undefined
|
|
406
|
+
} else if (rec.type === 'run-settled') {
|
|
407
|
+
fold.runSettled = {
|
|
408
|
+
outcome: typeof rec.outcome === 'string' ? rec.outcome : undefined,
|
|
409
|
+
reason: typeof rec.reason === 'string' ? rec.reason : undefined,
|
|
410
|
+
ts: typeof rec.ts === 'number' ? rec.ts : 0,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** [applyRecordStreamLine 拆分] agent-started 归并(已有行不覆盖——首见即权威)。 */
|
|
416
|
+
function applyAgentStartedToFold(
|
|
417
|
+
fold: RecordStreamFold,
|
|
418
|
+
rec: RecordStreamLine,
|
|
419
|
+
): void {
|
|
420
|
+
const taskIndex = rec.taskIndex
|
|
421
|
+
if (typeof taskIndex !== 'number') return
|
|
422
|
+
if (fold.calls.has(taskIndex)) return
|
|
423
|
+
fold.calls.set(taskIndex, {
|
|
424
|
+
index: taskIndex,
|
|
425
|
+
agentName: typeof rec.agentName === 'string' ? rec.agentName : undefined,
|
|
426
|
+
phase: typeof rec.phase === 'string' ? rec.phase : undefined,
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** [applyRecordStreamLine 拆分] agent-settled 归并(终局字段覆盖,session 锚点取 result 载荷、缺席保留既有)。 */
|
|
431
|
+
function applyAgentSettledToFold(
|
|
432
|
+
fold: RecordStreamFold,
|
|
433
|
+
rec: RecordStreamLine,
|
|
434
|
+
): void {
|
|
435
|
+
const taskIndex = rec.taskIndex
|
|
436
|
+
if (typeof taskIndex !== 'number') return
|
|
437
|
+
const existing = fold.calls.get(taskIndex) ?? { index: taskIndex }
|
|
438
|
+
const result = typeof rec.result === 'object' && rec.result !== null ? (rec.result as Record<string, unknown>) : {}
|
|
439
|
+
fold.calls.set(taskIndex, {
|
|
440
|
+
...existing,
|
|
441
|
+
settledOutcome: typeof rec.outcome === 'string' ? rec.outcome : undefined,
|
|
442
|
+
durationMs: typeof rec.durationMs === 'number' ? rec.durationMs : undefined,
|
|
443
|
+
sessionFile: typeof result.sessionFile === 'string' ? result.sessionFile : existing.sessionFile,
|
|
444
|
+
sessionId: typeof result.sessionId === 'string' ? result.sessionId : existing.sessionId,
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** [parseRunRecordStream 拆分] status 三态投影([D2]):终局帧 > 中断转移 > running(空流也 running——活跃兜底)。 */
|
|
449
|
+
function projectRecordStreamStatus(fold: RecordStreamFold): {
|
|
450
|
+
status: string
|
|
451
|
+
reason: string | undefined
|
|
452
|
+
completedAt: string | undefined
|
|
453
|
+
error: string | undefined
|
|
454
|
+
} {
|
|
455
|
+
if (fold.runSettled !== undefined) {
|
|
456
|
+
return {
|
|
457
|
+
status: 'done',
|
|
458
|
+
reason: fold.runSettled.outcome,
|
|
459
|
+
completedAt: new Date(fold.runSettled.ts).toISOString(),
|
|
460
|
+
error: fold.runSettled.outcome === 'failed' ? fold.runSettled.reason ?? fold.runSettled.outcome : undefined,
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (fold.interrupted !== undefined) {
|
|
464
|
+
return { status: 'interrupted', reason: undefined, completedAt: undefined, error: fold.interrupted.reason }
|
|
465
|
+
}
|
|
466
|
+
return { status: 'running', reason: undefined, completedAt: undefined, error: undefined }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** [parseRunRecordStream 拆分] call 行 → 概览 steps(index 升序,条件展开保持键缺席语义)。 */
|
|
470
|
+
function foldCallsToOverviewSteps(fold: RecordStreamFold): WorkflowStep[] {
|
|
471
|
+
return [...fold.calls.values()]
|
|
472
|
+
.sort((a, b) => a.index - b.index)
|
|
473
|
+
.map((call) => ({
|
|
474
|
+
index: call.index,
|
|
475
|
+
status: call.settledOutcome === undefined ? 'running' : 'done',
|
|
476
|
+
...(call.agentName !== undefined ? { description: call.agentName } : {}),
|
|
477
|
+
...(call.durationMs !== undefined ? { durationMs: call.durationMs } : {}),
|
|
478
|
+
...(call.sessionId !== undefined ? { sessionId: call.sessionId } : {}),
|
|
479
|
+
...(call.sessionFile !== undefined ? { sessionFile: call.sessionFile } : {}),
|
|
480
|
+
}))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function parseRunRecordStream(
|
|
484
|
+
content: string | undefined,
|
|
485
|
+
runId: string,
|
|
486
|
+
stateFile: string,
|
|
487
|
+
): WorkflowOverview {
|
|
488
|
+
const fold: RecordStreamFold = { calls: new Map() }
|
|
489
|
+
for (const line of content?.split('\n') ?? []) {
|
|
490
|
+
const rec = parseRecordStreamLine(line)
|
|
491
|
+
if (rec === undefined) continue
|
|
492
|
+
applyRecordStreamLine(fold, rec)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const { status, reason, completedAt, error } = projectRecordStreamStatus(fold)
|
|
496
|
+
const steps = foldCallsToOverviewSteps(fold)
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
runId,
|
|
500
|
+
stateFile,
|
|
501
|
+
status,
|
|
502
|
+
version: 'wf-run-v2',
|
|
503
|
+
...(fold.workflowName !== undefined ? { script: fold.workflowName } : {}),
|
|
504
|
+
...(fold.startedAt !== undefined ? { startedAt: new Date(fold.startedAt).toISOString() } : {}),
|
|
505
|
+
...(completedAt !== undefined ? { completedAt } : {}),
|
|
506
|
+
...(reason !== undefined ? { reason } : {}),
|
|
507
|
+
...(error !== undefined ? { error } : {}),
|
|
508
|
+
budget: {},
|
|
509
|
+
steps,
|
|
510
|
+
}
|
|
511
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import { parseSessionContent, type Entry } from '@zhushanwen/session-core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* zcode 锚的 entry 兜底定位(设计 session-reader-shared-core §3.1 第②步)。
|
|
7
|
+
*
|
|
8
|
+
* 定位链第②层:zcode manifest 直读(第①层,zcode-manifest.ts)缺位/残缺时,
|
|
9
|
+
* 在候选主 session 文件里按 sa-id 扫 `subagent-record` custom entry,取其
|
|
10
|
+
* `engineHandle.sessionRef`(zcode 形态 = `{sessionId, dbPath}`)。
|
|
11
|
+
*
|
|
12
|
+
* 边界承载(设计四要素登记):本函数只认「候选文件列表」,越界语义(不做根内
|
|
13
|
+
* 全扫、liveSessionDir 优先排序)由调用方构造候选集时决定——第一梯队只传
|
|
14
|
+
* liveSessionDir 内的主 session 文件,越界查询返回 not-found 信号(undefined,
|
|
15
|
+
* 调用方映射 `zcode_record_not_found` 错误码,§3.4)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* zcode 锚(zcode 引擎自描述定位符的 zcode 形态)。与 manifest 直读产出的锚同形;
|
|
20
|
+
* `sessionRef` 写侧整体透传不枚举内部键(subagent-core record-entry.ts
|
|
21
|
+
* engineHandle.sessionRef 注释),本单元按 zcode 形态校验并窄化为双键。
|
|
22
|
+
*/
|
|
23
|
+
export interface ZcodeAnchor {
|
|
24
|
+
sessionId: string
|
|
25
|
+
dbPath: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `subagent-record` custom entry 的 customType 值。
|
|
30
|
+
*
|
|
31
|
+
* 磁盘 JSONL 协议字符串(跨侧契约):写侧单源 = subagent-core record-entry.ts 的
|
|
32
|
+
* SUBAGENT_RECORD_CUSTOM_TYPE。reader 生产依赖面不引 subagent-core(该包是
|
|
33
|
+
* devDependency + vitest alias 形态),故按协议字符串本地持有;两侧漂移由
|
|
34
|
+
* entry-anchor.test.ts 的跨包断言守卫。
|
|
35
|
+
*/
|
|
36
|
+
const SUBAGENT_RECORD_CUSTOM_TYPE = 'subagent-record'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* v2 条目 data 的认识版本词表(W1 起 = 2)。与写侧 subagent-core
|
|
40
|
+
* SUBAGENT_RECORD_ENTRY_VERSION 同值、同「本地持有 + 测试守卫漂移」模式——写侧
|
|
41
|
+
* 再升 v3 时本守卫先红,提醒评估新版本的锚读取面。
|
|
42
|
+
*/
|
|
43
|
+
const SUBAGENT_RECORD_ENTRY_V2 = 2
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 从单条 entry 提取 zcode 锚。形状校验窄而严,任一不满足 = 该条不算命中(返回
|
|
47
|
+
* undefined,调用方继续扫——缺键条目不遮蔽更早的完整条目):
|
|
48
|
+
* - `type === 'custom'` ∧ `customType === 'subagent-record'`
|
|
49
|
+
* - 版本/判别键二选一(schema 版本守卫,不认识的版本跳过而非猜测——写侧
|
|
50
|
+
* record-entry.ts classifySubagentRecordEntryData 同口径):
|
|
51
|
+
* - v1:`data.v === 1`(全量快照形态,兼容读面);
|
|
52
|
+
* - v2:`data.v === 2` ∧ `data.kind === 'settled'`(W1 注册/终态两条小条目——
|
|
53
|
+
* **锚只取自终态条**:registered 条目不携带 engineHandle,且设计 D1 裁决
|
|
54
|
+
* 「sessionRef 双键取自终态条」;kind 非法 = unknown-kind 跳过);
|
|
55
|
+
* - `data.id === saId`(record id 即 sa-id,record-access.ts `sa-${uuid}`)
|
|
56
|
+
* - `engineHandle.sessionRef.sessionId` / `.dbPath` 均为非空 string(F8 双键齐判据的
|
|
57
|
+
* 锚字段部分;pi record 的 sessionRef 无 dbPath → 天然不命中,无需 engine 判别)
|
|
58
|
+
*/
|
|
59
|
+
function zcodeAnchorOfEntry(entry: Entry, saId: string): ZcodeAnchor | undefined {
|
|
60
|
+
if (entry.type !== 'custom' || entry.customType !== SUBAGENT_RECORD_CUSTOM_TYPE) return undefined
|
|
61
|
+
const data: unknown = entry.data
|
|
62
|
+
if (typeof data !== 'object' || data === null) return undefined
|
|
63
|
+
const d = data as Record<string, unknown>
|
|
64
|
+
if (d.id !== saId) return undefined
|
|
65
|
+
if (!isAnchorEligibleRecordEntryVersion(d)) return undefined
|
|
66
|
+
return extractZcodeSessionRefAnchor(d.engineHandle)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** [zcodeAnchorOfEntry 拆分] 条目形态判别:v1 全量快照 或 v2 终态条(registered
|
|
70
|
+
* 条目不携带 engineHandle,且设计 D1 裁决「sessionRef 双键取自终态条」;kind
|
|
71
|
+
* 非法 = unknown-kind 跳过)。 */
|
|
72
|
+
function isAnchorEligibleRecordEntryVersion(d: Record<string, unknown>): boolean {
|
|
73
|
+
if (d.v === SUBAGENT_RECORD_ENTRY_V2) {
|
|
74
|
+
return d.kind === 'settled'
|
|
75
|
+
}
|
|
76
|
+
return d.v === 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** [zcodeAnchorOfEntry 拆分] engineHandle.sessionRef 双键提取(sessionId/dbPath
|
|
80
|
+
* 均非空 string 才命中;pi record 的 sessionRef 无 dbPath → 天然不命中)。 */
|
|
81
|
+
function extractZcodeSessionRefAnchor(engineHandle: unknown): ZcodeAnchor | undefined {
|
|
82
|
+
if (typeof engineHandle !== 'object' || engineHandle === null) return undefined
|
|
83
|
+
const ref: unknown = (engineHandle as Record<string, unknown>).sessionRef
|
|
84
|
+
if (typeof ref !== 'object' || ref === null) return undefined
|
|
85
|
+
const r = ref as Record<string, unknown>
|
|
86
|
+
const { sessionId, dbPath } = r
|
|
87
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined
|
|
88
|
+
if (typeof dbPath !== 'string' || dbPath === '') return undefined
|
|
89
|
+
return { sessionId, dbPath }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 在候选主 session 文件里按 sa-id 定位 zcode 锚。
|
|
94
|
+
*
|
|
95
|
+
* 取条语义(设计 §2.5 裁决,subagent-core collectLastRecordEntries 逐字同构——
|
|
96
|
+
* 「主 session 全文 → 每 id 末条 record data」,record-store-rebuild.ts:212-227):
|
|
97
|
+
* 同一 sa-id 每次状态迁移写一条全量快照 entry 且 sessionRef 每轮覆写(F16),故
|
|
98
|
+
* 同文件内后条覆盖前条 = 文件顺序末条。**禁止另创取首条/合并语义**(附录 B-6)。
|
|
99
|
+
* W1 v2 双形态下末条锚定语义不变:registered 条目不命中(无 engineHandle),
|
|
100
|
+
* 「末条」= 最后一条可命中的 v1 快照条或 v2 终态条(v2 settled 条目也随 reopen
|
|
101
|
+
* 多轮各写一条,后写覆盖前写,与 v1 覆写同构)。
|
|
102
|
+
*
|
|
103
|
+
* 多候选文件:按候选列表顺序逐文件扫,文件内有命中锚(含末条裁决后)即止——
|
|
104
|
+
* 候选顺序 = 调用方声明的优先级(liveSessionDir 优先),不跨文件合并。
|
|
105
|
+
*
|
|
106
|
+
* 行级容错:逐行解析由基座 parseSessionContent 承担(坏行跳过继续、末行半行记
|
|
107
|
+
* lastLinePartial 不中断)——best-effort,不因一行坏 JSON 失败整文件。
|
|
108
|
+
*
|
|
109
|
+
* 候选文件读失败(不存在/不可读)= 该候选无命中,跳过继续下一候选;本函数契约
|
|
110
|
+
* 是「锚或 not-found」,IO 错误同样收敛到 not-found 信号(调用方映射错误码),
|
|
111
|
+
* 不向上抛。
|
|
112
|
+
*
|
|
113
|
+
* 读取用 node:fs/promises 的 async readFile——本函数跑在 pi 扩展进程内,同步
|
|
114
|
+
* readFileSync 全文读入会阻塞事件循环(同函数族 classifyIncompleteEntryAnchor
|
|
115
|
+
* 同形态;对照 readSessionHeaderIdSync 只在首行 4KB 有界读时才允许同步)。
|
|
116
|
+
*
|
|
117
|
+
* @returns 命中的锚;所有候选均未命中(或全部候选不可读)→ undefined(not-found 信号)
|
|
118
|
+
*/
|
|
119
|
+
export async function findZcodeEntryAnchor(
|
|
120
|
+
candidateFiles: readonly string[],
|
|
121
|
+
saId: string,
|
|
122
|
+
): Promise<ZcodeAnchor | undefined> {
|
|
123
|
+
for (const file of candidateFiles) {
|
|
124
|
+
let content: string
|
|
125
|
+
try {
|
|
126
|
+
content = await readFile(file, 'utf8')
|
|
127
|
+
} catch {
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
const { entries } = parseSessionContent(content)
|
|
131
|
+
let anchor: ZcodeAnchor | undefined
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const hit = zcodeAnchorOfEntry(entry, saId)
|
|
134
|
+
if (hit !== undefined) anchor = hit
|
|
135
|
+
}
|
|
136
|
+
if (anchor !== undefined) return anchor
|
|
137
|
+
}
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
package/src/discovery/roots.ts
CHANGED
|
@@ -67,8 +67,7 @@ const SUBAGENTS_DIRNAME = 'subagents'
|
|
|
67
67
|
/**
|
|
68
68
|
* `[live]` 规范化(§6.1/§7B 要点 3):`getSessionDir()` 在纯 pi 返回按 cwd 编码的子目录
|
|
69
69
|
*(`<agentDir>/sessions/--Users-x--`),在 taiji 覆盖态返回根本身。判据:basename
|
|
70
|
-
* 匹配 encodeCwd 形态(`--` 开头 `--`
|
|
71
|
-
* 与 real-data.ts)则取 dirname,否则取自身。
|
|
70
|
+
* 匹配 encodeCwd 形态(`--` 开头 `--` 结尾)则取 dirname,否则取自身。
|
|
72
71
|
*/
|
|
73
72
|
export function normalizeLiveSessionDir(liveSessionDir: string): string {
|
|
74
73
|
const base = basename(liveSessionDir)
|