@zhushanwen/pi-session-reader 0.7.0 → 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.
@@ -1,8 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { segmentTurns } from '../core/turns.js'
3
- import { parseSessionFile, type Entry } from '@zhushanwen/session-core'
3
+ import type { Entry } from '@zhushanwen/session-core'
4
4
  import { buildTreeView } from '../core/tree.js'
5
- import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
6
5
 
7
6
  // ---- Entry 构造助手(turns.ts 只消费 type/id/parentId/message.role) ----
8
7
 
@@ -120,23 +119,26 @@ describe('segmentTurns', () => {
120
119
  expect(turns[0].startTime).toBe('2026-05-28T03:17:12.844Z')
121
120
  })
122
121
 
123
- it.skipIf(!HAS_REAL_SESSION)('真实 019e6c96:leaf 视图分段(26 user + 5 compaction + 1 前置 = 32 turn)', async () => {
124
- // 注:design P-outline / plan T1.3 基线为 26(仅数 user 的旧定义)。
125
- // 本实现按冻结接口 Turn.isCompaction + design §3.5 算法 3 规则 2(compaction 独立成 turn)
126
- // + 规则 4(首 user 前的 model_change/thinking_level_change 成 preface turn),
127
- // 真实数据为 32 turn。26 基线早于 compaction-split 细化,详见交接说明。
128
- const parsed = await parseSessionFile(REAL_SESSION)
129
- const tree = buildTreeView(parsed.entries)
130
- const turns = segmentTurns(parsed.entries, new Set(tree.leafPath))
131
-
132
- expect(turns.length).toBe(32)
133
- // 首个 turn 是 preface(model_change/thinking_level_change),无 userEntry
122
+ it('leaf 视图分段结构形态:preface(首 user 前的 model_change)+ user turns + compaction turn', () => {
123
+ // 合成分段结构形态(1 preface + 1 compaction + 2 user turn):
124
+ // 规则 2(compaction 独立成 turn)+ 规则 4(首 user 前的 model_change/thinking_level_change
125
+ // 成 preface turn,无 userEntry)
126
+ const entries = [
127
+ entry('MC', null, 'model_change'),
128
+ msg('U1', 'MC', 'user'),
129
+ msg('A1', 'U1', 'assistant'),
130
+ entry('C1', 'A1', 'compaction'),
131
+ msg('U2', 'C1', 'user'),
132
+ msg('A2', 'U2', 'assistant'),
133
+ ]
134
+ const tree = buildTreeView(entries)
135
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
136
+
137
+ expect(turns.length).toBe(4)
138
+ // 首 turn 是 preface(model_change),无 userEntry、非 compaction
134
139
  expect(turns[0].userEntry).toBeUndefined()
135
140
  expect(turns[0].isCompaction).toBe(false)
136
- // 其中有 5 个 compaction turn
137
- expect(turns.filter((t) => t.isCompaction).length).toBe(5)
138
- // 其中有 26 个 user turn
139
- expect(turns.filter((t) => t.userEntry !== undefined).length).toBe(26)
140
- // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
141
- }, 60000)
141
+ expect(turns.filter((t) => t.isCompaction).length).toBe(1)
142
+ expect(turns.filter((t) => t.userEntry !== undefined).length).toBe(2)
143
+ })
142
144
  })
@@ -1,19 +1,29 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
- import { existsSync } from 'node:fs'
4
- import { mkdtemp, writeFile, rm } from 'node:fs/promises'
3
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
5
4
  import { join } from 'node:path'
6
5
 
6
+ import {
7
+ WORKFLOW_RECORD_CUSTOM_TYPE,
8
+ WORKFLOW_RECORD_ENTRY_VERSION,
9
+ WORKFLOW_STATE_LINK_CUSTOM_TYPE,
10
+ } from '@zhushanwen/subagent-core'
11
+
7
12
  import { parseRunSnapshot, renderWorkflowOverview } from '../core/workflow.js'
8
- import { extractCallSessionFiles, readRunSnapshot } from '../discovery/workflows.js'
9
- import { REAL_AGENT_DIR } from './real-data.js'
13
+ import {
14
+ extractCallSessionFiles,
15
+ extractRecordStreamSessionFiles,
16
+ readRunSnapshot,
17
+ resolveWorkflows,
18
+ RUN_RECORD_STREAM_SUFFIX,
19
+ } from '../discovery/workflows.js'
10
20
 
11
21
  // ============================================================
12
22
  // fixture(结构对齐真实 wf-state 探针数据)
13
23
  // ============================================================
14
24
 
15
25
  /**
16
- * NEW 格式 fixture(对齐 ~/.pi/agent/workflow-state/wf-1785762350110-d297tr.jsonl)。
26
+ * NEW 格式 fixture(快照字段形态 = pi-subagent-workflow 写侧落盘契约)。
17
27
  * runId 故意写成 'wf-ignore' 验证 parseRunSnapshot 用参数透传不读 snapshot.runId。
18
28
  */
19
29
  const NEW_SNAPSHOT_FIXTURE = {
@@ -367,39 +377,325 @@ describe('readRunSnapshot', () => {
367
377
  })
368
378
 
369
379
  // ============================================================
370
- // 真实数据守卫(~/.pi/agent/workflow-state,CI 无本机数据时 skipIf 跳过)
380
+ // resolveWorkflows 三档发现链(W1 D10 断链修复:v2 journalPath 主源 / v1 快照层 /
381
+ // 旧指针 fallback——W17 前 / W17~W1 / W1+ 三类 run 全部有发现通道)
382
+ // ============================================================
383
+
384
+ /** v1 快照对象(RunSnapshot 形态:v/runId/state.calls——v1 workflow-record 条目的 snapshot 载荷)。 */
385
+ function v1Snapshot(runId: string, sessionFiles: string[]): Record<string, unknown> {
386
+ return {
387
+ v: 'wf-run-v2',
388
+ runId,
389
+ spec: { scriptName: 'mid-window', name: 'Mid' },
390
+ state: {
391
+ status: 'done',
392
+ reason: 'completed',
393
+ budget: { usedTokens: 10, usedCost: 0, totalCallCount: sessionFiles.length, maxTokens: 1000 },
394
+ calls: sessionFiles.map((sf, i) => ({
395
+ id: i,
396
+ opts: { prompt: 'work', model: 'm', description: `step-${i}` },
397
+ status: 'done',
398
+ attempts: 1,
399
+ result: { content: 'ok', durationMs: 1, sessionId: `019v1${i}`, sessionFile: sf },
400
+ })),
401
+ },
402
+ meta: { startedAt: '2026-09-20T00:00:00.000Z', completedAt: '2026-09-20T00:01:00.000Z' },
403
+ }
404
+ }
405
+
406
+ describe('resolveWorkflows 三档发现链(W1 D10)', () => {
407
+ let dir: string
408
+ afterEach(async () => {
409
+ if (dir) await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }).catch(() => {})
410
+ })
411
+
412
+ /** 写 main session 文件(首行 header + 追加行集);返回路径与 sessionIdToPath 单条目映射。 */
413
+ async function writeMain(lines: string[]): Promise<{
414
+ path: string
415
+ sessionIdToPath: Map<string, string>
416
+ }> {
417
+ const sessionId = 'main-session-1'
418
+ const path = join(dir, `${sessionId}.jsonl`)
419
+ await writeFile(path, [JSON.stringify({ type: 'session', id: sessionId, cwd: '/proj' }), ...lines].join('\n') + '\n')
420
+ return { path, sessionIdToPath: new Map([[sessionId, path]]) }
421
+ }
422
+
423
+ /** 写 wf-state 快照文件(尾行最新快照)。返回绝对路径。 */
424
+ async function writeStateFile(runId: string, snapshot: Record<string, unknown>): Promise<string> {
425
+ const wfDir = join(dir, 'workflow-state')
426
+ await mkdir(wfDir, { recursive: true })
427
+ const path = join(wfDir, `${runId}.jsonl`)
428
+ await writeFile(path, JSON.stringify(snapshot) + '\n')
429
+ return path
430
+ }
431
+
432
+ /** 写 record 流文件([D16③] v2 档数据源——目录自建)。 */
433
+ async function writeRecordStream(runId: string, lines: string[]): Promise<string> {
434
+ const wfDir = join(dir, 'workflow-state')
435
+ await mkdir(wfDir, { recursive: true })
436
+ const p = join(wfDir, `${runId}.record.jsonl`)
437
+ await writeFile(p, lines.join('\n') + '\n')
438
+ return p
439
+ }
440
+
441
+ /** workflow-record v2 注册条目行(journalPath 锚点)。 */
442
+ function v2RegisteredLine(runId: string, journalPath: string): string {
443
+ return JSON.stringify({
444
+ type: 'custom',
445
+ customType: 'workflow-record',
446
+ id: `e-${runId}`,
447
+ parentId: null,
448
+ data: {
449
+ v: 2,
450
+ kind: 'registered',
451
+ runId,
452
+ workflowName: 'probe-flow',
453
+ scriptName: 'probe-flow',
454
+ slug: 'probe',
455
+ startedAt: 1,
456
+ journalPath,
457
+ },
458
+ })
459
+ }
460
+
461
+ /** workflow-record v1 快照条目行(W17~W1 中间档形态:{v:1, snapshot, updatedAt})。 */
462
+ function v1SnapshotLine(runId: string, sessionFiles: string[]): string {
463
+ return JSON.stringify({
464
+ type: 'custom',
465
+ customType: 'workflow-record',
466
+ id: `e-${runId}`,
467
+ parentId: null,
468
+ data: { v: 1, snapshot: v1Snapshot(runId, sessionFiles), updatedAt: 2 },
469
+ })
470
+ }
471
+
472
+ /** workflow-record v2 终态条目行(不携带 journalPath——非发现链数据源)。 */
473
+ function v2SettledLine(runId: string): string {
474
+ return JSON.stringify({
475
+ type: 'custom',
476
+ customType: 'workflow-record',
477
+ id: `e-settled-${runId}`,
478
+ parentId: null,
479
+ data: {
480
+ v: 2,
481
+ kind: 'settled',
482
+ runId,
483
+ status: 'done',
484
+ reason: 'completed',
485
+ outcome: 'success',
486
+ settledAt: 3,
487
+ callCount: 1,
488
+ usedTokens: 10,
489
+ },
490
+ })
491
+ }
492
+
493
+ /** 旧指针条目行(W17 前形态:workflow-state-link)。 */
494
+ function linkLine(runId: string, path: string): string {
495
+ return JSON.stringify({
496
+ type: 'custom',
497
+ customType: 'workflow-state-link',
498
+ id: `e-link-${runId}`,
499
+ parentId: null,
500
+ data: { runId, path, updatedAt: '2026-08-07T16:48:24.933Z' },
501
+ })
502
+ }
503
+
504
+ const CALL_A = '/abs/subagents/--proj--/sessions/call-a.jsonl'
505
+ const CALL_B = '/abs/subagents/--proj--/sessions/call-b.jsonl'
506
+
507
+ it('v2 journalPath 主源([D16③] 重锚):注册条目 → record 流直读提 calls,stateFile = 流路径', async () => {
508
+ dir = await mkdtemp(join(tmpdir(), 'wf-v2-tier-'))
509
+ // record 流内容:run-created + agent-started + agent-settled(result 携带 sessionFile)
510
+ const recordPath = await writeRecordStream('wf-v2-1', [
511
+ JSON.stringify({ type: 'run-created', seq: 1, ts: 1000, runId: 'wf-v2-1', workflowName: 'v2-flow', argsSummary: '{}' }),
512
+ JSON.stringify({ type: 'agent-started', seq: 2, ts: 1100, taskIndex: 0, agentName: 'step-0', attempt: 1 }),
513
+ JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1500, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 400, result: { content: 'ok', sessionFile: CALL_A } }),
514
+ ])
515
+ const { sessionIdToPath } = await writeMain([v2RegisteredLine('wf-v2-1', recordPath)])
516
+
517
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
518
+
519
+ expect(workflows).toHaveLength(1)
520
+ expect(workflows[0].runId).toBe('wf-v2-1')
521
+ // stateFile = record 流路径(journalPath 锚点语义重定义——[D16③])
522
+ expect(workflows[0].stateFile).toBe(recordPath)
523
+ expect(workflows[0].calls).toHaveLength(1)
524
+ expect(workflows[0].calls[0].fileName).toBe(CALL_A)
525
+ })
526
+
527
+ it('v2 档窗外:record 流已被保留期清理 → calls=[],runId 存在性兜底(stateFile 仍是流路径)', async () => {
528
+ dir = await mkdtemp(join(tmpdir(), 'wf-v2-gc-'))
529
+ const recordPath = join(dir, 'workflow-state', 'wf-gc-1.record.jsonl')
530
+ const { sessionIdToPath } = await writeMain([v2RegisteredLine('wf-gc-1', recordPath)])
531
+
532
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
533
+
534
+ expect(workflows).toHaveLength(1)
535
+ expect(workflows[0].runId).toBe('wf-gc-1')
536
+ expect(workflows[0].calls).toEqual([])
537
+ // 窗外语义 = 锚点路径在、流文件不在(readFile undefined → calls 空)
538
+ expect(workflows[0].stateFile).toBe(recordPath)
539
+ })
540
+
541
+ it('v2 档旧后缀锚点(.events.jsonl 形态 = [D1] 历史实体)与形态损坏 → stateFile 原样保留 + calls 空(不读旧两件、不伪造数据)', async () => {
542
+ dir = await mkdtemp(join(tmpdir(), 'wf-v2-bad-'))
543
+ // 旧后缀锚点:[D1] 历史数据处置——旧两件不读(calls 退空,run 存在性兜底)
544
+ const legacyPath = join(dir, 'workflow-state', 'wf-legacy-1.events.jsonl')
545
+ await mkdir(join(dir, 'workflow-state'), { recursive: true })
546
+ await writeFile(legacyPath, JSON.stringify({ type: 'ask-settled', ts: 1 }) + '\n')
547
+ const legacyMain = await writeMain([v2RegisteredLine('wf-legacy-1', legacyPath)])
548
+ const legacy = await resolveWorkflows('main-session-1', legacyMain.sessionIdToPath, new Map())
549
+ expect(legacy).toHaveLength(1)
550
+ expect(legacy[0].runId).toBe('wf-legacy-1')
551
+ expect(legacy[0].stateFile).toBe(legacyPath)
552
+ expect(legacy[0].calls).toEqual([])
553
+
554
+ // 形态损坏(无已知尾段):同旧锚点分流
555
+ const oddMain = await writeMain([v2RegisteredLine('wf-bad-1', '/abs/odd/path.txt')])
556
+ const odd = await resolveWorkflows('main-session-1', oddMain.sessionIdToPath, new Map())
557
+ expect(odd).toHaveLength(1)
558
+ expect(odd[0].runId).toBe('wf-bad-1')
559
+ expect(odd[0].stateFile).toBe('/abs/odd/path.txt')
560
+ expect(odd[0].calls).toEqual([])
561
+ })
562
+
563
+ it('v1 快照层(W17~W1 中间档恢复):v1 快照条目直接提 snapshot.calls,无指针条目也可见', async () => {
564
+ // 该档现状发现链恒空(只认指针)——本用例锚定中间档恢复:升级窗口期的旧格式会话
565
+ dir = await mkdtemp(join(tmpdir(), 'wf-v1-tier-'))
566
+ const { sessionIdToPath } = await writeMain([v1SnapshotLine('wf-mid-1', [CALL_A, CALL_B])])
567
+
568
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
569
+
570
+ expect(workflows).toHaveLength(1)
571
+ expect(workflows[0].runId).toBe('wf-mid-1')
572
+ // v1 条目不携带 state 路径 → stateFile 空串(概览消费面自然降级,家族链 calls 完整)
573
+ expect(workflows[0].stateFile).toBe('')
574
+ expect(workflows[0].calls.map((c) => c.fileName)).toEqual([CALL_A, CALL_B])
575
+ })
576
+
577
+ it('旧指针 fallback:workflow-state-link 指针条目照旧读 link 指向的快照文件', async () => {
578
+ dir = await mkdtemp(join(tmpdir(), 'wf-link-tier-'))
579
+ const stateFile = await writeStateFile('wf-old-1', v1Snapshot('wf-old-1', [CALL_B]))
580
+ const { sessionIdToPath } = await writeMain([linkLine('wf-old-1', stateFile)])
581
+
582
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
583
+
584
+ expect(workflows).toHaveLength(1)
585
+ expect(workflows[0].runId).toBe('wf-old-1')
586
+ expect(workflows[0].stateFile).toBe(stateFile)
587
+ expect(workflows[0].calls.map((c) => c.fileName)).toEqual([CALL_B])
588
+ })
589
+
590
+ it('三档混合会话:三个 run 各处一档 → 各自正确,按条目首见序输出([D16③] v2 档 = record 流直读)', async () => {
591
+ dir = await mkdtemp(join(tmpdir(), 'wf-mixed-'))
592
+ const recordPath = await writeRecordStream('wf-v2-1', [
593
+ JSON.stringify({ type: 'run-created', seq: 1, ts: 1000, runId: 'wf-v2-1', workflowName: 'v2-flow', argsSummary: '{}' }),
594
+ JSON.stringify({ type: 'agent-started', seq: 2, ts: 1100, taskIndex: 0, agentName: 's0', attempt: 1 }),
595
+ JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1500, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 400, result: { content: 'ok', sessionFile: CALL_A } }),
596
+ ])
597
+ const oldState = await writeStateFile('wf-old-1', v1Snapshot('wf-old-1', [CALL_B]))
598
+ // 首见序:指针(早)→ v1 快照(中)→ v2 注册(晚)
599
+ const { sessionIdToPath } = await writeMain([
600
+ linkLine('wf-old-1', oldState),
601
+ v1SnapshotLine('wf-mid-1', [CALL_A]),
602
+ v2RegisteredLine('wf-v2-1', recordPath),
603
+ ])
604
+
605
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
606
+
607
+ expect(workflows.map((w) => w.runId)).toEqual(['wf-old-1', 'wf-mid-1', 'wf-v2-1'])
608
+ expect(workflows[0].stateFile).toBe(oldState)
609
+ expect(workflows[1].stateFile).toBe('')
610
+ expect(workflows[2].stateFile).toBe(recordPath)
611
+ expect(workflows[2].calls[0].fileName).toBe(CALL_A)
612
+ })
613
+
614
+ it('v2 终态条目(settled,无 journalPath)不参与发现链——不产 runId 幻影', async () => {
615
+ dir = await mkdtemp(join(tmpdir(), 'wf-settled-'))
616
+ const { sessionIdToPath } = await writeMain([v2SettledLine('wf-settled-1')])
617
+
618
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
619
+
620
+ expect(workflows).toEqual([])
621
+ })
622
+
623
+ it('同 runId 跨档坏数据防御:v2 注册条目在 → 用 v2 档(高档在即用高档;[D16③] v2 档 = record 流)', async () => {
624
+ dir = await mkdtemp(join(tmpdir(), 'wf-tier-prio-'))
625
+ const recordPath = await writeRecordStream('wf-both-1', [
626
+ JSON.stringify({ type: 'run-created', seq: 1, ts: 1000, runId: 'wf-both-1', workflowName: 'both', argsSummary: '{}' }),
627
+ JSON.stringify({ type: 'agent-started', seq: 2, ts: 1100, taskIndex: 0, agentName: 's0', attempt: 1 }),
628
+ JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1500, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 400, result: { content: 'ok', sessionFile: CALL_A } }),
629
+ ])
630
+ const oldState = await writeStateFile('wf-both-1-old', v1Snapshot('wf-both-1', [CALL_B]))
631
+ const { sessionIdToPath } = await writeMain([
632
+ linkLine('wf-both-1', oldState),
633
+ v2RegisteredLine('wf-both-1', recordPath),
634
+ ])
635
+
636
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
637
+
638
+ expect(workflows).toHaveLength(1)
639
+ // v2 档胜出:stateFile 是 record 流路径(非 link 的 oldState),calls 来自流
640
+ expect(workflows[0].stateFile).toBe(recordPath)
641
+ expect(workflows[0].calls.map((c) => c.fileName)).toEqual([CALL_A])
642
+ })
643
+
644
+ it('跨包契约守卫:三档判别的本地字面量 === subagent-core 写侧单源([D1] record 后缀)', () => {
645
+ // 源码本地持有磁盘协议字符串与版本/尾段(生产依赖面不引 subagent-core),锚定两侧同值
646
+ expect(WORKFLOW_RECORD_CUSTOM_TYPE).toBe('workflow-record')
647
+ expect(WORKFLOW_RECORD_ENTRY_VERSION).toBe(2)
648
+ expect(RUN_RECORD_STREAM_SUFFIX).toBe('.record.jsonl')
649
+ // legacy 指针档:collectWorkflowEntryTiers 本地字面量与 core 常量同值
650
+ expect(WORKFLOW_STATE_LINK_CUSTOM_TYPE).toBe('workflow-state-link')
651
+ })
652
+ })
653
+
654
+ // ============================================================
655
+ // readRunSnapshot + parseRunSnapshot 磁盘回路(fixture):快照形态已由
656
+ // NEW_SNAPSHOT_FIXTURE / OLD_SNAPSHOT_FIXTURE 单源锚定,此处补「wf-state 落盘 →
657
+ // 读回 → 解析」的端到端回路断言
371
658
  // ============================================================
372
659
 
373
- const REAL_WF_NEW = join(REAL_AGENT_DIR, 'workflow-state', 'wf-1785762350110-d297tr.jsonl')
374
- const REAL_WF_OLD = join(REAL_AGENT_DIR, 'workflow-state', 'wf-skip-ok.jsonl')
375
- const HAS_REAL_WF_NEW = existsSync(REAL_WF_NEW)
376
- const HAS_REAL_WF_OLD = existsSync(REAL_WF_OLD)
660
+ describe('readRunSnapshot + parseRunSnapshot 磁盘回路(fixture)', () => {
661
+ let tmpDir: string | undefined
377
662
 
378
- describe.skipIf(!HAS_REAL_WF_NEW)('真实数据守卫 - NEW wf-state(wf-1785762350110-d297tr)', () => {
379
- it('TC-w5-real-new-guard:readRunSnapshot+parseRunSnapshot 类型化 NEW 真实快照', async () => {
380
- const snap = await readRunSnapshot(REAL_WF_NEW)
663
+ afterEach(async () => {
664
+ if (tmpDir !== undefined) {
665
+ await rm(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
666
+ }
667
+ })
668
+
669
+ it('TC-w5-disk-new-guard:NEW 快照落盘读回,类型化为 wf-run-v1 overview', async () => {
670
+ tmpDir = await mkdtemp(join(tmpdir(), 'wf-disk-new-'))
671
+ const wfPath = join(tmpDir, 'wf-new.jsonl')
672
+ await writeFile(wfPath, JSON.stringify(NEW_SNAPSHOT_FIXTURE) + '\n')
673
+
674
+ const snap = await readRunSnapshot(wfPath)
381
675
  expect(snap).not.toBeUndefined()
382
- const overview = parseRunSnapshot(snap, 'wf-1785762350110-d297tr', REAL_WF_NEW)
676
+ const overview = parseRunSnapshot(snap, 'wf-disk-new', wfPath)
383
677
  expect(overview).not.toBeNull()
384
678
  expect(overview!.version).toBe('wf-run-v1')
385
679
  expect(overview!.steps.length).toBeGreaterThanOrEqual(1)
386
- // call 的 sessionFile 是真实绝对 .jsonl 路径(跳转入口)
680
+ // call 的 sessionFile 是绝对 .jsonl 路径(跳转入口)
387
681
  expect(overview!.steps[0].sessionFile).toMatch(/\.jsonl$/)
388
682
  expect(overview!.steps[0].sessionFile!.startsWith('/')).toBe(true)
389
683
  expect(overview!.steps[0].sessionId).toBeTruthy()
390
- }, 30000)
391
- })
684
+ })
392
685
 
393
- describe.skipIf(!HAS_REAL_WF_OLD)('真实数据守卫 - OLD wf-state(wf-skip-ok)', () => {
394
- it('TC-w5-real-old-guard:readRunSnapshot+parseRunSnapshot 尽力解析 OLD 真实快照', async () => {
395
- const snap = await readRunSnapshot(REAL_WF_OLD)
686
+ it('TC-w5-disk-old-guard:OLD 快照落盘读回,尽力解析为 legacy overview', async () => {
687
+ tmpDir = await mkdtemp(join(tmpdir(), 'wf-disk-old-'))
688
+ const wfPath = join(tmpDir, 'wf-old.jsonl')
689
+ await writeFile(wfPath, JSON.stringify(OLD_SNAPSHOT_FIXTURE) + '\n')
690
+
691
+ const snap = await readRunSnapshot(wfPath)
396
692
  expect(snap).not.toBeUndefined()
397
- const overview = parseRunSnapshot(snap, 'wf-skip-ok', REAL_WF_OLD)
693
+ const overview = parseRunSnapshot(snap, 'wf-skip-ok', wfPath)
398
694
  expect(overview).not.toBeNull()
399
695
  expect(overview!.version).toBe('legacy')
400
696
  expect(overview!.status).toBe('running')
401
697
  expect(overview!.script).toBe('workflow-wf-skip-ok') // name 映射
402
- // OLD callCache value 无 sessionFile(探针 112 文件 0 sessionFile)
698
+ // OLD callCache value 无 sessionFile → step 无跳转入口
403
699
  expect(overview!.steps[0].sessionFile).toBeUndefined()
404
- }, 30000)
700
+ })
405
701
  })
@@ -1,11 +1,13 @@
1
1
  // ============================================================
2
- // workflow 概览解析与渲染(w5 新增,纯逻辑零 IO)
2
+ // workflow 概览解析与渲染(w5 新增 → [D16③] record 流换源)
3
3
  // ============================================================
4
4
  //
5
- // 本文件消费 discovery/workflows.ts 的 readRunSnapshot(返 unknown 原始快照对象),
6
- // 把 unknown 类型化为 WorkflowOverview(NEW v='wf-run-v1'/'wf-run-v2' / OLD 无 v 双格式分支),
7
- // 再渲染为人类可读文本。零 IO:parseRunSnapshot/renderWorkflowOverview 喂 mock 即可单测
8
- //(w5 TC-wf-core-pure-logic,对齐 session-reader core/* 纯逻辑约定)。
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
+ }