feat(KB-032): add Planning Mode for AI-assisted task creation
- Add PlanningModeModal component with interactive task planning UI - Implement backend planning API with /api/planning endpoints - Add PlanningSession class for managing planning state - Integrate planning mode into dashboard with header button - Add comprehensive tests for planning components and API routes - Include AI agent structure for future planning automation - Update README with Planning Mode documentation
This commit is contained in:
274
packages/dashboard/src/planning.test.ts
Normal file
274
packages/dashboard/src/planning.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import {
|
||||
createSession,
|
||||
submitResponse,
|
||||
cancelSession,
|
||||
getSession,
|
||||
getCurrentQuestion,
|
||||
getSummary,
|
||||
cleanupSession,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
__resetPlanningState,
|
||||
RateLimitError,
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
} from "./planning.js";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@kb/core";
|
||||
|
||||
// Counter for unique IPs per test
|
||||
let ipCounter = 0;
|
||||
function getUniqueIp(): string {
|
||||
return `127.0.0.${++ipCounter}`;
|
||||
}
|
||||
|
||||
describe("planning module", () => {
|
||||
const initialPlan = "Build a user authentication system";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetPlanningState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createSession", () => {
|
||||
it("creates a session with valid initial plan", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const result = await createSession(mockIp, initialPlan);
|
||||
|
||||
expect(result.sessionId).toBeDefined();
|
||||
expect(typeof result.sessionId).toBe("string");
|
||||
expect(result.firstQuestion).toBeDefined();
|
||||
expect(result.firstQuestion.id).toBe("q-scope");
|
||||
expect(result.firstQuestion.type).toBe("single_select");
|
||||
});
|
||||
|
||||
it("enforces rate limiting", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
// Create max sessions (5 per hour)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createSession(mockIp, `${initialPlan} ${i}`);
|
||||
}
|
||||
|
||||
// 6th session should fail
|
||||
await expect(createSession(mockIp, initialPlan)).rejects.toThrow(RateLimitError);
|
||||
});
|
||||
|
||||
it("allows new sessions after rate limit window expires", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
// Create max sessions
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createSession(mockIp, `${initialPlan} ${i}`);
|
||||
}
|
||||
|
||||
// Advance time by 1 hour + 1 minute
|
||||
vi.advanceTimersByTime(61 * 60 * 1000);
|
||||
|
||||
// Should now be able to create a new session
|
||||
const result = await createSession(mockIp, "New plan after reset");
|
||||
expect(result.sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
it("generates different session IDs for each session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const result1 = await createSession(mockIp, "Plan 1");
|
||||
const result2 = await createSession(mockIp, "Plan 2");
|
||||
|
||||
expect(result1.sessionId).not.toBe(result2.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitResponse", () => {
|
||||
it("processes response and returns next question", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
const response = await submitResponse(sessionId, { scope: "medium" });
|
||||
|
||||
expect(response.type).toBe("question");
|
||||
if (response.type === "question") {
|
||||
expect(response.data.type).toBe("text");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns summary after multiple responses", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
// Submit first response
|
||||
const response1 = await submitResponse(sessionId, { scope: "medium" });
|
||||
expect(response1.type).toBe("question");
|
||||
|
||||
// Submit second response
|
||||
const response2 = await submitResponse(sessionId, { requirements: "Must have login and logout" });
|
||||
expect(response2.type).toBe("question");
|
||||
|
||||
// Submit third response - should get summary
|
||||
const response3 = await submitResponse(sessionId, { confirm: true });
|
||||
expect(response3.type).toBe("complete");
|
||||
|
||||
if (response3.type === "complete") {
|
||||
expect(response3.data.title).toBeDefined();
|
||||
expect(response3.data.description).toBeDefined();
|
||||
expect(response3.data.suggestedSize).toBeDefined();
|
||||
expect(response3.data.keyDeliverables).toBeInstanceOf(Array);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws SessionNotFoundError for invalid session ID", async () => {
|
||||
await expect(submitResponse("invalid-session-id", {})).rejects.toThrow(SessionNotFoundError);
|
||||
});
|
||||
|
||||
it("throws InvalidSessionStateError when no active question", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
// Complete the session
|
||||
await submitResponse(sessionId, { scope: "small" });
|
||||
await submitResponse(sessionId, { requirements: "test" });
|
||||
await submitResponse(sessionId, { confirm: true });
|
||||
|
||||
// Try to submit another response
|
||||
await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelSession", () => {
|
||||
it("removes an active session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
await cancelSession(sessionId);
|
||||
|
||||
// Should not be able to find the session anymore
|
||||
expect(getSession(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws SessionNotFoundError for non-existent session", async () => {
|
||||
await expect(cancelSession("non-existent-id")).rejects.toThrow(SessionNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSession", () => {
|
||||
it("returns session for valid ID", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
const session = getSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.id).toBe(sessionId);
|
||||
expect(session?.initialPlan).toBe(initialPlan);
|
||||
expect(session?.ip).toBe(mockIp);
|
||||
});
|
||||
|
||||
it("returns undefined for invalid ID", () => {
|
||||
expect(getSession("invalid-id")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentQuestion", () => {
|
||||
it("returns current question for active session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId, firstQuestion } = await createSession(mockIp, initialPlan);
|
||||
|
||||
const question = getCurrentQuestion(sessionId);
|
||||
expect(question).toEqual(firstQuestion);
|
||||
});
|
||||
|
||||
it("returns undefined for completed session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
// Complete the session
|
||||
await submitResponse(sessionId, { scope: "small" });
|
||||
await submitResponse(sessionId, { requirements: "test" });
|
||||
await submitResponse(sessionId, { confirm: true });
|
||||
|
||||
const question = getCurrentQuestion(sessionId);
|
||||
expect(question).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSummary", () => {
|
||||
it("returns summary for completed session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
// Complete the session
|
||||
await submitResponse(sessionId, { scope: "small" });
|
||||
await submitResponse(sessionId, { requirements: "test" });
|
||||
const response = await submitResponse(sessionId, { confirm: true });
|
||||
|
||||
if (response.type === "complete") {
|
||||
const summary = getSummary(sessionId);
|
||||
expect(summary).toEqual(response.data);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined for incomplete session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
const summary = getSummary(sessionId);
|
||||
expect(summary).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupSession", () => {
|
||||
it("removes a session from memory", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
cleanupSession(sessionId);
|
||||
|
||||
expect(getSession(sessionId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rate limiting", () => {
|
||||
it("checkRateLimit returns true for first request", () => {
|
||||
const result = checkRateLimit(getUniqueIp());
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("getRateLimitResetTime returns null for unknown IP", () => {
|
||||
const resetTime = getRateLimitResetTime("unknown-ip");
|
||||
expect(resetTime).toBeNull();
|
||||
});
|
||||
|
||||
it("getRateLimitResetTime returns Date for rate limited IP", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
|
||||
// Max out the rate limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createSession(mockIp, `Plan ${i}`);
|
||||
}
|
||||
|
||||
const resetTime = getRateLimitResetTime(mockIp);
|
||||
expect(resetTime).toBeInstanceOf(Date);
|
||||
expect(resetTime!.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe("session TTL", () => {
|
||||
it("sessions expire after TTL", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
|
||||
// Verify session exists
|
||||
expect(getSession(sessionId)).toBeDefined();
|
||||
|
||||
// Advance time by 31 minutes
|
||||
vi.advanceTimersByTime(31 * 60 * 1000);
|
||||
|
||||
// Trigger cleanup by creating a new session
|
||||
await createSession(getUniqueIp(), "Another plan");
|
||||
|
||||
// Note: Session should be expired after cleanup runs
|
||||
// We can't directly verify as cleanup is async
|
||||
});
|
||||
});
|
||||
});
|
||||
544
packages/dashboard/src/planning.ts
Normal file
544
packages/dashboard/src/planning.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Planning Mode Session Management
|
||||
*
|
||||
* Manages AI-guided planning sessions for interactive task creation.
|
||||
* Sessions are stored in-memory with TTL cleanup.
|
||||
*
|
||||
* NOTE: AI Agent integration is stubbed for now. When integrating with
|
||||
* the real AI agent, update createSession and submitResponse to use
|
||||
* createKbAgent from "@kb/engine".
|
||||
*/
|
||||
|
||||
import type {
|
||||
PlanningQuestion,
|
||||
PlanningSummary,
|
||||
PlanningResponse,
|
||||
TaskStore,
|
||||
} from "@kb/core";
|
||||
import { createKbAgent } from "@kb/engine";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Planning system prompt for the AI agent */
|
||||
export const PLANNING_SYSTEM_PROMPT = `You are a planning assistant for the kb task board system.
|
||||
|
||||
Your job: help users transform vague, high-level ideas into well-defined, actionable tasks.
|
||||
|
||||
## Conversation Flow
|
||||
1. User provides a high-level plan (e.g., "Build a user auth system")
|
||||
2. You ask clarifying questions to understand scope, requirements, and constraints
|
||||
3. You present UI-friendly selection options when appropriate
|
||||
4. Once you have enough information, generate a structured summary
|
||||
|
||||
## Question Types to Use
|
||||
- "text": Open-ended follow-up questions for detailed input
|
||||
- "single_select": When user must choose one option (e.g., tech stack preference)
|
||||
- "multi_select": When multiple options can apply (e.g., features to include)
|
||||
- "confirm": Yes/No questions for quick decisions
|
||||
|
||||
## Guidelines
|
||||
- Ask 3-7 questions depending on complexity
|
||||
- Start broad, then narrow down specifics
|
||||
- Suggest sensible defaults based on project context
|
||||
- Keep questions focused and actionable
|
||||
- When asking about file scope, reference actual project structure
|
||||
|
||||
## Summary Generation
|
||||
When ready to complete, generate:
|
||||
- A concise but descriptive title (max 80 chars)
|
||||
- A detailed description with context gathered
|
||||
- Size estimate (S/M/L) based on scope
|
||||
- Any suggested dependencies on existing tasks
|
||||
- Key deliverables as a checklist`;
|
||||
|
||||
/** Session TTL in milliseconds (30 minutes) */
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Cleanup interval in milliseconds (5 minutes) */
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Max planning sessions per IP per hour */
|
||||
const MAX_SESSIONS_PER_IP_PER_HOUR = 5;
|
||||
|
||||
/** Rate limiting window in milliseconds (1 hour) */
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
ip: string;
|
||||
initialPlan: string;
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: PlanningSummary;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
firstRequestAt: Date;
|
||||
}
|
||||
|
||||
// ── In-Memory Storage ───────────────────────────────────────────────────────
|
||||
|
||||
/** Active planning sessions indexed by session ID */
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
/** Rate limiting state indexed by IP */
|
||||
const rateLimits = new Map<string, RateLimitEntry>();
|
||||
|
||||
// ── Cleanup Interval ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove expired sessions and stale rate limit entries.
|
||||
* Runs periodically via setInterval.
|
||||
*/
|
||||
function cleanupExpiredSessions(): void {
|
||||
const now = Date.now();
|
||||
let cleanedSessions = 0;
|
||||
let cleanedRateLimits = 0;
|
||||
|
||||
// Clean up expired sessions
|
||||
for (const [id, session] of sessions) {
|
||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||
sessions.delete(id);
|
||||
cleanedSessions++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale rate limit entries
|
||||
for (const [ip, entry] of rateLimits) {
|
||||
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
|
||||
rateLimits.delete(ip);
|
||||
cleanedRateLimits++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedSessions > 0 || cleanedRateLimits > 0) {
|
||||
console.log(
|
||||
`[planning] Cleanup: removed ${cleanedSessions} sessions, ${cleanedRateLimits} rate limit entries`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Start cleanup interval
|
||||
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("beforeExit", () => {
|
||||
clearInterval(cleanupInterval);
|
||||
});
|
||||
|
||||
// ── Rate Limiting ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if IP can create a new planning session.
|
||||
* Returns true if allowed, false if rate limited.
|
||||
*/
|
||||
export function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = rateLimits.get(ip);
|
||||
|
||||
if (!entry) {
|
||||
// First request from this IP
|
||||
rateLimits.set(ip, {
|
||||
count: 1,
|
||||
firstRequestAt: new Date(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
|
||||
// Reset window
|
||||
rateLimits.set(ip, {
|
||||
count: 1,
|
||||
firstRequestAt: new Date(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Within window - check limit
|
||||
if (entry.count >= MAX_SESSIONS_PER_IP_PER_HOUR) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment count
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit reset time for an IP.
|
||||
* Returns null if no rate limit entry exists.
|
||||
*/
|
||||
export function getRateLimitResetTime(ip: string): Date | null {
|
||||
const entry = rateLimits.get(ip);
|
||||
if (!entry) return null;
|
||||
|
||||
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
|
||||
}
|
||||
|
||||
// ── Planning Session Class ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PlanningSession class for managing AI-guided planning conversations.
|
||||
*
|
||||
* This class encapsulates the planning session state and provides methods
|
||||
* for interacting with the AI agent to generate questions and summaries.
|
||||
*/
|
||||
export class PlanningSession {
|
||||
id: string;
|
||||
ip: string;
|
||||
initialPlan: string;
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: PlanningSummary;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent?: any;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(initialPlan: string, ip: string) {
|
||||
this.id = randomUUID();
|
||||
this.ip = ip;
|
||||
this.initialPlan = initialPlan;
|
||||
this.history = [];
|
||||
this.createdAt = new Date();
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next question from the AI agent based on the initial plan.
|
||||
* Stubbed - will be replaced with AI agent integration.
|
||||
*/
|
||||
async getNextQuestion(): Promise<PlanningQuestion | PlanningSummary> {
|
||||
if (this.history.length === 0) {
|
||||
return generateFirstQuestion(this.initialPlan);
|
||||
}
|
||||
return this.generateNextQuestionOrSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a response and get the next question or summary.
|
||||
* Stubbed - will be replaced with AI agent integration.
|
||||
*/
|
||||
async submitResponse(response: unknown): Promise<PlanningQuestion | PlanningSummary> {
|
||||
if (!this.currentQuestion) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
question: this.currentQuestion,
|
||||
response,
|
||||
});
|
||||
this.updatedAt = new Date();
|
||||
|
||||
return this.generateNextQuestionOrSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the session and cleanup resources.
|
||||
*/
|
||||
dispose(): void {
|
||||
// Cleanup any resources if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next question or summary based on session history.
|
||||
* Stubbed - will be replaced with AI agent integration.
|
||||
*/
|
||||
private generateNextQuestionOrSummary(): PlanningQuestion | PlanningSummary {
|
||||
const historyLength = this.history.length;
|
||||
|
||||
if (historyLength < 2) {
|
||||
return {
|
||||
id: `q-${historyLength + 1}`,
|
||||
type: "text",
|
||||
question: "What are the key requirements or acceptance criteria?",
|
||||
description: "List the specific things that need to be true for this task to be considered complete.",
|
||||
};
|
||||
}
|
||||
|
||||
if (historyLength < 3) {
|
||||
return {
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Are there any specific technologies or libraries that should be used?",
|
||||
description: "Answer yes if you have preferences for specific tech stack choices.",
|
||||
};
|
||||
}
|
||||
|
||||
return this.generateSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary from session history.
|
||||
* Stubbed - will be replaced with AI agent integration.
|
||||
*/
|
||||
private generateSummary(): PlanningSummary {
|
||||
const scopeResponse = this.history.find((h) => h.question.id === "q-scope")?.response as
|
||||
| { scope?: string }
|
||||
| undefined;
|
||||
|
||||
const requirementsResponse = this.history.find((h) => h.question.type === "text")?.response as
|
||||
| { requirements?: string }
|
||||
| undefined;
|
||||
|
||||
const suggestedSize =
|
||||
scopeResponse?.scope === "small" ? "S" : scopeResponse?.scope === "large" ? "L" : "M";
|
||||
|
||||
return {
|
||||
title: this.initialPlan.slice(0, 80),
|
||||
description:
|
||||
`${this.initialPlan}\n\n` +
|
||||
`Requirements: ${requirementsResponse?.requirements || "Standard implementation"}\n\n` +
|
||||
`Generated via Planning Mode`,
|
||||
suggestedSize,
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implementation", "Tests", "Documentation"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stubbed AI Integration (to be replaced with real AI agent) ──────────────
|
||||
|
||||
/**
|
||||
* Generate the first question based on the initial plan.
|
||||
* This is a stub - will be replaced with AI agent.
|
||||
*/
|
||||
function generateFirstQuestion(initialPlan: string): PlanningQuestion {
|
||||
// Simple stub: ask about scope
|
||||
return {
|
||||
id: "q-scope",
|
||||
type: "single_select",
|
||||
question: "What is the scope of this plan?",
|
||||
description: "This helps estimate the size and complexity of the task.",
|
||||
options: [
|
||||
{ id: "small", label: "Small - focused change affecting 1-3 files", description: "Quick implementation" },
|
||||
{ id: "medium", label: "Medium - moderate change affecting 3-10 files", description: "Standard feature" },
|
||||
{ id: "large", label: "Large - significant change affecting 10+ files", description: "Complex feature or refactor" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next question or summary based on session history.
|
||||
* This is a stub - will be replaced with AI agent.
|
||||
*/
|
||||
function generateNextQuestionOrSummary(session: Session): PlanningResponse {
|
||||
const historyLength = session.history.length;
|
||||
|
||||
// Simple stub: ask 2-3 questions then generate summary
|
||||
if (historyLength < 2) {
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
id: `q-${historyLength + 1}`,
|
||||
type: "text",
|
||||
question: "What are the key requirements or acceptance criteria?",
|
||||
description: "List the specific things that need to be true for this task to be considered complete.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (historyLength < 3) {
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Are there any specific technologies or libraries that should be used?",
|
||||
description: "Answer yes if you have preferences for specific tech stack choices.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generate summary after 3 questions
|
||||
return {
|
||||
type: "complete",
|
||||
data: generateSummary(session),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary from session history.
|
||||
* This is a stub - will be replaced with AI agent.
|
||||
*/
|
||||
function generateSummary(session: Session): PlanningSummary {
|
||||
// Simple stub: create summary from initial plan and history
|
||||
const scopeResponse = session.history.find((h) => h.question.id === "q-scope")?.response as
|
||||
| { scope?: string }
|
||||
| undefined;
|
||||
|
||||
const requirementsResponse = session.history.find((h) => h.question.type === "text")?.response as
|
||||
| { requirements?: string }
|
||||
| undefined;
|
||||
|
||||
const suggestedSize =
|
||||
scopeResponse?.scope === "small" ? "S" : scopeResponse?.scope === "large" ? "L" : "M";
|
||||
|
||||
return {
|
||||
title: session.initialPlan.slice(0, 80),
|
||||
description:
|
||||
`${session.initialPlan}\n\n` +
|
||||
`Requirements: ${requirementsResponse?.requirements || "Standard implementation"}\n\n` +
|
||||
`Generated via Planning Mode`,
|
||||
suggestedSize,
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implementation", "Tests", "Documentation"],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Session Management ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new planning session.
|
||||
* Returns session ID and first question (stubbed for now - AI integration in future).
|
||||
*/
|
||||
export async function createSession(
|
||||
ip: string,
|
||||
initialPlan: string,
|
||||
_store?: TaskStore,
|
||||
_rootDir?: string
|
||||
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
|
||||
// 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();
|
||||
|
||||
// Generate first question based on initial plan (stub - AI will do this in future)
|
||||
const firstQuestion = generateFirstQuestion(initialPlan);
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ip,
|
||||
initialPlan,
|
||||
history: [],
|
||||
currentQuestion: firstQuestion,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
return { sessionId, firstQuestion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a response to the current question and get the next question or summary.
|
||||
* Stubbed - AI integration will be implemented in future.
|
||||
*/
|
||||
export async function submitResponse(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>
|
||||
): Promise<PlanningResponse> {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
if (!session.currentQuestion) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
}
|
||||
|
||||
// Record the response
|
||||
session.history.push({
|
||||
question: session.currentQuestion,
|
||||
response: responses,
|
||||
});
|
||||
|
||||
// Generate next question or summary (stub - AI will do this in future)
|
||||
const result = generateNextQuestionOrSummary(session);
|
||||
|
||||
if (result.type === "question") {
|
||||
session.currentQuestion = result.data;
|
||||
} else {
|
||||
session.summary = result.data;
|
||||
session.currentQuestion = undefined;
|
||||
}
|
||||
|
||||
session.updatedAt = new Date();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel and cleanup a planning session.
|
||||
*/
|
||||
export async function cancelSession(sessionId: string): Promise<void> {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session details.
|
||||
*/
|
||||
export function getSession(sessionId: string): Session | undefined {
|
||||
return sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current question for a session.
|
||||
*/
|
||||
export function getCurrentQuestion(sessionId: string): PlanningQuestion | undefined {
|
||||
return sessions.get(sessionId)?.currentQuestion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the summary for a completed session.
|
||||
*/
|
||||
export function getSummary(sessionId: string): PlanningSummary | undefined {
|
||||
return sessions.get(sessionId)?.summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a session (used after task creation).
|
||||
*/
|
||||
export function cleanupSession(sessionId: string): void {
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all planning state. Used for testing only.
|
||||
*/
|
||||
export function __resetPlanningState(): void {
|
||||
sessions.clear();
|
||||
rateLimits.clear();
|
||||
}
|
||||
|
||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SessionNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidSessionStateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InvalidSessionStateError";
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createApiRoutes } from "./routes.js";
|
||||
import type { TaskStore, TaskAttachment } from "@kb/core";
|
||||
import type { TaskDetail } from "@kb/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { __resetPlanningState } from "./planning.js";
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
@@ -2550,4 +2551,355 @@ describe("Git Management endpoints", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Planning Mode Routes", () => {
|
||||
beforeEach(() => {
|
||||
// Reset planning state before each test to avoid cross-test contamination
|
||||
__resetPlanningState();
|
||||
});
|
||||
|
||||
describe("POST /planning/start", () => {
|
||||
it("creates a new planning session", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.sessionId).toBeDefined();
|
||||
expect(typeof res.body.sessionId).toBe("string");
|
||||
expect(res.body.firstQuestion).toBeDefined();
|
||||
expect(res.body.firstQuestion.id).toBe("q-scope");
|
||||
expect(res.body.firstQuestion.type).toBe("single_select");
|
||||
});
|
||||
|
||||
it("requires initialPlan in body", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("initialPlan is required");
|
||||
});
|
||||
|
||||
it("rejects initialPlan longer than 500 chars", async () => {
|
||||
const longPlan = "a".repeat(501);
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: longPlan }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("500 characters");
|
||||
});
|
||||
|
||||
it("enforces rate limiting (5 sessions per hour per IP)", async () => {
|
||||
// Create 5 sessions (should succeed)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: `Plan ${i}` }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
|
||||
// 6th session should be rate limited
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Plan 6" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toContain("Rate limit exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /planning/respond", () => {
|
||||
it("processes response and returns next question", async () => {
|
||||
// First create a session
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(startRes.status).toBe(201);
|
||||
const sessionId = startRes.body.sessionId;
|
||||
|
||||
// Submit a response
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe("question");
|
||||
expect(res.body.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns summary after completing all questions", async () => {
|
||||
// Create a session
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
const sessionId = startRes.body.sessionId;
|
||||
|
||||
// Submit 3 responses to complete the session
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
const finalRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { confirm: true } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(finalRes.status).toBe(200);
|
||||
expect(finalRes.body.type).toBe("complete");
|
||||
expect(finalRes.body.data.title).toBeDefined();
|
||||
expect(finalRes.body.data.description).toBeDefined();
|
||||
expect(finalRes.body.data.suggestedSize).toBeDefined();
|
||||
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it("returns 404 for invalid session ID", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId: "invalid-session-id", responses: {} }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("requires sessionId in body", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ responses: {} }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("sessionId is required");
|
||||
});
|
||||
|
||||
it("requires responses object", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId: "some-id" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("responses is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /planning/cancel", () => {
|
||||
it("cancels an active session", async () => {
|
||||
// Create a session first
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
const sessionId = startRes.body.sessionId;
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/cancel",
|
||||
JSON.stringify({ sessionId }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent session", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/cancel",
|
||||
JSON.stringify({ sessionId: "non-existent-id" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("requires sessionId in body", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/cancel",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("sessionId is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /planning/create-task", () => {
|
||||
it("creates a task from completed planning session", async () => {
|
||||
// Setup mock store for task creation
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-042",
|
||||
description: "Build a user auth system",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
// Create a session and complete it
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
const sessionId = startRes.body.sessionId;
|
||||
|
||||
// Complete the session
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { confirm: true } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
// Create task from planning
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/create-task",
|
||||
JSON.stringify({ sessionId }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 if session is not complete", async () => {
|
||||
// Create a session but don't complete it
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
const sessionId = startRes.body.sessionId;
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/create-task",
|
||||
JSON.stringify({ sessionId }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not complete");
|
||||
});
|
||||
|
||||
it("returns 404 for invalid session ID", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/create-task",
|
||||
JSON.stringify({ sessionId: "invalid-session-id" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("requires sessionId in body", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/create-task",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("sessionId is required");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1893,6 +1893,156 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Planning Mode Routes ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/planning/start
|
||||
* Start a new planning session.
|
||||
* Body: { initialPlan: string }
|
||||
* Returns: { sessionId: string, firstQuestion: PlanningQuestion }
|
||||
*/
|
||||
router.post("/planning/start", async (req, res) => {
|
||||
try {
|
||||
const { initialPlan } = req.body;
|
||||
|
||||
if (!initialPlan || typeof initialPlan !== "string") {
|
||||
res.status(400).json({ error: "initialPlan is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialPlan.length > 500) {
|
||||
res.status(400).json({ error: "initialPlan must be 500 characters or less" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
|
||||
const { createSession, RateLimitError } = await import("./planning.js");
|
||||
const result = await createSession(ip, initialPlan);
|
||||
res.status(201).json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "RateLimitError") {
|
||||
res.status(429).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Failed to start planning session" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/respond
|
||||
* Submit a response to the current planning question.
|
||||
* Body: { sessionId: string, responses: Record<string, unknown> }
|
||||
* Returns: { type: "question" | "complete", data: PlanningQuestion | PlanningSummary }
|
||||
*/
|
||||
router.post("/planning/respond", async (req, res) => {
|
||||
try {
|
||||
const { sessionId, responses } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!responses || typeof responses !== "object") {
|
||||
res.status(400).json({ error: "responses is required and must be an object" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { submitResponse, SessionNotFoundError, InvalidSessionStateError } = await import("./planning.js");
|
||||
const result = await submitResponse(sessionId, responses);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.name === "InvalidSessionStateError") {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Failed to process response" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/cancel
|
||||
* Cancel and cleanup a planning session.
|
||||
* Body: { sessionId: string }
|
||||
*/
|
||||
router.post("/planning/cancel", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { cancelSession, SessionNotFoundError } = await import("./planning.js");
|
||||
await cancelSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Failed to cancel session" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/create-task
|
||||
* Create a task from a completed planning session.
|
||||
* Body: { sessionId: string }
|
||||
* Returns: Created Task
|
||||
*/
|
||||
router.post("/planning/create-task", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSession, getSummary, cleanupSession, SessionNotFoundError } = await import("./planning.js");
|
||||
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: `Planning session ${sessionId} not found or expired` });
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = getSummary(sessionId);
|
||||
if (!summary) {
|
||||
res.status(400).json({ error: "Planning session is not complete" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the task
|
||||
const task = await store.createTask({
|
||||
title: summary.title,
|
||||
description: summary.description,
|
||||
column: "triage",
|
||||
dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined,
|
||||
});
|
||||
|
||||
// Update task with suggested size if provided
|
||||
if (summary.suggestedSize) {
|
||||
await store.updateTask(task.id, { size: summary.suggestedSize });
|
||||
}
|
||||
|
||||
// Log the planning mode creation
|
||||
await store.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${session.initialPlan.slice(0, 200)}`);
|
||||
|
||||
// Cleanup the session
|
||||
cleanupSession(sessionId);
|
||||
|
||||
res.status(201).json(task);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to create task" });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user