@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.
Files changed (43) hide show
  1. package/README.md +100 -0
  2. package/package.json +6 -3
  3. package/src/__tests__/entry-anchor.test.ts +370 -0
  4. package/src/__tests__/execution-tree.test.ts +12 -12
  5. package/src/__tests__/family-zcode.test.ts +255 -0
  6. package/src/__tests__/family.test.ts +1 -1
  7. package/src/__tests__/find.test.ts +32 -49
  8. package/src/__tests__/fixtures.ts +15 -0
  9. package/src/__tests__/parser.test.ts +37 -13
  10. package/src/__tests__/render.test.ts +22 -19
  11. package/src/__tests__/result.test.ts +2 -2
  12. package/src/__tests__/roots.test.ts +32 -34
  13. package/src/__tests__/subagents.test.ts +131 -108
  14. package/src/__tests__/tool-handler.test.ts +506 -153
  15. package/src/__tests__/toolcall.test.ts +1 -1
  16. package/src/__tests__/tree.test.ts +1 -1
  17. package/src/__tests__/turns.test.ts +21 -20
  18. package/src/__tests__/workflow.test.ts +319 -23
  19. package/src/__tests__/zcode-manifest.test.ts +361 -0
  20. package/src/__tests__/zcode-routing.test.ts +751 -0
  21. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  22. package/src/core/execution-tree.ts +3 -1
  23. package/src/core/family.ts +80 -1
  24. package/src/core/render.ts +1 -1
  25. package/src/core/toolcall.ts +2 -2
  26. package/src/core/tree.ts +1 -1
  27. package/src/core/turns.ts +1 -1
  28. package/src/core/workflow.ts +196 -6
  29. package/src/discovery/entry-anchor.ts +139 -0
  30. package/src/discovery/roots.ts +1 -2
  31. package/src/discovery/session-header.ts +30 -69
  32. package/src/discovery/subagents.ts +9 -2
  33. package/src/discovery/whitelist.ts +87 -0
  34. package/src/discovery/workflows.ts +232 -22
  35. package/src/discovery/zcode-manifest.ts +277 -0
  36. package/src/index.ts +7 -0
  37. package/src/result-action.ts +14 -5
  38. package/src/search-across.ts +1 -1
  39. package/src/tool-format.ts +455 -0
  40. package/src/tool-handler.ts +400 -344
  41. package/src/zcode-anchor-classify.ts +74 -0
  42. package/src/__tests__/real-data.ts +0 -96
  43. package/src/core/parser.ts +0 -167
@@ -1,44 +1,28 @@
1
- import { open, type FileHandle } from 'node:fs/promises'
2
- import { closeSync, openSync, readSync } from 'node:fs'
1
+ import {
2
+ readFirstJsonlLine,
3
+ readFirstJsonlLineSync,
4
+ type SessionHeader,
5
+ } from '@zhushanwen/session-core'
3
6
 
4
7
  /**
5
- * [ext-simplify-18 D5] session 文件首行 header 读取的包内单源(原 subagents.ts / find.ts
6
- * 两份 async 副本 + tool-handler.ts 一份 sync 副本合一)。不进 shared——跨包第二消费方
7
- * 不存在(runtime 侧首行读取是另一形态 + 分层禁反向 import,设计负面清单同口径)。
8
+ * [session-reader-shared-core Phase 1] session 文件首行 header 读取的包内单源(原
9
+ * subagents.ts / find.ts 两份 async 副本 + tool-handler.ts 一份 sync 副本合一后的单源)。
10
+ *
11
+ * 字节原语(buffer 管理/续读/CRLF/空文件)已收敛至基座 `readFirstJsonlLine`(sync/async
12
+ * 双形态)——本模块只剩两件 reader 私有事:
13
+ * ① **header 谓词薄包装**(G4 红线,设计 §5 Phase 0):reader 仅要求 `type==='session'`
14
+ * 且 `id` 为 string,cwd/parentSession 可缺。runtime 侧「id+cwd 非空」严谓词是不同的
15
+ * 行为契约(缺 cwd 的 header 在 reader 候选集必须可见),谓词语义绝不统一进基座;
16
+ * ② **错误分流薄包装**:旧副本契约是「任何失败都视为无 header → undefined/null」,基座
17
+ * 原语 IO 错误上抛(错误分类留给有消费方的调用侧),reader 无错误分类消费方,catch-all
18
+ * 收敛回旧契约。
8
19
  */
9
20
 
10
- /** session header(首行 type==='session' entry 的消费子集)。 */
11
- export interface SessionHeader {
12
- id: string
13
- cwd?: string
14
- parentSession?: string
15
- }
21
+ export type { SessionHeader }
16
22
 
17
23
  /**
18
- * 首行读取 buffer 上限。header 超 8KB 的极端情况会截断致 parse 失败(parseSessionHeader
19
- * 返回 null)——session header(id/cwd/parentSession)实测 < 300 字节,8KB 约 27 倍余量。
24
+ * 解析 header 首行为 SessionHeader(谓词薄包装)。非 session 行/缺 id/坏 JSON → null。
20
25
  */
21
- export const HEADER_READ_BYTES = 8192
22
-
23
- /** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
24
- export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
25
- let fh: FileHandle | undefined
26
- try {
27
- fh = await open(path, 'r')
28
- const buf = Buffer.alloc(HEADER_READ_BYTES)
29
- const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
30
- if (bytesRead === 0) return undefined
31
- const text = buf.subarray(0, bytesRead).toString('utf8')
32
- const nl = text.indexOf('\n')
33
- return nl === -1 ? text : text.slice(0, nl)
34
- } catch {
35
- return undefined
36
- } finally {
37
- await fh?.close().catch(() => {})
38
- }
39
- }
40
-
41
- /** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
42
26
  export function parseSessionHeader(line: string | undefined): SessionHeader | null {
43
27
  if (!line) return null
44
28
  let raw: unknown
@@ -56,48 +40,25 @@ export function parseSessionHeader(line: string | undefined): SessionHeader | nu
56
40
  return h
57
41
  }
58
42
 
59
- /** readSessionHeaderIdSync 读首行的 buffer 上限。session header 实测 < 300 字节,4KB 足够。 */
60
- const SYNC_HEADER_READ_BYTES = 4096
43
+ /** 读文件首行(header)薄包装:基座原语 + IO 失败收敛 undefined(空文件基座已返 undefined)。 */
44
+ export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
45
+ try {
46
+ return await readFirstJsonlLine(path)
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
61
51
 
62
52
  /**
63
53
  * 同步读 session 文件首行 header,返回 type==='session' 的 id。
64
- *
65
- * 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 async 版
66
- * readSessionHeaderFirstLine/parseSessionHeader 同构(定长 buffer 读首行 + JSON.parse +
67
- * type 校验),但用同步 fs API(resolveSessionId 内仅调用 1 次,同步开销可接受),
68
- * 窗口取 4KB(仅取 id)——sync 形态与窗口差异是刻意的调用上下文适配,独立常量、
69
- * 不与 async 版 8KB 强行统一。
54
+ * 任何失败(文件不存在/空文件/解析失败/type 不符)→ undefined(旧 sync 副本契约不变)。
70
55
  */
71
56
  export function readSessionHeaderIdSync(filePath: string): string | undefined {
72
- let fd: number | undefined
57
+ let line: string | undefined
73
58
  try {
74
- fd = openSync(filePath, 'r')
75
- const buf = Buffer.alloc(SYNC_HEADER_READ_BYTES)
76
- const bytesRead = readSync(fd, buf, 0, SYNC_HEADER_READ_BYTES, 0)
77
- if (bytesRead === 0) return undefined
78
- const text = buf.subarray(0, bytesRead).toString('utf8')
79
- const nl = text.indexOf('\n')
80
- const line = nl === -1 ? text : text.slice(0, nl)
81
- let raw: unknown
82
- try {
83
- raw = JSON.parse(line)
84
- } catch {
85
- return undefined
86
- }
87
- if (typeof raw !== 'object' || raw === null) return undefined
88
- const o = raw as Record<string, unknown>
89
- if (o.type !== 'session' || typeof o.id !== 'string') return undefined
90
- return o.id
59
+ line = readFirstJsonlLineSync(filePath)
91
60
  } catch {
92
61
  return undefined
93
- } finally {
94
- if (fd !== undefined) {
95
- try {
96
- closeSync(fd)
97
- } catch {
98
- // closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
99
- void fd
100
- }
101
- }
102
62
  }
63
+ return parseSessionHeader(line)?.id
103
64
  }
@@ -1,9 +1,10 @@
1
1
  import { readFile, readdir, open, stat } from 'node:fs/promises'
2
2
  import type { FileHandle } from 'node:fs/promises'
3
3
  import { join, basename } from 'node:path'
4
- import type { Entry } from '../core/parser.js'
4
+ import type { Entry } from '@zhushanwen/session-core'
5
5
  import type { Family, SessionRef } from '../core/family.js'
6
6
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
7
+ import { listZcodeManifests } from './zcode-manifest.js'
7
8
  import { resolveSessionRoots, type SessionFileMeta } from './roots.js'
8
9
  import { parseSessionHeader, readSessionHeaderFirstLine } from './session-header.js'
9
10
  import { resolveWorkflows } from './workflows.js'
@@ -54,7 +55,13 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
54
55
  if (!scan.sessionIdToPath.has(sessionId)) {
55
56
  throw new Error(await formatSessionNotFound(sessionId, agentDir))
56
57
  }
57
- const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats)
58
+ // U9 family 接线(design session-reader-shared-core §5 Phase 3 单元三):zcode 节点
59
+ // 并入 subagentsByRoot(D5-1 判据在 core 层 buildZcodeSubagentsByRoot)——**纯追加**,
60
+ // pi 现路径(listRecordManifests/isRecordManifest/appendOrphanIdentities)零改动;
61
+ // zcode manifest 无 sessionFile 键本就被 isRecordManifest 整条丢弃(F21),与 pi 扫描
62
+ // 结果集天然不相交,两条来源互不污染。枚举失败容错内置(根不存在 → 空数组不报错)。
63
+ const zcodeNodes = await listZcodeManifests(agentDir)
64
+ const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats, zcodeNodes)
58
65
  const family = resolveFamily(sessionId, index)
59
66
 
60
67
  // ---- 6. workflows(enrichRefs 回填已删除,ext-simplify-04 U4/E2:fileName/cwd 占位
@@ -0,0 +1,87 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { zcodeImportDbAllowlist } from '@zhushanwen/zcode-session-source'
5
+
6
+ /**
7
+ * zcode 会话库白名单闸(design session-reader-shared-core §3.1 第③步 / §3.3 D5 /
8
+ * §3.4 检查顺序 / 探针 P-whitelist)。
9
+ *
10
+ * dbPath 来自 session 数据(manifest engineHandle.sessionRef / 主 session custom
11
+ * entry),是**不可信输入**(会话文件可被构造)——开库前必须过本闸,白名单是必需
12
+ * 闸而非可选优化。集合与 runtime 导入侧 `zcodeImportDbAllowlist(dataDir)` **同源
13
+ * 同形**:隔离库(现役)+ 宿主库(存量兼容)两项,路径段单源 = 该函数内部引用的
14
+ * `@zhushanwen/subagent-engine-sdk` zcode-db-paths 常量(跨侧契约根,禁手拼字面量
15
+ * 段——SDK 头注「两侧必须同源推导,禁止各自拼字符串漂移」)。
16
+ *
17
+ * **检查顺序铁律(§3.4 三段递进,实现与测试双锚定)**:存在性先于路径——
18
+ * `existsSync` 为 false 报 `zcode_db_unreadable`(库文件不存在)而非
19
+ * `zcode_db_path_forbidden`,避免把「只是还没落库」误报成安全事件(forbidden 是
20
+ * 安全面错误,语义更重、更惊扰 agent)。存在才 realpath 后与派生允许集合精确比对。
21
+ * 开库(第 3 段)由调用方在本闸通过后进行。
22
+ *
23
+ * **TOCTOU 残留(设计 §3.4 显式登记,不在本梯队硬化)**:realpath 之后仍按原
24
+ * dbPath 开库,两步间存在理论竞态窗口;利用它需攻击者具备同用户写权限,而同用户
25
+ * 权限下本就可直接读该库——实测方向安全,fd 复用/O_NOFOLLOW 属纵深防御非实际威胁面。
26
+ *
27
+ * **realpath 对齐**:dbPath 与集合项都经 realpath 归一后再比对(realpath 失败——
28
+ * 竞态删除/权限——按原串参与)。taiji 生产布局(`~/.taiji` 下无 symlink)两者等价;
29
+ * macOS 临时目录(`/var` → `/private/var`)等 symlink 环境下不对齐会把合法库误判
30
+ * 集合外,P-whitelist 单测按 tmp dataDir 布局锚定此语义。
31
+ */
32
+
33
+ /** symlink 归一(失败按原串——不存在的路径进不了比对成功分支,由调用方顺序保证)。 */
34
+ function realOf(p: string): string {
35
+ try {
36
+ return realpathSync(p)
37
+ } catch {
38
+ return p
39
+ }
40
+ }
41
+
42
+ /**
43
+ * dbPath 的派生允许集合(realpath 对齐后):`zcodeIsolatedDbPath(dirname(agentDir))`
44
+ * + `hostZcodeDbPath()`。F11:taiji 下 agentDir = `<dataDir>/agent` →
45
+ * dirname(agentDir) = dataDir,与引擎侧/导入侧的隔离库推导同一 dataDir。纯 pi 宿主
46
+ * (agentDir 退化为 `~/.pi/...`)下隔离库项恒不可达且不存在 taiji 派生的 zcode 锚
47
+ * (D5「纯 pi 宿主边界」),无害,不需特例分支。
48
+ */
49
+ export function zcodeDbAllowlistFor(agentDir: string): readonly string[] {
50
+ return zcodeImportDbAllowlist(dirname(agentDir)).map(realOf)
51
+ }
52
+
53
+ /** `zcode_db_unreadable` 的 👉 恢复指引(§3.4 表逐行;隔离库路径运行时动态推导)。 */
54
+ export function formatZcodeDbUnreadable(dataDir: string, detail: string): string {
55
+ const isolated = zcodeImportDbAllowlist(dataDir)[0]
56
+ return (
57
+ `[zcode_db_unreadable] zcode 会话库不可读(恢复阶梯 L1-L3 均未成功)。${detail}\n` +
58
+ `👉 使原目录可写,或把库连 -wal/-shm 一起拷回白名单内原路径(隔离库 = ${isolated})后重试。\n` +
59
+ `⚠️ 告诫:缺 -wal 的库未 checkpoint 的已提交数据可能已丢——这是写者未干净退出造成的既成事实,非恢复引入;完整性以连 -wal 一起拷贝为准。`
60
+ )
61
+ }
62
+
63
+ /** `zcode_db_path_forbidden` 的 👉 恢复指引(§3.4 表逐行)。 */
64
+ export function formatZcodeDbPathForbidden(dbPath: string): string {
65
+ return (
66
+ `[zcode_db_path_forbidden] 该会话引用的 zcode 库路径不在允许范围:${dbPath}\n` +
67
+ `👉 在 taiji 内打开原会话读取,或用会话导入对话框导入后读取。`
68
+ )
69
+ }
70
+
71
+ /**
72
+ * 白名单闸(第 1 段存在性 → 第 2 段路径;通过才允许开库)。
73
+ *
74
+ * @throws Error(消费侧映射为 zcode_db_unreadable / zcode_db_path_forbidden,
75
+ * 消息即 §3.4 的 👉 指引文案——tool-handler 直接 throw 给 pi 错误面)
76
+ */
77
+ export function assertZcodeDbPathAllowed(dbPath: string, agentDir: string): void {
78
+ // 第 1 段 存在性(先于路径——「还没落库」不是安全事件)
79
+ if (!existsSync(dbPath)) {
80
+ throw new Error(formatZcodeDbUnreadable(dirname(agentDir), `(库文件不存在:${dbPath})`))
81
+ }
82
+ // 第 2 段 路径(存在才 realpath 比对;TOCTOU 残留见文件头注)
83
+ const allowed = zcodeDbAllowlistFor(agentDir)
84
+ if (!allowed.includes(realOf(dbPath))) {
85
+ throw new Error(formatZcodeDbPathForbidden(dbPath))
86
+ }
87
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { basename } from 'node:path'
3
- import { parseSessionContent } from '../core/parser.js'
3
+ import { parseSessionContent, type Entry } from '@zhushanwen/session-core'
4
4
  import type { SessionRef, WorkflowRef } from '../core/family.js'
5
5
  import { extractSessionIdFromFilename } from './subagents.js'
6
6
 
@@ -9,10 +9,22 @@ import { extractSessionIdFromFilename } from './subagents.js'
9
9
  // ============================================================
10
10
  //
11
11
  // 本文件持有 workflow run 的发现与 sessionFile 提取逻辑(IO 适配层):
12
- // - resolveWorkflows:读目标 session 的 workflow-state-link custom entry → 每个 link 的
13
- // wf-state 文件 → 提 calls 的 sessionFile → SessionRef[]。返回 WorkflowRef[](family.workflows 腿)。
12
+ // - resolveWorkflows:三档发现链(按 runId 合并,高档条目在即用高档)——
13
+ // ① v2 journalPath 主源([D16③] 发现链重锚:journalPath 锚点语义 = record 流路径):
14
+ // workflow-record v2 注册条目的 journalPath 指向 <runId>.record.jsonl([D1] record
15
+ // 单源事件流),发现链读 record 流行自提 calls——sessionFile 取 agent-settled 帧
16
+ // result 全文携带的 sessionFile([D1] 载荷表:result 全文入事件,AgentResult
17
+ // 自带 sessionFile 字段——家族链数据源同 record 流,无第二承载位);旧后缀锚点
18
+ // (.events.jsonl 形态)= 历史实体([D1] 历史数据处置:旧两件不读)→ calls=[]
19
+ // 仅 run 存在性兜底;
20
+ // ② v1 快照层:workflow-record v1 快照条目(W17~W1 期间创建的 run——快照是全量的,
21
+ // 直接从 snapshot.calls 提 sessionFile,家族链数据完整);
22
+ // ③ 旧指针 fallback:workflow-state-link 指针条目(W17 前的 run,照旧读 link 指向的
23
+ // wf-state 文件)。
14
24
  // - readRunSnapshot:读 wf-state 文件尾向找首个可解析行,返回原始对象(unknown,格式收窄交 core 层)。
15
25
  // - extractCallSessionFiles:从快照对象提 calls 的 sessionFile 绝对路径数组(NEW/OLD 双格式)。
26
+ // - extractRecordStreamSessionFiles:[D16③] 从 record 事件流提 calls 的 sessionFile
27
+ // (agent-settled 帧 result.sessionFile——活跃 run 与已收编 run 同源可用)。
16
28
  // - sessionRefFromPath:sessionFile 路径 → SessionRef(命中 pathToRef 取完整,否则文件名提取最小 ref)。
17
29
  //
18
30
  // 分层约定(w5 TC-wf-core-pure-logic):IO 全在 discovery/,core/workflow.ts 的
@@ -133,13 +145,185 @@ function sessionRefFromPath(path: string, pathToRef: Map<string, SessionRef>): S
133
145
  }
134
146
  }
135
147
 
148
+ // ============================================================
149
+ // workflow-record 条目契约(三档发现链的条目判别——本地字面量 + 测试守卫漂移)
150
+ // ============================================================
151
+
136
152
  /**
137
- * 读目标 session 文件全文,解析 workflow-state-link custom entries,构造 WorkflowRef[]。
138
- * 同一 runId 的多个 link(workflow 多次更新产生)按 runId 去重,取最新 link(path 相同)。
153
+ * `workflow-record` custom entry 的 customType 值。
139
154
  *
140
- * 内部用 readRunSnapshot + extractCallSessionFiles 组合替代原 readWorkflowCallSessionFiles
141
- *(w5 迁移,行为等价)。**签名与返回值结构(WorkflowRef[]{runId,stateFile,calls:SessionRef[]})
142
- * 完全不变**(C-resolveworkflows-signature,保 m1 已冻结交付的消费者)。
155
+ * 磁盘 JSONL 协议字符串(跨侧契约):写侧单源 = subagent-core
156
+ * workflow-record-entry.ts 的 WORKFLOW_RECORD_CUSTOM_TYPE。reader 生产依赖面不引
157
+ * subagent-core(同 entry-anchor.ts 的协议字面量模式),两侧漂移由 workflow.test.ts
158
+ * 的跨包断言守卫。
159
+ */
160
+ const WORKFLOW_RECORD_CUSTOM_TYPE = 'workflow-record'
161
+
162
+ /** v2 条目 data 的认识版本词表(W1 起 = 2;写侧 WORKFLOW_RECORD_ENTRY_VERSION 同值)。 */
163
+ const WORKFLOW_RECORD_ENTRY_V2 = 2
164
+
165
+ /**
166
+ * run record 流文件名尾段(`<runId>.record.jsonl`)。写侧 subagent-core
167
+ * RUN_EVENT_JOURNAL_SUFFIX 同值([D1] record 单源后缀)——v2 注册条目的
168
+ * journalPath 锚点后缀判定用(新锚点直读 record 流;旧 `.events.jsonl` 锚点 =
169
+ * 历史实体分流),本地持有 + 测试守卫漂移。
170
+ */
171
+ export const RUN_RECORD_STREAM_SUFFIX = '.record.jsonl'
172
+
173
+ /** 单遍扫描 entries 的三档收集结果(档内同 runId 后写覆盖前写,跨档优先级见 resolveWorkflows)。 */
174
+ interface WorkflowEntryTiers {
175
+ /** ① v2 注册条目:runId → journalPath 锚点(W1+ 的 run)。 */
176
+ v2ByRunId: Map<string, string>
177
+ /** ② v1 快照条目:runId → 全量快照对象(W17~W1 期间创建的 run)。 */
178
+ v1ByRunId: Map<string, unknown>
179
+ /** ③ 旧指针条目:runId → link path(W17 前的 run)。 */
180
+ linkByRunId: Map<string, { runId: string; path: string }>
181
+ /** runId 首见序(输出稳定序——条目在 session 文件中的出现顺序)。 */
182
+ runIdOrder: string[]
183
+ }
184
+
185
+ /**
186
+ * 单遍扫描 entries 按三档形态分桶(纯收集,无 IO):
187
+ * - workflow-record v2 注册条目:`v === 2` ∧ `kind === 'registered'` ∧ runId/journalPath
188
+ * 均非空 string(终态条目不携带 journalPath,非发现链数据源,不收集);
189
+ * - workflow-record v1 快照条目:`v === 1` ∧ snapshot 为对象 ∧ `snapshot.runId` 为
190
+ * string(runId 在快照内——entry data 顶层无 runId 字段);
191
+ * - workflow-state-link 指针条目:现状判别。
192
+ */
193
+ function collectWorkflowEntryTiers(entries: readonly Entry[]): WorkflowEntryTiers {
194
+ const tiers: WorkflowEntryTiers = {
195
+ v2ByRunId: new Map(),
196
+ v1ByRunId: new Map(),
197
+ linkByRunId: new Map(),
198
+ runIdOrder: [],
199
+ }
200
+ const seen = new Set<string>()
201
+ const noteRunId = (runId: string): void => {
202
+ if (!seen.has(runId)) {
203
+ seen.add(runId)
204
+ tiers.runIdOrder.push(runId)
205
+ }
206
+ }
207
+ for (const e of entries) {
208
+ const data = e.data as Record<string, unknown> | undefined
209
+ if (e.customType === WORKFLOW_RECORD_CUSTOM_TYPE && data !== undefined) {
210
+ collectWorkflowRecordEntryTier(tiers, noteRunId, data)
211
+ continue
212
+ }
213
+ // legacy 指针档字面量:常量 SSOT 在 subagent-core(WORKFLOW_STATE_LINK_CUSTOM_TYPE,
214
+ // orchestration/workflow-record-entry.ts)。reader 生产依赖面不引 subagent-core
215
+ // (包架构裁决,同 entry-anchor.ts 的协议字面量模式),保持本地镜像,值等价由
216
+ // __tests__/workflow.test.ts 跨包契约守卫断言锚定。
217
+ if (e.customType === 'workflow-state-link') {
218
+ collectWorkflowStateLinkTier(tiers, noteRunId, data)
219
+ }
220
+ }
221
+ return tiers
222
+ }
223
+
224
+ /** [collectWorkflowEntryTiers 拆分] workflow-record 条目两代分流(v2 注册 / v1 快照)。 */
225
+ function collectWorkflowRecordEntryTier(
226
+ tiers: WorkflowEntryTiers,
227
+ noteRunId: (runId: string) => void,
228
+ data: Record<string, unknown>,
229
+ ): void {
230
+ if (data.v === WORKFLOW_RECORD_ENTRY_V2 && data.kind === 'registered') {
231
+ const runId = data.runId
232
+ const journalPath = data.journalPath
233
+ if (typeof runId === 'string' && runId !== '' && typeof journalPath === 'string' && journalPath !== '') {
234
+ tiers.v2ByRunId.set(runId, journalPath)
235
+ noteRunId(runId)
236
+ }
237
+ } else if (data.v === 1 && typeof data.snapshot === 'object' && data.snapshot !== null) {
238
+ const snapshotRunId = (data.snapshot as Record<string, unknown>).runId
239
+ if (typeof snapshotRunId === 'string' && snapshotRunId !== '') {
240
+ tiers.v1ByRunId.set(snapshotRunId, data.snapshot)
241
+ noteRunId(snapshotRunId)
242
+ }
243
+ }
244
+ }
245
+
246
+ /** [collectWorkflowEntryTiers 拆分] workflow-state-link 指针条目(后写覆盖前写——取最新 link)。 */
247
+ function collectWorkflowStateLinkTier(
248
+ tiers: WorkflowEntryTiers,
249
+ noteRunId: (runId: string) => void,
250
+ data: Record<string, unknown> | undefined,
251
+ ): void {
252
+ const runId = data?.runId
253
+ const path = data?.path
254
+ if (typeof runId === 'string' && typeof path === 'string') {
255
+ tiers.linkByRunId.set(runId, { runId, path }) // 后写覆盖前写(取最新 link)
256
+ noteRunId(runId)
257
+ }
258
+ }
259
+
260
+ /**
261
+ * [D16③] journalPath 后缀分流:`.record.jsonl` 锚点 = 新形态(record 流直读);
262
+ * 其余(含旧 `.events.jsonl` 锚点)= 历史实体([D1] 历史数据处置——旧两件不读
263
+ * 不写,历史 run 从发现链数据面退空即预期行为)。
264
+ */
265
+ function isNewRecordStreamAnchor(journalPath: string): boolean {
266
+ return journalPath.endsWith(RUN_RECORD_STREAM_SUFFIX)
267
+ }
268
+
269
+ /** agent-settled 帧的最小消费视图(record 流宽容解析只消费 taskIndex 与
270
+ * result.sessionFile 两字段,其余载荷不读取)。 */
271
+ interface AgentSettledSessionFileView {
272
+ taskIndex: number
273
+ result: { sessionFile: string }
274
+ }
275
+
276
+ /** [extractRecordStreamSessionFiles 守卫] 单帧是否为携带非空 result.sessionFile
277
+ * 的 agent-settled 帧:逐字段 typeof 校验(type → taskIndex → result →
278
+ * sessionFile),不过关 = 宽容跳过(与 readRunSnapshot 同容忍度)。 */
279
+ function isAgentSettledSessionFileFrame(v: unknown): v is AgentSettledSessionFileView {
280
+ if (typeof v !== 'object' || v === null) return false
281
+ const rec = v as Record<string, unknown>
282
+ if (rec.type !== 'agent-settled' || typeof rec.taskIndex !== 'number') return false
283
+ const result: unknown = rec.result
284
+ if (typeof result !== 'object' || result === null) return false
285
+ const sessionFile: unknown = (result as Record<string, unknown>).sessionFile
286
+ return typeof sessionFile === 'string' && sessionFile !== ''
287
+ }
288
+
289
+ /**
290
+ * [D16③] record 事件流 → calls 的 sessionFile 绝对路径数组:逐行宽容解析
291
+ * (跳过坏行/空行——与 readRunSnapshot 同容忍度),agent-settled 帧(含
292
+ * result 全文)按 taskIndex 归位——首见生效(重试波多帧时终局帧 result 后到
293
+ * 覆盖,与 fold 后到覆盖语义一致)。
294
+ */
295
+ export function extractRecordStreamSessionFiles(content: string): string[] {
296
+ const byIndex = new Map<number, string>()
297
+ for (const line of content.split('\n')) {
298
+ const trimmed = line.trim()
299
+ if (trimmed === '') continue
300
+ let parsed: unknown
301
+ try {
302
+ parsed = JSON.parse(trimmed)
303
+ } catch {
304
+ continue // 坏行(截断行)跳过
305
+ }
306
+ if (!isAgentSettledSessionFileFrame(parsed)) continue
307
+ byIndex.set(parsed.taskIndex, parsed.result.sessionFile)
308
+ }
309
+ return [...byIndex.entries()].sort((a, b) => a[0] - b[0]).map(([, sf]) => sf)
310
+ }
311
+
312
+ /**
313
+ * 读目标 session 文件全文,按三档发现链构造 WorkflowRef[](D10 断链修复:W17 前/
314
+ * W17~W1/W1+ 三类 run 全部有发现通道)。同一 runId 的多条目按 runId 去重取档内
315
+ * 最新(后写覆盖);跨档优先级 = v2 注册条目 > v1 快照条目 > 旧指针——一个 run 只
316
+ * 以一种形态写条目(创建时点决定),跨档同 runId 是坏数据防御,高档在即用高档。
317
+ *
318
+ * **签名与返回值结构(WorkflowRef[]{runId,stateFile,calls:SessionRef[]})完全不变**
319
+ * (C-resolveworkflows-signature,保 m1 已冻结交付的消费者)。三档的 stateFile 语义:
320
+ * - v2 档:stateFile = journalPath 本身(record 流路径——[D16③] 锚点语义重定义,
321
+ * D1 后 v2 条目携带的锚点即 record 事件流文件);record 流直读提 calls
322
+ * (agent-settled.result.sessionFile),流被清理/不可读 → calls=[](run 存在性
323
+ * 兜底);
324
+ * - v1 档:空串(v1 快照条目不携带 state 路径,快照数据直接从条目提 calls;概览
325
+ * 消费面 readRunSnapshot('') 自然落「快照不可读」跳过,家族链 calls 不受影响);
326
+ * - 旧指针档:link 的 path(现状)。
143
327
  */
144
328
  export async function resolveWorkflows(
145
329
  sessionId: string,
@@ -155,23 +339,49 @@ export async function resolveWorkflows(
155
339
  return []
156
340
  }
157
341
  const { entries } = parseSessionContent(content)
158
- const linkByRunId = new Map<string, { runId: string; path: string }>()
159
- for (const e of entries) {
160
- if (e.customType !== 'workflow-state-link') continue
161
- const data = e.data as Record<string, unknown> | undefined
162
- const runId = data?.runId
163
- const path = data?.path
164
- if (typeof runId === 'string' && typeof path === 'string') {
165
- linkByRunId.set(runId, { runId, path }) // 后写覆盖前写(取最新 link)
166
- }
167
- }
342
+ const tiers = collectWorkflowEntryTiers(entries)
168
343
  const workflows: WorkflowRef[] = []
169
- for (const { runId, path } of linkByRunId.values()) {
170
- const snap = await readRunSnapshot(path)
344
+ for (const runId of tiers.runIdOrder) {
345
+ const journalPath = tiers.v2ByRunId.get(runId)
346
+ if (journalPath !== undefined) {
347
+ // ① v2 档([D16③] 重锚):journalPath = record 流路径——新后缀锚点直读流提
348
+ // calls(agent-settled.result.sessionFile);旧后缀锚点 = 历史实体([D1]
349
+ // 不读旧两件)→ calls=[] 仅 run 存在性兜底。
350
+ let sessionFiles: string[] = []
351
+ if (isNewRecordStreamAnchor(journalPath)) {
352
+ let content: string | undefined
353
+ try {
354
+ content = await readFile(journalPath, 'utf8')
355
+ } catch {
356
+ content = undefined // 流被保留期清理/不可读 → calls=[](run 存在性兜底)
357
+ }
358
+ sessionFiles = content === undefined ? [] : extractRecordStreamSessionFiles(content)
359
+ }
360
+ workflows.push({
361
+ runId,
362
+ stateFile: journalPath,
363
+ calls: sessionFiles.map((sf) => sessionRefFromPath(sf, pathToRef)),
364
+ })
365
+ continue
366
+ }
367
+ const snapshot = tiers.v1ByRunId.get(runId)
368
+ if (snapshot !== undefined) {
369
+ // ② v1 快照档:快照全量在场,直接提 calls(W17~W1 中间档恢复)
370
+ workflows.push({
371
+ runId,
372
+ stateFile: '',
373
+ calls: extractCallSessionFiles(snapshot).map((sf) => sessionRefFromPath(sf, pathToRef)),
374
+ })
375
+ continue
376
+ }
377
+ // ③ 旧指针档:现状(link path → wf-state 文件)
378
+ const link = tiers.linkByRunId.get(runId)
379
+ if (link === undefined) continue
380
+ const snap = await readRunSnapshot(link.path)
171
381
  const sessionFiles = snap === undefined ? [] : extractCallSessionFiles(snap)
172
382
  workflows.push({
173
- runId,
174
- stateFile: path,
383
+ runId: link.runId,
384
+ stateFile: link.path,
175
385
  calls: sessionFiles.map((sf) => sessionRefFromPath(sf, pathToRef)),
176
386
  })
177
387
  }