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:
gsxdsm
2026-04-10 19:18:20 -07:00
parent 2713616c7d
commit 93bfdc1e76
4 changed files with 256 additions and 7 deletions

View File

@@ -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": []
}
]
}`,
},
};
/**

View File

@@ -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" });

View File

@@ -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) {

View File

@@ -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;