feat(FN-1674): add roadmap export and handoff system

- Add RoadmapStore with read APIs for project-scoped roadmap data
- Add roadmap-handoff mapper to transform roadmap data for export
- Add project-scoped handoff API route (/api/projects/:id/roadmap/handoff)
- Add useRoadmaps hook for fetching and exposing roadmap data to components
- Update RoadmapsView with export/handoff UX path and roadmap detail view
- Add roadmap routes with project-scoped handoff endpoint
- Add comprehensive tests for handoff mapper and roadmap routes
- Update architecture.md and add dashboard-guide.md documentation
This commit is contained in:
Fusion
2026-04-15 17:06:36 -07:00
committed by gsxdsm
parent 9d42dbe93c
commit 07b406d412
12 changed files with 1151 additions and 6 deletions

View File

@@ -4600,6 +4600,17 @@ export function getRoadmapFeatureHandoff(
);
}
/** Combined handoff response type for roadmap handoff endpoint */
export interface RoadmapHandoffResponse {
mission: RoadmapMissionPlanningHandoff;
features: RoadmapFeatureTaskPlanningHandoff[];
}
/** Get both mission and feature handoff payloads for a roadmap */
export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise<RoadmapHandoffResponse> {
return api<RoadmapHandoffResponse>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/handoff`, projectId));
}
/** Response from milestone suggestion generation */
export interface MilestoneSuggestionsResponse {
suggestions: Array<{

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles } from "lucide-react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader } from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
import type {
@@ -12,6 +12,8 @@ import type {
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion/core";
export interface RoadmapsViewProps {
@@ -64,6 +66,113 @@ interface CreateFormState {
description: string;
}
// ── Handoff Modal Types ─────────────────────────────────────────────
interface HandoffModalProps {
isOpen: boolean;
onClose: () => void;
roadmapId: string;
roadmapTitle: string;
handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
isLoading: boolean;
error: Error | null;
onFetchHandoff: () => void;
onCopyToClipboard: () => void;
}
// ── Handoff Modal Component ─────────────────────────────────────────
function HandoffModal({
isOpen,
onClose,
roadmapTitle,
handoffPayload,
isLoading,
error,
onFetchHandoff,
onCopyToClipboard,
}: HandoffModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={onClose} role="presentation">
<div className="modal modal-lg" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="handoff-modal-title">
<div className="modal-header">
<h2 id="handoff-modal-title">Export Roadmap: {roadmapTitle}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close modal">
<X size={18} />
</button>
</div>
<div className="modal-body">
<p className="text-muted" style={{ marginBottom: "var(--space-lg)" }}>
Export roadmap data for use in mission and task planning flows.
This is a read-only export no missions or tasks will be created.
</p>
{error && (
<div className="form-error" style={{ marginBottom: "var(--space-lg)" }}>
Error loading handoff data: {error.message}
</div>
)}
{!handoffPayload && !isLoading && (
<div style={{ textAlign: "center", padding: "var(--space-xl)" }}>
<button className="btn btn-primary" onClick={onFetchHandoff}>
<Download size={16} style={{ marginRight: "var(--space-sm)" }} />
Load Handoff Data
</button>
</div>
)}
{isLoading && (
<div style={{ textAlign: "center", padding: "var(--space-xl)" }}>
<Loader size={24} className="spin" />
<p style={{ marginTop: "var(--space-md)" }}>Loading handoff data...</p>
</div>
)}
{handoffPayload && (
<>
<div style={{ marginBottom: "var(--space-lg)" }}>
<h3 style={{ marginBottom: "var(--space-sm)" }}>Mission Planning Handoff</h3>
<div className="card" style={{ padding: "var(--space-md)" }}>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "12px", maxHeight: "200px", overflow: "auto" }}>
{JSON.stringify(handoffPayload.mission, null, 2)}
</pre>
</div>
</div>
<div style={{ marginBottom: "var(--space-lg)" }}>
<h3 style={{ marginBottom: "var(--space-sm)" }}>
Feature Task Planning Handoffs ({handoffPayload.features.length})
</h3>
<div className="card" style={{ padding: "var(--space-md)" }}>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "12px", maxHeight: "300px", overflow: "auto" }}>
{JSON.stringify(handoffPayload.features, null, 2)}
</pre>
</div>
</div>
</>
)}
</div>
<div className="modal-actions">
<div className="modal-actions-left">
{handoffPayload && (
<button className="btn btn-sm" onClick={onCopyToClipboard}>
<Copy size={14} style={{ marginRight: "var(--space-xs)" }} />
Copy to Clipboard
</button>
)}
</div>
<div className="modal-actions-right">
<button className="btn" onClick={onClose}>Close</button>
</div>
</div>
</div>
</div>
);
}
// ── Roadmap Item ─────────────────────────────────────────────────────
function RoadmapItem({
@@ -72,12 +181,14 @@ function RoadmapItem({
onSelect,
onEdit,
onDelete,
onExport,
}: {
roadmap: Roadmap;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
onExport: () => void;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
@@ -95,6 +206,11 @@ function RoadmapItem({
onDelete();
};
const handleExportClick = (e: React.MouseEvent) => {
e.stopPropagation();
onExport();
};
return (
<div
className={`roadmaps-view__sidebar-item${isSelected ? " roadmaps-view__sidebar-item--active" : ""}`}
@@ -112,6 +228,17 @@ function RoadmapItem({
)}
</div>
<div className="roadmaps-view__sidebar-item-actions" onClick={handleEditClick} role="presentation">
<span
className="roadmaps-view__icon-btn"
onClick={handleExportClick}
role="button"
title="Export roadmap"
aria-label="Export roadmap"
data-testid={`roadmap-export-${roadmap.id}`}
tabIndex={0}
>
<Download size={14} />
</span>
<span
className="roadmaps-view__icon-btn"
onClick={handleEditClick}
@@ -1127,8 +1254,18 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
acceptFeatureSuggestion,
acceptAllFeatureSuggestions,
clearFeatureSuggestions,
handoffPayload,
isFetchingHandoff,
handoffError,
fetchHandoff,
clearHandoff,
} = useRoadmaps({ projectId });
// Handoff modal state
const [handoffModalOpen, setHandoffModalOpen] = useState(false);
const [handoffRoadmapId, setHandoffRoadmapId] = useState<string | null>(null);
const [handoffRoadmapTitle, setHandoffRoadmapTitle] = useState<string>("");
// Goal prompt state for milestone suggestion generation
const [goalPrompt, setGoalPrompt] = useState("");
@@ -1460,6 +1597,41 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
[deleteRoadmap, addToast]
);
// Handoff handlers
const handleOpenHandoffModal = useCallback((roadmapId: string, roadmapTitle: string) => {
setHandoffRoadmapId(roadmapId);
setHandoffRoadmapTitle(roadmapTitle);
setHandoffModalOpen(true);
// Clear any previous handoff data
clearHandoff();
}, [clearHandoff]);
const handleCloseHandoffModal = useCallback(() => {
setHandoffModalOpen(false);
setHandoffRoadmapId(null);
setHandoffRoadmapTitle("");
clearHandoff();
}, [clearHandoff]);
const handleFetchHandoff = useCallback(() => {
if (handoffRoadmapId) {
fetchHandoff(handoffRoadmapId, {
onError: (err) => addToast(`Failed to load handoff: ${err.message}`, "error"),
});
}
}, [handoffRoadmapId, fetchHandoff, addToast]);
const handleCopyHandoffToClipboard = useCallback(() => {
if (handoffPayload) {
const data = JSON.stringify(handoffPayload, null, 2);
navigator.clipboard.writeText(data).then(() => {
addToast("Handoff data copied to clipboard", "success");
}).catch(() => {
addToast("Failed to copy to clipboard", "error");
});
}
}, [handoffPayload, addToast]);
const handleCreateRoadmap = useCallback(
async (input: RoadmapCreateInput) => {
try {
@@ -1758,6 +1930,7 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
onSelect={() => selectRoadmap(roadmap.id)}
onEdit={() => handleStartRoadmapEdit(roadmap)}
onDelete={() => handleDeleteRoadmap(roadmap.id)}
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
/>
))
)}
@@ -2015,6 +2188,19 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
/>
</div>
)}
{/* Handoff export modal */}
<HandoffModal
isOpen={handoffModalOpen}
onClose={handleCloseHandoffModal}
roadmapId={handoffRoadmapId || ""}
roadmapTitle={handoffRoadmapTitle}
handoffPayload={handoffPayload}
isLoading={isFetchingHandoff}
error={handoffError}
onFetchHandoff={handleFetchHandoff}
onCopyToClipboard={handleCopyHandoffToClipboard}
/>
</div>
);
}

View File

@@ -41,6 +41,9 @@ vi.mock("lucide-react", () => ({
X: (props: unknown) => <span data-testid="x-icon" {...props}>X</span>,
GripVertical: (props: unknown) => <span data-testid="grip-icon" {...props}>Grip</span>,
Sparkles: (props: unknown) => <span data-testid="sparkles-icon" {...props}>Sparkles</span>,
Download: (props: unknown) => <span data-testid="download-icon" {...props}>Download</span>,
Copy: (props: unknown) => <span data-testid="copy-icon" {...props}>Copy</span>,
Loader: (props: unknown) => <span data-testid="loader-icon" {...props}>Loader</span>,
}));
const mockRoadmaps: Roadmap[] = [

View File

@@ -10,6 +10,8 @@ import type {
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapWithHierarchy,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion/core";
import * as api from "../api";
@@ -132,6 +134,18 @@ export interface UseRoadmapsResult {
/** Clear pending feature suggestions for a specific milestone */
clearFeatureSuggestions: (milestoneId: string) => void;
// Handoff / Export callbacks
/** Current handoff payload (mission + feature handoffs) */
handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
/** Whether handoff is currently being fetched */
isFetchingHandoff: boolean;
/** Error from the last handoff fetch attempt */
handoffError: Error | null;
/** Fetch handoff payload for a roadmap */
fetchHandoff: (roadmapId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Clear the current handoff payload */
clearHandoff: () => void;
/** Refresh all roadmaps */
refresh: () => Promise<void>;
}
@@ -146,6 +160,11 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Handoff state
const [handoffPayload, setHandoffPayload] = useState<{ mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null>(null);
const [isFetchingHandoff, setIsFetchingHandoff] = useState(false);
const [handoffError, setHandoffError] = useState<Error | null>(null);
// Ephemeral milestone suggestion state (in-memory only, not persisted)
const [milestoneSuggestions, setMilestoneSuggestions] = useState<MilestoneSuggestion[]>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
@@ -167,18 +186,22 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
const previousProjectIdRef = useRef<string | undefined>(projectId);
// Project context version for stale-response protection
const projectContextVersionRef = useRef(0);
// Handoff fetch version for stale-response discard
const handoffFetchVersionRef = useRef(0);
// Refs to access latest state in callbacks
const roadmapsRef = useRef(roadmaps);
const selectedRoadmapIdRef = useRef(selectedRoadmapId);
const milestonesRef = useRef(milestones);
const featuresByMilestoneIdRef = useRef(featuresByMilestoneId);
const projectIdRef = useRef(projectId);
const handoffPayloadRef = useRef(handoffPayload);
roadmapsRef.current = roadmaps;
selectedRoadmapIdRef.current = selectedRoadmapId;
milestonesRef.current = milestones;
featuresByMilestoneIdRef.current = featuresByMilestoneId;
projectIdRef.current = projectId;
handoffPayloadRef.current = handoffPayload;
// Clear selection and suggestions when project changes
useEffect(() => {
@@ -189,6 +212,9 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
setSelectedRoadmap(null);
setMilestones([]);
setFeaturesByMilestoneId({});
// Clear handoff state
setHandoffPayload(null);
setHandoffError(null);
// Clear ephemeral suggestion state
setMilestoneSuggestions([]);
setIsGeneratingSuggestions(false);
@@ -1046,6 +1072,52 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
});
}, []);
// ── Handoff / Export Functions ────────────────────────────────────────
const fetchHandoff = useCallback(async (
roadmapId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
const requestVersion = ++handoffFetchVersionRef.current;
const requestProjectId = projectId; // Capture projectId at request time
setIsFetchingHandoff(true);
setHandoffError(null);
try {
const data = await api.fetchRoadmapHandoff(roadmapId, requestProjectId);
// Reject stale responses: check if project changed or version is stale
if (handoffFetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
return; // Stale response, discard
}
setHandoffPayload(data);
opts?.onSuccess?.();
} catch (err) {
// Reject stale errors: check if project changed or version is stale
if (handoffFetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
return; // Stale error, discard
}
const error = err instanceof Error ? err : new Error(String(err));
setHandoffError(error);
setHandoffPayload(null);
opts?.onError?.(error);
} finally {
// Only clear loading if this is still the current request
if (handoffFetchVersionRef.current === requestVersion) {
setIsFetchingHandoff(false);
}
}
}, [projectId]);
const clearHandoff = useCallback(() => {
setHandoffPayload(null);
setHandoffError(null);
setIsFetchingHandoff(false);
}, []);
const refresh = useCallback(async () => {
await fetchRoadmaps();
if (selectedRoadmapIdRef.current) {
@@ -1088,6 +1160,11 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
acceptFeatureSuggestion,
acceptAllFeatureSuggestions,
clearFeatureSuggestions,
handoffPayload,
isFetchingHandoff,
handoffError,
fetchHandoff,
clearHandoff,
refresh,
};
}

View File

@@ -171,6 +171,51 @@ function createMockRoadmapStore(): RoadmapStore {
description: feature.description,
};
}),
getMissionPlanningHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: ms.map((m) => {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceMilestoneId: m.id,
title: m.title,
description: m.description,
orderIndex: m.orderIndex,
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
};
}),
};
}),
listFeatureTaskPlanningHandoffs: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const handoffs = [];
for (const m of ms) {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
for (const f of fs) {
handoffs.push({
source: {
roadmapId: roadmap.id,
milestoneId: m.id,
featureId: f.id,
roadmapTitle: roadmap.title,
milestoneTitle: m.title,
milestoneOrderIndex: m.orderIndex,
featureOrderIndex: f.orderIndex,
},
title: f.title,
description: f.description,
});
}
}
return handoffs;
}),
} as unknown as RoadmapStore;
}
@@ -190,6 +235,19 @@ describe("Roadmap Routes", () => {
app = express();
app.use(express.json());
app.use("/api/roadmaps", createRoadmapRouter(mockStore));
// Add error handler for tests that check 404 responses
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
if (err instanceof Error) {
res.status(500).json({ error: err.message });
return;
}
res.status(500).json({ error: "Internal server error" });
});
});
afterEach(() => {
@@ -344,6 +402,68 @@ describe("Roadmap Routes", () => {
});
});
describe("GET /api/roadmaps/:roadmapId/handoff", () => {
it("returns both mission and feature handoffs", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Combined Handoff" });
const milestone1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const milestone2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 2" });
const feature1 = mockRoadmapStore.createFeature(milestone1.id, { title: "Feature A" });
const feature2 = mockRoadmapStore.createFeature(milestone2.id, { title: "Feature B" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
// Verify mission handoff structure
expect(response.body.mission).toBeDefined();
expect(response.body.mission.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.mission.title).toBe("Combined Handoff");
expect(response.body.mission.milestones).toHaveLength(2);
// Verify feature handoffs structure
expect(response.body.features).toBeDefined();
expect(response.body.features).toHaveLength(2);
expect(response.body.features[0].title).toBe("Feature A");
expect(response.body.features[0].source.milestoneId).toBe(milestone1.id);
expect(response.body.features[1].title).toBe("Feature B");
expect(response.body.features[1].source.milestoneId).toBe(milestone2.id);
});
it("returns empty features array when roadmap has no features", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Empty Handoff" });
mockRoadmapStore.createMilestone(roadmap.id, { title: "Empty Phase" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
expect(response.body.features).toHaveLength(0);
});
it("returns 404 when roadmap not found", async () => {
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff");
expect(response.status).toBe(404);
});
it("returns 404 for cross-project isolation", async () => {
// Create roadmap in default store
const roadmap = mockRoadmapStore.createRoadmap({ title: "Isolated Roadmap" });
// Mock a different project store that returns no roadmap
mockGetOrCreateProjectStore.mockResolvedValueOnce({
getRoadmapStore: vi.fn(() => ({
getMissionPlanningHandoff: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
listFeatureTaskPlanningHandoffs: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
})),
getRootDir: vi.fn(() => "/test/root"),
});
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff?projectId=other-project");
expect(response.status).toBe(404);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff/mission", () => {
it("returns mission handoff payload", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });

View File

@@ -2,7 +2,8 @@
* Roadmap REST API Routes
*
* Provides CRUD endpoints for standalone roadmaps, milestones, and features.
* Also includes AI-powered suggestion endpoints for milestone and feature creation.
* Also includes AI-powered suggestion endpoints for milestone and feature creation,
* and read-only handoff endpoints for exporting roadmap data to mission/task planning.
*
* Endpoints:
* - Roadmaps: GET /, POST /, GET /:id, PATCH /:id, DELETE /:id
@@ -16,6 +17,9 @@
* POST /features/:id/move
* - Suggestions: POST /:roadmapId/suggestions/milestones,
* POST /milestones/:milestoneId/suggestions/features
* - Export/Handoff: GET /:roadmapId/export, GET /:roadmapId/handoff,
* GET /:roadmapId/handoff/mission,
* GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
*/
import { Router, type Request, type Response } from "express";
@@ -594,6 +598,36 @@ export function createRoadmapRouter(store: TaskStore): Router {
}
});
/**
* GET /api/roadmaps/:roadmapId/handoff
* Get both mission-oriented and task-oriented handoff payloads for the roadmap.
*
* This is a convenience endpoint that combines both handoff types in a single response.
* Returns 404 if the roadmap is not found.
*/
router.get("/:roadmapId/handoff", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
// Get both handoff types
const missionHandoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
const featureHandoffs = roadmapStore.listFeatureTaskPlanningHandoffs(roadmapId);
res.json({
mission: missionHandoff,
features: featureHandoffs,
});
} catch (err) {
if (err instanceof ApiError) throw err;
// Handle not-found case from store methods
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err, "Failed to generate handoff");
}
});
/**
* GET /api/roadmaps/:roadmapId/handoff/mission
* Get a mission planning handoff payload for the roadmap.
@@ -603,10 +637,14 @@ export function createRoadmapRouter(store: TaskStore): Router {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const handoff = roadmapStore.getRoadmapMissionHandoff(roadmapId);
const handoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
res.json(handoff);
} catch (err) {
if (err instanceof ApiError) throw err;
// Handle not-found case from store methods
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err, "Failed to generate mission handoff");
}
});