From a20f81d44a40d5535584e3da5c5536fbdfb3b406 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Tue, 13 Jan 2026 12:35:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=20discovery-state.jso?= =?UTF-8?q?n=20=E6=96=B0=E6=97=A7=E4=B8=A4=E7=A7=8D=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readDiscoveryProgress: 自动检测 perspectives 格式(对象数组/字符串数组) - readDiscoveryIndex: 兼容从 perspectives 和 metadata.perspectives 提取视角 - 列表 API: 优先从 results 对象提取统计数据,回退到顶层字段 - 新增 3 个测试用例验证新格式兼容性 - bump version to 6.3.22 Co-Authored-By: Claude Opus 4.5 --- ccw/src/core/routes/discovery-routes.ts | 120 +++++++++--- .../integration/discovery-routes.test.ts | 172 ++++++++++++++++++ package.json | 2 +- 3 files changed, 267 insertions(+), 27 deletions(-) diff --git a/ccw/src/core/routes/discovery-routes.ts b/ccw/src/core/routes/discovery-routes.ts index 333b94ca..8fb24e7b 100644 --- a/ccw/src/core/routes/discovery-routes.ts +++ b/ccw/src/core/routes/discovery-routes.ts @@ -60,12 +60,30 @@ function readDiscoveryIndex(discoveriesDir: string): { discoveries: any[]; total if (existsSync(statePath)) { try { const state = JSON.parse(readFileSync(statePath, 'utf8')); + + // Extract perspectives - handle both old and new formats + let perspectives: string[] = []; + if (state.perspectives && Array.isArray(state.perspectives)) { + // New format: string array or old format: object array + if (state.perspectives.length > 0 && typeof state.perspectives[0] === 'object') { + perspectives = state.perspectives.map((p: any) => p.name || p.perspective || ''); + } else { + perspectives = state.perspectives; + } + } else if (state.metadata?.perspectives) { + // Legacy format + perspectives = state.metadata.perspectives; + } + + // Extract created_at - handle both formats + const created_at = state.created_at || state.metadata?.created_at; + discoveries.push({ discovery_id: entry.name, target_pattern: state.target_pattern, - perspectives: state.metadata?.perspectives || [], - created_at: state.metadata?.created_at, - completed_at: state.completed_at + perspectives, + created_at, + completed_at: state.completed_at || state.updated_at }); } catch { // Skip invalid entries @@ -110,29 +128,71 @@ function readDiscoveryProgress(discoveriesDir: string, discoveryId: string): any if (existsSync(statePath)) { try { const state = JSON.parse(readFileSync(statePath, 'utf8')); - // New merged schema: perspectives array + results object + + // Check if perspectives is an array if (state.perspectives && Array.isArray(state.perspectives)) { - const completed = state.perspectives.filter((p: any) => p.status === 'completed').length; - const total = state.perspectives.length; - return { - discovery_id: discoveryId, - phase: state.phase, - last_update: state.updated_at || state.created_at, - progress: { - perspective_analysis: { - total, - completed, - in_progress: state.perspectives.filter((p: any) => p.status === 'in_progress').length, - percent_complete: total > 0 ? Math.round((completed / total) * 100) : 0 + // Detect format: object array (old) vs string array (new) + const isObjectArray = state.perspectives.length > 0 && typeof state.perspectives[0] === 'object'; + + if (isObjectArray) { + // Old merged schema: perspectives is array of objects with status + const completed = state.perspectives.filter((p: any) => p.status === 'completed').length; + const total = state.perspectives.length; + return { + discovery_id: discoveryId, + phase: state.phase, + last_update: state.updated_at || state.created_at, + progress: { + perspective_analysis: { + total, + completed, + in_progress: state.perspectives.filter((p: any) => p.status === 'in_progress').length, + percent_complete: total > 0 ? Math.round((completed / total) * 100) : 0 + }, + external_research: state.external_research || { enabled: false, completed: false }, + aggregation: { completed: state.phase === 'aggregation' || state.phase === 'complete' }, + issue_generation: { completed: state.phase === 'complete', issues_count: state.results?.issues_generated || 0 } }, - external_research: state.external_research || { enabled: false, completed: false }, - aggregation: { completed: state.phase === 'aggregation' || state.phase === 'complete' }, - issue_generation: { completed: state.phase === 'complete', issues_count: state.results?.issues_generated || 0 } - }, - agent_status: state.perspectives - }; + agent_status: state.perspectives + }; + } else { + // New schema: perspectives is string array, status in perspectives_completed/perspectives_failed + const total = state.perspectives.length; + const completedList = state.perspectives_completed || []; + const failedList = state.perspectives_failed || []; + const completed = completedList.length; + const failed = failedList.length; + const inProgress = total - completed - failed; + + return { + discovery_id: discoveryId, + phase: state.phase, + last_update: state.updated_at || state.created_at, + progress: { + perspective_analysis: { + total, + completed, + failed, + in_progress: inProgress, + percent_complete: total > 0 ? Math.round(((completed + failed) / total) * 100) : 0 + }, + external_research: state.external_research || { enabled: false, completed: false }, + aggregation: { completed: state.phase === 'aggregation' || state.phase === 'complete' }, + issue_generation: { + completed: state.phase === 'complete', + issues_count: state.results?.issues_generated || state.issues_generated || 0 + } + }, + // Convert string array to object array for UI compatibility + agent_status: state.perspectives.map((p: string) => ({ + name: p, + status: completedList.includes(p) ? 'completed' : (failedList.includes(p) ? 'failed' : 'pending') + })) + }; + } } - // Old schema: metadata.perspectives (backward compat) + + // Legacy schema: metadata.perspectives (backward compat) if (state.metadata?.perspectives) { return { discovery_id: discoveryId, @@ -294,12 +354,20 @@ export async function handleDiscoveryRoutes(ctx: RouteContext): Promise const enrichedDiscoveries = index.discoveries.map((d: any) => { const state = readDiscoveryState(discoveriesDir, d.discovery_id); const progress = readDiscoveryProgress(discoveriesDir, d.discovery_id); + + // Extract statistics - handle both old and new formats + // New format: stats in state.results object + // Old format: stats directly in state + const total_findings = state?.results?.total_findings ?? state?.total_findings ?? 0; + const issues_generated = state?.results?.issues_generated ?? state?.issues_generated ?? 0; + const priority_distribution = state?.results?.priority_distribution ?? state?.priority_distribution ?? {}; + return { ...d, phase: state?.phase || 'unknown', - total_findings: state?.total_findings || 0, - issues_generated: state?.issues_generated || 0, - priority_distribution: state?.priority_distribution || {}, + total_findings, + issues_generated, + priority_distribution, progress: progress?.progress || null }; }); diff --git a/ccw/tests/integration/discovery-routes.test.ts b/ccw/tests/integration/discovery-routes.test.ts index 6a1806b1..eecfc01f 100644 --- a/ccw/tests/integration/discovery-routes.test.ts +++ b/ccw/tests/integration/discovery-routes.test.ts @@ -182,6 +182,80 @@ function createDiscoveryFixture(projectRoot: string): { discoveryId: string; fin return { discoveryId, findingId, discoveryDir }; } +/** + * Creates a discovery fixture using the NEW format: + * - perspectives is a string array + * - status tracked in perspectives_completed/perspectives_failed + * - stats in results object + */ +function createNewFormatDiscoveryFixture(projectRoot: string): { discoveryId: string; findingId: string; discoveryDir: string } { + const discoveryId = `DSC-NEW-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const findingId = 'F-NEW-001'; + + const discoveryDir = join(projectRoot, '.workflow', 'issues', 'discoveries', discoveryId); + const perspectivesDir = join(discoveryDir, 'perspectives'); + mkdirSync(perspectivesDir, { recursive: true }); + + const createdAt = new Date().toISOString(); + writeFileSync( + join(discoveryDir, 'discovery-state.json'), + JSON.stringify( + { + discovery_id: discoveryId, + target_pattern: 'src/**/*.ts', + phase: 'complete', + created_at: createdAt, + updated_at: createdAt, + target: { + files_count: { total: 10 }, + project: { name: 'test', path: projectRoot }, + }, + // New format: perspectives as string array + perspectives: ['bug', 'security', 'performance'], + perspectives_completed: ['bug', 'security'], + perspectives_failed: ['performance'], + external_research: { enabled: false, completed: false }, + // New format: stats in results object + results: { + total_findings: 5, + issues_generated: 2, + priority_distribution: { critical: 1, high: 2, medium: 1, low: 1 }, + findings_by_perspective: { bug: 3, security: 2 }, + }, + }, + null, + 2, + ), + 'utf8', + ); + + writeFileSync( + join(perspectivesDir, 'bug.json'), + JSON.stringify( + { + summary: { total: 3 }, + findings: [ + { + id: findingId, + title: 'New format finding', + description: 'Example from new format', + priority: 'high', + perspective: 'bug', + file: 'src/example.ts', + line: 100, + suggested_issue: { title: 'New format issue', priority: 2, labels: ['bug'] }, + }, + ], + }, + null, + 2, + ), + 'utf8', + ); + + return { discoveryId, findingId, discoveryDir }; +} + describe('discovery routes integration', async () => { before(async () => { mock.method(console, 'log', () => {}); @@ -358,5 +432,103 @@ describe('discovery routes integration', async () => { rmSync(projectRoot, { recursive: true, force: true }); } }); + + // ========== NEW FORMAT TESTS ========== + + it('GET /api/discoveries lists new format discovery sessions with correct stats', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'ccw-discovery-routes-newformat-')); + try { + const { discoveryId } = createNewFormatDiscoveryFixture(projectRoot); + const { server, baseUrl } = await createServer(projectRoot); + try { + const res = await requestJson(baseUrl, 'GET', '/api/discoveries'); + assert.equal(res.status, 200); + assert.equal(Array.isArray(res.json.discoveries), true); + assert.equal(res.json.total, 1); + + const discovery = res.json.discoveries[0]; + assert.equal(discovery.discovery_id, discoveryId); + assert.equal(discovery.phase, 'complete'); + // Verify stats are extracted from results object + assert.equal(discovery.total_findings, 5); + assert.equal(discovery.issues_generated, 2); + assert.deepEqual(discovery.priority_distribution, { critical: 1, high: 2, medium: 1, low: 1 }); + // Verify perspectives is string array + assert.ok(Array.isArray(discovery.perspectives)); + assert.ok(discovery.perspectives.includes('bug')); + assert.ok(discovery.perspectives.includes('security')); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it('GET /api/discoveries/:id/progress returns correct progress for new format', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'ccw-discovery-routes-newformat-')); + try { + const { discoveryId } = createNewFormatDiscoveryFixture(projectRoot); + const { server, baseUrl } = await createServer(projectRoot); + try { + const res = await requestJson(baseUrl, 'GET', `/api/discoveries/${encodeURIComponent(discoveryId)}/progress`); + assert.equal(res.status, 200); + assert.equal(res.json.discovery_id, discoveryId); + assert.ok(res.json.progress); + + const pa = res.json.progress.perspective_analysis; + assert.equal(pa.total, 3); // bug, security, performance + assert.equal(pa.completed, 2); // bug, security + assert.equal(pa.failed, 1); // performance + assert.equal(pa.in_progress, 0); + assert.equal(pa.percent_complete, 100); // (completed + failed) / total = 3/3 = 100% + + // Verify agent_status is converted to object array for UI compatibility + assert.ok(Array.isArray(res.json.agent_status)); + const bugStatus = res.json.agent_status.find((s: any) => s.name === 'bug'); + assert.ok(bugStatus); + assert.equal(bugStatus.status, 'completed'); + const perfStatus = res.json.agent_status.find((s: any) => s.name === 'performance'); + assert.ok(perfStatus); + assert.equal(perfStatus.status, 'failed'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it('mixed old and new format discoveries are listed correctly', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'ccw-discovery-routes-mixed-')); + try { + const oldFormat = createDiscoveryFixture(projectRoot); + const newFormat = createNewFormatDiscoveryFixture(projectRoot); + const { server, baseUrl } = await createServer(projectRoot); + try { + const res = await requestJson(baseUrl, 'GET', '/api/discoveries'); + assert.equal(res.status, 200); + assert.equal(res.json.total, 2); + + // Both formats should be parsed correctly + const oldDiscovery = res.json.discoveries.find((d: any) => d.discovery_id === oldFormat.discoveryId); + const newDiscovery = res.json.discoveries.find((d: any) => d.discovery_id === newFormat.discoveryId); + + assert.ok(oldDiscovery); + assert.ok(newDiscovery); + + // Old format stats + assert.equal(oldDiscovery.total_findings, 1); + + // New format stats from results object + assert.equal(newDiscovery.total_findings, 5); + assert.equal(newDiscovery.issues_generated, 2); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + }); }); diff --git a/package.json b/package.json index 0bc78c2d..e7294734 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-workflow", - "version": "6.3.21", + "version": "6.3.22", "description": "JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution", "type": "module", "main": "ccw/src/index.js",