FN-8288: add native structure preview foundation

Provide reusable, typed previews for native dashboard structures.

- Define five-kind native structure references and preview payloads.
- Add project-scoped preview resolution API with unavailable states and capped excerpts.
- Add a callback-driven inline preview card, tests, documentation, and release note.

Files changed:
 .changeset/fn-8288-native-structure-preview.md     |   7 ++
 docs/dashboard-guide.md                            |   4 +
 packages/core/src/index.gate.ts                    |   2 +-
 packages/core/src/index.ts                         |   1 +
 packages/core/src/types.ts                         |  54 ++++++++++
 packages/dashboard/app/api/legacy.ts               |   1 +
 packages/dashboard/app/api/task-content.ts         |  13 ++-
 .../app/components/NativeStructurePreview.css      |  61 +++++++++++
 .../app/components/NativeStructurePreview.tsx      | 115 +++++++++++++++++++++
 .../__tests__/NativeStructurePreview.test.tsx      | 102 ++++++++++++++++++
 packages/dashboard/src/native-structure-preview.ts |  88 ++++++++++++++++
 .../native-structure-preview-routes.test.ts        | 102 ++++++++++++++++++
 .../src/routes/register-task-workflow-routes.ts    |  23 +++++
 13 files changed, 571 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-8288

Fusion-Task-Lineage: 1a4ce369-9f9e-4a45-b533-2d53b5f1020c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 18:11:02 -07:00
parent ed2ffc4885
commit e2b5532290
13 changed files with 571 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add reusable native structure preview payloads and dashboard cards.
category: feature
dev: Exposes five-kind NativeStructureRef contracts and a callback-based open target for chat and mail consumers.

View File

@@ -1880,6 +1880,10 @@ The dashboard's CSS is split into a global stylesheet (`packages/dashboard/app/s
**Rule:** New CSS for a component goes in `app/components/ComponentName.css`, NOT `styles.css`. Only design tokens, primitives (`.btn`, `.card`, `.modal`, `.form-input`), and cross-component `@media` overrides belong in the global file.
### Native structure previews
`NativeStructurePreview` is the shared compact card for mission, milestone, research-finding, eval-result, and goal references. It resolves `GET /api/native-structures/:kind/:id/preview` to a typed available or unavailable payload and uses a required consumer-supplied `onOpen(ref, payload)` callback. `openTarget` is a view-state descriptor, not a URL, because dashboard navigation is callback based. `roadmap-item` is intentionally deferred until its plugin provides a backend-safe reader and dashboard destination.
PR tab note: `PrPanel` cards use tokenized `.pr-card` grid spacing (`padding` + `gap`) and boxed token-based hint callouts for empty/loading states. Manual PR merges now show in-progress feedback (`Merging…` button state + status hint) until the merge call resolves.
The `index.html` shell is templated server-side: the server injects a per-user `<link rel="modulepreload">` for the last-used `taskView` chunk, sourced from Vite's `dist/client/.vite/manifest.json` and `kb:<projectId>:kb-dashboard-task-view` in localStorage.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -866,6 +866,60 @@ export function isReviewArtifactGenerationEligible(
return mode === "on" || (mode === "user-facing" && classification === "user-facing");
}
/**
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
* Chat and mail share this compact reference contract so their consumers never invent
* incompatible structure identifiers. `roadmap-item` remains a deferred future kind until
* its plugin exposes a PostgreSQL-safe read adapter and a restored dashboard destination.
*/
export interface NativeStructureRef {
kind: "mission" | "milestone" | "research-finding" | "eval-result" | "goal";
id: string;
projectId?: string;
}
/**
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
* Dashboard destinations are callback/view-state based rather than HTML routes. Consumers use
* this stable descriptor with their navigation callback; it is intentionally not a URL.
*/
export interface NativeStructureOpenTarget {
view: "missions" | "insights" | "evals" | "goals";
id: string;
missionId?: string;
}
/**
* FNXC:NativeStructureEmbed 2026-07-18-18:15:
* A previewable native structure projected by the dashboard read layer.
*/
export interface NativeStructurePreviewPayload {
available: true;
kind: NativeStructureRef["kind"];
kindLabel: string;
title: string;
excerpt: string;
openTarget: NativeStructureOpenTarget;
}
/**
* FNXC:NativeStructureEmbed 2026-07-18-18:15:
* A native structure whose existing lifecycle state makes it unavailable for preview.
*/
export interface NativeStructureUnavailablePayload {
available: false;
kind: NativeStructureRef["kind"];
id: string;
reason: "missing" | "soft-deleted";
}
/**
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
* Unavailability is a typed result so shared consumers show a safe placeholder instead of
* crashing. Eval results have no archive lifecycle and therefore only return `missing`.
*/
export type NativeStructurePreviewResult = NativeStructurePreviewPayload | NativeStructureUnavailablePayload;
/**
* Goal-citation Slice 2 success-signal surfaces where goal IDs are extracted.
*/

View File

@@ -254,6 +254,7 @@ export {
artifactMediaUrl,
artifactMediaUrlWithToken,
fetchArtifact,
fetchNativeStructurePreview,
updateArtifact,
fetchAllDocuments,
fetchProjectMarkdownFiles,

View File

@@ -12,6 +12,8 @@ import type {
Artifact,
ArtifactType,
ArtifactWithTask,
NativeStructureRef,
NativeStructurePreviewResult,
AgentLogEntry,
} from "@fusion/core";
import { appendTokenQuery, withTokenHeader } from "../auth";
@@ -206,6 +208,16 @@ export async function fetchArtifact(id: string, projectId?: string): Promise<Art
return api<Artifact>(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId));
}
/**
* FNXC:NativeStructureEmbed 2026-07-18-18:15:
* Fetch the shared compact projection for an in-app native structure reference.
*/
export async function fetchNativeStructurePreview(ref: NativeStructureRef): Promise<NativeStructurePreviewResult> {
return api<NativeStructurePreviewResult>(
withProjectId(`/native-structures/${encodeURIComponent(ref.kind)}/${encodeURIComponent(ref.id)}/preview`, ref.projectId),
);
}
export interface UpdateArtifactInput {
title?: string;
description?: string;
@@ -274,4 +286,3 @@ export function deleteTaskDocument(taskId: string, key: string, projectId?: stri
method: "DELETE",
});
}

View File

@@ -0,0 +1,61 @@
/* FNXC:NativeStructureEmbed 2026-07-16-12:00: Keep the shared chat/mail card compact and token-driven while preserving a reachable consumer-owned open control on narrow screens. */
.native-structure-preview {
align-items: start;
background: var(--bg-secondary);
border: solid var(--border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-sm);
grid-template-columns: auto minmax(0, 1fr) auto;
padding: var(--space-md);
}
.native-structure-preview > svg {
color: var(--accent);
}
.native-structure-preview__content {
min-width: 0;
}
.native-structure-preview__label {
color: var(--text-muted);
font-size: var(--font-size-xs);
text-transform: uppercase;
}
.native-structure-preview__title,
.native-structure-preview__excerpt {
display: block;
overflow-wrap: anywhere;
}
.native-structure-preview__excerpt {
color: var(--text-muted);
font-size: var(--font-size-sm);
margin: var(--space-xs) 0 0;
}
.native-structure-preview__open {
align-self: center;
white-space: nowrap;
}
.native-structure-preview--unavailable {
background: color-mix(in srgb, var(--color-error) 10%, var(--bg-secondary));
}
.native-structure-preview--unavailable > svg {
color: var(--color-error);
}
@media (max-width: 768px) {
.native-structure-preview {
grid-template-columns: auto minmax(0, 1fr);
}
.native-structure-preview__open {
grid-column: 2;
justify-self: start;
}
}

View File

@@ -0,0 +1,115 @@
import { memo, useEffect, useState } from "react";
import { BarChart3, CircleAlert, Flag, Lightbulb, Map, Target } from "lucide-react";
import type { NativeStructurePreviewResult, NativeStructureRef } from "@fusion/core";
import { fetchNativeStructurePreview } from "../api";
import "./NativeStructurePreview.css";
export interface NativeStructurePreviewProps {
ref: NativeStructureRef;
payload?: NativeStructurePreviewResult;
onOpen: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
}
const icons = {
mission: Map,
milestone: Flag,
"research-finding": Lightbulb,
"eval-result": BarChart3,
goal: Target,
} satisfies Record<NativeStructureRef["kind"], typeof Map>;
function isSupportedKind(kind: string): kind is NativeStructureRef["kind"] {
return Object.prototype.hasOwnProperty.call(icons, kind);
}
function unavailableLabel(kind: string): string {
return kind.replace(/-/g, " ");
}
/**
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
* Chat and mail use this one memoized renderer for compact structure cards. Navigation remains
* owned by each consumer through `onOpen` because dashboard views use callback/view state rather
* than URL routes; rendering an anchor here would create dead destinations.
*/
export const NativeStructurePreview = memo(function NativeStructurePreview({ ref, payload, onOpen }: NativeStructurePreviewProps) {
const supportedKind = isSupportedKind(ref.kind);
const refKey = `${ref.kind}\u0000${ref.id}\u0000${ref.projectId ?? ""}`;
const [fetchedPayload, setFetchedPayload] = useState<{ refKey: string; result: NativeStructurePreviewResult } | undefined>();
const [error, setError] = useState(false);
// FNXC:NativeStructureEmbed 2026-07-16-12:00: A ref update must not briefly render a prior fetch result; consumers can replace cards while messages or drafts rehydrate.
const result = payload ?? (fetchedPayload?.refKey === refKey ? fetchedPayload.result : undefined);
const Icon = supportedKind ? icons[ref.kind] : CircleAlert;
useEffect(() => {
/*
FNXC:NativeStructureEmbed 2026-07-19-18:00:
Refs can arrive from persisted chat/mail content, so reject a future or malformed kind before
fetching. The five-kind route is the sole resolver contract; roadmap-item must not trigger a
plugin read or turn an invalid icon lookup into a render crash.
*/
if (payload || !supportedKind) return;
let active = true;
setFetchedPayload(undefined);
setError(false);
void fetchNativeStructurePreview(ref)
.then((nextPayload) => {
if (active) setFetchedPayload({ refKey, result: nextPayload });
})
.catch(() => {
if (active) setError(true);
});
return () => { active = false; };
}, [payload, ref.kind, ref.id, ref.projectId, refKey]);
if (!supportedKind) {
return (
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-unavailable" data-reason="missing">
<Icon aria-hidden="true" />
<div className="native-structure-preview__content"><span className="native-structure-preview__label">Preview unavailable</span><p>This structure is unavailable.</p></div>
</section>
);
}
// FNXC:NativeStructureEmbed 2026-07-16-14:05: A caller-supplied projection is authoritative after a transient fetch failure, so it must replace the error placeholder for the same ref.
if (error && !payload) {
return (
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-error">
<Icon aria-hidden="true" />
<div className="native-structure-preview__content"><span className="native-structure-preview__label">Preview unavailable</span><p>Could not load this {unavailableLabel(ref.kind)}.</p></div>
</section>
);
}
if (!result) {
return (
<section className="native-structure-preview" data-testid="native-structure-preview-loading" aria-busy="true">
<Icon aria-hidden="true" />
<div className="native-structure-preview__content"><span className="native-structure-preview__label">Loading {unavailableLabel(ref.kind)}</span></div>
</section>
);
}
if (!result.available) {
return (
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-unavailable" data-reason={result.reason}>
<Icon aria-hidden="true" />
<div className="native-structure-preview__content"><span className="native-structure-preview__label">{unavailableLabel(result.kind)}</span><p>This structure is unavailable.</p></div>
</section>
);
}
return (
<section className="native-structure-preview" data-testid="native-structure-preview" data-kind={result.kind}>
<Icon aria-hidden="true" />
<div className="native-structure-preview__content">
<span className="native-structure-preview__label">{result.kindLabel}</span>
<strong className="native-structure-preview__title">{result.title}</strong>
<p className="native-structure-preview__excerpt">{result.excerpt}</p>
</div>
<button className="btn native-structure-preview__open" type="button" onClick={() => onOpen(ref, result)} aria-label={`Open ${result.kindLabel}: ${result.title}`}>
Open
</button>
</section>
);
});

View File

@@ -0,0 +1,102 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { NativeStructurePreviewPayload, NativeStructureRef } from "@fusion/core";
import { fetchNativeStructurePreview } from "../../api";
import { NativeStructurePreview } from "../NativeStructurePreview";
import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../api", () => ({ fetchNativeStructurePreview: vi.fn() }));
const fetchPreview = vi.mocked(fetchNativeStructurePreview);
const refs: NativeStructureRef[] = [
{ kind: "mission", id: "M-1" },
{ kind: "milestone", id: "MS-1" },
{ kind: "research-finding", id: "INS-1" },
{ kind: "eval-result", id: "EV-1" },
{ kind: "goal", id: "G-1" },
];
function payload(ref: NativeStructureRef): NativeStructurePreviewPayload {
const views = { mission: "missions", milestone: "missions", "research-finding": "insights", "eval-result": "evals", goal: "goals" } as const;
return { available: true, kind: ref.kind, kindLabel: ref.kind, title: `${ref.kind} title`, excerpt: "Compact excerpt", openTarget: { view: views[ref.kind], id: ref.id } };
}
describe("NativeStructurePreview", () => {
it.each(refs)("renders pre-resolved %s cards without fetching", (ref) => {
const onOpen = vi.fn();
render(<NativeStructurePreview ref={ref} payload={payload(ref)} onOpen={onOpen} />);
expect(screen.getByTestId("native-structure-preview")).toHaveAttribute("data-kind", ref.kind);
expect(screen.getByText(`${ref.kind} title`)).toBeInTheDocument();
expect(screen.getByRole("button", { name: new RegExp("Open") })).toBeInTheDocument();
expect(fetchPreview).not.toHaveBeenCalled();
});
it.each(refs)("fetches and renders a ref-only %s card", async (ref) => {
const result = payload(ref);
fetchPreview.mockResolvedValueOnce(result);
render(<NativeStructurePreview ref={ref} onOpen={vi.fn()} />);
await waitFor(() => expect(screen.getByTestId("native-structure-preview")).toHaveAttribute("data-kind", ref.kind));
expect(fetchPreview).toHaveBeenCalledWith(ref);
expect(screen.getByText(`${ref.kind} title`)).toBeInTheDocument();
});
it("dispatches consumer navigation without a dead anchor", () => {
const ref = refs[0];
const result = payload(ref);
const onOpen = vi.fn();
const { container } = render(<NativeStructurePreview ref={ref} payload={result} onOpen={onOpen} />);
fireEvent.click(screen.getByRole("button", { name: "Open mission: mission title" }));
expect(onOpen).toHaveBeenCalledWith(ref, result);
expect(container.querySelector('a[href*="/missions/"]')).toBeNull();
});
it.each(["missing", "soft-deleted"] as const)("renders a graceful %s placeholder", (reason) => {
render(<NativeStructurePreview ref={refs[0]} payload={{ available: false, kind: "mission", id: "M-1", reason }} onOpen={vi.fn()} />);
expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", reason);
expect(screen.getByText("This structure is unavailable.")).toBeInTheDocument();
});
it("does not render a previous ref's fetched payload after a ref change", async () => {
fetchPreview.mockResolvedValueOnce(payload(refs[0]));
const { rerender } = render(<NativeStructurePreview ref={refs[0]} onOpen={vi.fn()} />);
await waitFor(() => expect(screen.getByText("mission title")).toBeInTheDocument());
fetchPreview.mockImplementationOnce(() => new Promise(() => {}));
rerender(<NativeStructurePreview ref={refs[1]} onOpen={vi.fn()} />);
expect(screen.getByTestId("native-structure-preview-loading")).toBeInTheDocument();
expect(screen.queryByText("mission title")).toBeNull();
});
it("renders a deliberate error card when fetching fails", async () => {
fetchPreview.mockRejectedValueOnce(new Error("offline"));
render(<NativeStructurePreview ref={refs[0]} onOpen={vi.fn()} />);
await waitFor(() => expect(screen.getByTestId("native-structure-preview-error")).toBeInTheDocument());
});
it("does not fetch or crash for a deferred runtime kind", () => {
fetchPreview.mockClear();
const deferredRef = { kind: "roadmap-item", id: "R-1" } as unknown as NativeStructureRef;
render(<NativeStructurePreview ref={deferredRef} onOpen={vi.fn()} />);
expect(screen.getByTestId("native-structure-preview-unavailable")).toBeInTheDocument();
expect(fetchPreview).not.toHaveBeenCalled();
});
it("lets a later caller-supplied payload replace a failed fetch", async () => {
fetchPreview.mockRejectedValueOnce(new Error("offline"));
const { rerender } = render(<NativeStructurePreview ref={refs[0]} onOpen={vi.fn()} />);
await waitFor(() => expect(screen.getByTestId("native-structure-preview-error")).toBeInTheDocument());
rerender(<NativeStructurePreview ref={refs[0]} payload={payload(refs[0])} onOpen={vi.fn()} />);
expect(screen.getByTestId("native-structure-preview")).toBeInTheDocument();
expect(screen.queryByTestId("native-structure-preview-error")).toBeNull();
});
it("keeps the open affordance inside the mobile layout contract", () => {
const { container } = render(<NativeStructurePreview ref={refs[0]} payload={payload(refs[0])} onOpen={vi.fn()} />);
expect(container.querySelector(".native-structure-preview__open")).toBeInTheDocument();
const css = loadAllAppCss();
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.native-structure-preview__open/);
});
});

View File

@@ -0,0 +1,88 @@
import type {
NativeStructurePreviewPayload,
NativeStructurePreviewResult,
NativeStructureRef,
TaskStore,
} from "@fusion/core";
const KIND_LABELS: Record<NativeStructureRef["kind"], string> = {
mission: "Mission",
milestone: "Milestone",
"research-finding": "Research finding",
"eval-result": "Evaluation result",
goal: "Goal",
};
const MAX_EXCERPT_LENGTH = 180;
function unavailable(ref: NativeStructureRef, reason: "missing" | "soft-deleted"): NativeStructurePreviewResult {
return { available: false, kind: ref.kind, id: ref.id, reason };
}
function preview(
ref: NativeStructureRef,
title: string,
excerpt: string,
openTarget: NativeStructurePreviewPayload["openTarget"],
): NativeStructurePreviewPayload {
return { available: true, kind: ref.kind, kindLabel: KIND_LABELS[ref.kind], title, excerpt, openTarget };
}
function text(value: string | null | undefined, fallback: string): string {
const normalized = value?.replace(/\s+/g, " ").trim();
const resolved = normalized || fallback;
return resolved.length <= MAX_EXCERPT_LENGTH
? resolved
: `${resolved.slice(0, MAX_EXCERPT_LENGTH - 1).trimEnd()}…`;
}
/**
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
* This is a read-only projection over the task-scoped stores; it never duplicates structure
* persistence. Existing archived/dismissed lifecycle status supplies `soft-deleted` because no
* target has a tombstone column. Missing and unavailable structures are returned, never thrown;
* eval results have no archive lifecycle and can only be missing.
*/
export async function resolveNativeStructurePreview(
store: TaskStore,
ref: NativeStructureRef,
): Promise<NativeStructurePreviewResult> {
switch (ref.kind) {
case "mission": {
const mission = await store.getMissionStore().getMission(ref.id);
if (!mission) return unavailable(ref, "missing");
if (mission.status === "archived") return unavailable(ref, "soft-deleted");
return preview(ref, mission.title, text(mission.description, `Status: ${mission.status}`), { view: "missions", id: mission.id });
}
case "milestone": {
const missionStore = store.getMissionStore();
const milestone = await missionStore.getMilestone(ref.id);
if (!milestone) return unavailable(ref, "missing");
const mission = await missionStore.getMission(milestone.missionId);
if (!mission) return unavailable(ref, "missing");
if (mission.status === "archived") return unavailable(ref, "soft-deleted");
return preview(ref, milestone.title, text(milestone.description, `Status: ${milestone.status}`), {
view: "missions",
id: milestone.id,
missionId: mission.id,
});
}
case "research-finding": {
const insight = await store.getInsightStore().getInsight(ref.id);
if (!insight) return unavailable(ref, "missing");
if (insight.status === "dismissed" || insight.status === "archived") return unavailable(ref, "soft-deleted");
return preview(ref, insight.title, text(insight.content, `Status: ${insight.status}`), { view: "insights", id: insight.id });
}
case "eval-result": {
const result = await store.getEvalStore().getTaskResult(ref.id);
if (!result) return unavailable(ref, "missing");
const score = result.overallScore === undefined ? "Score unavailable" : `Score: ${result.overallScore}${result.maxScore === undefined ? "" : `/${result.maxScore}`}`;
return preview(ref, result.taskSnapshot.title || result.taskId, text(result.summary ?? result.rationale, score), { view: "evals", id: result.id });
}
case "goal": {
const goal = await store.getGoalStore().getGoal(ref.id);
if (!goal) return unavailable(ref, "missing");
if (goal.status === "archived") return unavailable(ref, "soft-deleted");
return preview(ref, goal.title, text(goal.description, `Status: ${goal.status}`), { view: "goals", id: goal.id });
}
}
}

View File

@@ -0,0 +1,102 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { resolveNativeStructurePreview } from "../../native-structure-preview.js";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
const mission = { id: "M-1", title: "Mission", description: "Mission description", status: "active" };
const milestone = { id: "MS-1", missionId: "M-1", title: "Milestone", description: "Milestone description", status: "planning" };
const insight = { id: "INS-1", title: "Finding", content: "Finding content", status: "stale" };
const evaluation = { id: "EV-1", taskId: "FN-1", taskSnapshot: { title: "Evaluated task" }, overallScore: 8, maxScore: 10 };
const goal = { id: "G-1", title: "Goal", description: "Goal description", status: "active" };
function store(overrides: Record<string, unknown> = {}): TaskStore {
return {
getMissionStore: vi.fn(() => ({ getMission: vi.fn(async (id) => id === mission.id ? mission : undefined), getMilestone: vi.fn(async (id) => id === milestone.id ? milestone : undefined) })),
getInsightStore: vi.fn(() => ({ getInsight: vi.fn(async (id) => id === insight.id ? insight : undefined) })),
getEvalStore: vi.fn(() => ({ getTaskResult: vi.fn(async (id) => id === evaluation.id ? evaluation : undefined) })),
getGoalStore: vi.fn(() => ({ getGoal: vi.fn(async (id) => id === goal.id ? goal : null) })),
getRootDir: vi.fn(() => process.cwd()),
...overrides,
} as unknown as TaskStore;
}
describe("resolveNativeStructurePreview", () => {
it.each([
["mission", mission.id, { view: "missions", id: mission.id }],
["milestone", milestone.id, { view: "missions", id: milestone.id, missionId: mission.id }],
["research-finding", insight.id, { view: "insights", id: insight.id }],
["eval-result", evaluation.id, { view: "evals", id: evaluation.id }],
["goal", goal.id, { view: "goals", id: goal.id }],
] as const)("projects %s into its owning view", async (kind, id, openTarget) => {
const result = await resolveNativeStructurePreview(store(), { kind, id });
expect(result).toMatchObject({ available: true, kind, openTarget });
});
it.each(["mission", "milestone", "research-finding", "eval-result", "goal"] as const)("returns missing for absent %s", async (kind) => {
const result = await resolveNativeStructurePreview(store(), { kind, id: "absent" });
expect(result).toEqual({ available: false, kind, id: "absent", reason: "missing" });
});
it.each([
["mission", { getMissionStore: vi.fn(() => ({ getMission: vi.fn(async () => ({ ...mission, status: "archived" })) })) }],
["milestone", { getMissionStore: vi.fn(() => ({ getMilestone: vi.fn(async () => milestone), getMission: vi.fn(async () => ({ ...mission, status: "archived" })) })) }],
["research-finding", { getInsightStore: vi.fn(() => ({ getInsight: vi.fn(async () => ({ ...insight, status: "dismissed" })) })) }],
["goal", { getGoalStore: vi.fn(() => ({ getGoal: vi.fn(async () => ({ ...goal, status: "archived" })) })) }],
] as const)("maps archived %s to soft-deleted", async (kind, overrides) => {
const result = await resolveNativeStructurePreview(store(overrides), { kind, id: "target" });
expect(result).toMatchObject({ available: false, reason: "soft-deleted" });
});
it("never maps a missing eval to soft-deleted", async () => {
await expect(resolveNativeStructurePreview(store(), { kind: "eval-result", id: "absent" })).resolves.toMatchObject({ reason: "missing" });
});
it("normalizes and bounds long excerpts for compact cards", async () => {
const longContent = ` ${"finding detail ".repeat(30)} `;
const result = await resolveNativeStructurePreview(store({
getInsightStore: vi.fn(() => ({ getInsight: vi.fn(async () => ({ ...insight, content: longContent })) })),
}), { kind: "research-finding", id: insight.id });
expect(result).toMatchObject({ available: true });
if (result.available) {
expect(result.excerpt.length).toBeLessThanOrEqual(180);
expect(result.excerpt).not.toMatch(/\s{2,}/);
expect(result.excerpt.endsWith("…")).toBe(true);
}
});
});
describe("native structure preview route", () => {
it.each([
["mission", mission.id, { view: "missions", id: mission.id }],
["milestone", milestone.id, { view: "missions", id: milestone.id, missionId: mission.id }],
["research-finding", insight.id, { view: "insights", id: insight.id }],
["eval-result", evaluation.id, { view: "evals", id: evaluation.id }],
["goal", goal.id, { view: "goals", id: goal.id }],
] as const)("returns the %s preview projection", async (kind, id, openTarget) => {
const app = express();
app.use("/api", createApiRoutes(store()));
const res = await REQUEST(app, "GET", `/api/native-structures/${kind}/${id}/preview`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ available: true, kind, openTarget });
});
it("returns the typed unavailable payload with 200", async () => {
const app = express();
app.use("/api", createApiRoutes(store()));
const res = await REQUEST(app, "GET", "/api/native-structures/mission/absent/preview");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ available: false, reason: "missing" });
});
it.each(["roadmap-item", "unknown"])("rejects unsupported %s", async (kind) => {
const app = express();
app.use("/api", createApiRoutes(store()));
const res = await REQUEST(app, "GET", `/api/native-structures/${kind}/id/preview`);
expect(res.status).toBe(400);
});
});

View File

@@ -75,6 +75,7 @@ import {
type WorkspaceRepoRevertPrBranch,
} from "@fusion/engine";
import { buildBoardWorkflowsPayload } from "./board-workflows.js";
import { resolveNativeStructurePreview } from "../native-structure-preview.js";
import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js";
import { computePlanApprovalFingerprint, isWorkspaceTask, type RunAuditEventInput } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
@@ -3889,6 +3890,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
/**
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
* Native-structure consumers need a single project-scoped read endpoint. Unavailable targets
* deliberately return HTTP 200 with a typed payload so chat and mail render a placeholder;
* unsupported kinds are malformed requests and remain HTTP 400.
*/
router.get("/native-structures/:kind/:id/preview", async (req, res) => {
try {
const { kind, id } = req.params;
if (kind !== "mission" && kind !== "milestone" && kind !== "research-finding" && kind !== "eval-result" && kind !== "goal") {
throw badRequest("kind must be one of: mission, milestone, research-finding, eval-result, goal");
}
if (!id.trim()) throw badRequest("id must be non-empty");
const { store: scopedStore } = await getProjectContext(req);
const preview = await resolveNativeStructurePreview(scopedStore, { kind, id });
res.json(preview);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
throw new ApiError(500, err instanceof Error ? err.message : String(err));
}
});
/**
* FNXC:ArtifactRegistry 2026-06-21-04:46:
* Documents view needs a cross-agent registry read surface for all artifact media classes. Keep query validation aligned with `/documents` so dashboard tabs share bounded pagination behavior while rejecting unknown artifact types before store access.