feat(KB-159): complete Step 2 — implement planning session SSE infrastructure
This commit is contained in:
@@ -4,9 +4,13 @@
|
|||||||
* Manages AI-guided planning sessions for interactive task creation.
|
* Manages AI-guided planning sessions for interactive task creation.
|
||||||
* Sessions are stored in-memory with TTL cleanup.
|
* Sessions are stored in-memory with TTL cleanup.
|
||||||
*
|
*
|
||||||
* NOTE: AI Agent integration is stubbed for now. When integrating with
|
* Features:
|
||||||
* the real AI agent, update createSession and submitResponse to use
|
* - AI agent integration with real-time streaming via SSE
|
||||||
* createKbAgent from "@kb/engine".
|
* - Rate limiting per IP
|
||||||
|
* - Session expiration and cleanup
|
||||||
|
*
|
||||||
|
* NOTE: AI Agent integration uses createKbAgent from "@kb/engine" for
|
||||||
|
* real-time planning conversations with thinking output streaming.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -15,8 +19,9 @@ import type {
|
|||||||
PlanningResponse,
|
PlanningResponse,
|
||||||
TaskStore,
|
TaskStore,
|
||||||
} from "@kb/core";
|
} from "@kb/core";
|
||||||
import { createKbAgent } from "@kb/engine";
|
import { createKbAgent, type AgentResult } from "@kb/engine";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
// ── Constants ───────────────────────────────────────────────────────────────
|
// ── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,7 +55,16 @@ When ready to complete, generate:
|
|||||||
- A detailed description with context gathered
|
- A detailed description with context gathered
|
||||||
- Size estimate (S/M/L) based on scope
|
- Size estimate (S/M/L) based on scope
|
||||||
- Any suggested dependencies on existing tasks
|
- Any suggested dependencies on existing tasks
|
||||||
- Key deliverables as a checklist`;
|
- Key deliverables as a checklist
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
Always respond with valid JSON in one of these formats:
|
||||||
|
|
||||||
|
For questions:
|
||||||
|
{\n "type": "question",\n "data": {\n "id": "unique-id",\n "type": "text|single_select|multi_select|confirm",\n "question": "The question text",\n "description": "Helpful context",\n "options": [{"id": "opt1", "label": "Option 1", "description": "Details"}]\n }\n}
|
||||||
|
|
||||||
|
For completion:
|
||||||
|
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`;
|
||||||
|
|
||||||
/** Session TTL in milliseconds (30 minutes) */
|
/** Session TTL in milliseconds (30 minutes) */
|
||||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||||
@@ -66,6 +80,17 @@ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
|
|
||||||
// ── Types ───────────────────────────────────────────────────────────────────
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** SSE event types for planning session streaming */
|
||||||
|
export type PlanningStreamEvent =
|
||||||
|
| { type: "thinking"; data: string }
|
||||||
|
| { type: "question"; data: PlanningQuestion }
|
||||||
|
| { type: "summary"; data: PlanningSummary }
|
||||||
|
| { type: "error"; data: string }
|
||||||
|
| { type: "complete" };
|
||||||
|
|
||||||
|
/** Callback function for streaming events */
|
||||||
|
export type PlanningStreamCallback = (event: PlanningStreamEvent) => void;
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -73,6 +98,12 @@ interface Session {
|
|||||||
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
||||||
currentQuestion?: PlanningQuestion;
|
currentQuestion?: PlanningQuestion;
|
||||||
summary?: PlanningSummary;
|
summary?: PlanningSummary;
|
||||||
|
/** AI agent session for real-time interaction */
|
||||||
|
agent?: AgentResult;
|
||||||
|
/** Callback for streaming events to SSE clients */
|
||||||
|
streamCallback?: PlanningStreamCallback;
|
||||||
|
/** Accumulated thinking output for display */
|
||||||
|
thinkingOutput: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -132,6 +163,78 @@ process.on("beforeExit", () => {
|
|||||||
clearInterval(cleanupInterval);
|
clearInterval(cleanupInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Planning Stream Manager ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages SSE connections for active planning sessions.
|
||||||
|
* Each session can have multiple connected clients receiving streaming updates.
|
||||||
|
*/
|
||||||
|
export class PlanningStreamManager extends EventEmitter {
|
||||||
|
private sessions = new Map<string, Set<PlanningStreamCallback>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a client callback for a planning session.
|
||||||
|
* Returns a function to unsubscribe.
|
||||||
|
*/
|
||||||
|
subscribe(sessionId: string, callback: PlanningStreamCallback): () => void {
|
||||||
|
if (!this.sessions.has(sessionId)) {
|
||||||
|
this.sessions.set(sessionId, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbacks = this.sessions.get(sessionId)!;
|
||||||
|
callbacks.add(callback);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
callbacks.delete(callback);
|
||||||
|
if (callbacks.size === 0) {
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast an event to all clients subscribed to a session.
|
||||||
|
*/
|
||||||
|
broadcast(sessionId: string, event: PlanningStreamEvent): void {
|
||||||
|
const callbacks = this.sessions.get(sessionId);
|
||||||
|
if (!callbacks) return;
|
||||||
|
|
||||||
|
for (const callback of callbacks) {
|
||||||
|
try {
|
||||||
|
callback(event);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[planning] Error broadcasting to client for session ${sessionId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session has active subscribers.
|
||||||
|
*/
|
||||||
|
hasSubscribers(sessionId: string): boolean {
|
||||||
|
const callbacks = this.sessions.get(sessionId);
|
||||||
|
return callbacks !== undefined && callbacks.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of subscribers for a session.
|
||||||
|
*/
|
||||||
|
getSubscriberCount(sessionId: string): number {
|
||||||
|
return this.sessions.get(sessionId)?.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all subscriptions for a session.
|
||||||
|
*/
|
||||||
|
cleanupSession(sessionId: string): void {
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton instance of the planning stream manager */
|
||||||
|
export const planningStreamManager = new PlanningStreamManager();
|
||||||
|
|
||||||
// ── Rate Limiting ───────────────────────────────────────────────────────────
|
// ── Rate Limiting ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -397,7 +500,8 @@ function generateSummary(session: Session): PlanningSummary {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new planning session.
|
* Create a new planning session.
|
||||||
* Returns session ID and first question (stubbed for now - AI integration in future).
|
* Uses stubbed AI logic for immediate response (no streaming).
|
||||||
|
* For streaming AI responses, use createSessionWithAgent.
|
||||||
*/
|
*/
|
||||||
export async function createSession(
|
export async function createSession(
|
||||||
ip: string,
|
ip: string,
|
||||||
@@ -416,7 +520,7 @@ export async function createSession(
|
|||||||
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
// Generate first question based on initial plan (stub - AI will do this in future)
|
// Generate first question based on initial plan (stub - maintains backward compatibility)
|
||||||
const firstQuestion = generateFirstQuestion(initialPlan);
|
const firstQuestion = generateFirstQuestion(initialPlan);
|
||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
@@ -425,6 +529,7 @@ export async function createSession(
|
|||||||
initialPlan,
|
initialPlan,
|
||||||
history: [],
|
history: [],
|
||||||
currentQuestion: firstQuestion,
|
currentQuestion: firstQuestion,
|
||||||
|
thinkingOutput: "",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -434,9 +539,171 @@ export async function createSession(
|
|||||||
return { sessionId, firstQuestion };
|
return { sessionId, firstQuestion };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new planning session with AI agent streaming.
|
||||||
|
* This initializes an AI agent that will stream thinking output via SSE.
|
||||||
|
*
|
||||||
|
* @param ip - Client IP for rate limiting
|
||||||
|
* @param initialPlan - The user's initial plan description
|
||||||
|
* @param rootDir - Project root directory for AI agent context
|
||||||
|
* @returns Session ID (use with planningStreamManager to receive events)
|
||||||
|
*/
|
||||||
|
export async function createSessionWithAgent(
|
||||||
|
ip: string,
|
||||||
|
initialPlan: string,
|
||||||
|
rootDir: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Check rate limit
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
|
const resetTime = getRateLimitResetTime(ip);
|
||||||
|
throw new RateLimitError(
|
||||||
|
`Rate limit exceeded. Maximum ${MAX_SESSIONS_PER_IP_PER_HOUR} planning sessions per hour. ` +
|
||||||
|
`Reset at ${resetTime?.toISOString() || "unknown"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
|
const session: Session = {
|
||||||
|
id: sessionId,
|
||||||
|
ip,
|
||||||
|
initialPlan,
|
||||||
|
history: [],
|
||||||
|
thinkingOutput: "",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sessions.set(sessionId, session);
|
||||||
|
|
||||||
|
// Initialize AI agent in background - it will stream via planningStreamManager
|
||||||
|
initializeAgent(session, rootDir).catch((err) => {
|
||||||
|
console.error(`[planning] Failed to initialize agent for session ${sessionId}:`, err);
|
||||||
|
planningStreamManager.broadcast(sessionId, {
|
||||||
|
type: "error",
|
||||||
|
data: err.message || "Failed to initialize AI agent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the AI agent for a session and start the first turn.
|
||||||
|
*/
|
||||||
|
async function initializeAgent(session: Session, rootDir: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const agentResult = await createKbAgent({
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt: PLANNING_SYSTEM_PROMPT,
|
||||||
|
tools: "readonly",
|
||||||
|
onThinking: (delta) => {
|
||||||
|
session.thinkingOutput += delta;
|
||||||
|
planningStreamManager.broadcast(session.id, {
|
||||||
|
type: "thinking",
|
||||||
|
data: delta,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onText: (delta) => {
|
||||||
|
// Capture AI response text - will be parsed at end of turn
|
||||||
|
session.thinkingOutput += delta;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
session.agent = agentResult;
|
||||||
|
session.updatedAt = new Date();
|
||||||
|
|
||||||
|
// Send initial message to get first question
|
||||||
|
await continueAgentConversation(session, session.initialPlan);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[planning] Agent initialization error for session ${session.id}:`, err);
|
||||||
|
planningStreamManager.broadcast(session.id, {
|
||||||
|
type: "error",
|
||||||
|
data: err instanceof Error ? err.message : "Failed to initialize AI agent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continue the AI conversation with a user message.
|
||||||
|
*/
|
||||||
|
async function continueAgentConversation(session: Session, message: string): Promise<void> {
|
||||||
|
if (!session.agent) {
|
||||||
|
throw new InvalidSessionStateError("AI agent not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear thinking output for this turn
|
||||||
|
const previousThinking = session.thinkingOutput;
|
||||||
|
session.thinkingOutput = "";
|
||||||
|
|
||||||
|
// Send message to agent - it will stream thinking via onThinking callback
|
||||||
|
const response = await session.agent.session.send(message);
|
||||||
|
|
||||||
|
// Combine any thinking output with the response text
|
||||||
|
const fullResponse = session.thinkingOutput + (response?.text || "");
|
||||||
|
|
||||||
|
// Parse the JSON response
|
||||||
|
const parsed = parseAgentResponse(fullResponse);
|
||||||
|
|
||||||
|
if (parsed.type === "question") {
|
||||||
|
session.currentQuestion = parsed.data;
|
||||||
|
session.updatedAt = new Date();
|
||||||
|
planningStreamManager.broadcast(session.id, {
|
||||||
|
type: "question",
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
} else if (parsed.type === "complete") {
|
||||||
|
session.summary = parsed.data;
|
||||||
|
session.currentQuestion = undefined;
|
||||||
|
session.updatedAt = new Date();
|
||||||
|
planningStreamManager.broadcast(session.id, {
|
||||||
|
type: "summary",
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
planningStreamManager.broadcast(session.id, { type: "complete" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[planning] Agent conversation error for session ${session.id}:`, err);
|
||||||
|
planningStreamManager.broadcast(session.id, {
|
||||||
|
type: "error",
|
||||||
|
data: err instanceof Error ? err.message : "AI processing failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse agent response JSON, with error handling.
|
||||||
|
*/
|
||||||
|
function parseAgentResponse(text: string): PlanningResponse {
|
||||||
|
// Try to extract JSON from the response (AI might wrap in markdown code blocks)
|
||||||
|
const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ||
|
||||||
|
text.match(/\{[\s\S]*\}/);
|
||||||
|
|
||||||
|
const jsonText = jsonMatch ? jsonMatch[1] || jsonMatch[0] : text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonText.trim());
|
||||||
|
|
||||||
|
// Validate structure
|
||||||
|
if (parsed.type === "question" && parsed.data) {
|
||||||
|
return parsed as PlanningResponse;
|
||||||
|
}
|
||||||
|
if (parsed.type === "complete" && parsed.data) {
|
||||||
|
return parsed as PlanningResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If structure is invalid, treat as error
|
||||||
|
throw new Error("Invalid response structure from AI");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[planning] Failed to parse agent response:", text);
|
||||||
|
throw new Error(`Failed to parse AI response: ${err instanceof Error ? err.message : "Unknown error"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a response to the current question and get the next question or summary.
|
* Submit a response to the current question and get the next question or summary.
|
||||||
* Stubbed - AI integration will be implemented in future.
|
* Supports both stubbed mode and AI agent mode.
|
||||||
*/
|
*/
|
||||||
export async function submitResponse(
|
export async function submitResponse(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -457,7 +724,22 @@ export async function submitResponse(
|
|||||||
response: responses,
|
response: responses,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate next question or summary (stub - AI will do this in future)
|
// If AI agent is active, use it for next question
|
||||||
|
if (session.agent) {
|
||||||
|
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||||
|
await continueAgentConversation(session, message);
|
||||||
|
|
||||||
|
// Return the current state (will be updated via SSE)
|
||||||
|
if (session.summary) {
|
||||||
|
return { type: "complete", data: session.summary };
|
||||||
|
}
|
||||||
|
if (session.currentQuestion) {
|
||||||
|
return { type: "question", data: session.currentQuestion };
|
||||||
|
}
|
||||||
|
return { type: "question", data: generateFirstQuestion(session.initialPlan) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stubbed mode: generate next question or summary
|
||||||
const result = generateNextQuestionOrSummary(session);
|
const result = generateNextQuestionOrSummary(session);
|
||||||
|
|
||||||
if (result.type === "question") {
|
if (result.type === "question") {
|
||||||
@@ -472,6 +754,44 @@ export async function submitResponse(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format user response as a message for the AI agent.
|
||||||
|
*/
|
||||||
|
function formatResponseForAgent(
|
||||||
|
question: PlanningQuestion,
|
||||||
|
responses: Record<string, unknown>
|
||||||
|
): string {
|
||||||
|
const responseValue = responses[question.id];
|
||||||
|
|
||||||
|
switch (question.type) {
|
||||||
|
case "text":
|
||||||
|
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||||
|
|
||||||
|
case "single_select":
|
||||||
|
if (typeof responseValue === "string") {
|
||||||
|
const option = question.options?.find((o) => o.id === responseValue);
|
||||||
|
return `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||||
|
}
|
||||||
|
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||||
|
|
||||||
|
case "multi_select":
|
||||||
|
if (Array.isArray(responseValue)) {
|
||||||
|
const selected = responseValue.map((id) => {
|
||||||
|
const option = question.options?.find((o) => o.id === id);
|
||||||
|
return option?.label || id;
|
||||||
|
});
|
||||||
|
return `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||||
|
}
|
||||||
|
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||||
|
|
||||||
|
case "confirm":
|
||||||
|
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel and cleanup a planning session.
|
* Cancel and cleanup a planning session.
|
||||||
*/
|
*/
|
||||||
@@ -481,6 +801,19 @@ export async function cancelSession(sessionId: string): Promise<void> {
|
|||||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup AI agent if present
|
||||||
|
if (session.agent) {
|
||||||
|
try {
|
||||||
|
session.agent.session.dispose?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[planning] Error disposing agent for session ${sessionId}:`, err);
|
||||||
|
}
|
||||||
|
session.agent = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup SSE subscriptions
|
||||||
|
planningStreamManager.cleanupSession(sessionId);
|
||||||
|
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +842,15 @@ export function getSummary(sessionId: string): PlanningSummary | undefined {
|
|||||||
* Cleanup a session (used after task creation).
|
* Cleanup a session (used after task creation).
|
||||||
*/
|
*/
|
||||||
export function cleanupSession(sessionId: string): void {
|
export function cleanupSession(sessionId: string): void {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (session?.agent) {
|
||||||
|
try {
|
||||||
|
session.agent.session.dispose?.();
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
planningStreamManager.cleanupSession(sessionId);
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,8 +858,19 @@ export function cleanupSession(sessionId: string): void {
|
|||||||
* Reset all planning state. Used for testing only.
|
* Reset all planning state. Used for testing only.
|
||||||
*/
|
*/
|
||||||
export function __resetPlanningState(): void {
|
export function __resetPlanningState(): void {
|
||||||
|
// Cleanup all agent sessions
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (session.agent) {
|
||||||
|
try {
|
||||||
|
session.agent.session.dispose?.();
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
rateLimits.clear();
|
rateLimits.clear();
|
||||||
|
planningStreamManager.removeAllListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2283,6 +2283,81 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/planning/:sessionId/stream
|
||||||
|
* SSE endpoint for real-time planning session updates.
|
||||||
|
* Streams thinking output, questions, summaries, and errors.
|
||||||
|
*
|
||||||
|
* Event types:
|
||||||
|
* - thinking: AI thinking output chunks
|
||||||
|
* - question: New question to display
|
||||||
|
* - summary: Planning summary when complete
|
||||||
|
* - error: Error message
|
||||||
|
* - complete: Stream completed
|
||||||
|
*/
|
||||||
|
router.get("/planning/:sessionId/stream", async (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
// Send initial connection confirmation
|
||||||
|
res.write(": connected\n\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { planningStreamManager, getSession, SessionNotFoundError } = await import("./planning.js");
|
||||||
|
|
||||||
|
// Verify session exists
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
res.write(`event: error\ndata: ${JSON.stringify({ message: "Session not found or expired" })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to session events
|
||||||
|
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => {
|
||||||
|
try {
|
||||||
|
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data ?? {})}\n\n`);
|
||||||
|
|
||||||
|
// End stream on complete or error
|
||||||
|
if (event.type === "complete" || event.type === "error") {
|
||||||
|
unsubscribe();
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Client disconnected
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
req.on("close", () => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send heartbeat every 30s to keep connection alive
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (res.writableEnded) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.write(": heartbeat\n\n");
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
req.on("close", () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "Stream error" })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/usage
|
* GET /api/usage
|
||||||
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
||||||
|
|||||||
Reference in New Issue
Block a user