diff --git a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts
index 251d924c..e5b9cd8f 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,
@@ -86,6 +86,8 @@ describe('Roadmap Store', () => {
afterEach(() => {
vi.clearAllMocks();
+ // Reset XState actors to prevent test pollution
+ resetActors();
});
describe('setRoadmap', () => {
@@ -623,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 9b4c1bcc..05a9fce0 100644
--- a/apps/frontend/src/renderer/stores/roadmap-store.ts
+++ b/apps/frontend/src/renderer/stores/roadmap-store.ts
@@ -1,4 +1,7 @@
+///
import { create } from 'zustand';
+import { createActor } from 'xstate';
+import type { Actor } from 'xstate';
import type {
Competitor,
CompetitorAnalysis,
@@ -10,6 +13,118 @@ import type {
TaskOutcome,
FeatureSource
} from '../../shared/types';
+import {
+ roadmapGenerationMachine,
+ roadmapFeatureMachine,
+ mapGenerationStateToPhase,
+ mapFeatureStateToStatus,
+ type RoadmapGenerationEvent,
+ type RoadmapFeatureEvent
+} from '@shared/state-machines';
+
+// ---------------------------------------------------------------------------
+// Module-level XState actor singletons
+// ---------------------------------------------------------------------------
+
+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.
+ * Optionally provide an initial state and context to restore from persisted data.
+ */
+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) {
+ 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;
+}
+
+/**
+ * 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,
+ 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
+ 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: initialContext?.linkedSpecId ?? undefined,
+ taskOutcome: initialContext?.taskOutcome ?? undefined,
+ previousStatus: initialContext?.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
@@ -79,6 +194,23 @@ 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: ctx.lastActivityAt ? new Date(ctx.lastActivityAt) : undefined
+ };
+}
+
export const useRoadmapStore = create((set) => ({
// Initial state
roadmap: null,
@@ -87,93 +219,284 @@ export const useRoadmapStore = create((set) => ({
currentProjectId: null,
// Actions
- setRoadmap: (roadmap) => set({ roadmap }),
+ setRoadmap: (roadmap) => {
+ // 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 });
+ },
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(
+ 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
+ );
- 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': {
+ 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': {
+ // 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' });
+ } else if (cs === 'complete' || cs === 'error') {
+ actor.send({ type: 'RESET' });
+ actor.send({ type: 'START_GENERATION' });
+ }
+ event = { type: 'DISCOVERY_STARTED' };
+ break;
+ }
+ case 'generating': {
+ const cs = String(actor.getSnapshot().value);
+ 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' });
+ actor.send({ type: 'DISCOVERY_STARTED' });
+ }
+ event = { type: 'GENERATION_STARTED' };
+ break;
+ }
+ 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': {
+ 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);
+ 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
+ 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 }),
- updateFeatureStatus: (featureId, status) =>
- set((state) => {
- if (!state.roadmap) return state;
+ 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;
- const updatedFeatures = state.roadmap.features.map((feature) =>
- feature.id === featureId
- ? { ...feature, status, ...(status !== 'done' ? { taskOutcome: undefined, previousStatus: undefined } : {}) }
- : feature
+ const feature = state.roadmap.features.find((f) => f.id === featureId);
+ if (!feature) 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 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;
+
+ // 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) =>
+ f.id === featureId
+ ? {
+ ...f,
+ status: derivedStatus,
+ 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;
- 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];
+
+ // 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 ctx = snapshot.context;
+ featureUpdates.set(feature.id, {
+ status: mapFeatureStateToStatus(String(snapshot.value)),
+ taskOutcome: ctx.taskOutcome,
+ previousStatus: ctx.previousStatus
+ });
+ }
+
+ 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: {
- ...state.roadmap,
- features: updatedFeatures,
- updatedAt: new Date()
- }
+ roadmap: { ...s.roadmap, features: updatedFeatures, updatedAt: new Date() }
};
- }),
+ });
+ },
- updateFeatureLinkedSpec: (featureId, specId) =>
- set((state) => {
- if (!state.roadmap) return state;
+ updateFeatureLinkedSpec: (featureId, specId) => {
+ const state = useRoadmapStore.getState();
+ if (!state.roadmap) return;
- 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;
+
+ 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;
+
+ // 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;
+ const updatedFeatures = s.roadmap.features.map((f) =>
+ f.id === featureId
+ ? { ...f, linkedSpecId: ctx.linkedSpecId, status: derivedStatus }
+ : f
);
-
return {
- roadmap: {
- ...state.roadmap,
- features: updatedFeatures,
- updatedAt: new Date()
- }
+ roadmap: { ...s.roadmap, features: updatedFeatures, updatedAt: new Date() }
};
- }),
+ });
+ },
+
+ deleteFeature: (featureId) => {
+ // Stop and remove the feature's actor outside set()
+ const actor = featureActors.get(featureId);
+ if (actor) {
+ actor.stop();
+ featureActors.delete(featureId);
+ }
- deleteFeature: (featureId) =>
set((state) => {
if (!state.roadmap) return state;
@@ -188,15 +511,27 @@ export const useRoadmapStore = create((set) => ({
updatedAt: new Date()
}
};
- }),
+ });
+ },
- 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) =>
@@ -349,24 +684,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 above
+ 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;
}
}
@@ -578,3 +917,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
new file mode 100644
index 00000000..02712e27
--- /dev/null
+++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts
@@ -0,0 +1,399 @@
+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('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
+ actor.send({ type: 'MOVE_TO_REVIEW' });
+ expect(actor.getSnapshot().value).toBe('under_review');
+ actor.stop();
+ });
+
+ 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 (no-op)', () => {
+ const snapshot = runEvents([
+ { type: 'START_PROGRESS' },
+ { type: 'START_PROGRESS' }
+ ]);
+ expect(snapshot.value).toBe('in_progress');
+ });
+
+ it('should handle MARK_DONE in done state (self-transition)', () => {
+ const snapshot = runEvents([{ type: 'MARK_DONE' }, { type: 'MARK_DONE' }]);
+ expect(snapshot.value).toBe('done');
+ });
+ });
+
+ describe('task events from various states', () => {
+ it('should transition TASK_COMPLETED from under_review to done', () => {
+ const snapshot = runEvents([{ type: 'TASK_COMPLETED' }]);
+ 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', () => {
+ const snapshot = runEvents([{ type: 'PLAN' }, { type: 'TASK_DELETED' }]);
+ expect(snapshot.value).toBe('done');
+ expect(snapshot.context.taskOutcome).toBe('deleted');
+ expect(snapshot.context.previousStatus).toBe('planned');
+ });
+
+ 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');
+ });
+ });
+
+ 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();
+ });
+ });
+});
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..28012519
--- /dev/null
+++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts
@@ -0,0 +1,461 @@
+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');
+ });
+
+ 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', () => {
+ 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(firstStartedAt).toBeDefined();
+ expect(secondStartedAt).toBeDefined();
+ if (firstStartedAt && secondStartedAt) {
+ 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();
+ });
+ });
+});
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..6cb9dacb
--- /dev/null
+++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from 'vitest';
+import {
+ GENERATION_STATE_NAMES,
+ FEATURE_STATE_NAMES,
+ 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', () => {
+ 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);
+ }
+ });
+
+ 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', () => {
+ 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);
+ }
+ });
+
+ 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);
+ }
+ });
+});
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';
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..277970e1
--- /dev/null
+++ b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts
@@ -0,0 +1,189 @@
+import { assign, createMachine } from 'xstate';
+import type { TaskOutcome, RoadmapFeatureStatus } from '../types/roadmap';
+
+export interface RoadmapFeatureContext {
+ linkedSpecId?: string;
+ taskOutcome?: TaskOutcome;
+ previousStatus?: RoadmapFeatureStatus;
+}
+
+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'
+ },
+ TASK_COMPLETED: {
+ target: 'done',
+ actions: ['savePreviousUnderReview', 'setTaskOutcomeCompleted']
+ },
+ TASK_DELETED: {
+ target: 'done',
+ actions: ['savePreviousUnderReview', 'setTaskOutcomeDeleted']
+ },
+ TASK_ARCHIVED: {
+ target: 'done',
+ actions: ['savePreviousUnderReview', 'setTaskOutcomeArchived']
+ }
+ }
+ },
+ planned: {
+ on: {
+ START_PROGRESS: 'in_progress',
+ LINK_SPEC: {
+ target: 'in_progress',
+ actions: 'setLinkedSpec'
+ },
+ MARK_DONE: {
+ 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'
+ }
+ },
+ 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'
+ }
+ ],
+ 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'
+ },
+ 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' 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
+ })
+ }
+ }
+);
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..606a577e
--- /dev/null
+++ b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts
@@ -0,0 +1,117 @@
+import { assign, createMachine } from 'xstate';
+
+export interface RoadmapGenerationContext {
+ progress: number;
+ message?: string;
+ error?: string;
+ startedAt?: number;
+ completedAt?: number;
+ lastActivityAt?: 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,
+ lastActivityAt: 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,
+ 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,
+ lastActivityAt: () => Date.now(),
+ }),
+ resetContext: assign({
+ progress: () => 0,
+ message: () => undefined,
+ 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
new file mode 100644
index 00000000..1f8626a8
--- /dev/null
+++ b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts
@@ -0,0 +1,120 @@
+/**
+ * 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 { 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.
+ *
+ * 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];
+
+// 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;
+
+/**
+ * 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];
+
+// 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;
+
+/**
+ * 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'
+]);
+
+/**
+ * Feature states where the machine has settled — the feature is done.
+ * Stale task lifecycle events should NOT overwrite this state.
+ *
+ * NOTE: Exported for future consumer use (e.g., UI components that need to
+ * check if feature is settled before allowing drag-and-drop or status changes).
+ * Currently unused but intentionally retained as public API for feature state checking.
+ */
+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';
+ }
+}