@zhushanwen/pi-session-reader 0.7.0 → 0.8.1
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/package.json +4 -4
- package/src/__tests__/entry-anchor.test.ts +148 -4
- package/src/__tests__/execution-tree.test.ts +6 -12
- package/src/__tests__/find.test.ts +9 -49
- package/src/__tests__/fixtures.ts +15 -0
- package/src/__tests__/parser.test.ts +36 -11
- package/src/__tests__/render.test.ts +22 -18
- package/src/__tests__/roots.test.ts +32 -34
- package/src/__tests__/subagents.test.ts +131 -108
- package/src/__tests__/tool-handler.test.ts +496 -147
- package/src/__tests__/turns.test.ts +21 -19
- package/src/__tests__/workflow.test.ts +319 -23
- package/src/core/workflow.ts +196 -6
- package/src/discovery/entry-anchor.ts +36 -5
- package/src/discovery/roots.ts +1 -2
- package/src/discovery/workflows.ts +232 -22
- package/src/tool-handler.ts +82 -22
- package/src/zcode-anchor-classify.ts +23 -9
- package/src/__tests__/real-data.ts +0 -96
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { segmentTurns } from '../core/turns.js'
|
|
3
|
-
import {
|
|
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
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
expect(turns.filter((t) => t.
|
|
138
|
-
|
|
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 {
|
|
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 {
|
|
9
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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-
|
|
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
|
|
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
|
-
}
|
|
391
|
-
})
|
|
684
|
+
})
|
|
392
685
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
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',
|
|
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
|
|
698
|
+
// OLD callCache value 无 sessionFile → step 无跳转入口
|
|
403
699
|
expect(overview!.steps[0].sessionFile).toBeUndefined()
|
|
404
|
-
}
|
|
700
|
+
})
|
|
405
701
|
})
|
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
|
+
}
|