@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
|
@@ -1,44 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
readFirstJsonlLine,
|
|
3
|
+
readFirstJsonlLineSync,
|
|
4
|
+
type SessionHeader,
|
|
5
|
+
} from '@zhushanwen/session-core'
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
|
-
* [
|
|
6
|
-
* 两份 async 副本 + tool-handler.ts 一份 sync
|
|
7
|
-
*
|
|
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
|
-
|
|
11
|
-
export interface SessionHeader {
|
|
12
|
-
id: string
|
|
13
|
-
cwd?: string
|
|
14
|
-
parentSession?: string
|
|
15
|
-
}
|
|
21
|
+
export type { SessionHeader }
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
|
-
*
|
|
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
|
-
/**
|
|
60
|
-
|
|
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
|
|
57
|
+
let line: string | undefined
|
|
73
58
|
try {
|
|
74
|
-
|
|
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 '
|
|
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
|
-
|
|
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 '
|
|
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
|
|
13
|
-
//
|
|
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
|
-
*
|
|
138
|
-
* 同一 runId 的多个 link(workflow 多次更新产生)按 runId 去重,取最新 link(path 相同)。
|
|
153
|
+
* `workflow-record` custom entry 的 customType 值。
|
|
139
154
|
*
|
|
140
|
-
*
|
|
141
|
-
|
|
142
|
-
*
|
|
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
|
|
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
|
|
170
|
-
const
|
|
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
|
}
|