diff --git a/packages/factory/src/client/visualizations/catwalk/mappers/__tests__/run-to-catwalk.test.ts b/packages/factory/src/client/visualizations/catwalk/mappers/__tests__/run-to-catwalk.test.ts new file mode 100644 index 00000000..cbf1b0db --- /dev/null +++ b/packages/factory/src/client/visualizations/catwalk/mappers/__tests__/run-to-catwalk.test.ts @@ -0,0 +1,484 @@ +import { describe, expect, it } from 'vitest'; + +import { + createCompletedRunPhases, + createInProgressReviewPhases, + createMockRunStatus, + emptyPhases, +} from '../../../../../__test-helpers__/fixtures.js'; +import { ARTIFACT_COLORS } from '../../../../../shared/constants/artifact-colors.js'; +import { mapRunToCatwalk } from '../run-to-catwalk.js'; + +describe('mapRunToCatwalk', () => { + describe('empty in_progress run', () => { + it('infers architecture as current phase, with arch agent working, orchestrator working, and all others idle', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: emptyPhases(), + }); + + const config = mapRunToCatwalk(status); + + // 7 stations + expect(config.stations).toHaveLength(7); + + // Architecture agent should be working (inferred current phase) + const archAgent = config.agents.find((a) => a.id === 'arch'); + expect(archAgent?.state).toBe('working'); + + // All other agents should be idle + const nonArchAgents = config.agents.filter((a) => a.id !== 'arch'); + for (const agent of nonArchAgents) { + expect(agent.state).toBe('idle'); + } + + // Orchestrator at station 0, working (architecture is inferred current, no data yet) + expect(config.orchestrator.stationIndex).toBe(0); + expect(config.orchestrator.working).toBe(true); + + // All 6 gates closed (no phases evaluated) + expect(config.gates).toHaveLength(6); + for (const gate of config.gates) { + expect(gate.open).toBe(false); + } + + // No artifacts + expect(config.artifacts).toHaveLength(0); + }); + }); + + describe('implementation complete, review in progress', () => { + it('shows coder resting, reviewer working, orchestrator at station 3, gates 0-2 open', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: createInProgressReviewPhases(), + }); + + const config = mapRunToCatwalk(status); + + // Coder agent should be resting + const coderAgent = config.agents.find((a) => a.id === 'coder'); + expect(coderAgent).toBeDefined(); + expect(coderAgent?.state).toBe('resting'); + + // Reviewer agent(s) should be working + const reviewerAgents = config.agents.filter((a) => a.id.startsWith('reviewer-')); + expect(reviewerAgents.length).toBeGreaterThanOrEqual(1); + for (const reviewer of reviewerAgents) { + expect(reviewer.state).toBe('working'); + } + + // Orchestrator at station 3 (review), not working (review data is present) + expect(config.orchestrator.stationIndex).toBe(3); + expect(config.orchestrator.working).toBe(false); + + // Gates 0, 1, 2 should be open (architecture, planning, implementation evaluated) + expect(config.gates[0]?.open).toBe(true); + expect(config.gates[1]?.open).toBe(true); + expect(config.gates[2]?.open).toBe(true); + + // Gates 3, 4, 5 should be closed (review in progress, simplifier/holistic not reached) + expect(config.gates[3]?.open).toBe(false); + expect(config.gates[4]?.open).toBe(false); + expect(config.gates[5]?.open).toBe(false); + }); + }); + + describe('completed run', () => { + it('shows all agents celebrating, orchestrator at station 6, all gates open', () => { + const status = createMockRunStatus({ + status: 'completed', + completedAt: '2026-01-01T01:00:00Z', + phases: createCompletedRunPhases(), + }); + + const config = mapRunToCatwalk(status); + + // All agents celebrating + for (const agent of config.agents) { + expect(agent.state).toBe('celebrating'); + } + + // Orchestrator at final station + expect(config.orchestrator.stationIndex).toBe(6); + + // All 6 gates open + for (const gate of config.gates) { + expect(gate.open).toBe(true); + } + }); + }); + + describe('failed run', () => { + it('shows all agents as concerned and orchestrator at sentinel stationIndex -1', () => { + const status = createMockRunStatus({ + status: 'failed', + phases: { + ...emptyPhases(), + architecture: { status: 'completed', impactLevel: 'high', artifact: 'arch.md' }, + planning: { status: 'completed', stepCount: 7, artifacts: ['plan.md'] }, + }, + }); + + const config = mapRunToCatwalk(status); + + for (const agent of config.agents) { + expect(agent.state).toBe('concerned'); + } + + // Orchestrator uses sentinel stationIndex -1 for failed runs + expect(config.orchestrator.stationIndex).toBe(-1); + }); + }); + + describe('needs_manual_review run', () => { + it('uses per-phase logic, not all-concerned, with orchestrator stationIndex -1', () => { + const status = createMockRunStatus({ + status: 'needs_manual_review', + phases: { + ...emptyPhases(), + architecture: { status: 'completed', impactLevel: 'high', artifact: 'arch.md' }, + planning: { status: 'completed', stepCount: 7, artifacts: ['plan.md'] }, + implementation: { status: 'completed', artifact: 'code.md', qualityGates: undefined }, + }, + }); + + const config = mapRunToCatwalk(status); + + // Per-phase logic: completed phases produce 'resting', not 'concerned' + const archAgent = config.agents.find((a) => a.id === 'arch'); + expect(archAgent?.state).toBe('resting'); + + const planAgent = config.agents.find((a) => a.id === 'plan'); + expect(planAgent?.state).toBe('resting'); + + const coderAgent = config.agents.find((a) => a.id === 'coder'); + expect(coderAgent?.state).toBe('resting'); + + // Orchestrator stationIndex is -1 (falls to else branch, not in_progress) + expect(config.orchestrator.stationIndex).toBe(-1); + }); + }); + + describe('absent phase', () => { + it('marks architecture station as absent and skipped, gate at index 0 is open', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: emptyPhases(), + phaseDecisions: { + architecture: { run: false, reason: 'not needed' }, + }, + }); + + const config = mapRunToCatwalk(status); + + // Architecture station should be absent and skipped + expect(config.stations[0]?.absent).toBe(true); + expect(config.stations[0]?.skipped).toBe(true); + + // Gate at index 0 (between stations 0 and 1) should be open because station 0 is absent + expect(config.gates[0]?.open).toBe(true); + expect(config.gates[0]?.betweenStations).toEqual([0, 1]); + }); + }); + + describe('multiple reviewers', () => { + it('produces reviewer agents with sequential IDs and slot indices from Shape 1 (flat reviewers)', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: { + ...emptyPhases(), + architecture: { status: 'completed', impactLevel: 'high', artifact: 'arch.md' }, + planning: { status: 'completed', stepCount: 7, artifacts: ['plan.md'] }, + implementation: { status: 'completed', artifact: 'code.md', qualityGates: undefined }, + parallelReview: { + aggregatedCriticality: undefined, + reviewRoundsUsed: 1, + reviewers: { + 'correctness-reviewer': { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + 'security-reviewer': { + ran: true, + status: undefined, + criticality: undefined, + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + }, + coderFixCycleRan: false, + selectiveReReview: undefined, + }, + }, + }); + + const config = mapRunToCatwalk(status); + + const reviewerAgents = config.agents.filter((a) => a.id.startsWith('reviewer-')); + expect(reviewerAgents).toHaveLength(2); + expect(reviewerAgents[0]?.id).toBe('reviewer-0'); + expect(reviewerAgents[0]?.slotIndex).toBe(0); + expect(reviewerAgents[1]?.id).toBe('reviewer-1'); + expect(reviewerAgents[1]?.slotIndex).toBe(1); + }); + + it('extracts reviewer names from Shape 2 (iterations[].perReviewer)', () => { + // perReviewer is an untyped extra property that passes through Zod .loose(). + // Use Object.assign to add it without a type assertion. + const iterationWithPerReviewer = Object.assign( + { reviewers: [] }, + { perReviewer: { 'alpha-reviewer': {}, 'beta-reviewer': {} } }, + ); + const status = createMockRunStatus({ + status: 'in_progress', + phases: { + ...emptyPhases(), + architecture: { status: 'completed', impactLevel: 'high', artifact: 'arch.md' }, + planning: { status: 'completed', stepCount: 7, artifacts: ['plan.md'] }, + implementation: { status: 'completed', artifact: 'code.md', qualityGates: undefined }, + parallelReview: { + aggregatedCriticality: undefined, + reviewRoundsUsed: 1, + coderFixCycleRan: false, + selectiveReReview: undefined, + iterations: [iterationWithPerReviewer], + }, + }, + }); + + const config = mapRunToCatwalk(status); + + const reviewerAgents = config.agents.filter((a) => a.id.startsWith('reviewer-')); + expect(reviewerAgents).toHaveLength(2); + expect(reviewerAgents[0]?.role).toBe('alpha-reviewer'); + expect(reviewerAgents[1]?.role).toBe('beta-reviewer'); + }); + + it('extracts reviewer names from Shape 2 string-array reviewers (iterations[].reviewers)', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: { + ...emptyPhases(), + architecture: { status: 'completed', impactLevel: 'high', artifact: 'arch.md' }, + planning: { status: 'completed', stepCount: 7, artifacts: ['plan.md'] }, + implementation: { status: 'completed', artifact: 'code.md', qualityGates: undefined }, + parallelReview: { + aggregatedCriticality: undefined, + reviewRoundsUsed: 1, + coderFixCycleRan: false, + selectiveReReview: undefined, + iterations: [{ reviewers: ['name-a', 'name-b'] }], + }, + }, + }); + + const config = mapRunToCatwalk(status); + + const reviewerAgents = config.agents.filter((a) => a.id.startsWith('reviewer-')); + expect(reviewerAgents).toHaveLength(2); + expect(reviewerAgents[0]?.role).toBe('name-a'); + expect(reviewerAgents[1]?.role).toBe('name-b'); + }); + + it('extracts reviewer names from Shape 3 (top-level reviewerDetails)', () => { + // reviewerDetails is an untyped extra property that passes through Zod .loose(). + // Use Object.assign to add it without a type assertion. + const parallelReviewWithDetails = Object.assign( + { + aggregatedCriticality: undefined, + reviewRoundsUsed: 1, + coderFixCycleRan: false, + selectiveReReview: undefined, + }, + { reviewerDetails: { 'gamma-reviewer': {}, 'delta-reviewer': {}, 'epsilon-reviewer': {} } }, + ); + const status = createMockRunStatus({ + status: 'in_progress', + phases: { + ...emptyPhases(), + architecture: { status: 'completed', impactLevel: 'high', artifact: 'arch.md' }, + planning: { status: 'completed', stepCount: 7, artifacts: ['plan.md'] }, + implementation: { status: 'completed', artifact: 'code.md', qualityGates: undefined }, + parallelReview: parallelReviewWithDetails, + }, + }); + + const config = mapRunToCatwalk(status); + + const reviewerAgents = config.agents.filter((a) => a.id.startsWith('reviewer-')); + expect(reviewerAgents).toHaveLength(3); + expect(reviewerAgents[0]?.role).toBe('gamma-reviewer'); + expect(reviewerAgents[1]?.role).toBe('delta-reviewer'); + expect(reviewerAgents[2]?.role).toBe('epsilon-reviewer'); + }); + }); + + describe('reviewer fallback when review is not current phase', () => { + it('produces a single fallback reviewer-0 when parallelReview is absent and current phase is implementation', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: { + ...emptyPhases(), + architecture: { status: 'completed', impactLevel: 'high', artifact: 'arch.md' }, + planning: { status: 'completed', stepCount: 7, artifacts: ['plan.md'] }, + implementation: { status: 'in_progress', artifact: undefined, qualityGates: undefined }, + }, + }); + + const config = mapRunToCatwalk(status); + + const reviewerAgents = config.agents.filter((a) => a.id.startsWith('reviewer-')); + expect(reviewerAgents).toHaveLength(1); + expect(reviewerAgents[0]?.id).toBe('reviewer-0'); + expect(reviewerAgents[0]?.state).toBe('idle'); + }); + }); + + describe('artifact mapping', () => { + it('maps artifacts to StationArtifactConfig with correct stationIndex, label, color, and slot', () => { + const status = createMockRunStatus({ + status: 'completed', + completedAt: '2026-01-01T01:00:00Z', + phases: createCompletedRunPhases(), + artifacts: [ + { + filename: 'arch-assessment.md', + role: 'analyst', + roleType: 'analyst', + agent: 'orchestrated-architect', + type: 'architecture', + phase: 'architecture', + createdAt: '2026-01-01T00:10:00Z', + }, + { + filename: 'plan.json', + role: 'planner', + roleType: 'planner', + agent: 'orchestrated-planner', + type: 'plan', + phase: 'planning', + createdAt: '2026-01-01T00:20:00Z', + }, + { + filename: 'change-summary.md', + role: 'author', + roleType: 'author', + agent: 'orchestrated-coder', + type: 'code', + phase: 'implementation', + createdAt: '2026-01-01T00:30:00Z', + iteration: 2, + }, + ], + }); + + const config = mapRunToCatwalk(status); + + expect(config.artifacts).toHaveLength(3); + + // Architecture artifact at station 0 + expect(config.artifacts[0]?.stationIndex).toBe(0); + expect(config.artifacts[0]?.label).toBe('architecture'); + expect(config.artifacts[0]?.color).toBe(ARTIFACT_COLORS.arch); + expect(config.artifacts[0]?.slot).toBe('output'); + + // Planning artifact at station 1 + expect(config.artifacts[1]?.stationIndex).toBe(1); + expect(config.artifacts[1]?.label).toBe('plan'); + expect(config.artifacts[1]?.color).toBe(ARTIFACT_COLORS.plan); + expect(config.artifacts[1]?.slot).toBe('output'); + + // Code artifact at station 2 with version from iteration + expect(config.artifacts[2]?.stationIndex).toBe(2); + expect(config.artifacts[2]?.label).toBe('code'); + expect(config.artifacts[2]?.color).toBe(ARTIFACT_COLORS.code); + expect(config.artifacts[2]?.slot).toBe('output'); + expect(config.artifacts[2]?.version).toBe(2); + }); + + it('uses fallback color for unrecognized artifact type', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: emptyPhases(), + artifacts: [ + { + filename: 'mystery.md', + role: 'analyst', + roleType: 'analyst', + agent: 'unknown-agent', + type: 'unknown-type', + phase: 'architecture', + createdAt: '2026-01-01T00:10:00Z', + }, + ], + }); + + const config = mapRunToCatwalk(status); + + expect(config.artifacts).toHaveLength(1); + expect(config.artifacts[0]?.color).toBe(ARTIFACT_COLORS.code); + }); + + it('skips artifacts with unknown phase', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: emptyPhases(), + artifacts: [ + { + filename: 'arch.md', + role: 'analyst', + roleType: 'analyst', + agent: 'orchestrated-architect', + type: 'architecture', + phase: 'architecture', + createdAt: '2026-01-01T00:10:00Z', + }, + { + filename: 'unknown.md', + role: 'unknown', + roleType: 'unknown', + agent: 'unknown-agent', + type: 'mystery', + phase: 'nonexistent-phase', + createdAt: '2026-01-01T00:20:00Z', + }, + ], + }); + + const config = mapRunToCatwalk(status); + + // Only the architecture artifact should be present; the unknown phase is skipped + expect(config.artifacts).toHaveLength(1); + expect(config.artifacts[0]?.stationIndex).toBe(0); + }); + + it('omits version when iteration is not present on artifact', () => { + const status = createMockRunStatus({ + status: 'in_progress', + phases: emptyPhases(), + artifacts: [ + { + filename: 'plan.json', + role: 'planner', + roleType: 'planner', + agent: 'orchestrated-planner', + type: 'plan', + phase: 'planning', + createdAt: '2026-01-01T00:10:00Z', + }, + ], + }); + + const config = mapRunToCatwalk(status); + + expect(config.artifacts).toHaveLength(1); + expect(config.artifacts[0]).not.toHaveProperty('version'); + }); + }); +}); diff --git a/packages/factory/src/client/visualizations/catwalk/mappers/run-to-catwalk.ts b/packages/factory/src/client/visualizations/catwalk/mappers/run-to-catwalk.ts new file mode 100644 index 00000000..5dc8e3e0 --- /dev/null +++ b/packages/factory/src/client/visualizations/catwalk/mappers/run-to-catwalk.ts @@ -0,0 +1,358 @@ +import { ARTIFACT_COLORS } from '../../../../shared/constants/artifact-colors.js'; +import type { PhaseName } from '../../../../shared/constants/role-types.js'; +import { PHASE_NAMES, PHASE_ROLE, PHASE_ROLE_TYPE, ROLE_TYPE_COLORS } from '../../../../shared/constants/role-types.js'; +import { + findCurrentPhase, + findPhaseDecision, + isPhaseEvaluated, + isPhasePresentInData, +} from '../../../../shared/phase-inference.js'; +import type { CanonicalRunStatus, ParallelReviewPhase, Phases } from '../../../../shared/types/canonical.js'; +import type { + AgentAnimationState, + AgentConfig, + CarriedArtifactConfig, + CatwalkSceneConfig, + GateConfig, + OrchestratorConfig, + StationArtifactConfig, + StationConfig, +} from '../types.js'; + +/** Maps run-index artifact type names to shared ARTIFACT_COLORS keys. */ +const ARTIFACT_TYPE_COLOR_KEY: Record = { + architecture: 'arch', + plan: 'plan', + code: 'code', + review: 'review', + simplifier: 'clean', + holistic: 'holi', +}; + +function lookupArtifactColor(type: string): string { + const key = ARTIFACT_TYPE_COLOR_KEY[type]; + return key === undefined ? ARTIFACT_COLORS.code : ARTIFACT_COLORS[key]; +} + +/** + * Map from phase names (both visualization-layer PHASE_NAMES aliases and + * data-model property names) to station indices. Entries with unknown + * phases are skipped during artifact building. + */ +const PHASE_TO_STATION: Record = { + architecture: 0, + planning: 1, + implementation: 2, + review: 3, + parallelReview: 3, + simplifier: 4, + codeSimplifier: 4, + holistic: 5, + holisticReview: 5, + summary: 6, +}; + +/** Short phase alias used as agent IDs for non-review phases. */ +const PHASE_ID: Record = { + architecture: 'arch', + planning: 'plan', + implementation: 'coder', + simplifier: 'simp', + holistic: 'holi', +}; + +/** + * The Phases type uses `| undefined` but runtime data from Zod can carry `null` + * phase values. This helper handles both cases while satisfying the eqeqeq lint rule. + */ +function isPresent(value: T | null | undefined): value is T { + return value !== undefined && value !== null; +} + +/** Narrow an unknown value to a non-null object (safe for `Object.keys`). */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Extract reviewer names from any known parallelReview data shape. + * + * The orchestrate skill evolved its run-index.json format, producing three + * known shapes for the parallelReview phase: + * 1. Flat `reviewers` record (older runs) -- keyed by reviewer name + * 2. `iterations[].perReviewer` records -- keyed by reviewer name + * 3. Top-level `reviewerDetails` record -- keyed by reviewer name + * + * Shapes 2 and 3 pass through Zod's `.partial().loose()` validation + * as untyped extra properties. Runtime access uses defensive type narrowing. + */ +function extractReviewerNames(parallelReview: ParallelReviewPhase): string[] { + // Shape 1: flat reviewers record (canonical typed shape) + const reviewers = parallelReview.reviewers; + if (isPresent(reviewers) && Object.keys(reviewers).length > 0) { + return Object.keys(reviewers); + } + + // Shape 2: iterations[].perReviewer (passes through Zod .loose()) + const iterations = parallelReview.iterations; + if (isPresent(iterations) && iterations.length > 0) { + const names = new Set(); + for (const iteration of iterations) { + // perReviewer is an untyped property that passes through Zod .loose() + if ('perReviewer' in iteration) { + const perReviewer: unknown = iteration.perReviewer; + if (isRecord(perReviewer)) { + for (const name of Object.keys(perReviewer)) { + names.add(name); + } + } + } + // Also collect from the typed reviewers: string[] array + if (Array.isArray(iteration.reviewers)) { + for (const name of iteration.reviewers) { + names.add(name); + } + } + } + if (names.size > 0) return Array.from(names); + } + + // Shape 3: top-level reviewerDetails (passes through Zod .loose()) + if ('reviewerDetails' in parallelReview) { + const reviewerDetails: unknown = parallelReview.reviewerDetails; + if (isRecord(reviewerDetails)) { + const keys = Object.keys(reviewerDetails); + if (keys.length > 0) return keys; + } + } + + return []; +} + +// --------------------------------------------------------------------------- +// Phase status accessors (mirrors agent-state-resolver.ts) +// --------------------------------------------------------------------------- + +type PhaseStatusAccessor = (phases: Phases) => string | undefined; + +const PHASE_STATUS_ACCESSORS: Record = { + architecture: (phases) => phases.architecture?.status, + planning: (phases) => phases.planning?.status, + implementation: (phases) => phases.implementation?.status, + review: (phases) => { + if (phases.parallelReview !== undefined) { + if (phases.parallelReview.status !== undefined) { + return phases.parallelReview.status; + } + const hasRunningReviewer = Object.values(phases.parallelReview.reviewers ?? {}).some( + (r) => r.status === undefined, + ); + return hasRunningReviewer ? 'in_progress' : 'completed'; + } + return phases.review?.status; + }, + simplifier: (phases) => phases.codeSimplifier?.status, + holistic: (phases) => phases.holisticReview?.status, + summary: () => {}, +}; + +// --------------------------------------------------------------------------- +// Sub-function A: stations +// --------------------------------------------------------------------------- + +function buildStations(status: CanonicalRunStatus): StationConfig[] { + return PHASE_NAMES.map((phase) => { + const decision = findPhaseDecision(phase, status.phaseDecisions); + const isAbsent = decision?.run === false; + return { + phase, + label: PHASE_ROLE[phase], + color: ROLE_TYPE_COLORS[PHASE_ROLE_TYPE[phase]], + absent: isAbsent, + skipped: isAbsent, + }; + }); +} + +// --------------------------------------------------------------------------- +// Sub-function B: orchestrator +// --------------------------------------------------------------------------- + +function buildOrchestrator(status: CanonicalRunStatus, currentPhase: PhaseName | undefined): OrchestratorConfig { + let stationIndex: number; + + if (status.status === 'completed') { + stationIndex = 6; + } else if (status.status === 'in_progress' && currentPhase !== undefined) { + stationIndex = PHASE_NAMES.indexOf(currentPhase); + } else { + stationIndex = -1; + } + + const working = currentPhase !== undefined && !isPhasePresentInData(currentPhase, status.phases); + + const carriedArtifacts: CarriedArtifactConfig[] = []; + const codeBadge: OrchestratorConfig['codeBadge'] = null; + + return { stationIndex, working, carriedArtifacts, codeBadge }; +} + +// --------------------------------------------------------------------------- +// Agent state resolution +// --------------------------------------------------------------------------- + +function resolveAgentState( + phase: PhaseName, + status: CanonicalRunStatus, + currentPhase: PhaseName | undefined, +): AgentAnimationState { + if (status.status === 'completed') return 'celebrating'; + if (status.status === 'failed') return 'concerned'; + + // in_progress and needs_manual_review use per-phase logic + if (phase === currentPhase && !isPhasePresentInData(phase, status.phases)) { + return 'working'; + } + + if (PHASE_STATUS_ACCESSORS[phase](status.phases) === 'in_progress') { + return 'working'; + } + + if (isPhaseEvaluated(phase, status.phases)) { + return 'resting'; + } + + const currentPhaseIndex = currentPhase === undefined ? -1 : PHASE_NAMES.indexOf(currentPhase); + if (PHASE_NAMES.indexOf(phase) < currentPhaseIndex) { + return 'resting'; + } + + return 'idle'; +} + +// --------------------------------------------------------------------------- +// Sub-function C: agents +// --------------------------------------------------------------------------- + +function buildAgents(status: CanonicalRunStatus, currentPhase: PhaseName | undefined): AgentConfig[] { + const agents: AgentConfig[] = []; + + for (const phase of PHASE_NAMES) { + if (phase === 'summary') continue; + + if (phase === 'review') { + agents.push(...buildReviewerAgents(status, currentPhase)); + continue; + } + + const phaseId = PHASE_ID[phase]; + if (phaseId === undefined) continue; + + agents.push({ + id: phaseId, + role: PHASE_ROLE[phase], + roleType: PHASE_ROLE_TYPE[phase], + stationIndex: PHASE_NAMES.indexOf(phase), + slotIndex: 0, + state: resolveAgentState(phase, status, currentPhase), + }); + } + + return agents; +} + +/** Build a single fallback reviewer agent when no reviewer names are available. */ +function defaultReviewerAgent(status: CanonicalRunStatus, currentPhase: PhaseName | undefined): AgentConfig[] { + return [ + { + id: 'reviewer-0', + role: 'reviewer', + roleType: PHASE_ROLE_TYPE.review, + stationIndex: 3, + slotIndex: 0, + state: resolveAgentState('review', status, currentPhase), + }, + ]; +} + +function buildReviewerAgents(status: CanonicalRunStatus, currentPhase: PhaseName | undefined): AgentConfig[] { + const parallelReview = status.phases.parallelReview; + + if (!isPresent(parallelReview)) { + return defaultReviewerAgent(status, currentPhase); + } + + const names = extractReviewerNames(parallelReview); + if (names.length === 0) { + return defaultReviewerAgent(status, currentPhase); + } + + return names.map((name, i) => ({ + id: `reviewer-${String(i)}`, + role: name, + roleType: PHASE_ROLE_TYPE.review, + stationIndex: 3, + slotIndex: i, + state: resolveAgentState('review', status, currentPhase), + })); +} + +// --------------------------------------------------------------------------- +// Sub-function D: gates +// --------------------------------------------------------------------------- + +function buildGates(stations: StationConfig[], phases: Phases): GateConfig[] { + const gates: GateConfig[] = []; + for (let i = 0; i < stations.length - 1; i++) { + const station = stations[i]; + if (station === undefined) continue; + const isOpen = station.absent || isPhaseEvaluated(station.phase, phases); + gates.push({ + betweenStations: [i, i + 1], + open: isOpen, + }); + } + return gates; +} + +// --------------------------------------------------------------------------- +// Sub-function E: artifacts +// --------------------------------------------------------------------------- + +function buildArtifacts(status: CanonicalRunStatus): StationArtifactConfig[] { + if (!isPresent(status.artifacts) || status.artifacts.length === 0) { + return []; + } + + const artifacts: StationArtifactConfig[] = []; + + for (const entry of status.artifacts) { + const stationIndex = PHASE_TO_STATION[entry.phase]; + if (stationIndex === undefined) continue; + + artifacts.push({ + stationIndex, + label: entry.type, + color: lookupArtifactColor(entry.type), + slot: 'output', + ...(entry.iteration === undefined ? {} : { version: entry.iteration }), + }); + } + + return artifacts; +} + +// --------------------------------------------------------------------------- +// Public mapper +// --------------------------------------------------------------------------- + +export function mapRunToCatwalk(status: CanonicalRunStatus): CatwalkSceneConfig { + const currentPhase = findCurrentPhase(status.phases, status.phaseDecisions, status.status); + const stations = buildStations(status); + const orchestrator = buildOrchestrator(status, currentPhase); + const agents = buildAgents(status, currentPhase); + const gates = buildGates(stations, status.phases); + const artifacts = buildArtifacts(status); + + return { orchestrator, stations, agents, gates, artifacts }; +} diff --git a/packages/factory/src/client/visualizations/catwalk/types.ts b/packages/factory/src/client/visualizations/catwalk/types.ts new file mode 100644 index 00000000..082b6606 --- /dev/null +++ b/packages/factory/src/client/visualizations/catwalk/types.ts @@ -0,0 +1,58 @@ +import type { PhaseName, RoleType } from '../../../shared/constants/role-types.js'; + +/** + * Animation states for catwalk agents. Intentionally duplicated from + * `sprite-definitions.ts` so that the catwalk mapper has no dependency + * on the Excalibur game layer. + */ +export type AgentAnimationState = 'idle' | 'working' | 'walking' | 'resting' | 'celebrating' | 'concerned'; + +export interface CatwalkSceneConfig { + orchestrator: OrchestratorConfig; + stations: StationConfig[]; + agents: AgentConfig[]; + gates: GateConfig[]; + artifacts: StationArtifactConfig[]; +} + +export interface OrchestratorConfig { + stationIndex: number; + working: boolean; + carriedArtifacts: CarriedArtifactConfig[]; + codeBadge: { label: string; color: string } | null; +} + +export interface StationConfig { + phase: PhaseName; + label: string; + color: string; + absent: boolean; + skipped: boolean; +} + +export interface AgentConfig { + id: string; + role: string; + roleType: RoleType; + stationIndex: number; + slotIndex: number; + state: AgentAnimationState; +} + +export interface GateConfig { + betweenStations: [number, number]; + open: boolean; +} + +export interface StationArtifactConfig { + stationIndex: number; + label: string; + color: string; + slot: 'input' | 'output'; + version?: number; +} + +export interface CarriedArtifactConfig { + label: string; + color: string; +}