feat(FN-1559): enforce feature status invariants at API boundary
- Guard PATCH /api/missions/features/:featureId to reject status transitions to execution states (triaged, in-progress, done, blocked) when feature has no taskId - 'defined' status remains always allowed (initial state) - Non-status field updates (title, description) are unaffected - Add 6 new tests covering guard behavior and edge cases - All 147 mission-e2e tests passing
This commit is contained in:
@@ -36,7 +36,9 @@ export type PromptKey =
|
||||
| "reviewer-verdict"
|
||||
| "merger-conflicts"
|
||||
| "agent-generation-system"
|
||||
| "workflow-step-refine";
|
||||
| "workflow-step-refine"
|
||||
| "planning-system"
|
||||
| "subtask-breakdown-system";
|
||||
|
||||
/**
|
||||
* Metadata describing a prompt key including its purpose and default content.
|
||||
@@ -251,6 +253,86 @@ The prompt should:
|
||||
|
||||
Output ONLY the prompt text (no markdown, no explanations).`,
|
||||
},
|
||||
"planning-system": {
|
||||
key: "planning-system",
|
||||
name: "Planning System",
|
||||
roles: ["triage"],
|
||||
description: "System prompt for the AI planning assistant that guides users through task definition",
|
||||
defaultContent: `You are a planning assistant for the fn 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
|
||||
|
||||
## 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}`,
|
||||
},
|
||||
"subtask-breakdown-system": {
|
||||
key: "subtask-breakdown-system",
|
||||
name: "Subtask Breakdown System",
|
||||
roles: ["executor"],
|
||||
description: "System prompt for the AI subtask decomposition assistant",
|
||||
defaultContent: `You are a task decomposition assistant for the fn task board system.
|
||||
|
||||
Analyze the user's task description and break it down into 2-5 smaller, independently executable subtasks.
|
||||
|
||||
For each subtask, provide:
|
||||
1. Title (short and descriptive)
|
||||
2. Description (1-2 sentences, implementation-focused)
|
||||
3. Size estimate (S: <2h, M: 2-4h, L: 4-8h)
|
||||
4. Dependencies (which other subtask IDs must be completed first)
|
||||
|
||||
Guidelines:
|
||||
- Prefer parallelizable subtasks when possible
|
||||
- Only add dependencies when truly required
|
||||
- Order subtasks so prerequisites appear earlier
|
||||
- Keep the overall scope aligned with the original task
|
||||
- Use IDs like "subtask-1", "subtask-2", etc.
|
||||
|
||||
Return ONLY valid JSON in this format:
|
||||
{
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "subtask-1",
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"suggestedSize": "S",
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1006,6 +1006,9 @@ describe("Mission API", () => {
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
|
||||
|
||||
// Pre-link the feature to a task so the status transition is allowed
|
||||
missionStore.getFeature.mockReturnValue({ ...feature, taskId: "FN-001" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
@@ -1048,6 +1051,134 @@ describe("Mission API", () => {
|
||||
expect(missionStore.updateFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject status transitions to execution states without taskId", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
|
||||
// Feature has no taskId (taskId is undefined by default)
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/features/${feature.id}`,
|
||||
JSON.stringify({ status: "triaged" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Cannot set status to 'triaged' without a linked task");
|
||||
expect(missionStore.updateFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow status transitions to execution states when taskId is present", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
|
||||
missionStore.getFeature.mockReturnValue({ ...feature, taskId: "FN-001" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/features/${feature.id}`,
|
||||
JSON.stringify({ status: "in-progress" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, {
|
||||
status: "in-progress",
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject 'done' status without taskId", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/features/${feature.id}`,
|
||||
JSON.stringify({ status: "done" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Cannot set status to 'done' without a linked task");
|
||||
expect(missionStore.updateFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject 'blocked' status without taskId", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/features/${feature.id}`,
|
||||
JSON.stringify({ status: "blocked" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Cannot set status to 'blocked' without a linked task");
|
||||
expect(missionStore.updateFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow 'defined' status without taskId", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
|
||||
// Feature has no taskId, but "defined" is always allowed
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/features/${feature.id}`,
|
||||
JSON.stringify({ status: "defined" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, {
|
||||
status: "defined",
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow non-status field updates without taskId", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
|
||||
// Updating title/description should be allowed without taskId
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/features/${feature.id}`,
|
||||
JSON.stringify({ title: "Updated Title", description: "New description" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, {
|
||||
title: "Updated Title",
|
||||
description: "New description",
|
||||
});
|
||||
});
|
||||
|
||||
it("should link feature to task", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
|
||||
@@ -1500,6 +1500,27 @@ export function createMissionRouter(
|
||||
throw badRequest("Invalid feature ID format");
|
||||
}
|
||||
|
||||
// Fetch existing feature to check invariants
|
||||
const existing = missionStore.getFeature(featureId);
|
||||
if (!existing) {
|
||||
throw notFound("Feature not found");
|
||||
}
|
||||
|
||||
// Guard: Reject status transitions to execution states without a linked task.
|
||||
// Features in "triaged", "in-progress", "done", or "blocked" must have a taskId.
|
||||
// "defined" status is allowed without a taskId (the initial state).
|
||||
if (status !== undefined) {
|
||||
const targetStatus = validateStatus(status, FEATURE_STATUSES) as FeatureStatus;
|
||||
const EXECUTION_STATUSES: FeatureStatus[] = ["triaged", "in-progress", "done", "blocked"];
|
||||
if (EXECUTION_STATUSES.includes(targetStatus) && !existing.taskId) {
|
||||
throw badRequest(
|
||||
`Cannot set status to '${targetStatus}' without a linked task. ` +
|
||||
"Use the triage endpoint to create and link a task first, or link an existing task via " +
|
||||
`POST /api/missions/features/${featureId}/link-task.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Partial<MissionFeature> = {};
|
||||
|
||||
if (title !== undefined) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
PlanningResponse,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { resolvePrompt, type PromptOverrideMap } from "@fusion/core";
|
||||
import type { SubtaskItem } from "./subtask-breakdown.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
@@ -537,7 +538,8 @@ export async function createSession(
|
||||
ip: string,
|
||||
initialPlan: string,
|
||||
_store?: TaskStore,
|
||||
rootDir?: string
|
||||
rootDir?: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
|
||||
// Check rate limit
|
||||
if (!checkRateLimit(ip)) {
|
||||
@@ -568,6 +570,9 @@ export async function createSession(
|
||||
sessions.set(sessionId, session);
|
||||
persistSession(session, "generating");
|
||||
|
||||
// Resolve the effective system prompt (override or default)
|
||||
const systemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
|
||||
|
||||
// Create AI agent and get the first question
|
||||
// Only await engineReady if createKbAgent hasn't been set externally (e.g., via __setCreateKbAgent)
|
||||
if (!createKbAgent) {
|
||||
@@ -576,7 +581,7 @@ export async function createSession(
|
||||
|
||||
const agentResult = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: PLANNING_SYSTEM_PROMPT,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
onThinking: () => {
|
||||
// Non-streaming path ignores thinking output
|
||||
@@ -709,6 +714,9 @@ async function getFirstQuestionFromAgent(
|
||||
* @param ip - Client IP for rate limiting
|
||||
* @param initialPlan - The user's initial plan description
|
||||
* @param rootDir - Project root directory for AI agent context
|
||||
* @param modelProvider - Optional AI model provider override
|
||||
* @param modelId - Optional AI model ID override
|
||||
* @param promptOverrides - Optional prompt override map for system prompt customization
|
||||
* @returns Session ID (use with planningStreamManager to receive events)
|
||||
*/
|
||||
export async function createSessionWithAgent(
|
||||
@@ -717,6 +725,7 @@ export async function createSessionWithAgent(
|
||||
rootDir: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<string> {
|
||||
// Check rate limit
|
||||
if (!checkRateLimit(ip)) {
|
||||
@@ -744,7 +753,7 @@ export async function createSessionWithAgent(
|
||||
persistSession(session, "generating");
|
||||
|
||||
// Initialize AI agent in background - it will stream via planningStreamManager
|
||||
initializeAgent(session, rootDir, modelProvider, modelId).catch((err) => {
|
||||
initializeAgent(session, rootDir, modelProvider, modelId, promptOverrides).catch((err) => {
|
||||
console.error(`[planning] Failed to initialize agent for session ${sessionId}:`, err);
|
||||
persistSession(session, "error", undefined, err.message || "Failed to initialize AI agent");
|
||||
planningStreamManager.broadcast(sessionId, {
|
||||
@@ -764,9 +773,10 @@ async function initializeAgent(
|
||||
rootDir: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<void> {
|
||||
try {
|
||||
session.agent = await createPlanningAgent(session, rootDir, modelProvider, modelId);
|
||||
session.agent = await createPlanningAgent(session, rootDir, modelProvider, modelId, promptOverrides);
|
||||
session.updatedAt = new Date();
|
||||
|
||||
// Send initial message to get first question
|
||||
@@ -789,13 +799,17 @@ async function createPlanningAgent(
|
||||
rootDir: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<AgentResult> {
|
||||
// Ensure engine is loaded before using createKbAgent
|
||||
await engineReady;
|
||||
|
||||
// Resolve the effective system prompt (override or default)
|
||||
const systemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
|
||||
|
||||
return createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: PLANNING_SYSTEM_PROMPT,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
@@ -837,6 +851,7 @@ async function ensureSessionAgent(
|
||||
session: Session,
|
||||
rootDir: string | undefined,
|
||||
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<void> {
|
||||
if (session.agent) {
|
||||
return;
|
||||
@@ -848,7 +863,7 @@ async function ensureSessionAgent(
|
||||
);
|
||||
}
|
||||
|
||||
session.agent = await createPlanningAgent(session, rootDir);
|
||||
session.agent = await createPlanningAgent(session, rootDir, undefined, undefined, promptOverrides);
|
||||
|
||||
if (historyForReplay.length === 0) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user