From 11e300c75da30c7f226cb3787758234bc94e4140 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:29:57 +0100 Subject: [PATCH 01/17] auto-claude: subtask-1-1 - Create roadmap generation XState machine Co-Authored-By: Claude Opus 4.6 --- .../roadmap-generation-machine.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts diff --git a/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts new file mode 100644 index 00000000..fec7d48a --- /dev/null +++ b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts @@ -0,0 +1,110 @@ +import { assign, createMachine } from 'xstate'; + +export interface RoadmapGenerationContext { + progress: number; + message?: string; + error?: string; + startedAt?: number; + completedAt?: number; +} + +export type RoadmapGenerationEvent = + | { type: 'START_GENERATION' } + | { type: 'PROGRESS_UPDATE'; progress: number; message: string } + | { type: 'DISCOVERY_STARTED' } + | { type: 'GENERATION_STARTED' } + | { type: 'GENERATION_COMPLETE' } + | { type: 'GENERATION_ERROR'; error: string } + | { type: 'STOP' } + | { type: 'RESET' }; + +export const roadmapGenerationMachine = createMachine( + { + id: 'roadmapGeneration', + initial: 'idle', + types: {} as { + context: RoadmapGenerationContext; + events: RoadmapGenerationEvent; + }, + context: { + progress: 0, + message: undefined, + error: undefined, + startedAt: undefined, + completedAt: undefined, + }, + states: { + idle: { + on: { + START_GENERATION: { target: 'analyzing', actions: 'setStarted' }, + }, + }, + analyzing: { + on: { + PROGRESS_UPDATE: { actions: 'updateProgress' }, + DISCOVERY_STARTED: 'discovering', + GENERATION_ERROR: { target: 'error', actions: 'setError' }, + STOP: { target: 'idle', actions: 'resetContext' }, + }, + }, + discovering: { + on: { + PROGRESS_UPDATE: { actions: 'updateProgress' }, + GENERATION_STARTED: 'generating', + GENERATION_ERROR: { target: 'error', actions: 'setError' }, + STOP: { target: 'idle', actions: 'resetContext' }, + }, + }, + generating: { + on: { + PROGRESS_UPDATE: { actions: 'updateProgress' }, + GENERATION_COMPLETE: { target: 'complete', actions: 'setCompleted' }, + GENERATION_ERROR: { target: 'error', actions: 'setError' }, + STOP: { target: 'idle', actions: 'resetContext' }, + }, + }, + complete: { + on: { + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + error: { + on: { + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + }, + }, + { + actions: { + setStarted: assign({ + progress: () => 0, + message: () => undefined, + error: () => undefined, + startedAt: () => Date.now(), + completedAt: () => undefined, + }), + updateProgress: assign({ + progress: ({ event }) => + event.type === 'PROGRESS_UPDATE' ? event.progress : 0, + message: ({ event }) => + event.type === 'PROGRESS_UPDATE' ? event.message : undefined, + }), + setCompleted: assign({ + progress: () => 100, + completedAt: () => Date.now(), + }), + setError: assign({ + error: ({ event }) => + event.type === 'GENERATION_ERROR' ? event.error : undefined, + }), + resetContext: assign({ + progress: () => 0, + message: () => undefined, + error: () => undefined, + startedAt: () => undefined, + completedAt: () => undefined, + }), + }, + } +); From 906b1f29c4efccd6d3492f4ec8eac3d552ba54aa Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:31:43 +0100 Subject: [PATCH 02/17] auto-claude: subtask-1-2 - Create roadmap feature state machine Add roadmap-feature-machine.ts with states: under_review, planned, in_progress, done. Handles LINK_SPEC auto-transition to in_progress, task completion events from in_progress only, REVERT from done restoring previousStatus via guards, and context cleanup on non-done transitions. Co-Authored-By: Claude Opus 4.6 --- .../state-machines/roadmap-feature-machine.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts diff --git a/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts new file mode 100644 index 00000000..7111e941 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts @@ -0,0 +1,149 @@ +import { assign, createMachine } from 'xstate'; + +export interface RoadmapFeatureContext { + linkedSpecId?: string; + taskOutcome?: string; + previousStatus?: string; +} + +export type RoadmapFeatureEvent = + | { type: 'PLAN' } + | { type: 'START_PROGRESS' } + | { type: 'MARK_DONE' } + | { type: 'LINK_SPEC'; specId: string } + | { type: 'TASK_COMPLETED' } + | { type: 'TASK_DELETED' } + | { type: 'TASK_ARCHIVED' } + | { type: 'REVERT' } + | { type: 'MOVE_TO_REVIEW' }; + +export const roadmapFeatureMachine = createMachine( + { + id: 'roadmapFeature', + initial: 'under_review', + types: {} as { + context: RoadmapFeatureContext; + events: RoadmapFeatureEvent; + }, + context: { + linkedSpecId: undefined, + taskOutcome: undefined, + previousStatus: undefined + }, + states: { + under_review: { + on: { + PLAN: 'planned', + START_PROGRESS: 'in_progress', + LINK_SPEC: { + target: 'in_progress', + actions: 'setLinkedSpec' + }, + MARK_DONE: { + target: 'done', + actions: 'savePreviousUnderReview' + } + } + }, + planned: { + on: { + START_PROGRESS: 'in_progress', + LINK_SPEC: { + target: 'in_progress', + actions: 'setLinkedSpec' + }, + MARK_DONE: { + target: 'done', + actions: 'savePreviousPlanned' + }, + MOVE_TO_REVIEW: 'under_review' + } + }, + in_progress: { + on: { + TASK_COMPLETED: { + target: 'done', + actions: ['savePreviousInProgress', 'setTaskOutcomeCompleted'] + }, + TASK_DELETED: { + target: 'done', + actions: ['savePreviousInProgress', 'setTaskOutcomeDeleted'] + }, + TASK_ARCHIVED: { + target: 'done', + actions: ['savePreviousInProgress', 'setTaskOutcomeArchived'] + }, + MARK_DONE: { + target: 'done', + actions: 'savePreviousInProgress' + }, + MOVE_TO_REVIEW: { + target: 'under_review', + actions: 'clearDoneContext' + }, + PLAN: { + target: 'planned', + actions: 'clearDoneContext' + }, + LINK_SPEC: { + actions: 'setLinkedSpec' + } + } + }, + done: { + on: { + REVERT: [ + { + target: 'in_progress', + guard: 'previousWasInProgress', + actions: 'clearDoneContext' + }, + { + target: 'planned', + guard: 'previousWasPlanned', + actions: 'clearDoneContext' + }, + { + target: 'under_review', + actions: 'clearDoneContext' + } + ], + MOVE_TO_REVIEW: { + target: 'under_review', + actions: 'clearDoneContext' + }, + PLAN: { + target: 'planned', + actions: 'clearDoneContext' + }, + START_PROGRESS: { + target: 'in_progress', + actions: 'clearDoneContext' + } + } + } + } + }, + { + guards: { + previousWasInProgress: ({ context }) => context.previousStatus === 'in_progress', + previousWasPlanned: ({ context }) => context.previousStatus === 'planned' + }, + actions: { + setLinkedSpec: assign({ + linkedSpecId: ({ event }) => + event.type === 'LINK_SPEC' ? event.specId : undefined + }), + savePreviousUnderReview: assign({ previousStatus: () => 'under_review' }), + savePreviousPlanned: assign({ previousStatus: () => 'planned' }), + savePreviousInProgress: assign({ previousStatus: () => 'in_progress' }), + setTaskOutcomeCompleted: assign({ taskOutcome: () => 'completed' }), + setTaskOutcomeDeleted: assign({ taskOutcome: () => 'deleted' }), + setTaskOutcomeArchived: assign({ taskOutcome: () => 'archived' }), + clearDoneContext: assign({ + taskOutcome: () => undefined, + previousStatus: () => undefined + }) + } + } +); From fa4e29f5917017be928fd6f0fd429b8ff7f452e4 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:33:14 +0100 Subject: [PATCH 03/17] auto-claude: subtask-1-3 - Create roadmap-state-utils.ts with state name constants, settled states, and mapping utilities Co-Authored-By: Claude Opus 4.6 --- .../state-machines/roadmap-state-utils.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/frontend/src/shared/state-machines/roadmap-state-utils.ts diff --git a/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts new file mode 100644 index 00000000..bdb3a16d --- /dev/null +++ b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts @@ -0,0 +1,99 @@ +/** + * Shared XState roadmap state utilities. + * + * Provides type-safe state names, settled states, and mapping helpers + * derived from the roadmap machine definitions. Used by roadmap-store + * and roadmap hooks to avoid duplicate constants. + */ +import type { RoadmapGenerationStatus, RoadmapFeatureStatus } from '../types/roadmap'; + +/** + * All XState generation state names. + * + * IMPORTANT: These must match the state keys in roadmap-generation-machine.ts. + * If you add/remove a state in the machine, update this array. + */ +export const GENERATION_STATE_NAMES = [ + 'idle', 'analyzing', 'discovering', 'generating', 'complete', 'error' +] as const; + +export type GenerationStateName = typeof GENERATION_STATE_NAMES[number]; + +/** + * All XState feature state names. + * + * IMPORTANT: These must match the state keys in roadmap-feature-machine.ts. + * If you add/remove a state in the machine, update this array. + */ +export const FEATURE_STATE_NAMES = [ + 'under_review', 'planned', 'in_progress', 'done' +] as const; + +export type FeatureStateName = typeof FEATURE_STATE_NAMES[number]; + +/** + * Generation states where the machine has settled — the generation is + * complete or has errored. Stale progress events should NOT overwrite + * these states, as XState is the source of truth. + */ +export const GENERATION_SETTLED_STATES: ReadonlySet = new Set([ + 'complete', 'error' +]); + +/** + * Feature states where the machine has settled — the feature is done. + * Stale task lifecycle events should NOT overwrite this state. + */ +export const FEATURE_SETTLED_STATES: ReadonlySet = new Set([ + 'done' +]); + +/** + * Maps an XState generation state to the RoadmapGenerationStatus phase. + * + * The generation machine states map 1:1 to the phase union type, so this + * is a type-safe identity mapping with a fallback for unknown states. + */ +export function mapGenerationStateToPhase( + state: string +): RoadmapGenerationStatus['phase'] { + switch (state) { + case 'idle': + return 'idle'; + case 'analyzing': + return 'analyzing'; + case 'discovering': + return 'discovering'; + case 'generating': + return 'generating'; + case 'complete': + return 'complete'; + case 'error': + return 'error'; + default: + return 'idle'; + } +} + +/** + * Maps an XState feature state to the RoadmapFeatureStatus type. + * + * The feature machine states map 1:1 to the status union type, so this + * is a type-safe identity mapping with a fallback for unknown states. + */ +export function mapFeatureStateToStatus( + state: string +): RoadmapFeatureStatus { + switch (state) { + case 'under_review': + return 'under_review'; + case 'planned': + return 'planned'; + case 'in_progress': + return 'in_progress'; + case 'done': + return 'done'; + default: + return 'under_review'; + } +} From b6ae439d166a4eedcef698bf1b8c6ab22eb75ea8 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:34:30 +0100 Subject: [PATCH 04/17] auto-claude: subtask-1-4 - Update barrel export for roadmap machine artifacts --- .../src/shared/state-machines/index.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/frontend/src/shared/state-machines/index.ts b/apps/frontend/src/shared/state-machines/index.ts index 68cd1f3a..2a92e180 100644 --- a/apps/frontend/src/shared/state-machines/index.ts +++ b/apps/frontend/src/shared/state-machines/index.ts @@ -7,3 +7,28 @@ export { mapStateToLegacy, } from './task-state-utils'; export type { TaskStateName } from './task-state-utils'; + +export { roadmapGenerationMachine } from './roadmap-generation-machine'; +export type { + RoadmapGenerationContext, + RoadmapGenerationEvent, +} from './roadmap-generation-machine'; + +export { roadmapFeatureMachine } from './roadmap-feature-machine'; +export type { + RoadmapFeatureContext, + RoadmapFeatureEvent, +} from './roadmap-feature-machine'; + +export { + GENERATION_STATE_NAMES, + FEATURE_STATE_NAMES, + GENERATION_SETTLED_STATES, + FEATURE_SETTLED_STATES, + mapGenerationStateToPhase, + mapFeatureStateToStatus, +} from './roadmap-state-utils'; +export type { + GenerationStateName, + FeatureStateName, +} from './roadmap-state-utils'; From 6b7f2410459d6a1067fbfa69df5bac3a88d96879 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:36:05 +0100 Subject: [PATCH 05/17] auto-claude: subtask-2-1 - Write comprehensive unit tests for the roadmap generation machine Co-Authored-By: Claude Opus 4.6 --- .../roadmap-generation-machine.test.ts | 446 ++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts new file mode 100644 index 00000000..8518fe6e --- /dev/null +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createActor } from 'xstate'; +import { + roadmapGenerationMachine, + type RoadmapGenerationEvent, +} from '../roadmap-generation-machine'; + +/** + * Helper to run a sequence of events and get the final state + */ +function runEvents(events: RoadmapGenerationEvent[]) { + const actor = createActor(roadmapGenerationMachine); + actor.start(); + + for (const event of events) { + actor.send(event); + } + + const snapshot = actor.getSnapshot(); + actor.stop(); + return snapshot; +} + +describe('roadmapGenerationMachine', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('initial state', () => { + it('should start in idle state', () => { + const actor = createActor(roadmapGenerationMachine); + actor.start(); + expect(actor.getSnapshot().value).toBe('idle'); + actor.stop(); + }); + + it('should have default context initially', () => { + const actor = createActor(roadmapGenerationMachine); + actor.start(); + const snapshot = actor.getSnapshot(); + expect(snapshot.context.progress).toBe(0); + expect(snapshot.context.message).toBeUndefined(); + expect(snapshot.context.error).toBeUndefined(); + expect(snapshot.context.startedAt).toBeUndefined(); + expect(snapshot.context.completedAt).toBeUndefined(); + actor.stop(); + }); + }); + + describe('happy path: idle → analyzing → discovering → generating → complete', () => { + it('should transition through the standard workflow', () => { + const events: RoadmapGenerationEvent[] = [ + { type: 'START_GENERATION' }, + { type: 'PROGRESS_UPDATE', progress: 20, message: 'Analyzing...' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'PROGRESS_UPDATE', progress: 50, message: 'Discovering...' }, + { type: 'GENERATION_STARTED' }, + { type: 'PROGRESS_UPDATE', progress: 80, message: 'Generating...' }, + { type: 'GENERATION_COMPLETE' }, + ]; + + const snapshot = runEvents(events); + expect(snapshot.value).toBe('complete'); + expect(snapshot.context.progress).toBe(100); + expect(snapshot.context.completedAt).toBeDefined(); + }); + + it('should transition from idle to analyzing on START_GENERATION', () => { + const snapshot = runEvents([{ type: 'START_GENERATION' }]); + expect(snapshot.value).toBe('analyzing'); + }); + + it('should transition from analyzing to discovering on DISCOVERY_STARTED', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + ]); + expect(snapshot.value).toBe('discovering'); + }); + + it('should transition from discovering to generating on GENERATION_STARTED', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + ]); + expect(snapshot.value).toBe('generating'); + }); + + it('should transition from generating to complete on GENERATION_COMPLETE', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_COMPLETE' }, + ]); + expect(snapshot.value).toBe('complete'); + }); + }); + + describe('PROGRESS_UPDATE updates context in all active states', () => { + it('should update progress in analyzing state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'PROGRESS_UPDATE', progress: 25, message: 'Analyzing codebase' }, + ]); + expect(snapshot.value).toBe('analyzing'); + expect(snapshot.context.progress).toBe(25); + expect(snapshot.context.message).toBe('Analyzing codebase'); + }); + + it('should update progress in discovering state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'PROGRESS_UPDATE', progress: 50, message: 'Discovering features' }, + ]); + expect(snapshot.value).toBe('discovering'); + expect(snapshot.context.progress).toBe(50); + expect(snapshot.context.message).toBe('Discovering features'); + }); + + it('should update progress in generating state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'PROGRESS_UPDATE', progress: 80, message: 'Generating roadmap' }, + ]); + expect(snapshot.value).toBe('generating'); + expect(snapshot.context.progress).toBe(80); + expect(snapshot.context.message).toBe('Generating roadmap'); + }); + }); + + describe('error flow: GENERATION_ERROR from any active state → error', () => { + it('should transition to error from analyzing', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'GENERATION_ERROR', error: 'Analysis failed' }, + ]); + expect(snapshot.value).toBe('error'); + expect(snapshot.context.error).toBe('Analysis failed'); + }); + + it('should transition to error from discovering', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_ERROR', error: 'Discovery failed' }, + ]); + expect(snapshot.value).toBe('error'); + expect(snapshot.context.error).toBe('Discovery failed'); + }); + + it('should transition to error from generating', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_ERROR', error: 'Generation failed' }, + ]); + expect(snapshot.value).toBe('error'); + expect(snapshot.context.error).toBe('Generation failed'); + }); + }); + + describe('stop flow: STOP from analyzing/discovering/generating → idle', () => { + it('should transition to idle from analyzing on STOP', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'STOP' }, + ]); + expect(snapshot.value).toBe('idle'); + expect(snapshot.context.progress).toBe(0); + expect(snapshot.context.startedAt).toBeUndefined(); + }); + + it('should transition to idle from discovering on STOP', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'STOP' }, + ]); + expect(snapshot.value).toBe('idle'); + expect(snapshot.context.progress).toBe(0); + }); + + it('should transition to idle from generating on STOP', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'STOP' }, + ]); + expect(snapshot.value).toBe('idle'); + expect(snapshot.context.progress).toBe(0); + }); + }); + + describe('RESET from complete/error → idle', () => { + it('should transition from complete to idle on RESET', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_COMPLETE' }, + { type: 'RESET' }, + ]); + expect(snapshot.value).toBe('idle'); + expect(snapshot.context.progress).toBe(0); + expect(snapshot.context.completedAt).toBeUndefined(); + }); + + it('should transition from error to idle on RESET', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'GENERATION_ERROR', error: 'Some error' }, + { type: 'RESET' }, + ]); + expect(snapshot.value).toBe('idle'); + expect(snapshot.context.error).toBeUndefined(); + expect(snapshot.context.progress).toBe(0); + }); + }); + + describe('guard: START_GENERATION rejected when not idle', () => { + it('should ignore START_GENERATION in analyzing state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'START_GENERATION' }, + ]); + expect(snapshot.value).toBe('analyzing'); + }); + + it('should ignore START_GENERATION in discovering state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'START_GENERATION' }, + ]); + expect(snapshot.value).toBe('discovering'); + }); + + it('should ignore START_GENERATION in generating state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'START_GENERATION' }, + ]); + expect(snapshot.value).toBe('generating'); + }); + + it('should ignore START_GENERATION in complete state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_COMPLETE' }, + { type: 'START_GENERATION' }, + ]); + expect(snapshot.value).toBe('complete'); + }); + + it('should ignore START_GENERATION in error state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'GENERATION_ERROR', error: 'err' }, + { type: 'START_GENERATION' }, + ]); + expect(snapshot.value).toBe('error'); + }); + }); + + describe('stale events ignored after complete/error', () => { + it('should ignore PROGRESS_UPDATE in complete state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_COMPLETE' }, + { type: 'PROGRESS_UPDATE', progress: 50, message: 'stale' }, + ]); + expect(snapshot.value).toBe('complete'); + expect(snapshot.context.progress).toBe(100); + }); + + it('should ignore PROGRESS_UPDATE in error state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'GENERATION_ERROR', error: 'err' }, + { type: 'PROGRESS_UPDATE', progress: 50, message: 'stale' }, + ]); + expect(snapshot.value).toBe('error'); + expect(snapshot.context.progress).toBe(0); + }); + + it('should ignore STOP in complete state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_COMPLETE' }, + { type: 'STOP' }, + ]); + expect(snapshot.value).toBe('complete'); + }); + + it('should ignore STOP in error state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'GENERATION_ERROR', error: 'err' }, + { type: 'STOP' }, + ]); + expect(snapshot.value).toBe('error'); + }); + + it('should ignore GENERATION_ERROR in complete state', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_COMPLETE' }, + { type: 'GENERATION_ERROR', error: 'late error' }, + ]); + expect(snapshot.value).toBe('complete'); + expect(snapshot.context.error).toBeUndefined(); + }); + }); + + describe('timestamp tracking', () => { + it('should set startedAt on START_GENERATION', () => { + const snapshot = runEvents([{ type: 'START_GENERATION' }]); + expect(snapshot.context.startedAt).toBe(Date.now()); + }); + + it('should clear startedAt when returning to idle via STOP', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'STOP' }, + ]); + expect(snapshot.context.startedAt).toBeUndefined(); + }); + + it('should clear startedAt when returning to idle via RESET', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'GENERATION_ERROR', error: 'err' }, + { type: 'RESET' }, + ]); + expect(snapshot.context.startedAt).toBeUndefined(); + }); + + it('should set completedAt on GENERATION_COMPLETE', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_COMPLETE' }, + ]); + expect(snapshot.context.completedAt).toBe(Date.now()); + }); + + it('should clear completedAt on RESET from complete', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'DISCOVERY_STARTED' }, + { type: 'GENERATION_STARTED' }, + { type: 'GENERATION_COMPLETE' }, + { type: 'RESET' }, + ]); + expect(snapshot.context.completedAt).toBeUndefined(); + }); + + it('should reset startedAt on new START_GENERATION', () => { + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + const actor = createActor(roadmapGenerationMachine); + actor.start(); + + actor.send({ type: 'START_GENERATION' }); + const firstStartedAt = actor.getSnapshot().context.startedAt; + + actor.send({ type: 'GENERATION_ERROR', error: 'err' }); + actor.send({ type: 'RESET' }); + + vi.setSystemTime(new Date('2025-01-01T01:00:00Z')); + actor.send({ type: 'START_GENERATION' }); + + const secondStartedAt = actor.getSnapshot().context.startedAt; + expect(secondStartedAt).toBeGreaterThan(firstStartedAt!); + actor.stop(); + }); + }); + + describe('state restoration from snapshot', () => { + it('should restore to correct state from snapshot', () => { + const testStates = ['idle', 'analyzing', 'discovering', 'generating', 'complete', 'error']; + + for (const state of testStates) { + const actor = createActor(roadmapGenerationMachine, { + snapshot: roadmapGenerationMachine.resolveState({ + value: state, + context: { + progress: 0, + message: undefined, + error: undefined, + startedAt: undefined, + completedAt: undefined, + }, + }), + }); + actor.start(); + expect(actor.getSnapshot().value).toBe(state); + actor.stop(); + } + }); + + it('should restore context from snapshot', () => { + const actor = createActor(roadmapGenerationMachine, { + snapshot: roadmapGenerationMachine.resolveState({ + value: 'generating', + context: { + progress: 75, + message: 'Almost done', + error: undefined, + startedAt: 1000, + completedAt: undefined, + }, + }), + }); + actor.start(); + const snapshot = actor.getSnapshot(); + expect(snapshot.value).toBe('generating'); + expect(snapshot.context.progress).toBe(75); + expect(snapshot.context.message).toBe('Almost done'); + expect(snapshot.context.startedAt).toBe(1000); + actor.stop(); + }); + }); +}); From 7a69743ae1a9bab45807182d07f677acfade36a1 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:37:45 +0100 Subject: [PATCH 06/17] auto-claude: subtask-2-2 - Write comprehensive unit tests for roadmap feature machine Co-Authored-By: Claude Opus 4.6 --- .../__tests__/roadmap-feature-machine.test.ts | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts new file mode 100644 index 00000000..8a9d9488 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect } from 'vitest'; +import { createActor } from 'xstate'; +import { + roadmapFeatureMachine, + type RoadmapFeatureEvent +} from '../roadmap-feature-machine'; + +/** + * Helper to run a sequence of events and get the final snapshot + */ +function runEvents(events: RoadmapFeatureEvent[], initialState?: string) { + const actor = initialState + ? createActor(roadmapFeatureMachine, { + snapshot: roadmapFeatureMachine.resolveState({ + value: initialState, + context: {} + }) + }) + : createActor(roadmapFeatureMachine); + actor.start(); + + for (const event of events) { + actor.send(event); + } + + const snapshot = actor.getSnapshot(); + actor.stop(); + return snapshot; +} + +describe('roadmapFeatureMachine', () => { + describe('initial state', () => { + it('should start in under_review state', () => { + const actor = createActor(roadmapFeatureMachine); + actor.start(); + expect(actor.getSnapshot().value).toBe('under_review'); + actor.stop(); + }); + + it('should have empty context initially', () => { + const actor = createActor(roadmapFeatureMachine); + actor.start(); + const { context } = actor.getSnapshot(); + expect(context.linkedSpecId).toBeUndefined(); + expect(context.taskOutcome).toBeUndefined(); + expect(context.previousStatus).toBeUndefined(); + actor.stop(); + }); + }); + + describe('status transitions: under_review → planned → in_progress → done', () => { + it('should transition under_review → planned via PLAN', () => { + const snapshot = runEvents([{ type: 'PLAN' }]); + expect(snapshot.value).toBe('planned'); + }); + + it('should transition planned → in_progress via START_PROGRESS', () => { + const snapshot = runEvents([{ type: 'PLAN' }, { type: 'START_PROGRESS' }]); + expect(snapshot.value).toBe('in_progress'); + }); + + it('should transition in_progress → done via MARK_DONE', () => { + const snapshot = runEvents([ + { type: 'PLAN' }, + { type: 'START_PROGRESS' }, + { type: 'MARK_DONE' } + ]); + expect(snapshot.value).toBe('done'); + }); + + it('should transition under_review → in_progress via START_PROGRESS', () => { + const snapshot = runEvents([{ type: 'START_PROGRESS' }]); + expect(snapshot.value).toBe('in_progress'); + }); + + it('should transition under_review → done via MARK_DONE', () => { + const snapshot = runEvents([{ type: 'MARK_DONE' }]); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.previousStatus).toBe('under_review'); + }); + + it('should transition planned → done via MARK_DONE', () => { + const snapshot = runEvents([{ type: 'PLAN' }, { type: 'MARK_DONE' }]); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.previousStatus).toBe('planned'); + }); + + it('should allow reverse: planned → under_review via MOVE_TO_REVIEW', () => { + const snapshot = runEvents([{ type: 'PLAN' }, { type: 'MOVE_TO_REVIEW' }]); + expect(snapshot.value).toBe('under_review'); + }); + + it('should allow reverse: in_progress → planned via PLAN', () => { + const snapshot = runEvents([{ type: 'START_PROGRESS' }, { type: 'PLAN' }]); + expect(snapshot.value).toBe('planned'); + }); + + it('should allow reverse: in_progress → under_review via MOVE_TO_REVIEW', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'MOVE_TO_REVIEW' } + ]); + expect(snapshot.value).toBe('under_review'); + }); + }); + + describe('LINK_SPEC', () => { + it('should set linkedSpecId and auto-transition to in_progress from under_review', () => { + const snapshot = runEvents([{ type: 'LINK_SPEC', specId: 'spec-42' }]); + expect(snapshot.value).toBe('in_progress'); + expect(snapshot.context.linkedSpecId).toBe('spec-42'); + }); + + it('should set linkedSpecId and auto-transition to in_progress from planned', () => { + const snapshot = runEvents([ + { type: 'PLAN' }, + { type: 'LINK_SPEC', specId: 'spec-99' } + ]); + expect(snapshot.value).toBe('in_progress'); + expect(snapshot.context.linkedSpecId).toBe('spec-99'); + }); + + it('should update linkedSpecId without changing state when already in_progress', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'LINK_SPEC', specId: 'spec-7' } + ]); + expect(snapshot.value).toBe('in_progress'); + expect(snapshot.context.linkedSpecId).toBe('spec-7'); + }); + + it('should be ignored from done state (no LINK_SPEC transition defined)', () => { + const snapshot = runEvents([ + { type: 'MARK_DONE' }, + { type: 'LINK_SPEC', specId: 'spec-1' } + ]); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.linkedSpecId).toBeUndefined(); + }); + }); + + describe('TASK_COMPLETED from in_progress', () => { + it('should transition to done with taskOutcome="completed"', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'TASK_COMPLETED' } + ]); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.taskOutcome).toBe('completed'); + expect(snapshot.context.previousStatus).toBe('in_progress'); + }); + }); + + describe('TASK_DELETED from in_progress', () => { + it('should transition to done with taskOutcome="deleted"', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'TASK_DELETED' } + ]); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.taskOutcome).toBe('deleted'); + expect(snapshot.context.previousStatus).toBe('in_progress'); + }); + }); + + describe('TASK_ARCHIVED from in_progress', () => { + it('should transition to done with taskOutcome="archived"', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'TASK_ARCHIVED' } + ]); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.taskOutcome).toBe('archived'); + expect(snapshot.context.previousStatus).toBe('in_progress'); + }); + }); + + describe('REVERT from done', () => { + it('should revert to in_progress when previousStatus was in_progress', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'MARK_DONE' }, + { type: 'REVERT' } + ]); + expect(snapshot.value).toBe('in_progress'); + }); + + it('should revert to planned when previousStatus was planned', () => { + const snapshot = runEvents([ + { type: 'PLAN' }, + { type: 'MARK_DONE' }, + { type: 'REVERT' } + ]); + expect(snapshot.value).toBe('planned'); + }); + + it('should revert to under_review when previousStatus was under_review', () => { + const snapshot = runEvents([{ type: 'MARK_DONE' }, { type: 'REVERT' }]); + expect(snapshot.value).toBe('under_review'); + }); + + it('should revert to under_review when no previousStatus is set (fallback)', () => { + // Use state restoration to put in done without previousStatus + const actor = createActor(roadmapFeatureMachine, { + snapshot: roadmapFeatureMachine.resolveState({ + value: 'done', + context: { previousStatus: undefined } + }) + }); + actor.start(); + actor.send({ type: 'REVERT' }); + expect(actor.getSnapshot().value).toBe('under_review'); + actor.stop(); + }); + }); + + describe('moving away from done clears taskOutcome and previousStatus', () => { + it('should clear context on REVERT', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'TASK_COMPLETED' }, + { type: 'REVERT' } + ]); + expect(snapshot.value).toBe('in_progress'); + expect(snapshot.context.taskOutcome).toBeUndefined(); + expect(snapshot.context.previousStatus).toBeUndefined(); + }); + + it('should clear context on MOVE_TO_REVIEW from done', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'TASK_COMPLETED' }, + { type: 'MOVE_TO_REVIEW' } + ]); + expect(snapshot.value).toBe('under_review'); + expect(snapshot.context.taskOutcome).toBeUndefined(); + expect(snapshot.context.previousStatus).toBeUndefined(); + }); + + it('should clear context on PLAN from done', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'TASK_DELETED' }, + { type: 'PLAN' } + ]); + expect(snapshot.value).toBe('planned'); + expect(snapshot.context.taskOutcome).toBeUndefined(); + expect(snapshot.context.previousStatus).toBeUndefined(); + }); + + it('should clear context on START_PROGRESS from done', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'TASK_ARCHIVED' }, + { type: 'START_PROGRESS' } + ]); + expect(snapshot.value).toBe('in_progress'); + expect(snapshot.context.taskOutcome).toBeUndefined(); + expect(snapshot.context.previousStatus).toBeUndefined(); + }); + }); + + describe('same-status transition is no-op', () => { + it('should ignore MOVE_TO_REVIEW when already in under_review', () => { + const actor = createActor(roadmapFeatureMachine); + actor.start(); + // MOVE_TO_REVIEW is not defined on under_review, so it's ignored + actor.send({ type: 'MOVE_TO_REVIEW' }); + expect(actor.getSnapshot().value).toBe('under_review'); + actor.stop(); + }); + + it('should ignore PLAN when already in planned', () => { + const snapshot = runEvents([{ type: 'PLAN' }, { type: 'PLAN' }]); + expect(snapshot.value).toBe('planned'); + }); + + it('should ignore START_PROGRESS when already in in_progress', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'START_PROGRESS' } + ]); + expect(snapshot.value).toBe('in_progress'); + }); + + it('should ignore MARK_DONE when already in done', () => { + const snapshot = runEvents([{ type: 'MARK_DONE' }, { type: 'MARK_DONE' }]); + expect(snapshot.value).toBe('done'); + }); + }); + + describe('task events only valid from in_progress', () => { + it('should ignore TASK_COMPLETED from under_review', () => { + const snapshot = runEvents([{ type: 'TASK_COMPLETED' }]); + expect(snapshot.value).toBe('under_review'); + }); + + it('should ignore TASK_DELETED from planned', () => { + const snapshot = runEvents([{ type: 'PLAN' }, { type: 'TASK_DELETED' }]); + expect(snapshot.value).toBe('planned'); + }); + + it('should ignore TASK_ARCHIVED from done', () => { + const snapshot = runEvents([ + { type: 'MARK_DONE' }, + { type: 'TASK_ARCHIVED' } + ]); + expect(snapshot.value).toBe('done'); + }); + }); + + describe('state restoration from snapshot', () => { + it('should restore to planned state', () => { + const actor = createActor(roadmapFeatureMachine, { + snapshot: roadmapFeatureMachine.resolveState({ + value: 'planned', + context: {} + }) + }); + actor.start(); + expect(actor.getSnapshot().value).toBe('planned'); + actor.stop(); + }); + + it('should restore to in_progress with linkedSpecId', () => { + const actor = createActor(roadmapFeatureMachine, { + snapshot: roadmapFeatureMachine.resolveState({ + value: 'in_progress', + context: { linkedSpecId: 'spec-123' } + }) + }); + actor.start(); + const { value, context } = actor.getSnapshot(); + expect(value).toBe('in_progress'); + expect(context.linkedSpecId).toBe('spec-123'); + actor.stop(); + }); + + it('should restore to done with full context and allow revert', () => { + const actor = createActor(roadmapFeatureMachine, { + snapshot: roadmapFeatureMachine.resolveState({ + value: 'done', + context: { + linkedSpecId: 'spec-5', + taskOutcome: 'completed', + previousStatus: 'in_progress' + } + }) + }); + actor.start(); + expect(actor.getSnapshot().value).toBe('done'); + expect(actor.getSnapshot().context.taskOutcome).toBe('completed'); + + actor.send({ type: 'REVERT' }); + expect(actor.getSnapshot().value).toBe('in_progress'); + expect(actor.getSnapshot().context.taskOutcome).toBeUndefined(); + actor.stop(); + }); + }); + + describe('moving away from in_progress clears context', () => { + it('should clear taskOutcome and previousStatus when moving to planned', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'PLAN' } + ]); + expect(snapshot.context.taskOutcome).toBeUndefined(); + expect(snapshot.context.previousStatus).toBeUndefined(); + }); + + it('should clear taskOutcome and previousStatus when moving to under_review', () => { + const snapshot = runEvents([ + { type: 'START_PROGRESS' }, + { type: 'MOVE_TO_REVIEW' } + ]); + expect(snapshot.context.taskOutcome).toBeUndefined(); + expect(snapshot.context.previousStatus).toBeUndefined(); + }); + }); +}); From aa95986be8f1d672d31a3bbfafee6d2eee93dd91 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:40:37 +0100 Subject: [PATCH 07/17] auto-claude: subtask-3-1 - Refactor roadmap-store.ts to integrate XState actors Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/roadmap-store.ts | 249 +++++++++++++++--- 1 file changed, 214 insertions(+), 35 deletions(-) diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 3577e990..6f10d51a 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -1,4 +1,6 @@ import { create } from 'zustand'; +import { createActor } from 'xstate'; +import type { Actor } from 'xstate'; import type { CompetitorAnalysis, Roadmap, @@ -8,6 +10,57 @@ import type { TaskOutcome, FeatureSource } from '../../shared/types'; +import { + roadmapGenerationMachine, + roadmapFeatureMachine, + mapGenerationStateToPhase, + mapFeatureStateToStatus +} from '../../shared/state-machines'; +import type { RoadmapGenerationEvent } from '../../shared/state-machines'; +import type { RoadmapFeatureEvent } from '../../shared/state-machines'; + +// --------------------------------------------------------------------------- +// Module-level XState actor singletons +// --------------------------------------------------------------------------- + +let generationActor: Actor | null = null; +const featureActors = new Map>(); + +/** + * Get or create the singleton generation actor. + */ +function getOrCreateGenerationActor(): Actor { + if (!generationActor) { + generationActor = createActor(roadmapGenerationMachine); + generationActor.start(); + } + return generationActor; +} + +/** + * Get or create a feature actor for a given feature ID. + * Optionally provide an initial state to restore from persisted data. + */ +function getOrCreateFeatureActor( + featureId: string, + initialState?: RoadmapFeatureStatus +): Actor { + let actor = featureActors.get(featureId); + if (!actor) { + if (initialState) { + const resolvedSnapshot = roadmapFeatureMachine.resolveState({ + value: initialState, + context: { linkedSpecId: undefined, taskOutcome: undefined, previousStatus: undefined } + }); + actor = createActor(roadmapFeatureMachine, { snapshot: resolvedSnapshot }); + } else { + actor = createActor(roadmapFeatureMachine); + } + actor.start(); + featureActors.set(featureId, actor); + } + return actor; +} /** * Migrate roadmap data to latest schema @@ -76,6 +129,25 @@ const initialGenerationStatus: RoadmapGenerationStatus = { message: '' }; +/** + * Derive RoadmapGenerationStatus from the generation actor's current snapshot. + */ +function deriveGenerationStatus(actor: Actor): RoadmapGenerationStatus { + const snapshot = actor.getSnapshot(); + const phase = mapGenerationStateToPhase(String(snapshot.value)); + const ctx = snapshot.context; + return { + phase, + progress: ctx.progress, + message: ctx.message ?? '', + error: ctx.error, + startedAt: ctx.startedAt ? new Date(ctx.startedAt) : undefined, + lastActivityAt: phase !== 'idle' && phase !== 'complete' && phase !== 'error' + ? new Date() + : undefined + }; +} + export const useRoadmapStore = create((set) => ({ // Initial state roadmap: null, @@ -88,27 +160,57 @@ export const useRoadmapStore = create((set) => ({ setCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis }), - setGenerationStatus: (status) => - set((state) => { - const now = new Date(); - const isStartingGeneration = - state.generationStatus.phase === 'idle' && status.phase !== 'idle'; - const isStoppingGeneration = status.phase === 'idle' || status.phase === 'complete' || status.phase === 'error'; + setGenerationStatus: (status) => { + const actor = getOrCreateGenerationActor(); - return { - generationStatus: { - ...status, - // Set startedAt when transitioning from idle to active, but preserve passed timestamp if provided (for restoring persisted state) - startedAt: isStartingGeneration - ? (status.startedAt ?? now) - : isStoppingGeneration - ? undefined - : status.startedAt ?? state.generationStatus.startedAt, - // Update lastActivityAt on any status change, but preserve passed timestamp if provided (for restoring persisted state) - lastActivityAt: isStoppingGeneration ? undefined : (status.lastActivityAt ?? now) + // Map the incoming status phase to an XState event + let event: RoadmapGenerationEvent | null = null; + switch (status.phase) { + case 'analyzing': + // If idle, start generation; otherwise it's a progress update + if (String(actor.getSnapshot().value) === 'idle') { + event = { type: 'START_GENERATION' }; } - }; - }), + break; + case 'discovering': + event = { type: 'DISCOVERY_STARTED' }; + break; + case 'generating': + event = { type: 'GENERATION_STARTED' }; + break; + case 'complete': + event = { type: 'GENERATION_COMPLETE' }; + break; + case 'error': + event = { type: 'GENERATION_ERROR', error: status.error ?? 'Unknown error' }; + break; + case 'idle': { + // Stop or reset depending on current state + const currentState = String(actor.getSnapshot().value); + if (currentState === 'complete' || currentState === 'error') { + event = { type: 'RESET' }; + } else if (currentState !== 'idle') { + event = { type: 'STOP' }; + } + break; + } + } + + if (event) { + actor.send(event); + } + + // Send progress updates for active states + if (status.progress !== undefined && status.message) { + const currentState = String(actor.getSnapshot().value); + if (currentState === 'analyzing' || currentState === 'discovering' || currentState === 'generating') { + actor.send({ type: 'PROGRESS_UPDATE', progress: status.progress, message: status.message }); + } + } + + // Derive store state from the actor snapshot + set({ generationStatus: deriveGenerationStatus(actor) }); + }, setCurrentProjectId: (projectId) => set({ currentProjectId: projectId }), @@ -116,10 +218,34 @@ export const useRoadmapStore = create((set) => ({ set((state) => { if (!state.roadmap) return state; - const updatedFeatures = state.roadmap.features.map((feature) => - feature.id === featureId - ? { ...feature, status, ...(status !== 'done' ? { taskOutcome: undefined, previousStatus: undefined } : {}) } - : feature + // Determine the XState event based on target status + const eventMap: Record = { + planned: { type: 'PLAN' }, + in_progress: { type: 'START_PROGRESS' }, + done: { type: 'MARK_DONE' }, + under_review: { type: 'MOVE_TO_REVIEW' } + }; + + // Find the feature to get its current status for the actor initial state + const feature = state.roadmap.features.find((f) => f.id === featureId); + if (!feature) return state; + + const actor = getOrCreateFeatureActor(featureId, feature.status); + actor.send(eventMap[status]); + + const snapshot = actor.getSnapshot(); + const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); + const ctx = snapshot.context; + + const updatedFeatures = state.roadmap.features.map((f) => + f.id === featureId + ? { + ...f, + status: derivedStatus, + taskOutcome: ctx.taskOutcome as TaskOutcome | undefined, + previousStatus: ctx.previousStatus as RoadmapFeatureStatus | undefined + } + : f ); return { @@ -136,11 +262,32 @@ export const useRoadmapStore = create((set) => ({ set((state) => { if (!state.roadmap) return state; - const updatedFeatures = state.roadmap.features.map((feature) => - feature.linkedSpecId === specId - ? { ...feature, status: 'done' as RoadmapFeatureStatus, taskOutcome, previousStatus: feature.status !== 'done' ? feature.status : feature.previousStatus } - : feature - ); + // Determine the XState event based on task outcome + const outcomeEventMap: Record = { + completed: { type: 'TASK_COMPLETED' }, + deleted: { type: 'TASK_DELETED' }, + archived: { type: 'TASK_ARCHIVED' } + }; + + const event = outcomeEventMap[taskOutcome]; + + const updatedFeatures = state.roadmap.features.map((feature) => { + if (feature.linkedSpecId !== specId) return feature; + + const actor = getOrCreateFeatureActor(feature.id, feature.status); + actor.send(event); + + const snapshot = actor.getSnapshot(); + const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); + const ctx = snapshot.context; + + return { + ...feature, + status: derivedStatus, + taskOutcome: ctx.taskOutcome as TaskOutcome | undefined, + previousStatus: ctx.previousStatus as RoadmapFeatureStatus | undefined + }; + }); return { roadmap: { @@ -155,10 +302,24 @@ export const useRoadmapStore = create((set) => ({ set((state) => { if (!state.roadmap) return state; - const updatedFeatures = state.roadmap.features.map((feature) => - feature.id === featureId - ? { ...feature, linkedSpecId: specId, status: 'in_progress' as RoadmapFeatureStatus } - : feature + const feature = state.roadmap.features.find((f) => f.id === featureId); + if (!feature) return state; + + const actor = getOrCreateFeatureActor(featureId, feature.status); + actor.send({ type: 'LINK_SPEC', specId } satisfies RoadmapFeatureEvent); + + const snapshot = actor.getSnapshot(); + const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); + const ctx = snapshot.context; + + const updatedFeatures = state.roadmap.features.map((f) => + f.id === featureId + ? { + ...f, + linkedSpecId: ctx.linkedSpecId ?? specId, + status: derivedStatus + } + : f ); return { @@ -174,6 +335,13 @@ export const useRoadmapStore = create((set) => ({ set((state) => { if (!state.roadmap) return state; + // Stop and remove the feature's actor + const actor = featureActors.get(featureId); + if (actor) { + actor.stop(); + featureActors.delete(featureId); + } + const updatedFeatures = state.roadmap.features.filter( (feature) => feature.id !== featureId ); @@ -187,13 +355,24 @@ export const useRoadmapStore = create((set) => ({ }; }), - clearRoadmap: () => - set({ + clearRoadmap: () => { + // Stop all actors and clear Maps + if (generationActor) { + generationActor.stop(); + generationActor = null; + } + featureActors.forEach((actor) => { + actor.stop(); + }); + featureActors.clear(); + + return set({ roadmap: null, competitorAnalysis: null, generationStatus: initialGenerationStatus, currentProjectId: null - }), + }); + }, // Reorder features within a phase reorderFeatures: (phaseId, featureIds) => From b7d1ffe209285539dc311f2deec9014858a851a3 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:46:43 +0100 Subject: [PATCH 08/17] auto-claude: subtask-4-1 - Verify backward compatibility and fix XState integration - Add TASK_COMPLETED/DELETED/ARCHIVED transitions to planned and done states - Add MARK_DONE self-transition in done state for idempotent updates - Restore feature context (taskOutcome, previousStatus, linkedSpecId) when creating XState actors from persisted store data - Invalidate cached actors when state or context diverges from store truth - Update machine tests to reflect expanded transition coverage - All 3293 tests pass, typecheck clean, lint warnings only (pre-existing) Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/roadmap-store.ts | 43 ++++++++++++++++--- .../__tests__/roadmap-feature-machine.test.ts | 11 +++-- .../state-machines/roadmap-feature-machine.ts | 27 ++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 6f10d51a..86124e4d 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -43,14 +43,35 @@ function getOrCreateGenerationActor(): Actor { */ function getOrCreateFeatureActor( featureId: string, - initialState?: RoadmapFeatureStatus + initialState?: RoadmapFeatureStatus, + initialContext?: Partial<{ linkedSpecId: string; taskOutcome: string; previousStatus: string }> ): Actor { let actor = featureActors.get(featureId); + // Invalidate cached actor if its state or context doesn't match the expected values + if (actor && initialState) { + const snapshot = actor.getSnapshot(); + const currentValue = String(snapshot.value); + const ctx = snapshot.context; + const contextMismatch = initialContext && ( + ctx.taskOutcome !== (initialContext.taskOutcome ?? undefined) || + ctx.previousStatus !== (initialContext.previousStatus ?? undefined) || + ctx.linkedSpecId !== (initialContext.linkedSpecId ?? undefined) + ); + if (currentValue !== initialState || contextMismatch) { + actor.stop(); + featureActors.delete(featureId); + actor = undefined; + } + } if (!actor) { if (initialState) { const resolvedSnapshot = roadmapFeatureMachine.resolveState({ value: initialState, - context: { linkedSpecId: undefined, taskOutcome: undefined, previousStatus: undefined } + context: { + linkedSpecId: initialContext?.linkedSpecId ?? undefined, + taskOutcome: initialContext?.taskOutcome ?? undefined, + previousStatus: initialContext?.previousStatus ?? undefined + } }); actor = createActor(roadmapFeatureMachine, { snapshot: resolvedSnapshot }); } else { @@ -230,7 +251,11 @@ export const useRoadmapStore = create((set) => ({ const feature = state.roadmap.features.find((f) => f.id === featureId); if (!feature) return state; - const actor = getOrCreateFeatureActor(featureId, feature.status); + const actor = getOrCreateFeatureActor(featureId, feature.status, { + linkedSpecId: feature.linkedSpecId, + taskOutcome: feature.taskOutcome, + previousStatus: feature.previousStatus + }); actor.send(eventMap[status]); const snapshot = actor.getSnapshot(); @@ -274,7 +299,11 @@ export const useRoadmapStore = create((set) => ({ const updatedFeatures = state.roadmap.features.map((feature) => { if (feature.linkedSpecId !== specId) return feature; - const actor = getOrCreateFeatureActor(feature.id, feature.status); + const actor = getOrCreateFeatureActor(feature.id, feature.status, { + linkedSpecId: feature.linkedSpecId, + taskOutcome: feature.taskOutcome, + previousStatus: feature.previousStatus + }); actor.send(event); const snapshot = actor.getSnapshot(); @@ -305,7 +334,11 @@ export const useRoadmapStore = create((set) => ({ const feature = state.roadmap.features.find((f) => f.id === featureId); if (!feature) return state; - const actor = getOrCreateFeatureActor(featureId, feature.status); + const actor = getOrCreateFeatureActor(featureId, feature.status, { + linkedSpecId: feature.linkedSpecId, + taskOutcome: feature.taskOutcome, + previousStatus: feature.previousStatus + }); actor.send({ type: 'LINK_SPEC', specId } satisfies RoadmapFeatureEvent); const snapshot = actor.getSnapshot(); diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts index 8a9d9488..7aed1eb0 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts @@ -289,23 +289,26 @@ describe('roadmapFeatureMachine', () => { }); }); - describe('task events only valid from in_progress', () => { + describe('task events from various states', () => { it('should ignore TASK_COMPLETED from under_review', () => { const snapshot = runEvents([{ type: 'TASK_COMPLETED' }]); expect(snapshot.value).toBe('under_review'); }); - it('should ignore TASK_DELETED from planned', () => { + it('should transition TASK_DELETED from planned to done', () => { const snapshot = runEvents([{ type: 'PLAN' }, { type: 'TASK_DELETED' }]); - expect(snapshot.value).toBe('planned'); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.taskOutcome).toBe('deleted'); + expect(snapshot.context.previousStatus).toBe('planned'); }); - it('should ignore TASK_ARCHIVED from done', () => { + it('should handle TASK_ARCHIVED from done (update outcome)', () => { const snapshot = runEvents([ { type: 'MARK_DONE' }, { type: 'TASK_ARCHIVED' } ]); expect(snapshot.value).toBe('done'); + expect(snapshot.context.taskOutcome).toBe('archived'); }); }); diff --git a/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts index 7111e941..9e695078 100644 --- a/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts +++ b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts @@ -56,6 +56,18 @@ export const roadmapFeatureMachine = createMachine( target: 'done', actions: 'savePreviousPlanned' }, + TASK_COMPLETED: { + target: 'done', + actions: ['savePreviousPlanned', 'setTaskOutcomeCompleted'] + }, + TASK_DELETED: { + target: 'done', + actions: ['savePreviousPlanned', 'setTaskOutcomeDeleted'] + }, + TASK_ARCHIVED: { + target: 'done', + actions: ['savePreviousPlanned', 'setTaskOutcomeArchived'] + }, MOVE_TO_REVIEW: 'under_review' } }, @@ -108,6 +120,21 @@ export const roadmapFeatureMachine = createMachine( actions: 'clearDoneContext' } ], + MARK_DONE: { + target: 'done' + }, + TASK_COMPLETED: { + target: 'done', + actions: 'setTaskOutcomeCompleted' + }, + TASK_DELETED: { + target: 'done', + actions: 'setTaskOutcomeDeleted' + }, + TASK_ARCHIVED: { + target: 'done', + actions: 'setTaskOutcomeArchived' + }, MOVE_TO_REVIEW: { target: 'under_review', actions: 'clearDoneContext' From 5c12dbc66979923c6779d45c11349580fe79a49c Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 10:22:15 +0100 Subject: [PATCH 09/17] Fix PR review issues: strong types, actor cleanup, under_review transitions - Use TaskOutcome/RoadmapFeatureStatus types in feature machine context instead of loose strings, eliminating unsafe casts in roadmap-store - Add TASK_COMPLETED/DELETED/ARCHIVED handlers to under_review state, fixing a behavioral regression where linked task events were silently ignored - Move XState actor side effects outside Zustand set() callbacks in updateFeatureStatus, markFeatureDoneBySpecId, updateFeatureLinkedSpec - Clean up stale feature actors in setRoadmap to prevent memory leaks when switching projects or loading new roadmaps - Consolidate duplicate imports from shared/state-machines Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/roadmap-store.ts | 199 +++++++++--------- .../__tests__/roadmap-feature-machine.test.ts | 20 +- .../state-machines/roadmap-feature-machine.ts | 17 +- 3 files changed, 132 insertions(+), 104 deletions(-) diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 86124e4d..81ae7358 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -14,10 +14,10 @@ import { roadmapGenerationMachine, roadmapFeatureMachine, mapGenerationStateToPhase, - mapFeatureStateToStatus + mapFeatureStateToStatus, + type RoadmapGenerationEvent, + type RoadmapFeatureEvent } from '../../shared/state-machines'; -import type { RoadmapGenerationEvent } from '../../shared/state-machines'; -import type { RoadmapFeatureEvent } from '../../shared/state-machines'; // --------------------------------------------------------------------------- // Module-level XState actor singletons @@ -44,7 +44,7 @@ function getOrCreateGenerationActor(): Actor { function getOrCreateFeatureActor( featureId: string, initialState?: RoadmapFeatureStatus, - initialContext?: Partial<{ linkedSpecId: string; taskOutcome: string; previousStatus: string }> + initialContext?: Partial<{ linkedSpecId: string; taskOutcome: TaskOutcome; previousStatus: RoadmapFeatureStatus }> ): Actor { let actor = featureActors.get(featureId); // Invalidate cached actor if its state or context doesn't match the expected values @@ -177,7 +177,11 @@ export const useRoadmapStore = create((set) => ({ currentProjectId: null, // Actions - setRoadmap: (roadmap) => set({ roadmap }), + setRoadmap: (roadmap) => { + featureActors.forEach((actor) => actor.stop()); + featureActors.clear(); + return set({ roadmap }); + }, setCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis }), @@ -235,134 +239,129 @@ export const useRoadmapStore = create((set) => ({ setCurrentProjectId: (projectId) => set({ currentProjectId: projectId }), - updateFeatureStatus: (featureId, status) => - set((state) => { - if (!state.roadmap) return state; + updateFeatureStatus: (featureId, status) => { + const state = useRoadmapStore.getState(); + if (!state.roadmap) return; - // Determine the XState event based on target status - const eventMap: Record = { - planned: { type: 'PLAN' }, - in_progress: { type: 'START_PROGRESS' }, - done: { type: 'MARK_DONE' }, - under_review: { type: 'MOVE_TO_REVIEW' } - }; + const feature = state.roadmap.features.find((f) => f.id === featureId); + if (!feature) return; - // Find the feature to get its current status for the actor initial state - const feature = state.roadmap.features.find((f) => f.id === featureId); - if (!feature) return state; + // Determine the XState event based on target status + const eventMap: Record = { + planned: { type: 'PLAN' }, + in_progress: { type: 'START_PROGRESS' }, + done: { type: 'MARK_DONE' }, + under_review: { type: 'MOVE_TO_REVIEW' } + }; - const actor = getOrCreateFeatureActor(featureId, feature.status, { - linkedSpecId: feature.linkedSpecId, - taskOutcome: feature.taskOutcome, - previousStatus: feature.previousStatus - }); - actor.send(eventMap[status]); + const actor = getOrCreateFeatureActor(featureId, feature.status, { + linkedSpecId: feature.linkedSpecId, + taskOutcome: feature.taskOutcome, + previousStatus: feature.previousStatus + }); + actor.send(eventMap[status]); - const snapshot = actor.getSnapshot(); - const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); - const ctx = snapshot.context; + const snapshot = actor.getSnapshot(); + const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); + const ctx = snapshot.context; - const updatedFeatures = state.roadmap.features.map((f) => + set((s) => { + if (!s.roadmap) return s; + const updatedFeatures = s.roadmap.features.map((f) => f.id === featureId ? { ...f, status: derivedStatus, - taskOutcome: ctx.taskOutcome as TaskOutcome | undefined, - previousStatus: ctx.previousStatus as RoadmapFeatureStatus | undefined + taskOutcome: ctx.taskOutcome, + previousStatus: ctx.previousStatus } : f ); - return { - roadmap: { - ...state.roadmap, - features: updatedFeatures, - updatedAt: new Date() - } + roadmap: { ...s.roadmap, features: updatedFeatures, updatedAt: new Date() } }; - }), + }); + }, // Mark feature as done when its linked task completes - markFeatureDoneBySpecId: (specId: string, taskOutcome: TaskOutcome = 'completed') => - set((state) => { - if (!state.roadmap) return state; + markFeatureDoneBySpecId: (specId: string, taskOutcome: TaskOutcome = 'completed') => { + const state = useRoadmapStore.getState(); + if (!state.roadmap) return; - // Determine the XState event based on task outcome - const outcomeEventMap: Record = { - completed: { type: 'TASK_COMPLETED' }, - deleted: { type: 'TASK_DELETED' }, - archived: { type: 'TASK_ARCHIVED' } - }; + // Determine the XState event based on task outcome + const outcomeEventMap: Record = { + completed: { type: 'TASK_COMPLETED' }, + deleted: { type: 'TASK_DELETED' }, + archived: { type: 'TASK_ARCHIVED' } + }; - const event = outcomeEventMap[taskOutcome]; + const event = outcomeEventMap[taskOutcome]; - const updatedFeatures = state.roadmap.features.map((feature) => { - if (feature.linkedSpecId !== specId) return feature; + // Process actors outside set() — collect derived state per feature + const featureUpdates = new Map(); + for (const feature of state.roadmap.features) { + if (feature.linkedSpecId !== specId) continue; - const actor = getOrCreateFeatureActor(feature.id, feature.status, { - linkedSpecId: feature.linkedSpecId, - taskOutcome: feature.taskOutcome, - previousStatus: feature.previousStatus - }); - actor.send(event); - - const snapshot = actor.getSnapshot(); - const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); - const ctx = snapshot.context; - - return { - ...feature, - status: derivedStatus, - taskOutcome: ctx.taskOutcome as TaskOutcome | undefined, - previousStatus: ctx.previousStatus as RoadmapFeatureStatus | undefined - }; - }); - - return { - roadmap: { - ...state.roadmap, - features: updatedFeatures, - updatedAt: new Date() - } - }; - }), - - updateFeatureLinkedSpec: (featureId, specId) => - set((state) => { - if (!state.roadmap) return state; - - const feature = state.roadmap.features.find((f) => f.id === featureId); - if (!feature) return state; - - const actor = getOrCreateFeatureActor(featureId, feature.status, { + const actor = getOrCreateFeatureActor(feature.id, feature.status, { linkedSpecId: feature.linkedSpecId, taskOutcome: feature.taskOutcome, previousStatus: feature.previousStatus }); - actor.send({ type: 'LINK_SPEC', specId } satisfies RoadmapFeatureEvent); + actor.send(event); const snapshot = actor.getSnapshot(); - const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); const ctx = snapshot.context; + featureUpdates.set(feature.id, { + status: mapFeatureStateToStatus(String(snapshot.value)), + taskOutcome: ctx.taskOutcome, + previousStatus: ctx.previousStatus + }); + } - const updatedFeatures = state.roadmap.features.map((f) => + if (featureUpdates.size === 0) return; + + set((s) => { + if (!s.roadmap) return s; + const updatedFeatures = s.roadmap.features.map((f) => { + const update = featureUpdates.get(f.id); + return update ? { ...f, ...update } : f; + }); + return { + roadmap: { ...s.roadmap, features: updatedFeatures, updatedAt: new Date() } + }; + }); + }, + + updateFeatureLinkedSpec: (featureId, specId) => { + const state = useRoadmapStore.getState(); + if (!state.roadmap) return; + + const feature = state.roadmap.features.find((f) => f.id === featureId); + if (!feature) return; + + const actor = getOrCreateFeatureActor(featureId, feature.status, { + linkedSpecId: feature.linkedSpecId, + taskOutcome: feature.taskOutcome, + previousStatus: feature.previousStatus + }); + actor.send({ type: 'LINK_SPEC', specId } satisfies RoadmapFeatureEvent); + + const snapshot = actor.getSnapshot(); + const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); + const ctx = snapshot.context; + + set((s) => { + if (!s.roadmap) return s; + const updatedFeatures = s.roadmap.features.map((f) => f.id === featureId - ? { - ...f, - linkedSpecId: ctx.linkedSpecId ?? specId, - status: derivedStatus - } + ? { ...f, linkedSpecId: ctx.linkedSpecId ?? specId, status: derivedStatus } : f ); - return { - roadmap: { - ...state.roadmap, - features: updatedFeatures, - updatedAt: new Date() - } + roadmap: { ...s.roadmap, features: updatedFeatures, updatedAt: new Date() } }; - }), + }); + }, deleteFeature: (featureId) => set((state) => { diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts index 7aed1eb0..0b3ba887 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts @@ -290,9 +290,25 @@ describe('roadmapFeatureMachine', () => { }); describe('task events from various states', () => { - it('should ignore TASK_COMPLETED from under_review', () => { + it('should transition TASK_COMPLETED from under_review to done', () => { const snapshot = runEvents([{ type: 'TASK_COMPLETED' }]); - expect(snapshot.value).toBe('under_review'); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.taskOutcome).toBe('completed'); + expect(snapshot.context.previousStatus).toBe('under_review'); + }); + + it('should transition TASK_DELETED from under_review to done', () => { + const snapshot = runEvents([{ type: 'TASK_DELETED' }]); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.taskOutcome).toBe('deleted'); + expect(snapshot.context.previousStatus).toBe('under_review'); + }); + + it('should transition TASK_ARCHIVED from under_review to done', () => { + const snapshot = runEvents([{ type: 'TASK_ARCHIVED' }]); + expect(snapshot.value).toBe('done'); + expect(snapshot.context.taskOutcome).toBe('archived'); + expect(snapshot.context.previousStatus).toBe('under_review'); }); it('should transition TASK_DELETED from planned to done', () => { diff --git a/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts index 9e695078..0e01bae0 100644 --- a/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts +++ b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts @@ -1,9 +1,10 @@ import { assign, createMachine } from 'xstate'; +import type { TaskOutcome, RoadmapFeatureStatus } from '../types/roadmap'; export interface RoadmapFeatureContext { linkedSpecId?: string; - taskOutcome?: string; - previousStatus?: string; + taskOutcome?: TaskOutcome; + previousStatus?: RoadmapFeatureStatus; } export type RoadmapFeatureEvent = @@ -42,6 +43,18 @@ export const roadmapFeatureMachine = createMachine( MARK_DONE: { target: 'done', actions: 'savePreviousUnderReview' + }, + TASK_COMPLETED: { + target: 'done', + actions: ['savePreviousUnderReview', 'setTaskOutcomeCompleted'] + }, + TASK_DELETED: { + target: 'done', + actions: ['savePreviousUnderReview', 'setTaskOutcomeDeleted'] + }, + TASK_ARCHIVED: { + target: 'done', + actions: ['savePreviousUnderReview', 'setTaskOutcomeArchived'] } } }, From 730ed12d3cf10f114fbca1f2e7b1d4ed5debe2b5 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 11:26:21 +0100 Subject: [PATCH 10/17] Address follow-up review suggestions for XState roadmap integration - Move deleteFeature actor cleanup outside set() for consistency - Skip no-op store writes when XState silently ignores events - Use 'as const' on assign action string literals for type narrowing Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/roadmap-store.ts | 22 +++++++++++-------- .../state-machines/roadmap-feature-machine.ts | 12 +++++----- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 81ae7358..638e45b2 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -265,6 +265,9 @@ export const useRoadmapStore = create((set) => ({ const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); const ctx = snapshot.context; + // Skip store write if XState silently ignored the event (no-op transition) + if (derivedStatus === feature.status && ctx.taskOutcome === feature.taskOutcome && ctx.previousStatus === feature.previousStatus) return; + set((s) => { if (!s.roadmap) return s; const updatedFeatures = s.roadmap.features.map((f) => @@ -363,17 +366,17 @@ export const useRoadmapStore = create((set) => ({ }); }, - deleteFeature: (featureId) => + deleteFeature: (featureId) => { + // Stop and remove the feature's actor outside set() + const actor = featureActors.get(featureId); + if (actor) { + actor.stop(); + featureActors.delete(featureId); + } + set((state) => { if (!state.roadmap) return state; - // Stop and remove the feature's actor - const actor = featureActors.get(featureId); - if (actor) { - actor.stop(); - featureActors.delete(featureId); - } - const updatedFeatures = state.roadmap.features.filter( (feature) => feature.id !== featureId ); @@ -385,7 +388,8 @@ export const useRoadmapStore = create((set) => ({ updatedAt: new Date() } }; - }), + }); + }, clearRoadmap: () => { // Stop all actors and clear Maps diff --git a/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts index 0e01bae0..277970e1 100644 --- a/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts +++ b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts @@ -174,12 +174,12 @@ export const roadmapFeatureMachine = createMachine( linkedSpecId: ({ event }) => event.type === 'LINK_SPEC' ? event.specId : undefined }), - savePreviousUnderReview: assign({ previousStatus: () => 'under_review' }), - savePreviousPlanned: assign({ previousStatus: () => 'planned' }), - savePreviousInProgress: assign({ previousStatus: () => 'in_progress' }), - setTaskOutcomeCompleted: assign({ taskOutcome: () => 'completed' }), - setTaskOutcomeDeleted: assign({ taskOutcome: () => 'deleted' }), - setTaskOutcomeArchived: assign({ taskOutcome: () => 'archived' }), + savePreviousUnderReview: assign({ previousStatus: () => 'under_review' as const }), + savePreviousPlanned: assign({ previousStatus: () => 'planned' as const }), + savePreviousInProgress: assign({ previousStatus: () => 'in_progress' as const }), + setTaskOutcomeCompleted: assign({ taskOutcome: () => 'completed' as const }), + setTaskOutcomeDeleted: assign({ taskOutcome: () => 'deleted' as const }), + setTaskOutcomeArchived: assign({ taskOutcome: () => 'archived' as const }), clearDoneContext: assign({ taskOutcome: () => undefined, previousStatus: () => undefined From 948a55a82c6c937ae140a035e46a2f1ab9582b01 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 17:39:28 +0100 Subject: [PATCH 11/17] fix: address review findings for PR #1815 - Replace deprecated String.prototype.substr with substring - Add resetActors() helper for test cleanup and HMR Co-Authored-By: Claude Opus 4.6 --- .../frontend/src/renderer/stores/roadmap-store.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 638e45b2..107de1f0 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -26,6 +26,19 @@ import { let generationActor: Actor | null = null; const featureActors = new Map>(); +/** + * Reset all actors to clean state. + * Use this in tests (afterEach) and HMR dispose handlers to avoid stale actors. + */ +export function resetActors(): void { + if (generationActor) { + generationActor.stop(); + generationActor = null; + } + featureActors.forEach((actor) => actor.stop()); + featureActors.clear(); +} + /** * Get or create the singleton generation actor. */ @@ -457,7 +470,7 @@ export const useRoadmapStore = create((set) => ({ // Add a new feature to the roadmap addFeature: (featureData) => { - const newId = `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const newId = `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; const newFeature: RoadmapFeature = { ...featureData, id: newId From 960a6acb96826d378404ae8d24767f608f87522d Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 20:29:51 +0100 Subject: [PATCH 12/17] fix: address second round of review findings for PR #1815 Fixes 6 issues identified in follow-up review (2 HIGH, 1 MEDIUM, 3 LOW): 1. HIGH [65226bd9539f]: Generation can now restart from complete/error states - Added RESET + START_GENERATION logic in 'analyzing' case 2. HIGH [282536242c43]: Phase restoration fixed for discovering/generating - Drive actor through intermediate transitions to reach target state - Handle idle and complete/error states before sending phase events 3. MEDIUM [1b74aa0cd0f1]: Progress value clamping added to updateProgress - Clamp progress to 0-100 range using Math.min/max 4. LOW [c833788d76ce]: setError now resets progress to 0 on error - Consistent error state with progress reset 5. LOW [686d19af55d5]: Document unused SETTLED_STATES constants - Added comments explaining future public API use 6. LOW [605d439c4efd]: Document getState() outside set() pattern - Explain XState actors as external side effects Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/roadmap-store.ts | 36 ++++++++++++++++--- .../roadmap-generation-machine.ts | 3 +- .../state-machines/roadmap-state-utils.ts | 8 +++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 107de1f0..84054fa2 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -204,18 +204,40 @@ export const useRoadmapStore = create((set) => ({ // Map the incoming status phase to an XState event let event: RoadmapGenerationEvent | null = null; switch (status.phase) { - case 'analyzing': - // If idle, start generation; otherwise it's a progress update - if (String(actor.getSnapshot().value) === 'idle') { + case 'analyzing': { + const currentState = String(actor.getSnapshot().value); + if (currentState === 'idle') { + event = { type: 'START_GENERATION' }; + } else if (currentState === 'complete' || currentState === 'error') { + actor.send({ type: 'RESET' }); event = { type: 'START_GENERATION' }; } break; - case 'discovering': + } + case 'discovering': { + const cs = String(actor.getSnapshot().value); + if (cs === 'idle') { + actor.send({ type: 'START_GENERATION' }); + } else if (cs === 'complete' || cs === 'error') { + actor.send({ type: 'RESET' }); + actor.send({ type: 'START_GENERATION' }); + } event = { type: 'DISCOVERY_STARTED' }; break; - case 'generating': + } + case 'generating': { + const cs = String(actor.getSnapshot().value); + if (cs === 'idle') { + actor.send({ type: 'START_GENERATION' }); + actor.send({ type: 'DISCOVERY_STARTED' }); + } else if (cs === 'complete' || cs === 'error') { + actor.send({ type: 'RESET' }); + actor.send({ type: 'START_GENERATION' }); + actor.send({ type: 'DISCOVERY_STARTED' }); + } event = { type: 'GENERATION_STARTED' }; break; + } case 'complete': event = { type: 'GENERATION_COMPLETE' }; break; @@ -253,6 +275,10 @@ export const useRoadmapStore = create((set) => ({ setCurrentProjectId: (projectId) => set({ currentProjectId: projectId }), updateFeatureStatus: (featureId, status) => { + // NOTE: getState() is called outside set() because XState actors are external + // side effects that cannot run inside Zustand's synchronous updater. The feature + // lookup and actor state restoration use this snapshot, with the actual state + // write deferred to the set() call below. This is intentional architecture. const state = useRoadmapStore.getState(); if (!state.roadmap) return; diff --git a/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts index fec7d48a..4e351566 100644 --- a/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts +++ b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts @@ -86,7 +86,7 @@ export const roadmapGenerationMachine = createMachine( }), updateProgress: assign({ progress: ({ event }) => - event.type === 'PROGRESS_UPDATE' ? event.progress : 0, + event.type === 'PROGRESS_UPDATE' ? Math.min(100, Math.max(0, event.progress)) : 0, message: ({ event }) => event.type === 'PROGRESS_UPDATE' ? event.message : undefined, }), @@ -97,6 +97,7 @@ export const roadmapGenerationMachine = createMachine( setError: assign({ error: ({ event }) => event.type === 'GENERATION_ERROR' ? event.error : undefined, + progress: () => 0, }), resetContext: assign({ progress: () => 0, diff --git a/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts index bdb3a16d..2e3678f1 100644 --- a/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts +++ b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts @@ -35,6 +35,10 @@ export type FeatureStateName = typeof FEATURE_STATE_NAMES[number]; * Generation states where the machine has settled — the generation is * complete or has errored. Stale progress events should NOT overwrite * these states, as XState is the source of truth. + * + * NOTE: Exported for future consumer use (e.g., UI components that need to + * check if generation is settled before allowing user actions). Currently + * unused but intentionally retained as public API for roadmap state checking. */ export const GENERATION_SETTLED_STATES: ReadonlySet = new Set([ 'complete', 'error' @@ -43,6 +47,10 @@ export const GENERATION_SETTLED_STATES: ReadonlySet = new Set = new Set([ 'done' From 3f9cea9ecaf8fdf6e44b574c42ff68fe7ea3a3e9 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 22:20:30 +0100 Subject: [PATCH 13/17] refactor: address all code review findings for roadmap XState refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix all CodeRabbit review comments and Biome warnings: - Replace non-null assertions with explicit null checks in roadmap-store.ts (lines 548, 553, 559, 565) - Replace non-null assertion with proper type guards in test (line 396) - Use @shared/state-machines path alias instead of relative imports - Prune stale feature actors in setRoadmap when roadmap changes - Add lastActivityAt timestamp tracking to generation machine context - Update deriveGenerationStatus to use persisted lastActivityAt from context - Fix misleading test title "ignore MARK_DONE" → "self-transition" - Add test verifying progress preservation on GENERATION_ERROR - Remove progress reset in setError action to preserve progress on errors - Add compile-time assertions to ensure state name arrays stay in sync with XState machines All tests pass (77/77). TypeScript compilation successful. Zero Biome warnings in modified files. Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/roadmap-store.ts | 34 +++++++++++++------ .../__tests__/roadmap-feature-machine.test.ts | 2 +- .../roadmap-generation-machine.test.ts | 17 +++++++++- .../roadmap-generation-machine.ts | 8 ++++- .../state-machines/roadmap-state-utils.ts | 9 +++++ 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 84054fa2..829e78cb 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -17,7 +17,7 @@ import { mapFeatureStateToStatus, type RoadmapGenerationEvent, type RoadmapFeatureEvent -} from '../../shared/state-machines'; +} from '@shared/state-machines'; // --------------------------------------------------------------------------- // Module-level XState actor singletons @@ -176,9 +176,7 @@ function deriveGenerationStatus(actor: Actor): message: ctx.message ?? '', error: ctx.error, startedAt: ctx.startedAt ? new Date(ctx.startedAt) : undefined, - lastActivityAt: phase !== 'idle' && phase !== 'complete' && phase !== 'error' - ? new Date() - : undefined + lastActivityAt: ctx.lastActivityAt ? new Date(ctx.lastActivityAt) : undefined }; } @@ -191,8 +189,20 @@ export const useRoadmapStore = create((set) => ({ // Actions setRoadmap: (roadmap) => { - featureActors.forEach((actor) => actor.stop()); - featureActors.clear(); + // Prune stale actors: stop and remove actors for features not in the new roadmap + if (roadmap) { + const newFeatureIds = new Set(roadmap.features.map((f) => f.id)); + for (const [featureId, actor] of featureActors.entries()) { + if (!newFeatureIds.has(featureId)) { + actor.stop(); + featureActors.delete(featureId); + } + } + } else { + // No roadmap → cleanup all actors + featureActors.forEach((actor) => actor.stop()); + featureActors.clear(); + } return set({ roadmap }); }, @@ -545,24 +555,28 @@ async function reconcileLinkedFeatures(projectId: string, roadmap: Roadmap): Pro let hasChanges = false; for (const feature of featuresNeedingReconciliation) { - const task = taskMap.get(feature.linkedSpecId!); + // Safe: linkedSpecId is guaranteed to exist by the filter on line 531 + const linkedSpecId = feature.linkedSpecId; + if (!linkedSpecId) continue; + + const task = taskMap.get(linkedSpecId); if (!task) { // Task no longer exists → mark as done with deleted outcome if (feature.status !== 'done' || feature.taskOutcome !== 'deleted') { - store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'deleted'); + store.markFeatureDoneBySpecId(linkedSpecId, 'deleted'); hasChanges = true; } } else if (task.status === 'done' || task.status === 'pr_created') { // Task is completed → mark feature as done if (feature.status !== 'done' || !feature.taskOutcome) { - store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'completed'); + store.markFeatureDoneBySpecId(linkedSpecId, 'completed'); hasChanges = true; } } else if (task.metadata?.archivedAt) { // Task is archived → mark feature as done with archived outcome if (feature.status !== 'done' || feature.taskOutcome !== 'archived') { - store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'archived'); + store.markFeatureDoneBySpecId(linkedSpecId, 'archived'); hasChanges = true; } } diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts index 0b3ba887..d3814f61 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts @@ -283,7 +283,7 @@ describe('roadmapFeatureMachine', () => { expect(snapshot.value).toBe('in_progress'); }); - it('should ignore MARK_DONE when already in done', () => { + it('should remain in done on MARK_DONE self-transition', () => { const snapshot = runEvents([{ type: 'MARK_DONE' }, { type: 'MARK_DONE' }]); expect(snapshot.value).toBe('done'); }); diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts index 8518fe6e..28012519 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts @@ -168,6 +168,17 @@ describe('roadmapGenerationMachine', () => { expect(snapshot.value).toBe('error'); expect(snapshot.context.error).toBe('Generation failed'); }); + + it('should preserve progress when transitioning to error', () => { + const snapshot = runEvents([ + { type: 'START_GENERATION' }, + { type: 'PROGRESS_UPDATE', progress: 60, message: 'Processing...' }, + { type: 'GENERATION_ERROR', error: 'Something went wrong' }, + ]); + expect(snapshot.value).toBe('error'); + expect(snapshot.context.progress).toBe(60); + expect(snapshot.context.error).toBe('Something went wrong'); + }); }); describe('stop flow: STOP from analyzing/discovering/generating → idle', () => { @@ -393,7 +404,11 @@ describe('roadmapGenerationMachine', () => { actor.send({ type: 'START_GENERATION' }); const secondStartedAt = actor.getSnapshot().context.startedAt; - expect(secondStartedAt).toBeGreaterThan(firstStartedAt!); + expect(firstStartedAt).toBeDefined(); + expect(secondStartedAt).toBeDefined(); + if (firstStartedAt && secondStartedAt) { + expect(secondStartedAt).toBeGreaterThan(firstStartedAt); + } actor.stop(); }); }); diff --git a/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts index 4e351566..606a577e 100644 --- a/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts +++ b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts @@ -6,6 +6,7 @@ export interface RoadmapGenerationContext { error?: string; startedAt?: number; completedAt?: number; + lastActivityAt?: number; } export type RoadmapGenerationEvent = @@ -32,6 +33,7 @@ export const roadmapGenerationMachine = createMachine( error: undefined, startedAt: undefined, completedAt: undefined, + lastActivityAt: undefined, }, states: { idle: { @@ -83,21 +85,24 @@ export const roadmapGenerationMachine = createMachine( error: () => undefined, startedAt: () => Date.now(), completedAt: () => undefined, + lastActivityAt: () => Date.now(), }), updateProgress: assign({ progress: ({ event }) => event.type === 'PROGRESS_UPDATE' ? Math.min(100, Math.max(0, event.progress)) : 0, message: ({ event }) => event.type === 'PROGRESS_UPDATE' ? event.message : undefined, + lastActivityAt: () => Date.now(), }), setCompleted: assign({ progress: () => 100, completedAt: () => Date.now(), + lastActivityAt: () => Date.now(), }), setError: assign({ error: ({ event }) => event.type === 'GENERATION_ERROR' ? event.error : undefined, - progress: () => 0, + lastActivityAt: () => Date.now(), }), resetContext: assign({ progress: () => 0, @@ -105,6 +110,7 @@ export const roadmapGenerationMachine = createMachine( error: () => undefined, startedAt: () => undefined, completedAt: () => undefined, + lastActivityAt: () => undefined, }), }, } diff --git a/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts index 2e3678f1..e02751f7 100644 --- a/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts +++ b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts @@ -5,7 +5,10 @@ * derived from the roadmap machine definitions. Used by roadmap-store * and roadmap hooks to avoid duplicate constants. */ +import type { StateValueFrom } from 'xstate'; import type { RoadmapGenerationStatus, RoadmapFeatureStatus } from '../types/roadmap'; +import { roadmapGenerationMachine } from './roadmap-generation-machine'; +import { roadmapFeatureMachine } from './roadmap-feature-machine'; /** * All XState generation state names. @@ -19,6 +22,9 @@ export const GENERATION_STATE_NAMES = [ export type GenerationStateName = typeof GENERATION_STATE_NAMES[number]; +// Compile-time assertion: ensure GENERATION_STATE_NAMES stays in sync with roadmapGenerationMachine +const _genCheck: readonly StateValueFrom[] = GENERATION_STATE_NAMES; + /** * All XState feature state names. * @@ -31,6 +37,9 @@ export const FEATURE_STATE_NAMES = [ export type FeatureStateName = typeof FEATURE_STATE_NAMES[number]; +// Compile-time assertion: ensure FEATURE_STATE_NAMES stays in sync with roadmapFeatureMachine +const _featCheck: readonly StateValueFrom[] = FEATURE_STATE_NAMES; + /** * Generation states where the machine has settled — the generation is * complete or has errored. Stale progress events should NOT overwrite From da7d18695074cc4ed2990999c763b7603bbe3070 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Mon, 16 Feb 2026 08:49:50 +0100 Subject: [PATCH 14/17] fix: address all XState catch-up and cleanup findings for PR #1815 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves all 9 findings from auto-claude review (2026-02-16): HIGH severity (1): - NEW-001: Added catch-up transition for 'analyzing' state when setting status to 'generating' The actor now properly transitions through 'discovering' before reaching 'generating' MEDIUM severity (3): - NEW-002: Fixed updateFeatureLinkedSpec to respect XState state machine Removed fallback that bypassed XState for 'done' features. Now skips store write when LINK_SPEC event is silently ignored (no linkedSpecId in context) - NEW-003: Wired up HMR cleanup and test actor reset Added import.meta.hot.dispose handler to reset actors during HMR Added resetActors() call in test afterEach to prevent test pollution - NEW-004: Added comprehensive catch-up logic for 'complete' phase The actor now properly transitions through all intermediate states (analyzing → discovering → generating) before accepting GENERATION_COMPLETE LOW severity (1): - CMT-001: Clarified test describe block title Changed from "same-status transition is no-op" to "redundant status transitions" to distinguish true no-ops (ignored events) from self-transitions (MARK_DONE in done) All XState catch-up transitions now handle out-of-order status messages correctly, preventing the UI from showing incorrect generation/feature status. Co-Authored-By: Claude Opus 4.6 --- .../renderer/__tests__/roadmap-store.test.ts | 5 ++- .../src/renderer/stores/roadmap-store.ts | 34 +++++++++++++++++-- .../__tests__/roadmap-feature-machine.test.ts | 10 +++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts index 251d924c..9d79062b 100644 --- a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts +++ b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts @@ -84,8 +84,11 @@ describe('Roadmap Store', () => { }); }); - afterEach(() => { + afterEach(async () => { vi.clearAllMocks(); + // Reset XState actors to prevent test pollution + const { resetActors } = await import('../stores/roadmap-store'); + resetActors(); }); describe('setRoadmap', () => { diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 829e78cb..93156f66 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -240,6 +240,8 @@ export const useRoadmapStore = create((set) => ({ if (cs === 'idle') { actor.send({ type: 'START_GENERATION' }); actor.send({ type: 'DISCOVERY_STARTED' }); + } else if (cs === 'analyzing') { + actor.send({ type: 'DISCOVERY_STARTED' }); } else if (cs === 'complete' || cs === 'error') { actor.send({ type: 'RESET' }); actor.send({ type: 'START_GENERATION' }); @@ -248,9 +250,27 @@ export const useRoadmapStore = create((set) => ({ event = { type: 'GENERATION_STARTED' }; break; } - case 'complete': + case 'complete': { + const cs = String(actor.getSnapshot().value); + // Catch-up logic: advance actor to 'generating' state before sending GENERATION_COMPLETE + if (cs === 'idle') { + actor.send({ type: 'START_GENERATION' }); + actor.send({ type: 'DISCOVERY_STARTED' }); + actor.send({ type: 'GENERATION_STARTED' }); + } else if (cs === 'analyzing') { + actor.send({ type: 'DISCOVERY_STARTED' }); + actor.send({ type: 'GENERATION_STARTED' }); + } else if (cs === 'discovering') { + actor.send({ type: 'GENERATION_STARTED' }); + } else if (cs === 'error') { + actor.send({ type: 'RESET' }); + actor.send({ type: 'START_GENERATION' }); + actor.send({ type: 'DISCOVERY_STARTED' }); + actor.send({ type: 'GENERATION_STARTED' }); + } event = { type: 'GENERATION_COMPLETE' }; break; + } case 'error': event = { type: 'GENERATION_ERROR', error: status.error ?? 'Unknown error' }; break; @@ -402,11 +422,14 @@ export const useRoadmapStore = create((set) => ({ const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); const ctx = snapshot.context; + // Skip store write if XState silently ignored the event (no linkedSpecId in context) + if (!ctx.linkedSpecId) return; + set((s) => { if (!s.roadmap) return s; const updatedFeatures = s.roadmap.features.map((f) => f.id === featureId - ? { ...f, linkedSpecId: ctx.linkedSpecId ?? specId, status: derivedStatus } + ? { ...f, linkedSpecId: ctx.linkedSpecId, status: derivedStatus } : f ); return { @@ -788,3 +811,10 @@ export function getFeatureStats(roadmap: Roadmap | null): { byComplexity }; } + +// HMR cleanup: reset actors on hot module replacement +if (import.meta.hot) { + import.meta.hot.dispose(() => { + resetActors(); + }); +} diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts index d3814f61..02712e27 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts @@ -260,8 +260,8 @@ describe('roadmapFeatureMachine', () => { }); }); - describe('same-status transition is no-op', () => { - it('should ignore MOVE_TO_REVIEW when already in under_review', () => { + describe('redundant status transitions', () => { + it('should ignore MOVE_TO_REVIEW when already in under_review (no-op)', () => { const actor = createActor(roadmapFeatureMachine); actor.start(); // MOVE_TO_REVIEW is not defined on under_review, so it's ignored @@ -270,12 +270,12 @@ describe('roadmapFeatureMachine', () => { actor.stop(); }); - it('should ignore PLAN when already in planned', () => { + it('should ignore PLAN when already in planned (no-op)', () => { const snapshot = runEvents([{ type: 'PLAN' }, { type: 'PLAN' }]); expect(snapshot.value).toBe('planned'); }); - it('should ignore START_PROGRESS when already in in_progress', () => { + it('should ignore START_PROGRESS when already in in_progress (no-op)', () => { const snapshot = runEvents([ { type: 'START_PROGRESS' }, { type: 'START_PROGRESS' } @@ -283,7 +283,7 @@ describe('roadmapFeatureMachine', () => { expect(snapshot.value).toBe('in_progress'); }); - it('should remain in done on MARK_DONE self-transition', () => { + it('should handle MARK_DONE in done state (self-transition)', () => { const snapshot = runEvents([{ type: 'MARK_DONE' }, { type: 'MARK_DONE' }]); expect(snapshot.value).toBe('done'); }); From 7fb9b10bcdc5c144ec063c94e0fbc27683f2f465 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Tue, 17 Feb 2026 15:38:18 +0100 Subject: [PATCH 15/17] fix: address PR review findings for XState roadmap refactor - Add catch-up logic for 'error' phase in setGenerationStatus so GENERATION_ERROR is not silently dropped when actor is in idle/complete - Fix progress updates being dropped for empty string message by using !== undefined instead of truthy check on status.message - Improve compile-time assertion comments explaining both-direction sync enforcement strategy (forward via type assertion, reverse via switch exhaustiveness in map functions) - Replace unnecessary dynamic import of resetActors in test afterEach with static import - Document that backward transitions are intentionally unsupported in the forward-only generation pipeline Co-Authored-By: Claude Opus 4.6 --- .../renderer/__tests__/roadmap-store.test.ts | 5 ++--- .../src/renderer/stores/roadmap-store.ts | 17 +++++++++++++++-- .../state-machines/roadmap-state-utils.ts | 8 ++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts index 9d79062b..673dde60 100644 --- a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts +++ b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts @@ -3,7 +3,7 @@ * Tests Zustand store for roadmap state management including drag-and-drop actions */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { useRoadmapStore, getFeaturesByPhase, getFeaturesByPriority, getFeatureStats } from '../stores/roadmap-store'; +import { useRoadmapStore, getFeaturesByPhase, getFeaturesByPriority, getFeatureStats, resetActors } from '../stores/roadmap-store'; import type { Roadmap, RoadmapFeature, @@ -84,10 +84,9 @@ describe('Roadmap Store', () => { }); }); - afterEach(async () => { + afterEach(() => { vi.clearAllMocks(); // Reset XState actors to prevent test pollution - const { resetActors } = await import('../stores/roadmap-store'); resetActors(); }); diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 93156f66..0cf080e6 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -225,6 +225,9 @@ export const useRoadmapStore = create((set) => ({ break; } case 'discovering': { + // NOTE: Backward transitions (e.g., generating→discovering) are intentionally + // unsupported. The generation pipeline is strictly forward-progressing, so XState + // will silently drop the event if the actor is already past this phase. const cs = String(actor.getSnapshot().value); if (cs === 'idle') { actor.send({ type: 'START_GENERATION' }); @@ -271,9 +274,19 @@ export const useRoadmapStore = create((set) => ({ event = { type: 'GENERATION_COMPLETE' }; break; } - case 'error': + case 'error': { + const cs = String(actor.getSnapshot().value); + // Catch-up logic: GENERATION_ERROR is only handled in analyzing, discovering, + // and generating states. Advance from idle/complete so the event isn't dropped. + if (cs === 'idle') { + actor.send({ type: 'START_GENERATION' }); + } else if (cs === 'complete' || cs === 'error') { + actor.send({ type: 'RESET' }); + actor.send({ type: 'START_GENERATION' }); + } event = { type: 'GENERATION_ERROR', error: status.error ?? 'Unknown error' }; break; + } case 'idle': { // Stop or reset depending on current state const currentState = String(actor.getSnapshot().value); @@ -291,7 +304,7 @@ export const useRoadmapStore = create((set) => ({ } // Send progress updates for active states - if (status.progress !== undefined && status.message) { + if (status.progress !== undefined && status.message !== undefined) { const currentState = String(actor.getSnapshot().value); if (currentState === 'analyzing' || currentState === 'discovering' || currentState === 'generating') { actor.send({ type: 'PROGRESS_UPDATE', progress: status.progress, message: status.message }); diff --git a/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts index e02751f7..1f8626a8 100644 --- a/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts +++ b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts @@ -22,7 +22,10 @@ export const GENERATION_STATE_NAMES = [ export type GenerationStateName = typeof GENERATION_STATE_NAMES[number]; -// Compile-time assertion: ensure GENERATION_STATE_NAMES stays in sync with roadmapGenerationMachine +// Compile-time assertion: ensures every element in GENERATION_STATE_NAMES is a valid machine state. +// The reverse direction (every machine state is in the array) is enforced by the exhaustive switch +// in mapGenerationStateToPhase below — adding a new machine state without a switch case will cause +// it to silently map to 'idle' via default, which the mapGenerationStateToPhase tests will catch. const _genCheck: readonly StateValueFrom[] = GENERATION_STATE_NAMES; /** @@ -37,7 +40,8 @@ export const FEATURE_STATE_NAMES = [ export type FeatureStateName = typeof FEATURE_STATE_NAMES[number]; -// Compile-time assertion: ensure FEATURE_STATE_NAMES stays in sync with roadmapFeatureMachine +// Compile-time assertion: ensures every element in FEATURE_STATE_NAMES is a valid machine state. +// Reverse direction enforced by mapFeatureStateToStatus switch exhaustiveness (see comment above). const _featCheck: readonly StateValueFrom[] = FEATURE_STATE_NAMES; /** From 6c69e71a76a0eb2af7bdda78349d13d3d577c5ac Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Wed, 18 Feb 2026 09:49:49 +0100 Subject: [PATCH 16/17] fix: resolve 3 remaining PR #1815 issues - Add vite/client types reference to fix import.meta.hot TS errors - Add unit tests for mapGenerationStateToPhase/mapFeatureStateToStatus ensuring all machine states map correctly without silent fallthrough - Restore persisted state in getOrCreateGenerationActor matching the feature actor pattern with resolveState() Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/roadmap-store.ts | 32 +++++++- .../__tests__/roadmap-state-utils.test.ts | 75 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 0cf080e6..5e72024d 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -1,3 +1,4 @@ +/// import { create } from 'zustand'; import { createActor } from 'xstate'; import type { Actor } from 'xstate'; @@ -41,10 +42,37 @@ export function resetActors(): void { /** * Get or create the singleton generation actor. + * Optionally provide an initial state and context to restore from persisted data. */ -function getOrCreateGenerationActor(): Actor { +function getOrCreateGenerationActor( + initialState?: RoadmapGenerationStatus['phase'], + initialContext?: Partial<{ progress: number; message: string; error: string; startedAt: number; completedAt: number; lastActivityAt: number }> +): Actor { + // Invalidate cached actor if its state doesn't match the expected value + if (generationActor && initialState) { + const currentValue = String(generationActor.getSnapshot().value); + if (currentValue !== initialState) { + generationActor.stop(); + generationActor = null; + } + } if (!generationActor) { - generationActor = createActor(roadmapGenerationMachine); + if (initialState) { + const resolvedSnapshot = roadmapGenerationMachine.resolveState({ + value: initialState, + context: { + progress: initialContext?.progress ?? 0, + message: initialContext?.message, + error: initialContext?.error, + startedAt: initialContext?.startedAt, + completedAt: initialContext?.completedAt, + lastActivityAt: initialContext?.lastActivityAt + } + }); + generationActor = createActor(roadmapGenerationMachine, { snapshot: resolvedSnapshot }); + } else { + generationActor = createActor(roadmapGenerationMachine); + } generationActor.start(); } return generationActor; diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts new file mode 100644 index 00000000..53ca4f9e --- /dev/null +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { + GENERATION_STATE_NAMES, + FEATURE_STATE_NAMES, + mapGenerationStateToPhase, + mapFeatureStateToStatus, +} from '../roadmap-state-utils'; + +describe('mapGenerationStateToPhase', () => { + it('should map every GENERATION_STATE_NAMES entry to a non-default phase', () => { + for (const state of GENERATION_STATE_NAMES) { + const phase = mapGenerationStateToPhase(state); + // Each known state should map to itself (identity mapping), NOT the default 'idle' + expect(phase).toBe(state); + } + }); + + it('should map each generation state to a valid phase value', () => { + const validPhases = new Set(['idle', 'analyzing', 'discovering', 'generating', 'complete', 'error']); + for (const state of GENERATION_STATE_NAMES) { + const phase = mapGenerationStateToPhase(state); + expect(validPhases.has(phase)).toBe(true); + } + }); + + it('should return idle for unknown states', () => { + expect(mapGenerationStateToPhase('nonexistent')).toBe('idle'); + expect(mapGenerationStateToPhase('')).toBe('idle'); + }); + + it('should have a case for every generation state (no silent fallthrough)', () => { + // Verify that no known state falls through to the default case + // by checking that each maps to its own name (identity) + const defaultValue = mapGenerationStateToPhase('__unknown_sentinel__'); + for (const state of GENERATION_STATE_NAMES) { + if (state === defaultValue) continue; // 'idle' is both a valid state and the default + const result = mapGenerationStateToPhase(state); + expect(result).not.toBe(defaultValue); + } + }); +}); + +describe('mapFeatureStateToStatus', () => { + it('should map every FEATURE_STATE_NAMES entry to a non-default status', () => { + for (const state of FEATURE_STATE_NAMES) { + const status = mapFeatureStateToStatus(state); + // Each known state should map to itself (identity mapping), NOT the default 'under_review' + expect(status).toBe(state); + } + }); + + it('should map each feature state to a valid status value', () => { + const validStatuses = new Set(['under_review', 'planned', 'in_progress', 'done']); + for (const state of FEATURE_STATE_NAMES) { + const status = mapFeatureStateToStatus(state); + expect(validStatuses.has(status)).toBe(true); + } + }); + + it('should return under_review for unknown states', () => { + expect(mapFeatureStateToStatus('nonexistent')).toBe('under_review'); + expect(mapFeatureStateToStatus('')).toBe('under_review'); + }); + + it('should have a case for every feature state (no silent fallthrough)', () => { + // Verify that no known state falls through to the default case + // by checking that each maps to its own name (identity) + const defaultValue = mapFeatureStateToStatus('__unknown_sentinel__'); + for (const state of FEATURE_STATE_NAMES) { + if (state === defaultValue) continue; // 'under_review' is both valid and default + const result = mapFeatureStateToStatus(state); + expect(result).not.toBe(defaultValue); + } + }); +}); From 4349963f39c16b2d282d512ac5f8d7065c78dbb5 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Wed, 18 Feb 2026 10:23:06 +0100 Subject: [PATCH 17/17] fix: address PR #1815 review findings for roadmap XState refactor - Remove redundant conditional in setGenerationStatus (NEW-001) - Add comprehensive test coverage for catch-up logic (NEW-002) - Pass persisted context to getOrCreateGenerationActor on reload (CMT-001) - Add reverse-direction assertions for state name arrays (FNEW-003) - Fix stale line reference in comment (NEW-003) - Use precise no-op guard in updateFeatureLinkedSpec (NEW-004) Co-Authored-By: Claude Opus 4.6 --- .../renderer/__tests__/roadmap-store.test.ts | 159 ++++++++++++++++++ .../src/renderer/stores/roadmap-store.ts | 25 ++- .../__tests__/roadmap-state-utils.test.ts | 18 ++ 3 files changed, 193 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts index 673dde60..e5b9cd8f 100644 --- a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts +++ b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts @@ -625,6 +625,165 @@ describe('Roadmap Store', () => { }); }); + describe('setGenerationStatus catch-up logic', () => { + it('should advance from idle to analyzing', () => { + useRoadmapStore.getState().setGenerationStatus({ + phase: 'analyzing', + progress: 10, + message: 'Analyzing...' + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('analyzing'); + expect(status.progress).toBe(10); + expect(status.message).toBe('Analyzing...'); + }); + + it('should advance from idle to discovering via catch-up', () => { + useRoadmapStore.getState().setGenerationStatus({ + phase: 'discovering', + progress: 30, + message: 'Discovering...' + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('discovering'); + expect(status.progress).toBe(30); + expect(status.message).toBe('Discovering...'); + }); + + it('should advance from idle to generating via catch-up', () => { + useRoadmapStore.getState().setGenerationStatus({ + phase: 'generating', + progress: 60, + message: 'Generating...' + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('generating'); + expect(status.progress).toBe(60); + expect(status.message).toBe('Generating...'); + }); + + it('should advance from idle to complete via catch-up', () => { + // First go through active states to build up context, then complete + useRoadmapStore.getState().setGenerationStatus({ + phase: 'analyzing', + progress: 10, + message: 'Analyzing...' + }); + useRoadmapStore.getState().setGenerationStatus({ + phase: 'complete', + progress: 100, + message: 'Done' + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('complete'); + expect(status.progress).toBe(100); + }); + + it('should advance from idle to error via catch-up', () => { + useRoadmapStore.getState().setGenerationStatus({ + phase: 'error', + progress: 0, + message: '', + error: 'Something failed' + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('error'); + expect(status.error).toBe('Something failed'); + }); + + it('should reset from error and start new generation', () => { + // First put into error state + useRoadmapStore.getState().setGenerationStatus({ + phase: 'error', + progress: 0, + message: '', + error: 'Failed' + }); + expect(useRoadmapStore.getState().generationStatus.phase).toBe('error'); + + // Now start a new generation from error state + useRoadmapStore.getState().setGenerationStatus({ + phase: 'analyzing', + progress: 5, + message: 'Restarting...' + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('analyzing'); + expect(status.progress).toBe(5); + }); + + it('should send progress updates for active states', () => { + // Move to analyzing + useRoadmapStore.getState().setGenerationStatus({ + phase: 'analyzing', + progress: 0, + message: 'Starting...' + }); + + // Update progress in analyzing + useRoadmapStore.getState().setGenerationStatus({ + phase: 'analyzing', + progress: 50, + message: 'Halfway...' + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('analyzing'); + expect(status.progress).toBe(50); + expect(status.message).toBe('Halfway...'); + }); + + it('should be idempotent for idle-to-idle transitions', () => { + useRoadmapStore.getState().setGenerationStatus({ + phase: 'idle', + progress: 0, + message: '' + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('idle'); + expect(status.progress).toBe(0); + }); + + it('should handle complete-to-idle reset', () => { + useRoadmapStore.getState().setGenerationStatus({ + phase: 'complete', + progress: 100, + message: 'Done' + }); + expect(useRoadmapStore.getState().generationStatus.phase).toBe('complete'); + + useRoadmapStore.getState().setGenerationStatus({ + phase: 'idle', + progress: 0, + message: '' + }); + expect(useRoadmapStore.getState().generationStatus.phase).toBe('idle'); + }); + + it('should preserve startedAt from persisted status on reload', () => { + const persistedStartedAt = new Date('2025-06-01T12:00:00Z'); + useRoadmapStore.getState().setGenerationStatus({ + phase: 'generating', + progress: 70, + message: 'Generating...', + startedAt: persistedStartedAt, + lastActivityAt: new Date() + }); + + const status = useRoadmapStore.getState().generationStatus; + expect(status.phase).toBe('generating'); + expect(status.startedAt).toBeDefined(); + expect(status.startedAt!.getTime()).toBe(persistedStartedAt.getTime()); + }); + }); + describe('clearRoadmap', () => { it('should clear roadmap and reset status', () => { useRoadmapStore.setState({ diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 5e72024d..b60f935c 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -237,7 +237,16 @@ export const useRoadmapStore = create((set) => ({ setCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis }), setGenerationStatus: (status) => { - const actor = getOrCreateGenerationActor(); + const actor = getOrCreateGenerationActor( + status.phase !== 'idle' ? status.phase : undefined, + status.phase !== 'idle' ? { + progress: status.progress, + message: status.message, + error: status.error, + startedAt: status.startedAt?.getTime(), + lastActivityAt: status.lastActivityAt?.getTime() + } : undefined + ); // Map the incoming status phase to an XState event let event: RoadmapGenerationEvent | null = null; @@ -332,11 +341,9 @@ export const useRoadmapStore = create((set) => ({ } // Send progress updates for active states - if (status.progress !== undefined && status.message !== undefined) { - const currentState = String(actor.getSnapshot().value); - if (currentState === 'analyzing' || currentState === 'discovering' || currentState === 'generating') { - actor.send({ type: 'PROGRESS_UPDATE', progress: status.progress, message: status.message }); - } + const currentState = String(actor.getSnapshot().value); + if (currentState === 'analyzing' || currentState === 'discovering' || currentState === 'generating') { + actor.send({ type: 'PROGRESS_UPDATE', progress: status.progress, message: status.message }); } // Derive store state from the actor snapshot @@ -463,8 +470,8 @@ export const useRoadmapStore = create((set) => ({ const derivedStatus = mapFeatureStateToStatus(String(snapshot.value)); const ctx = snapshot.context; - // Skip store write if XState silently ignored the event (no linkedSpecId in context) - if (!ctx.linkedSpecId) return; + // Skip store write if nothing changed (same linkedSpecId and status) + if (ctx.linkedSpecId === feature.linkedSpecId && derivedStatus === feature.status) return; set((s) => { if (!s.roadmap) return s; @@ -619,7 +626,7 @@ async function reconcileLinkedFeatures(projectId: string, roadmap: Roadmap): Pro let hasChanges = false; for (const feature of featuresNeedingReconciliation) { - // Safe: linkedSpecId is guaranteed to exist by the filter on line 531 + // Safe: linkedSpecId is guaranteed to exist by the filter above const linkedSpecId = feature.linkedSpecId; if (!linkedSpecId) continue; diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts index 53ca4f9e..6cb9dacb 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts @@ -5,6 +5,8 @@ import { mapGenerationStateToPhase, mapFeatureStateToStatus, } from '../roadmap-state-utils'; +import { roadmapGenerationMachine } from '../roadmap-generation-machine'; +import { roadmapFeatureMachine } from '../roadmap-feature-machine'; describe('mapGenerationStateToPhase', () => { it('should map every GENERATION_STATE_NAMES entry to a non-default phase', () => { @@ -38,6 +40,14 @@ describe('mapGenerationStateToPhase', () => { expect(result).not.toBe(defaultValue); } }); + + it('should include every machine state in GENERATION_STATE_NAMES (reverse direction)', () => { + const machineStates = Object.keys(roadmapGenerationMachine.config.states ?? {}); + const stateNameSet = new Set(GENERATION_STATE_NAMES); + for (const machineState of machineStates) { + expect(stateNameSet.has(machineState), `Machine state '${machineState}' missing from GENERATION_STATE_NAMES`).toBe(true); + } + }); }); describe('mapFeatureStateToStatus', () => { @@ -72,4 +82,12 @@ describe('mapFeatureStateToStatus', () => { expect(result).not.toBe(defaultValue); } }); + + it('should include every machine state in FEATURE_STATE_NAMES (reverse direction)', () => { + const machineStates = Object.keys(roadmapFeatureMachine.config.states ?? {}); + const stateNameSet = new Set(FEATURE_STATE_NAMES); + for (const machineState of machineStates) { + expect(stateNameSet.has(machineState), `Machine state '${machineState}' missing from FEATURE_STATE_NAMES`).toBe(true); + } + }); });