feat(FN-4124): surface mission interview drafts across mission tooling

- Add dashboard API routes and mission interview store support to list and inspect draft missions
- Update Mission Manager UI to surface draft interview sessions with styling and regression coverage
- Extend CLI mission commands and extension tools to expose draft listings consistently
- Document the draft surfacing behavior and include a changeset for the published CLI package

Fusion-Task-Id: FN-4124
This commit is contained in:
Fusion
2026-05-12 19:38:36 -07:00
committed by gsxdsm
parent e6b1108067
commit af39d474d5
19 changed files with 912 additions and 26 deletions

View File

@@ -155,13 +155,22 @@ function createMockMissionStore(overrides = {}) {
};
}
function createMockDatabase(drafts: Array<{ id: string; title: string; status: string; updatedAt: string }> = []) {
return {
prepare: vi.fn().mockReturnValue({
all: vi.fn().mockReturnValue(drafts),
}),
};
}
function mockResolvedProjectStore(
missionStore: ReturnType<typeof createMockMissionStore>,
overrides: Partial<{ getTask: ReturnType<typeof vi.fn> }> = {},
overrides: Partial<{ getTask: ReturnType<typeof vi.fn>; getDatabase: ReturnType<typeof createMockDatabase> }> = {},
) {
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => missionStore,
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
getDatabase: () => createMockDatabase(),
...overrides,
} as any);
}
@@ -281,9 +290,7 @@ describe("mission commands", () => {
describe("runMissionList", () => {
it("displays missions in formatted output", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
mockResolvedProjectStore(mockMissionStore);
const consoleCapture = captureConsole();
@@ -313,9 +320,7 @@ describe("mission commands", () => {
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([]),
});
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
mockResolvedProjectStore(mockMissionStore);
const consoleCapture = captureConsole();
@@ -337,6 +342,102 @@ describe("mission commands", () => {
consoleCapture.restore();
}
});
it("shows drafts before mission status sections when present", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([
{
id: "draft-1",
title: "Draft mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList();
} catch {
// expected
}
const joined = consoleCapture.logs.join("\n");
expect(joined).toContain("◌ Drafts (1)");
expect(joined).toContain("draft-1 Draft mission — (draft · interview awaiting_input)");
expect(joined.indexOf("◌ Drafts (1)")).toBeLessThan(joined.indexOf("● Active (1)"));
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
it("suppresses drafts when includeDrafts is false", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([
{
id: "draft-1",
title: "Draft mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList(undefined, { includeDrafts: false });
} catch {
// expected
}
expect(consoleCapture.logs.join("\n")).not.toContain("Drafts");
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
it("omits drafts heading when no drafts exist", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList();
} catch {
// expected
}
expect(consoleCapture.logs.join("\n")).not.toContain("Drafts");
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
});
describe("runMissionShow", () => {

View File

@@ -93,22 +93,47 @@ export async function runMissionCreate(titleArg?: string, descriptionArg?: strin
console.log();
}
interface RunMissionListOptions {
includeDrafts?: boolean;
}
/**
* List all missions with status summary.
*/
export async function runMissionList(projectName?: string) {
export async function runMissionList(projectName?: string, options: RunMissionListOptions = {}) {
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
const includeDrafts = options.includeDrafts ?? true;
const missions = missionStore.listMissions();
const drafts = includeDrafts
? (store.getDatabase()
.prepare(
`SELECT id, title, status, updatedAt
FROM ai_sessions
WHERE type = 'mission_interview'
AND status IN ('generating', 'awaiting_input', 'error')
AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`,
)
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>)
: [];
if (missions.length === 0) {
if (missions.length === 0 && drafts.length === 0) {
console.log("\n No missions yet. Create one with: fn mission create\n");
process.exit(0);
}
console.log();
if (drafts.length > 0) {
console.log(` ◌ Drafts (${drafts.length})`);
for (const draft of drafts) {
console.log(`${draft.id} ${draft.title} — (draft · interview ${draft.status})`);
}
console.log();
}
// Group by status
const byStatus: Record<string, typeof missions> = {};
for (const mission of missions) {