Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 93 additions & 6 deletions apps/desktop/src/main/agent/agent-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AgentState } from './agent-state';
import type { AgentEvents } from './agent-events';
import { AgentProcessManager } from './agent-process';
import { RoadmapConfig } from './types';
import type { IdeationConfig, Idea } from '../../shared/types';
import type { IdeationConfig, Idea, RoadmapGenerationStatus } from '../../shared/types';
import { AUTO_BUILD_PATHS } from '../../shared/constants';
import { detectRateLimit, createSDKRateLimitInfo } from '../rate-limit-detector';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
Expand All @@ -21,6 +21,57 @@ import type { RoadmapStreamEvent } from '../ai/runners/roadmap';
import type { ModelShorthand, ThinkingLevel } from '../ai/config/types';
import { resolvePromptsDir } from '../ai/prompts/prompt-loader';

type RoadmapPhase = RoadmapGenerationStatus['phase'];
const ROADMAP_HEARTBEAT_INTERVAL_MS = 3000;

const ROADMAP_PHASE_PROGRESS_CAPS: Record<RoadmapPhase, number> = {
idle: 0,
analyzing: 25,
discovering: 55,
generating: 90,
complete: 100,
error: 100,
};

function getRoadmapHeartbeatMessage(phase: RoadmapPhase): string {
switch (phase) {
case 'analyzing':
return 'Analyzing project structure...';
case 'discovering':
return 'Discovering target audience and user needs...';
case 'generating':
return 'Generating roadmap features and phases...';
case 'complete':
return 'Roadmap generation complete';
case 'error':
return 'Roadmap generation failed';
default:
return 'Starting roadmap generation...';
}
}

/**
* Normalize runner-emitted roadmap phase names to the canonical UI/XState phases.
* Supports legacy aliases emitted by the TS roadmap runner.
*/
function normalizeRoadmapPhase(rawPhase: string): RoadmapPhase {
switch (rawPhase) {
case 'idle':
case 'analyzing':
case 'discovering':
case 'generating':
case 'complete':
case 'error':
return rawPhase;
case 'discovery':
return 'discovering';
case 'features':
return 'generating';
default:
return 'analyzing';
}
}

/**
* Queue management for ideation and roadmap generation
*/
Expand All @@ -30,7 +81,7 @@ export class AgentQueueManager {
private emitter: EventEmitter;
private debouncedPersistRoadmapProgress: (
projectPath: string,
phase: string,
phase: RoadmapPhase,
progress: number,
message: string,
startedAt: string,
Expand Down Expand Up @@ -76,7 +127,7 @@ export class AgentQueueManager {
*/
private async persistRoadmapProgress(
projectPath: string,
phase: string,
phase: RoadmapPhase,
progress: number,
message: string,
startedAt: string,
Expand Down Expand Up @@ -384,9 +435,10 @@ export class AgentQueueManager {
});

// Track progress
let progressPhase = 'analyzing';
let progressPhase: RoadmapPhase = 'analyzing';
let progressPercent = 10;
const roadmapStartedAt = new Date().toISOString();
let lastHeartbeatAt = 0;

// Persist initial progress
this.debouncedPersistRoadmapProgress(
Expand All @@ -405,6 +457,34 @@ export class AgentQueueManager {
message: 'Starting roadmap generation...'
});

const emitRoadmapHeartbeat = (message?: string, trickleProgress: boolean = false): void => {
const now = Date.now();
if (now - lastHeartbeatAt < ROADMAP_HEARTBEAT_INTERVAL_MS) return;
lastHeartbeatAt = now;

if (trickleProgress) {
const cap = ROADMAP_PHASE_PROGRESS_CAPS[progressPhase];
if (progressPercent < cap) {
progressPercent = Math.min(progressPercent + 1, cap);
}
}

const heartbeatMessage = message ?? getRoadmapHeartbeatMessage(progressPhase);
this.emitter.emit('roadmap-progress', projectId, {
phase: progressPhase,
progress: progressPercent,
message: heartbeatMessage
});
this.debouncedPersistRoadmapProgress(
projectPath,
progressPhase,
progressPercent,
heartbeatMessage,
roadmapStartedAt,
true
);
};

try {
const result = await runRoadmapGeneration(
{
Expand All @@ -418,9 +498,9 @@ export class AgentQueueManager {
(event: RoadmapStreamEvent) => {
switch (event.type) {
case 'phase-start': {
progressPhase = event.phase;
progressPhase = normalizeRoadmapPhase(event.phase);
progressPercent = Math.min(progressPercent + 20, 90);
const msg = `Running ${event.phase} phase...`;
const msg = `Running ${progressPhase} phase...`;
this.emitter.emit('roadmap-log', projectId, msg);
this.emitter.emit('roadmap-progress', projectId, {
phase: progressPhase,
Expand All @@ -439,6 +519,13 @@ export class AgentQueueManager {
}
case 'text-delta': {
this.emitter.emit('roadmap-log', projectId, event.text);
emitRoadmapHeartbeat(undefined, true);
break;
}
case 'tool-use': {
const msg = `Using tool: ${event.name}`;
this.emitter.emit('roadmap-log', projectId, msg);
emitRoadmapHeartbeat(msg, true);
break;
}
case 'error': {
Expand Down
28 changes: 24 additions & 4 deletions apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ function getFeatureSettings(): { model?: string; thinkingLevel?: string } {
return getActiveProviderFeatureSettings('roadmap');
}

/**
* Normalize legacy roadmap phase aliases to canonical frontend phases.
*/
function normalizeRoadmapProgressPhase(rawPhase: string): RoadmapGenerationStatus['phase'] | null {
switch (rawPhase) {
case 'idle':
case 'analyzing':
case 'discovering':
case 'generating':
case 'complete':
case 'error':
return rawPhase;
case 'discovery':
return 'discovering';
case 'features':
return 'generating';
default:
return null;
}
}

/**
* Register all roadmap-related IPC handlers
*/
Expand Down Expand Up @@ -705,18 +726,17 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n"
const content = await readFileWithRetry(progressPath, { encoding: "utf-8" }) as string;
const rawData = JSON.parse(content);

// Valid phase values that the frontend expects
const validPhases = ['idle', 'analyzing', 'discovering', 'generating', 'complete', 'error'];
const normalizedPhase = normalizeRoadmapProgressPhase(String(rawData.phase ?? ''));

// Validate required fields exist and phase is valid
if (!rawData.phase || typeof rawData.progress !== 'number' || !validPhases.includes(rawData.phase)) {
if (!normalizedPhase || typeof rawData.progress !== 'number') {
debugLog("[Roadmap Handler] Invalid progress file structure or phase, ignoring:", { projectId, phase: rawData.phase });
return { success: true, data: null };
}

// Transform snake_case to camelCase for frontend
const progressData: PersistedRoadmapProgress = {
phase: rawData.phase,
phase: normalizedPhase,
progress: rawData.progress,
message: rawData.message || '',
startedAt: rawData.started_at,
Expand Down
53 changes: 43 additions & 10 deletions apps/desktop/src/renderer/stores/roadmap-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,28 @@ const initialGenerationStatus: RoadmapGenerationStatus = {
message: ''
};

/**
* Normalize inbound phase aliases from backend/legacy progress files
* to the canonical roadmap generation phases expected by XState.
*/
function normalizeGenerationPhase(phase: string): RoadmapGenerationStatus['phase'] {
switch (phase) {
case 'idle':
case 'analyzing':
case 'discovering':
case 'generating':
case 'complete':
case 'error':
return phase;
case 'discovery':
return 'discovering';
case 'features':
return 'generating';
default:
return 'analyzing';
}
}
Comment on lines +197 to +217
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This normalization logic is duplicated across three files:

  • apps/desktop/src/main/agent/agent-queue.ts (normalizeRoadmapPhase)
  • apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts (normalizeRoadmapProgressPhase)
  • Here in roadmap-store.ts (normalizeGenerationPhase)

To improve maintainability, this logic should be centralized.

Furthermore, this normalization in the renderer might be redundant. The backend appears to normalize phases in agent-queue.ts (from the runner) and roadmap-handlers.ts (from persisted files) before sending data to the renderer. If the backend guarantees normalized phases over IPC, this function and its usage in setGenerationStatus could be removed, simplifying the renderer code.

If this defensive normalization is still desired, consider moving the function to a shared utility file (e.g., in apps/desktop/src/shared/utils/) to be used by both renderer and main process code, which would resolve the duplication.


/**
* Derive RoadmapGenerationStatus from the generation actor's current snapshot.
*/
Expand Down Expand Up @@ -240,20 +262,27 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
setCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis }),

setGenerationStatus: (status) => {
// Defensive normalization: runtime IPC payloads can contain legacy aliases
// ('discovery', 'features') even though the TS type is canonical.
const normalizedStatus: RoadmapGenerationStatus = {
...status,
phase: normalizeGenerationPhase(status.phase as string)
};

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()
normalizedStatus.phase !== 'idle' ? normalizedStatus.phase : undefined,
normalizedStatus.phase !== 'idle' ? {
progress: normalizedStatus.progress,
message: normalizedStatus.message,
error: normalizedStatus.error,
startedAt: normalizedStatus.startedAt?.getTime(),
lastActivityAt: normalizedStatus.lastActivityAt?.getTime()
} : undefined
);

// Map the incoming status phase to an XState event
let event: RoadmapGenerationEvent | null = null;
switch (status.phase) {
switch (normalizedStatus.phase) {
case 'analyzing': {
const currentState = String(actor.getSnapshot().value);
if (currentState === 'idle') {
Expand Down Expand Up @@ -324,7 +353,7 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
actor.send({ type: 'RESET' });
actor.send({ type: 'START_GENERATION' });
}
event = { type: 'GENERATION_ERROR', error: status.error ?? 'Unknown error' };
event = { type: 'GENERATION_ERROR', error: normalizedStatus.error ?? 'Unknown error' };
break;
}
case 'idle': {
Expand All @@ -346,7 +375,11 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
// 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 });
actor.send({
type: 'PROGRESS_UPDATE',
progress: normalizedStatus.progress,
message: normalizedStatus.message
});
}

// Derive store state from the actor snapshot
Expand Down