feat(compound-engineering): artifact hub view, discovery, read routes (U3)
Add allowlist-based artifact discovery over the conventional CE locations (STRATEGY.md, docs/ideation, docs/brainstorms, docs/plans, docs/solutions, CONCEPTS.md), grouped by stage, with error entries for unreadable artifacts and no reads outside the allowlist. Add list/read/preview routes (self-contained sandboxed HTML, data-section markers) and the primary CompoundEngineeringView with explicit empty/partial/error states, viewport-gated fetch, and a short-TTL cache. Bind the view via registerBundledPluginViews + dashboard workspace dep (the real binding; manifest componentPath is cosmetic).
This commit is contained in:
@@ -37,6 +37,18 @@ async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> {
|
||||
return { default: component as PluginViewComponent };
|
||||
}
|
||||
|
||||
async function loadCompoundEngineeringView(): Promise<{ default: PluginViewComponent }> {
|
||||
const moduleId = "@fusion-plugin-examples/compound-engineering/dashboard-view";
|
||||
const exportName = "CompoundEngineeringDashboardView";
|
||||
const mod = await import("@fusion-plugin-examples/compound-engineering/dashboard-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
|
||||
const component = mod[exportName];
|
||||
if (!component) {
|
||||
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
|
||||
return { default: createMissingPluginView(moduleId, exportName) };
|
||||
}
|
||||
return { default: component as PluginViewComponent };
|
||||
}
|
||||
|
||||
async function loadCliPrintingPressWizardView(): Promise<{ default: PluginViewComponent }> {
|
||||
const moduleId = "@fusion-plugin-examples/cli-printing-press/dashboard-view";
|
||||
const exportName = "CliPrintingPressWizardView";
|
||||
@@ -85,6 +97,12 @@ export function registerBundledPluginViews(): void {
|
||||
lazy(loadRoadmapView),
|
||||
);
|
||||
|
||||
registerPluginView(
|
||||
"fusion-plugin-compound-engineering",
|
||||
"compound-engineering",
|
||||
lazy(loadCompoundEngineeringView),
|
||||
);
|
||||
|
||||
registerPluginView(
|
||||
"fusion-plugin-cli-printing-press",
|
||||
"wizard",
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@fusion-plugin-examples/compound-engineering": "workspace:*",
|
||||
"@fusion-plugin-examples/dependency-graph": "workspace:*",
|
||||
"@fusion-plugin-examples/roadmap": "workspace:*",
|
||||
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as realFs from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Mock node:fs so we can observe/inject behaviour around readFileSync without
|
||||
// relying on vi.spyOn (ESM namespace exports are not configurable). The hooks
|
||||
// below default to passthrough and individual tests override them.
|
||||
let readFileHook: ((path: realFs.PathOrFileDescriptor, original: typeof realFs.readFileSync, args: unknown[]) => unknown) | undefined;
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof realFs>();
|
||||
return {
|
||||
...actual,
|
||||
readFileSync: (path: realFs.PathOrFileDescriptor, ...args: unknown[]) => {
|
||||
if (readFileHook) return readFileHook(path, actual.readFileSync, args);
|
||||
return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } = realFs;
|
||||
const { discoverArtifacts, readArtifactById } = await import("../discovery.js");
|
||||
|
||||
function makeRepo(): string {
|
||||
return mkdtempSync(join(tmpdir(), "ce-discovery-"));
|
||||
}
|
||||
|
||||
describe("discoverArtifacts", () => {
|
||||
let root: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
readFileHook = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns grouped artifacts from a fixture repo tree (happy path)", () => {
|
||||
root = makeRepo();
|
||||
writeFileSync(join(root, "STRATEGY.md"), "# Strategy");
|
||||
writeFileSync(join(root, "CONCEPTS.md"), "# Concepts");
|
||||
mkdirSync(join(root, "docs/ideation"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/ideation/a.md"), "ideation a");
|
||||
writeFileSync(join(root, "docs/ideation/b.md"), "ideation b");
|
||||
mkdirSync(join(root, "docs/brainstorms"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/brainstorms/x.md"), "brainstorm x");
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/plan1.md"), "plan 1");
|
||||
mkdirSync(join(root, "docs/solutions"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/solutions/sol.md"), "solution");
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
const byStage = Object.fromEntries(result.groups.map((g) => [g.stage, g]));
|
||||
|
||||
expect(result.totalArtifacts).toBe(7);
|
||||
expect(result.totalErrors).toBe(0);
|
||||
expect(byStage.strategy.entries).toHaveLength(1);
|
||||
expect(byStage.concepts.entries).toHaveLength(1);
|
||||
expect(byStage.ideation.entries).toHaveLength(2);
|
||||
expect(byStage.brainstorm.entries).toHaveLength(1);
|
||||
expect(byStage.plan.entries).toHaveLength(1);
|
||||
expect(byStage.solution.entries).toHaveLength(1);
|
||||
// Every group present is flagged present.
|
||||
expect(byStage.ideation.present).toBe(true);
|
||||
// All entries are artifacts in the happy path.
|
||||
expect(result.groups.flatMap((g) => g.entries).every((e) => e.kind === "artifact")).toBe(true);
|
||||
});
|
||||
|
||||
it("orders directory artifacts by updatedAt DESC", () => {
|
||||
root = makeRepo();
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
const older = join(root, "docs/plans/old.md");
|
||||
const newer = join(root, "docs/plans/new.md");
|
||||
writeFileSync(older, "old");
|
||||
writeFileSync(newer, "new");
|
||||
// Force deterministic mtimes: old < new.
|
||||
const now = Date.now();
|
||||
utimesSync(older, new Date(now - 10_000), new Date(now - 10_000));
|
||||
utimesSync(newer, new Date(now), new Date(now));
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
const plan = result.groups.find((g) => g.stage === "plan")!;
|
||||
expect(plan.entries.map((e) => e.name)).toEqual(["new.md", "old.md"]);
|
||||
});
|
||||
|
||||
it("reports a partial-discovery state: some categories present, others empty", () => {
|
||||
root = makeRepo();
|
||||
writeFileSync(join(root, "STRATEGY.md"), "# Strategy");
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/p.md"), "plan");
|
||||
// No ideation / brainstorms / solutions / CONCEPTS.
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
const populated = result.groups.filter((g) => g.entries.length > 0);
|
||||
const empty = result.groups.filter((g) => g.entries.length === 0);
|
||||
expect(populated.map((g) => g.stage).sort()).toEqual(["plan", "strategy"]);
|
||||
expect(empty.length).toBeGreaterThan(0);
|
||||
// Empty groups are still present in the result so the hub can render them.
|
||||
expect(result.groups).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("returns an all-empty result when nothing is present (first-run)", () => {
|
||||
root = makeRepo();
|
||||
const result = discoverArtifacts(root);
|
||||
expect(result.totalArtifacts).toBe(0);
|
||||
expect(result.totalErrors).toBe(0);
|
||||
expect(result.groups.every((g) => g.entries.length === 0 && !g.present)).toBe(true);
|
||||
});
|
||||
|
||||
it("represents an unreadable artifact as an error entry, not a crash or silent drop", () => {
|
||||
root = makeRepo();
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
const readable = join(root, "docs/plans/good.md");
|
||||
writeFileSync(readable, "good");
|
||||
|
||||
// Simulate a malformed/unreadable artifact: the specific file throws on read.
|
||||
readFileHook = (path, original, args) => {
|
||||
if (typeof path === "string" && path.endsWith("good.md")) {
|
||||
throw new Error("EIO: simulated read failure");
|
||||
}
|
||||
return (original as (...a: unknown[]) => unknown)(path, ...args);
|
||||
};
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
const plan = result.groups.find((g) => g.stage === "plan")!;
|
||||
expect(plan.entries).toHaveLength(1);
|
||||
const entry = plan.entries[0];
|
||||
expect(entry.kind).toBe("error");
|
||||
expect(entry.kind === "error" && entry.error).toContain("simulated read failure");
|
||||
expect(result.totalErrors).toBe(1);
|
||||
expect(result.totalArtifacts).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores unrelated files and does not read outside the conventional locations", () => {
|
||||
root = makeRepo();
|
||||
// Conventional artifact that SHOULD be read.
|
||||
writeFileSync(join(root, "STRATEGY.md"), "# Strategy");
|
||||
mkdirSync(join(root, "docs/ideation"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/ideation/keep.md"), "keep");
|
||||
// Unrelated files that must NOT be read.
|
||||
writeFileSync(join(root, "README.md"), "readme"); // root-level non-conventional .md
|
||||
writeFileSync(join(root, "package.json"), "{}");
|
||||
writeFileSync(join(root, "docs/ideation/notes.txt"), "non-md, ignore"); // non-.md in a scanned dir
|
||||
mkdirSync(join(root, "secrets"), { recursive: true });
|
||||
writeFileSync(join(root, "secrets/secret.md"), "TOP SECRET"); // outside the allowlist
|
||||
mkdirSync(join(root, "docs/random"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/random/r.md"), "unrelated"); // docs subtree but not conventional
|
||||
|
||||
const opened: string[] = [];
|
||||
readFileHook = (path, original, args) => {
|
||||
if (typeof path === "string") opened.push(path);
|
||||
return (original as (...a: unknown[]) => unknown)(path, ...args);
|
||||
};
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
|
||||
// Only the two conventional artifacts were read.
|
||||
expect(opened.some((p) => p.endsWith("STRATEGY.md"))).toBe(true);
|
||||
expect(opened.some((p) => p.endsWith(join("ideation", "keep.md")))).toBe(true);
|
||||
// Nothing outside the allowlist was opened.
|
||||
expect(opened.some((p) => p.includes(`${join("secrets", "secret.md")}`))).toBe(false);
|
||||
expect(opened.some((p) => p.endsWith("README.md"))).toBe(false);
|
||||
expect(opened.some((p) => p.endsWith("package.json"))).toBe(false);
|
||||
expect(opened.some((p) => p.endsWith("notes.txt"))).toBe(false);
|
||||
expect(opened.some((p) => p.includes(join("random", "r.md")))).toBe(false);
|
||||
|
||||
expect(result.totalArtifacts).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readArtifactById", () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = makeRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reads a conventional file artifact", () => {
|
||||
writeFileSync(join(root, "STRATEGY.md"), "# Strategy body");
|
||||
const res = readArtifactById(root, "strategy:STRATEGY.md");
|
||||
expect(res).toBeDefined();
|
||||
expect(res && "content" in res && res.content).toContain("Strategy body");
|
||||
});
|
||||
|
||||
it("reads a directory artifact's immediate Markdown child", () => {
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/p.md"), "plan body");
|
||||
const res = readArtifactById(root, "plan:docs/plans/p.md");
|
||||
expect(res && "content" in res && res.content).toBe("plan body");
|
||||
});
|
||||
|
||||
it("refuses a forged id that escapes the conventional location", () => {
|
||||
writeFileSync(join(root, "secrets.md"), "secret");
|
||||
// Attempt to traverse out of docs/plans into the repo root.
|
||||
expect(readArtifactById(root, "plan:../../secrets.md")).toBeUndefined();
|
||||
// Wrong stage/path pairing for a file location.
|
||||
expect(readArtifactById(root, "strategy:CONCEPTS.md")).toBeUndefined();
|
||||
// Unknown stage.
|
||||
expect(readArtifactById(root, "bogus:whatever.md")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a nested path under a directory location (non-immediate child)", () => {
|
||||
mkdirSync(join(root, "docs/plans/sub"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/sub/deep.md"), "deep");
|
||||
expect(readArtifactById(root, "plan:docs/plans/sub/deep.md")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
|
||||
/**
|
||||
* CE artifact discovery (U3).
|
||||
*
|
||||
* Scans a fixed allowlist of conventional CE artifact locations relative to a
|
||||
* project root and returns artifacts grouped by stage. The allowlist is the
|
||||
* ONLY filesystem surface this module touches — it never recurses outside a
|
||||
* conventional location and never reads a file that does not live under one of
|
||||
* them. An artifact that cannot be read or is malformed is represented as an
|
||||
* `error` entry rather than crashing the scan or being silently dropped.
|
||||
*
|
||||
* Locations (per the plan): STRATEGY.md, docs/ideation/, docs/brainstorms/,
|
||||
* docs/plans/, docs/solutions/, CONCEPTS.md.
|
||||
*/
|
||||
|
||||
export type CeArtifactStage =
|
||||
| "strategy"
|
||||
| "ideation"
|
||||
| "brainstorm"
|
||||
| "plan"
|
||||
| "solution"
|
||||
| "concepts";
|
||||
|
||||
/** Whether a conventional location is a single file or a directory of files. */
|
||||
type LocationKind = "file" | "directory";
|
||||
|
||||
interface ConventionalLocation {
|
||||
stage: CeArtifactStage;
|
||||
/** Human label for the stage group. */
|
||||
label: string;
|
||||
/** Project-root-relative path. */
|
||||
path: string;
|
||||
kind: LocationKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The conventional CE artifact locations. This is the discovery allowlist — the
|
||||
* scanner reads ONLY these paths (and, for directories, their immediate `.md`
|
||||
* children). Nothing outside this list is opened.
|
||||
*/
|
||||
export const CONVENTIONAL_LOCATIONS: readonly ConventionalLocation[] = [
|
||||
{ stage: "strategy", label: "Strategy", path: "STRATEGY.md", kind: "file" },
|
||||
{ stage: "ideation", label: "Ideation", path: "docs/ideation", kind: "directory" },
|
||||
{ stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" },
|
||||
{ stage: "plan", label: "Plans", path: "docs/plans", kind: "directory" },
|
||||
{ stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" },
|
||||
{ stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" },
|
||||
];
|
||||
|
||||
/** A discovered, readable artifact. */
|
||||
export interface CeArtifact {
|
||||
/** Stable id: `${stage}:${relativePath}`. Safe to use as a route param after encoding. */
|
||||
id: string;
|
||||
stage: CeArtifactStage;
|
||||
/** Project-root-relative path with forward slashes. */
|
||||
path: string;
|
||||
/** Filename (basename). */
|
||||
name: string;
|
||||
/** Size in bytes. */
|
||||
size: number;
|
||||
/** Last-modified epoch ms — used for `(stage, updatedAt DESC)` ordering. */
|
||||
updatedAt: number;
|
||||
/** Discriminator. */
|
||||
kind: "artifact";
|
||||
}
|
||||
|
||||
/** An artifact location that exists but could not be read / was malformed. */
|
||||
export interface CeArtifactError {
|
||||
id: string;
|
||||
stage: CeArtifactStage;
|
||||
path: string;
|
||||
name: string;
|
||||
/** Discriminator. */
|
||||
kind: "error";
|
||||
/** Human-readable reason the artifact could not be surfaced. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type CeArtifactEntry = CeArtifact | CeArtifactError;
|
||||
|
||||
/** Artifacts (and error entries) grouped by stage. */
|
||||
export interface CeArtifactGroup {
|
||||
stage: CeArtifactStage;
|
||||
label: string;
|
||||
/** True when the conventional location for this stage exists on disk. */
|
||||
present: boolean;
|
||||
/** Entries, ordered by `updatedAt DESC` (errors sort last, keyed by name). */
|
||||
entries: CeArtifactEntry[];
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
groups: CeArtifactGroup[];
|
||||
/** Convenience flags for the hub's empty / partial states. */
|
||||
totalArtifacts: number;
|
||||
totalErrors: number;
|
||||
}
|
||||
|
||||
const MAX_ARTIFACT_BYTES = 2_000_000;
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.split(sep).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: a resolved path must stay within the project root AND under the
|
||||
* specific conventional location it was discovered through. This is the
|
||||
* concrete enforcement of "do not read outside the conventional locations".
|
||||
*/
|
||||
function isWithin(root: string, locationAbs: string, candidate: string): boolean {
|
||||
const relToLocation = relative(locationAbs, candidate);
|
||||
if (relToLocation.startsWith("..") || isAbsolute(relToLocation)) return false;
|
||||
const relToRoot = relative(root, candidate);
|
||||
if (relToRoot.startsWith("..") || isAbsolute(relToRoot)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeId(stage: CeArtifactStage, relPath: string): string {
|
||||
return `${stage}:${relPath}`;
|
||||
}
|
||||
|
||||
function readArtifactEntry(
|
||||
stage: CeArtifactStage,
|
||||
root: string,
|
||||
locationAbs: string,
|
||||
abs: string,
|
||||
relPath: string,
|
||||
): CeArtifactEntry {
|
||||
const name = relPath.split("/").pop() ?? relPath;
|
||||
// Defense in depth: refuse anything that escaped the conventional location.
|
||||
if (!isWithin(root, locationAbs, abs)) {
|
||||
return {
|
||||
id: makeId(stage, relPath),
|
||||
stage,
|
||||
path: relPath,
|
||||
name,
|
||||
kind: "error",
|
||||
error: "Path is outside its conventional location",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const st = statSync(abs);
|
||||
if (st.size > MAX_ARTIFACT_BYTES) {
|
||||
return {
|
||||
id: makeId(stage, relPath),
|
||||
stage,
|
||||
path: relPath,
|
||||
name,
|
||||
kind: "error",
|
||||
error: `Artifact too large to read (${st.size} bytes)`,
|
||||
};
|
||||
}
|
||||
// Read eagerly so a malformed/unreadable file is surfaced now as an error
|
||||
// entry rather than crashing later at render time.
|
||||
readFileSync(abs, "utf8");
|
||||
return {
|
||||
id: makeId(stage, relPath),
|
||||
stage,
|
||||
path: relPath,
|
||||
name,
|
||||
size: st.size,
|
||||
updatedAt: st.mtimeMs,
|
||||
kind: "artifact",
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
id: makeId(stage, relPath),
|
||||
stage,
|
||||
path: relPath,
|
||||
name,
|
||||
kind: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sortEntries(entries: CeArtifactEntry[]): CeArtifactEntry[] {
|
||||
// Composite ordering analogue: artifacts by updatedAt DESC; errors last,
|
||||
// stable by name. (See docs/performance/dashboard-load.md — the persisted
|
||||
// equivalent is a `(type, updatedAt DESC)` index.)
|
||||
return [...entries].sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "artifact" ? -1 : 1;
|
||||
if (a.kind === "artifact" && b.kind === "artifact") return b.updatedAt - a.updatedAt;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGroup {
|
||||
const locationAbs = join(root, loc.path);
|
||||
const entries: CeArtifactEntry[] = [];
|
||||
let present = false;
|
||||
|
||||
let st: ReturnType<typeof statSync> | undefined;
|
||||
try {
|
||||
st = statSync(locationAbs);
|
||||
present = true;
|
||||
} catch {
|
||||
// Location simply does not exist — an empty (but valid) category.
|
||||
return { stage: loc.stage, label: loc.label, present: false, entries: [] };
|
||||
}
|
||||
|
||||
if (loc.kind === "file") {
|
||||
if (st.isFile()) {
|
||||
entries.push(readArtifactEntry(loc.stage, root, locationAbs, locationAbs, toPosix(loc.path)));
|
||||
} else {
|
||||
// A conventional file path that is actually a directory is malformed.
|
||||
entries.push({
|
||||
id: makeId(loc.stage, toPosix(loc.path)),
|
||||
stage: loc.stage,
|
||||
path: toPosix(loc.path),
|
||||
name: loc.path.split("/").pop() ?? loc.path,
|
||||
kind: "error",
|
||||
error: "Expected a file at the conventional location but found a directory",
|
||||
});
|
||||
}
|
||||
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
||||
}
|
||||
|
||||
// Directory location: read ONLY immediate children, only Markdown files.
|
||||
// Non-recursive on purpose — we never descend into unrelated subtrees.
|
||||
let names: string[] = [];
|
||||
try {
|
||||
if (!st.isDirectory()) {
|
||||
entries.push({
|
||||
id: makeId(loc.stage, toPosix(loc.path)),
|
||||
stage: loc.stage,
|
||||
path: toPosix(loc.path),
|
||||
name: loc.path.split("/").pop() ?? loc.path,
|
||||
kind: "error",
|
||||
error: "Expected a directory at the conventional location but found a file",
|
||||
});
|
||||
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
||||
}
|
||||
names = readdirSync(locationAbs);
|
||||
} catch (err) {
|
||||
entries.push({
|
||||
id: makeId(loc.stage, toPosix(loc.path)),
|
||||
stage: loc.stage,
|
||||
path: toPosix(loc.path),
|
||||
name: loc.path.split("/").pop() ?? loc.path,
|
||||
kind: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
||||
}
|
||||
|
||||
for (const childName of names) {
|
||||
// Ignore unrelated files: only Markdown artifacts count. Dotfiles and any
|
||||
// non-.md file are skipped outright (not read).
|
||||
if (childName.startsWith(".")) continue;
|
||||
if (!childName.toLowerCase().endsWith(".md")) continue;
|
||||
const abs = join(locationAbs, childName);
|
||||
const relPath = toPosix(join(loc.path, childName));
|
||||
// Skip nested directories named *.md — only regular files are artifacts.
|
||||
let childStat: ReturnType<typeof statSync>;
|
||||
try {
|
||||
childStat = statSync(abs);
|
||||
} catch (err) {
|
||||
entries.push({
|
||||
id: makeId(loc.stage, relPath),
|
||||
stage: loc.stage,
|
||||
path: relPath,
|
||||
name: childName,
|
||||
kind: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!childStat.isFile()) continue;
|
||||
entries.push(readArtifactEntry(loc.stage, root, locationAbs, abs, relPath));
|
||||
}
|
||||
|
||||
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover CE artifacts under `projectRoot`, grouped by stage. Never throws for
|
||||
* per-artifact problems — those become `error` entries. Always returns one
|
||||
* group per conventional location (empty groups included so the hub can render
|
||||
* a partial-discovery state).
|
||||
*/
|
||||
export function discoverArtifacts(projectRoot: string): DiscoveryResult {
|
||||
const root = projectRoot;
|
||||
const groups = CONVENTIONAL_LOCATIONS.map((loc) => discoverLocation(root, loc));
|
||||
let totalArtifacts = 0;
|
||||
let totalErrors = 0;
|
||||
for (const g of groups) {
|
||||
for (const e of g.entries) {
|
||||
if (e.kind === "artifact") totalArtifacts += 1;
|
||||
else totalErrors += 1;
|
||||
}
|
||||
}
|
||||
return { groups, totalArtifacts, totalErrors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single artifact by its `stage:relativePath` id and return its raw
|
||||
* content. Re-validates the path against the conventional-location allowlist so
|
||||
* a forged id can never read an arbitrary file. Returns `undefined` if the id
|
||||
* does not map to a known conventional location or the file is missing.
|
||||
*/
|
||||
export function readArtifactById(
|
||||
projectRoot: string,
|
||||
id: string,
|
||||
): { artifact: CeArtifact; content: string } | { error: string } | undefined {
|
||||
const sepIdx = id.indexOf(":");
|
||||
if (sepIdx <= 0) return undefined;
|
||||
const stage = id.slice(0, sepIdx) as CeArtifactStage;
|
||||
const relPath = id.slice(sepIdx + 1);
|
||||
const loc = CONVENTIONAL_LOCATIONS.find((l) => l.stage === stage);
|
||||
if (!loc) return undefined;
|
||||
|
||||
const locationAbs = join(projectRoot, loc.path);
|
||||
const abs = join(projectRoot, relPath);
|
||||
|
||||
// The requested path must live under the stage's conventional location.
|
||||
// For file locations, the path must equal the location itself.
|
||||
if (loc.kind === "file") {
|
||||
if (toPosix(relPath) !== toPosix(loc.path)) return undefined;
|
||||
} else if (!isWithin(projectRoot, locationAbs, abs)) {
|
||||
return undefined;
|
||||
}
|
||||
// Directory artifacts must be immediate Markdown children.
|
||||
if (loc.kind === "directory") {
|
||||
const rel = relative(locationAbs, abs);
|
||||
if (rel.includes(sep) || rel.startsWith("..") || !rel.toLowerCase().endsWith(".md")) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let content: string;
|
||||
let mtimeMs: number;
|
||||
let size: number;
|
||||
try {
|
||||
const st = statSync(abs);
|
||||
if (!st.isFile()) return { error: "Artifact is not a readable file" };
|
||||
if (st.size > MAX_ARTIFACT_BYTES) return { error: `Artifact too large to read (${st.size} bytes)` };
|
||||
mtimeMs = st.mtimeMs;
|
||||
size = st.size;
|
||||
} catch (err) {
|
||||
// A missing file is "not found" (404), not a malformed-artifact error (422).
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
try {
|
||||
content = readFileSync(abs, "utf8");
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
const name = relPath.split("/").pop() ?? relPath;
|
||||
return {
|
||||
artifact: {
|
||||
id,
|
||||
stage,
|
||||
path: toPosix(relPath),
|
||||
name,
|
||||
size,
|
||||
updatedAt: mtimeMs,
|
||||
kind: "artifact",
|
||||
},
|
||||
content,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
|
||||
|
||||
/**
|
||||
* Placeholder dashboard surface for the Compound Engineering plugin.
|
||||
* Dashboard surface entry for the Compound Engineering plugin (U3).
|
||||
*
|
||||
* U1 ships only a loadable scaffold. The real artifact hub and interactive
|
||||
* ce-* session surface arrive in later units (U3+).
|
||||
* Thin re-export of the real hub component (mirrors how reports splits
|
||||
* `src/dashboard-view.tsx` from `src/dashboard/ReportsView.tsx`). The export
|
||||
* name `CompoundEngineeringDashboardView` is the one `registerBundledPluginViews`
|
||||
* imports — `componentPath` in the manifest is cosmetic; this binding is real.
|
||||
*/
|
||||
export function CompoundEngineeringDashboardView(_props: { context?: PluginDashboardViewContext }) {
|
||||
return <div data-testid="compound-engineering-view">Compound Engineering</div>;
|
||||
}
|
||||
|
||||
export default CompoundEngineeringDashboardView;
|
||||
export {
|
||||
CompoundEngineeringView as CompoundEngineeringDashboardView,
|
||||
default,
|
||||
} from "./dashboard/CompoundEngineeringView.js";
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
.ce-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ce-view-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ce-view-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ce-view-summary {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ce-loading,
|
||||
.ce-view-error {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ce-view-error {
|
||||
color: var(--color-danger, #d23);
|
||||
}
|
||||
|
||||
.ce-empty {
|
||||
max-width: 36rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ce-empty h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ce-empty-hint {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ce-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ce-group {
|
||||
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.25));
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ce-group[data-empty="true"] {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.ce-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ce-group-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ce-group-count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ce-group-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ce-artifact-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ce-artifact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ce-artifact.is-selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ce-artifact-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: inherit;
|
||||
padding: 0.25rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ce-artifact-path {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ce-artifact-error .ce-artifact-error-msg {
|
||||
color: var(--color-danger, #d23);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ce-artifact-error {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.ce-view[data-mobile="true"] .ce-groups {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import "./CompoundEngineeringView.css";
|
||||
import { useState } from "react";
|
||||
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
|
||||
import { useArtifacts } from "./hooks/useArtifacts.js";
|
||||
import { useViewportMode } from "./hooks/useViewportMode.js";
|
||||
import { getArtifactPreviewUrl } from "./hooks/api.js";
|
||||
import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js";
|
||||
|
||||
interface CompoundEngineeringViewProps {
|
||||
context?: PluginDashboardViewContext;
|
||||
/** Test seam: override the active project id without a host context. */
|
||||
projectId?: string;
|
||||
/** Test seam: force the viewport-gated fetch on/off. */
|
||||
enabledOverride?: boolean;
|
||||
}
|
||||
|
||||
function readProjectId(props: CompoundEngineeringViewProps): string | undefined {
|
||||
if (props.projectId) return props.projectId;
|
||||
const ctx = props.context as { projectId?: string } | undefined;
|
||||
return ctx?.projectId;
|
||||
}
|
||||
|
||||
/** First-run / empty state: no artifacts AND no errors anywhere. */
|
||||
function EmptyState({ onStart }: { onStart: () => void }) {
|
||||
return (
|
||||
<div className="ce-empty card" data-testid="ce-empty-state">
|
||||
<h3>Start your compounding pipeline</h3>
|
||||
<p>
|
||||
No compound-engineering artifacts found yet. Compound Engineering tracks the documents your
|
||||
pipeline produces — strategy, ideation, brainstorms, plans, solutions, and concepts — as you
|
||||
move through each stage.
|
||||
</p>
|
||||
<p className="ce-empty-hint">
|
||||
Begin with a stage and its artifact will appear here, grouped and traceable.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" data-testid="ce-start-action" onClick={onStart}>
|
||||
Start a stage
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({
|
||||
entry,
|
||||
projectId,
|
||||
onSelect,
|
||||
selected,
|
||||
}: {
|
||||
entry: CeArtifactEntry;
|
||||
projectId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
if (entry.kind === "error") {
|
||||
return (
|
||||
<li className="ce-artifact ce-artifact-error" data-testid="ce-artifact-error">
|
||||
<span className="ce-artifact-name">{entry.name}</span>
|
||||
<span className="ce-artifact-error-msg" role="alert">
|
||||
Could not read: {entry.error}
|
||||
</span>
|
||||
<span className="ce-artifact-path">{entry.path}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li className={`ce-artifact${selected ? " is-selected" : ""}`} data-testid="ce-artifact">
|
||||
<button type="button" className="ce-artifact-btn" onClick={() => onSelect(entry.id)}>
|
||||
<span className="ce-artifact-name">{entry.name}</span>
|
||||
<span className="ce-artifact-path">{entry.path}</span>
|
||||
</button>
|
||||
<a
|
||||
className="ce-artifact-open"
|
||||
href={getArtifactPreviewUrl(entry.id, projectId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function StageGroup({
|
||||
group,
|
||||
projectId,
|
||||
onSelect,
|
||||
selectedId,
|
||||
}: {
|
||||
group: CeArtifactGroup;
|
||||
projectId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
selectedId?: string;
|
||||
}) {
|
||||
const empty = group.entries.length === 0;
|
||||
return (
|
||||
<section className="ce-group" data-testid="ce-group" data-stage={group.stage} data-empty={empty ? "true" : "false"}>
|
||||
<header className="ce-group-header">
|
||||
<h3>{group.label}</h3>
|
||||
<span className="ce-group-count">{group.entries.length}</span>
|
||||
</header>
|
||||
{empty ? (
|
||||
<p className="ce-group-empty" data-testid="ce-group-empty">
|
||||
No {group.label.toLowerCase()} artifacts yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="ce-artifact-list">
|
||||
{group.entries.map((entry) => (
|
||||
<ArtifactRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
projectId={projectId}
|
||||
onSelect={onSelect}
|
||||
selected={selectedId === entry.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
|
||||
const projectId = readProjectId(props);
|
||||
const { mobile, active } = useViewportMode();
|
||||
const enabled = props.enabledOverride ?? active;
|
||||
const { result, loading, error } = useArtifacts({ projectId, enabled });
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
|
||||
const totalArtifacts = result?.totalArtifacts ?? 0;
|
||||
const totalErrors = result?.totalErrors ?? 0;
|
||||
const hasAnything = totalArtifacts > 0 || totalErrors > 0;
|
||||
// Partial discovery: at least one category populated AND at least one empty.
|
||||
const populatedGroups = result?.groups.filter((g) => g.entries.length > 0).length ?? 0;
|
||||
const emptyGroups = result?.groups.filter((g) => g.entries.length === 0).length ?? 0;
|
||||
const isPartial = populatedGroups > 0 && emptyGroups > 0;
|
||||
|
||||
const onStart = () => {
|
||||
// Wiring to launch a stage session is U6. A placeholder affordance is fine
|
||||
// here; it makes the first-run orientation actionable without coupling U3 to
|
||||
// the session launcher.
|
||||
props.context?.addToast?.("Stage launcher arrives with the CE flow renderer (U6).", "info");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ce-view" data-testid="compound-engineering-view" data-mobile={mobile ? "true" : "false"}>
|
||||
<div className="ce-view-header">
|
||||
<h2>Compound Engineering</h2>
|
||||
{hasAnything ? (
|
||||
<span className="ce-view-summary" data-testid="ce-summary">
|
||||
{totalArtifacts} artifact{totalArtifacts === 1 ? "" : "s"}
|
||||
{totalErrors > 0 ? ` · ${totalErrors} unreadable` : ""}
|
||||
{isPartial ? " · partial" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="ce-view-error card" role="alert" data-testid="ce-fetch-error">
|
||||
Failed to load artifacts: {error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && !result ? (
|
||||
<div className="ce-loading" data-testid="ce-loading">
|
||||
Discovering artifacts…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result && !hasAnything ? (
|
||||
<EmptyState onStart={onStart} />
|
||||
) : null}
|
||||
|
||||
{result && hasAnything ? (
|
||||
<div className="ce-groups" data-partial={isPartial ? "true" : "false"}>
|
||||
{result.groups.map((group) => (
|
||||
<StageGroup
|
||||
key={group.stage}
|
||||
group={group}
|
||||
projectId={projectId}
|
||||
onSelect={setSelectedId}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompoundEngineeringView;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
|
||||
// Mock the network layer so the view renders from seeded discovery results.
|
||||
const listArtifacts = vi.fn(async (): Promise<DiscoveryResult> => {
|
||||
throw new Error("listArtifacts mock not configured");
|
||||
});
|
||||
vi.mock("../hooks/api.js", () => ({
|
||||
listArtifacts: () => listArtifacts(),
|
||||
getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
|
||||
}));
|
||||
|
||||
import { CompoundEngineeringView } from "../CompoundEngineeringView.js";
|
||||
import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js";
|
||||
|
||||
const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [
|
||||
{ stage: "strategy", label: "Strategy" },
|
||||
{ stage: "ideation", label: "Ideation" },
|
||||
{ stage: "brainstorm", label: "Brainstorms" },
|
||||
{ stage: "plan", label: "Plans" },
|
||||
{ stage: "solution", label: "Solutions" },
|
||||
{ stage: "concepts", label: "Concepts" },
|
||||
];
|
||||
|
||||
function makeResult(overrides: Partial<Record<DiscoveryResult["groups"][number]["stage"], DiscoveryResult["groups"][number]["entries"]>>): DiscoveryResult {
|
||||
const groups = ALL_STAGES.map(({ stage, label }) => ({
|
||||
stage,
|
||||
label,
|
||||
present: Boolean(overrides[stage]?.length),
|
||||
entries: overrides[stage] ?? [],
|
||||
}));
|
||||
let totalArtifacts = 0;
|
||||
let totalErrors = 0;
|
||||
for (const g of groups) {
|
||||
for (const e of g.entries) {
|
||||
if (e.kind === "artifact") totalArtifacts += 1;
|
||||
else totalErrors += 1;
|
||||
}
|
||||
}
|
||||
return { groups, totalArtifacts, totalErrors };
|
||||
}
|
||||
|
||||
describe("CompoundEngineeringView", () => {
|
||||
beforeEach(() => {
|
||||
__test_clearArtifactsCache();
|
||||
listArtifacts.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("renders the empty / first-run state with an orientation + start action", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-empty-state");
|
||||
expect(screen.getByText(/Start your compounding pipeline/i)).toBeInTheDocument();
|
||||
const start = screen.getByTestId("ce-start-action");
|
||||
expect(start).toBeInTheDocument();
|
||||
// Start affordance is wired to a placeholder (toast); clicking does not throw.
|
||||
fireEvent.click(start);
|
||||
});
|
||||
|
||||
it("renders the partial-discovery state (some categories present, others empty)", async () => {
|
||||
listArtifacts.mockResolvedValue(
|
||||
makeResult({
|
||||
strategy: [
|
||||
{ kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 },
|
||||
],
|
||||
plan: [
|
||||
{ kind: "artifact", id: "plan:docs/plans/p.md", stage: "plan", path: "docs/plans/p.md", name: "p.md", size: 5, updatedAt: 2 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-summary");
|
||||
// Partial flag surfaces in the summary and on the groups container.
|
||||
expect(screen.getByTestId("ce-summary").textContent).toMatch(/partial/i);
|
||||
const groups = screen.getByTestId("ce-summary").closest(".ce-view")!.querySelector(".ce-groups");
|
||||
expect(groups?.getAttribute("data-partial")).toBe("true");
|
||||
// Populated groups render artifacts; empty ones render an empty hint.
|
||||
expect(screen.getAllByTestId("ce-artifact")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("ce-group-empty").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders an error entry for an unreadable artifact (not a crash or silent drop)", async () => {
|
||||
listArtifacts.mockResolvedValue(
|
||||
makeResult({
|
||||
plan: [
|
||||
{ kind: "error", id: "plan:docs/plans/bad.md", stage: "plan", path: "docs/plans/bad.md", name: "bad.md", error: "EIO: simulated read failure" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
const errorEntry = await screen.findByTestId("ce-artifact-error");
|
||||
expect(errorEntry).toBeInTheDocument();
|
||||
expect(errorEntry.textContent).toMatch(/simulated read failure/i);
|
||||
// Surfaced as an unreadable count in the summary.
|
||||
expect(screen.getByTestId("ce-summary").textContent).toMatch(/unreadable/i);
|
||||
});
|
||||
|
||||
it("does not fetch when the viewport-gated flag is disabled", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride={false} />);
|
||||
// Give effects a tick.
|
||||
await waitFor(() => expect(screen.getByTestId("compound-engineering-view")).toBeInTheDocument());
|
||||
expect(listArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
|
||||
const BASE = "/api/plugins/fusion-plugin-compound-engineering";
|
||||
|
||||
function qp(params: Record<string, string | undefined>): string {
|
||||
const entries = Object.entries(params).filter(
|
||||
([, v]) => typeof v === "string" && v.length > 0,
|
||||
) as Array<[string, string]>;
|
||||
if (entries.length === 0) return "";
|
||||
return `?${entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")}`;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit, responseType: "json" | "text" = "json"): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, init);
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const data = (await response.json()) as { error?: string };
|
||||
if (data.error) message = data.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (responseType === "text") return (await response.text()) as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function listArtifacts(projectId?: string): Promise<DiscoveryResult> {
|
||||
return request<DiscoveryResult>(`/artifacts${qp({ projectId })}`);
|
||||
}
|
||||
|
||||
export async function getArtifact(
|
||||
id: string,
|
||||
projectId?: string,
|
||||
): Promise<{ content: string; name: string }> {
|
||||
const data = await request<{ artifact: { name: string }; content: string }>(
|
||||
`/artifacts/${encodeURIComponent(id)}${qp({ projectId })}`,
|
||||
);
|
||||
return { content: data.content, name: data.artifact.name };
|
||||
}
|
||||
|
||||
export function getArtifactPreviewUrl(id: string, projectId?: string): string {
|
||||
return `${BASE}/artifacts/${encodeURIComponent(id)}/preview.html${qp({ projectId })}`;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { listArtifacts } from "./api.js";
|
||||
import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
|
||||
/**
|
||||
* Short-TTL discovery cache keyed by `projectId` (the discovery scan has no
|
||||
* per-row id, so the project is the cache unit). Mirrors the dashboard
|
||||
* performance kit (docs/performance/dashboard-load.md): a 30s TTL balances
|
||||
* freshness against repeated viewport-driven refetches.
|
||||
*/
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
const discoveryCache = new Map<string, { value: DiscoveryResult; expiresAt: number }>();
|
||||
|
||||
function cacheKey(projectId?: string): string {
|
||||
return `discovery:${projectId ?? "__default__"}`;
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export function __test_clearArtifactsCache(): void {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
|
||||
export interface UseArtifactsResult {
|
||||
result?: DiscoveryResult;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover CE artifacts for the active project. The fetch is viewport-gated via
|
||||
* the `enabled` flag — when the CE view is offscreen/disabled it returns stable
|
||||
* empty state and triggers no network request (performance kit). Results are
|
||||
* served from a short-TTL cache to collapse repeated mounts.
|
||||
*/
|
||||
export function useArtifacts({
|
||||
projectId,
|
||||
enabled = true,
|
||||
}: {
|
||||
projectId?: string;
|
||||
enabled?: boolean;
|
||||
}): UseArtifactsResult {
|
||||
const [result, setResult] = useState<DiscoveryResult | undefined>(() => {
|
||||
const cached = discoveryCache.get(cacheKey(projectId));
|
||||
return cached && cached.expiresAt > Date.now() ? cached.value : undefined;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const key = cacheKey(projectId);
|
||||
const cached = discoveryCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
setResult(cached.value);
|
||||
setError(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
listArtifacts(projectId)
|
||||
.then((value) => {
|
||||
if (controller.signal.aborted) return;
|
||||
discoveryCache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
setResult(value);
|
||||
setError(undefined);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load artifacts");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [projectId, enabled]);
|
||||
|
||||
return useMemo(() => ({ result, loading, error }), [result, loading, error]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Tracks viewport state for the CE hub. `mobile` mirrors the dashboard's mobile
|
||||
* breakpoint (includes landscape phones). `active` reports whether the document
|
||||
* is currently visible — used to viewport-gate the discovery fetch so an
|
||||
* offscreen/backgrounded hub triggers no network work (performance kit).
|
||||
*/
|
||||
export function useViewportMode() {
|
||||
const [mobile, setMobile] = useState(false);
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 768px), (max-height: 480px)");
|
||||
const onChange = () => setMobile(mq.matches);
|
||||
onChange();
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibility = () => setActive(document.visibilityState !== "hidden");
|
||||
onVisibility();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||
}, []);
|
||||
|
||||
return { mobile, active };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
// @testing-library/react only auto-registers cleanup when vitest globals are
|
||||
// enabled. We don't enable globals here, so we wire it manually — otherwise
|
||||
// React leaves the test tree mounted, its scheduler fires a deferred update
|
||||
// via setImmediate after the jsdom environment is torn down, and the suite
|
||||
// fails with "ReferenceError: window is not defined".
|
||||
afterEach(() => cleanup());
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { COMPOUND_ENGINEERING_SKILLS } from "./skills.js";
|
||||
import { installBundledCeSkills } from "./skill-installation.js";
|
||||
import { ensureCeSchema } from "./schema.js";
|
||||
import { createSessionRoutes } from "./routes/session-routes.js";
|
||||
import { createArtifactRoutes } from "./routes/artifact-routes.js";
|
||||
|
||||
export { CompoundEngineeringDashboardView } from "./dashboard-view.js";
|
||||
export { COMPOUND_ENGINEERING_SKILLS } from "./skills.js";
|
||||
@@ -60,7 +61,7 @@ const plugin = definePlugin({
|
||||
}
|
||||
},
|
||||
},
|
||||
routes: [...createSessionRoutes()],
|
||||
routes: [...createSessionRoutes(), ...createArtifactRoutes()],
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "compound-engineering",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { PluginRouteResponse } from "@fusion/core";
|
||||
import { createArtifactRoutes } from "../artifact-routes.js";
|
||||
import { makeHarness, type TestHarness } from "../../__tests__/_harness.js";
|
||||
|
||||
function route(path: string, method = "GET") {
|
||||
const def = createArtifactRoutes().find((r) => r.path === path && r.method === method);
|
||||
if (!def) throw new Error(`route not found: ${method} ${path}`);
|
||||
return def;
|
||||
}
|
||||
|
||||
describe("artifact routes", () => {
|
||||
let h: TestHarness;
|
||||
|
||||
afterEach(() => h?.close());
|
||||
|
||||
it("GET /artifacts lists discovered artifacts grouped by stage", async () => {
|
||||
h = makeHarness();
|
||||
writeFileSync(join(h.projectRoot, "STRATEGY.md"), "# Strategy");
|
||||
mkdirSync(join(h.projectRoot, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(h.projectRoot, "docs/plans/p.md"), "plan body");
|
||||
|
||||
const res = (await route("/artifacts").handler({ query: {} }, h.ctx)) as PluginRouteResponse;
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { totalArtifacts: number; groups: Array<{ stage: string; entries: unknown[] }> };
|
||||
expect(body.totalArtifacts).toBe(2);
|
||||
const plan = body.groups.find((g) => g.stage === "plan")!;
|
||||
expect(plan.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /artifacts/:id returns raw content", async () => {
|
||||
h = makeHarness();
|
||||
writeFileSync(join(h.projectRoot, "STRATEGY.md"), "# Strategy body");
|
||||
|
||||
const res = (await route("/artifacts/:id").handler(
|
||||
{ params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} },
|
||||
h.ctx,
|
||||
)) as PluginRouteResponse;
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { content: string }).content).toContain("Strategy body");
|
||||
});
|
||||
|
||||
it("GET /artifacts/:id 404s an unknown id", async () => {
|
||||
h = makeHarness();
|
||||
const res = (await route("/artifacts/:id").handler(
|
||||
{ params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} },
|
||||
h.ctx,
|
||||
)) as PluginRouteResponse;
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /artifacts/:id/preview.html returns a self-contained, escaped HTML document", async () => {
|
||||
h = makeHarness();
|
||||
// Content with an injection attempt — must be escaped, not executed.
|
||||
writeFileSync(join(h.projectRoot, "STRATEGY.md"), "<script>alert('x')</script>\n# Plan");
|
||||
|
||||
const res = (await route("/artifacts/:id/preview.html").handler(
|
||||
{ params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} },
|
||||
h.ctx,
|
||||
)) as PluginRouteResponse;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.contentType).toContain("text/html");
|
||||
const html = res.body as string;
|
||||
expect(html.startsWith("<!DOCTYPE html>")).toBe(true);
|
||||
// Self-contained: inlined <style>, no remote asset URLs.
|
||||
expect(html).toContain("<style>");
|
||||
expect(html).not.toMatch(/https?:\/\//);
|
||||
// data-section markers present (reports rendering contract).
|
||||
expect(html).toContain('data-section="content"');
|
||||
// The raw script tag is escaped, not embedded as live markup.
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).not.toContain("<script>alert");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import { discoverArtifacts, readArtifactById } from "../artifacts/discovery.js";
|
||||
|
||||
/**
|
||||
* Artifact routes (U3): list discovered CE artifacts grouped by stage, and read
|
||||
* a single artifact's content. The render endpoint returns a SELF-CONTAINED HTML
|
||||
* document (sandboxed `srcDoc`, inlined styles, no remote assets) mirroring the
|
||||
* reports preview/export pattern (docs/plugins/reports.md) so the dashboard can
|
||||
* embed it in a sandboxed iframe without leaking host styles or scripts.
|
||||
*
|
||||
* Project root: artifacts live on disk relative to the project root, which the
|
||||
* route reaches via `ctx.taskStore.getRootDir()` (the same root the U5
|
||||
* orchestrator writes artifacts to). When a `projectId` is supplied and the host
|
||||
* exposes `resolveProjectTaskStore`, the per-project root is used.
|
||||
*/
|
||||
|
||||
interface RouteRequest {
|
||||
params: Record<string, string>;
|
||||
query?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
async function resolveProjectRoot(ctx: PluginContext, projectId?: string): Promise<string> {
|
||||
if (projectId && ctx.resolveProjectTaskStore) {
|
||||
try {
|
||||
const store = await ctx.resolveProjectTaskStore(projectId);
|
||||
return store.getRootDir();
|
||||
} catch {
|
||||
// Fall through to the default task store root.
|
||||
}
|
||||
}
|
||||
return ctx.taskStore.getRootDir();
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw artifact markdown in a fully self-contained HTML document. Content is
|
||||
* HTML-escaped and rendered inside `<pre>` so nothing in the artifact can inject
|
||||
* markup or script; styles are inlined; there are no remote `href`/`src` URLs.
|
||||
* `data-section` markers mirror the reports rendering contract so an embedding
|
||||
* viewer can offer section quick-jumps without re-parsing.
|
||||
*/
|
||||
export function renderArtifactDocument(name: string, content: string): string {
|
||||
const escaped = escapeHtml(content);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(name)}</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.5; background: #ffffff; color: #1a1a1a; }
|
||||
@media (prefers-color-scheme: dark) { body { background: #16181d; color: #e6e6e6; } }
|
||||
.artifact-doc { padding: 1.25rem 1.5rem; }
|
||||
.artifact-doc h1 { font-size: 1.1rem; margin: 0 0 1rem; font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
.artifact-body { white-space: pre-wrap; word-break: break-word; margin: 0; font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="artifact-doc" data-section="artifact">
|
||||
<h1 data-section="title">${escapeHtml(name)}</h1>
|
||||
<pre class="artifact-body" data-section="content">${escaped}</pre>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function createArtifactRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/artifacts",
|
||||
description: "List discovered CE artifacts grouped by stage.",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const query = (req as RouteRequest).query ?? {};
|
||||
const projectId = asString(query.projectId);
|
||||
const root = await resolveProjectRoot(ctx, projectId);
|
||||
const result = discoverArtifacts(root);
|
||||
return { status: 200, body: result };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/artifacts/:id",
|
||||
description: "Read a single CE artifact's raw content (JSON).",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = decodeURIComponent(request.params.id);
|
||||
const projectId = asString(request.query?.projectId);
|
||||
const root = await resolveProjectRoot(ctx, projectId);
|
||||
const result = readArtifactById(root, id);
|
||||
if (!result) return { status: 404, body: { error: `Artifact ${id} not found` } };
|
||||
if ("error" in result) return { status: 422, body: { error: result.error } };
|
||||
return { status: 200, body: { artifact: result.artifact, content: result.content } };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/artifacts/:id/preview.html",
|
||||
description: "Read a single CE artifact rendered as a self-contained HTML document.",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = decodeURIComponent(request.params.id);
|
||||
const projectId = asString(request.query?.projectId);
|
||||
const root = await resolveProjectRoot(ctx, projectId);
|
||||
const result = readArtifactById(root, id);
|
||||
if (!result) return { status: 404, body: { error: `Artifact ${id} not found` } };
|
||||
if ("error" in result) return { status: 422, body: { error: result.error } };
|
||||
return {
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
body: renderArtifactDocument(result.artifact.name, result.content),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest
|
||||
|
||||
const maxWorkers = computeMaxWorkers();
|
||||
|
||||
const coreSetup = fileURLToPath(
|
||||
new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url),
|
||||
);
|
||||
const dashboardSetup = fileURLToPath(new URL("./src/dashboard/test-setup.ts", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
@@ -28,12 +33,32 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
test: {
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
environment: "jsdom",
|
||||
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
|
||||
// coreSetup runs for all projects via extends: true inheritance.
|
||||
setupFiles: [coreSetup],
|
||||
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||
pool: "threads",
|
||||
maxWorkers,
|
||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "compound-engineering-dashboard",
|
||||
environment: "jsdom",
|
||||
include: ["src/dashboard/**/__tests__/**/*.test.{ts,tsx}", "src/dashboard/**/*.test.{ts,tsx}"],
|
||||
// jsdom-specific setup; coreSetup is inherited via extends: true.
|
||||
setupFiles: [dashboardSetup],
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "compound-engineering-node",
|
||||
environment: "node",
|
||||
include: ["src/**/__tests__/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
||||
exclude: ["src/dashboard/**/__tests__/**/*.test.{ts,tsx}", "src/dashboard/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -209,6 +209,9 @@ importers:
|
||||
'@fusion-plugin-examples/cli-printing-press':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/fusion-plugin-cli-printing-press
|
||||
'@fusion-plugin-examples/compound-engineering':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/fusion-plugin-compound-engineering
|
||||
'@fusion-plugin-examples/cursor-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/fusion-plugin-cursor-runtime
|
||||
|
||||
Reference in New Issue
Block a user