Address PR review feedback (#2001)
- scope session collections to the active project\n- clean up runtime stage registration in discovery tests\n- model brainstorms and plans as repeatable artifact collections\n- compact the singleton Strategy presentation
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Clarify Compound Engineering progress and preserve one plan from brainstorm through delivery.
|
||||
summary: Support repeatable Compound Engineering cycles with project-scoped progress and artifact collections.
|
||||
category: feature
|
||||
dev: Adds a stage rail, safer session controls, explicit choice confirmation, and terminal Work progression.
|
||||
dev: Adds collection-aware stages, safer session controls, explicit choice confirmation, and terminal Work progression.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e4dc3baed78f895c3a954ade947e3ee211da7612b6f1693127f8f67f74689a3
|
||||
size 234370
|
||||
oid sha256:a6c218824cbd21ec36305437e2adc5c810163ab25cd28fdee705bb12ddf48b93
|
||||
size 270173
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7e1320ecaac17cc84399f7cf58de13df5100175dd57e34fd1d6c0039e630f487
|
||||
size 90474
|
||||
oid sha256:9bbcbf6767d6982ebb4340498396dc1453eb2e7458c3f0877c20d7a4f5ece9d3
|
||||
size 95990
|
||||
|
||||
@@ -133,6 +133,20 @@ describe("session routes (polling transport)", () => {
|
||||
expect(sessions.map((s) => s.stage).sort()).toEqual(["brainstorm", "plan"]);
|
||||
});
|
||||
|
||||
it("GET /sessions scopes every session consumer to the requested project", async () => {
|
||||
const { getCeSessionStore } = await import("../session/session-store.js");
|
||||
const store = getCeSessionStore(h.ctx);
|
||||
const projectA = store.create({ stage: "brainstorm", projectId: "project-a" });
|
||||
store.create({ stage: "plan", projectId: "project-b" });
|
||||
store.create({ stage: "debug" });
|
||||
|
||||
const res = await call("GET", "/sessions", { params: {}, query: { projectId: "project-a" } }, h.ctx);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const sessions = (res.body as { sessions: Array<{ id: string; projectId: string | null }> }).sessions;
|
||||
expect(sessions).toEqual([expect.objectContaining({ id: projectA.id, projectId: "project-a" })]);
|
||||
});
|
||||
|
||||
it("GET /sessions keeps error, interrupted, awaiting_input, active, and completed rows independently manageable", async () => {
|
||||
const { getCeSessionStore } = await import("../session/session-store.js");
|
||||
const store = getCeSessionStore(h.ctx);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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";
|
||||
import { registerStage } from "../../session/stage-registry.js";
|
||||
import { registerStage, unregisterStage } from "../../session/stage-registry.js";
|
||||
|
||||
// Mock node:fs so we can observe/inject behaviour around readFileSync and
|
||||
// accessSync without relying on vi.spyOn (ESM namespace exports are not
|
||||
@@ -40,6 +40,7 @@ describe("discoverArtifacts", () => {
|
||||
|
||||
afterEach(() => {
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
unregisterStage("publish-check");
|
||||
readFileHook = undefined;
|
||||
accessHook = undefined;
|
||||
vi.restoreAllMocks();
|
||||
@@ -52,6 +53,8 @@ describe("discoverArtifacts", () => {
|
||||
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/requirements.md"), "requirements");
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/plan1.md"), "plan 1");
|
||||
mkdirSync(join(root, "docs/work"), { recursive: true });
|
||||
@@ -64,20 +67,21 @@ describe("discoverArtifacts", () => {
|
||||
const result = discoverArtifacts(root);
|
||||
const byStage = Object.fromEntries(result.groups.map((g) => [g.stage, g]));
|
||||
|
||||
expect(result.totalArtifacts).toBe(8);
|
||||
expect(result.totalArtifacts).toBe(9);
|
||||
expect(result.totalErrors).toBe(0);
|
||||
expect(byStage.strategy.entries).toHaveLength(1);
|
||||
expect(byStage.concepts.entries).toHaveLength(1);
|
||||
expect(byStage.ideate.entries).toHaveLength(2);
|
||||
expect(byStage.plan.entries).toHaveLength(1);
|
||||
expect(byStage.plan.entries[0]).toMatchObject({ path: "docs/plans/plan1.md" });
|
||||
expect(byStage.plan.label).toBe("Brainstorm / Plan");
|
||||
expect(byStage.brainstorm.entries).toHaveLength(1);
|
||||
expect(byStage.plan.label).toBe("Plan");
|
||||
expect(byStage.work.entries[0]).toMatchObject({ path: "docs/work/work.md" });
|
||||
expect(byStage.debug.entries[0]).toMatchObject({ path: "docs/debug/debug.md" });
|
||||
expect(byStage.solution.entries).toHaveLength(1);
|
||||
// Every group present is flagged present.
|
||||
expect(byStage.ideate.present).toBe(true);
|
||||
expect(byStage.brainstorm).toBeUndefined();
|
||||
expect(byStage.brainstorm.entries[0]).toMatchObject({ path: "docs/brainstorms/requirements.md" });
|
||||
// All entries are artifacts in the happy path.
|
||||
expect(result.groups.flatMap((g) => g.entries).every((e) => e.kind === "artifact")).toBe(true);
|
||||
});
|
||||
@@ -112,7 +116,7 @@ describe("discoverArtifacts", () => {
|
||||
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(7);
|
||||
expect(result.groups).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("returns an all-empty result when nothing is present (first-run)", () => {
|
||||
|
||||
@@ -55,14 +55,17 @@ interface ConventionalLocation {
|
||||
*/
|
||||
/*
|
||||
FNXC:CompoundEngineeringArtifacts 2026-07-10-12:00:
|
||||
The artifact hub must follow registered stage output locations so Work and Debug remain discoverable as the pipeline evolves. Brainstorm and Plan share one durable docs/plans collection; exposing the removed docs/brainstorms location or two groups for the same path misrepresents the unified-plan contract.
|
||||
The artifact hub must follow registered stage output locations so Work and Debug remain discoverable as the pipeline evolves.
|
||||
|
||||
FNXC:CompoundEngineeringArtifacts 2026-07-10-23:40:
|
||||
The upstream Compound Engineering workflow writes repeatable requirements documents to docs/brainstorms and implementation plans to docs/plans. Discovery must retain the dedicated requirements history while also supporting requirements-only unified plans produced by newer in-place handoffs.
|
||||
*/
|
||||
function stageLocations(): ConventionalLocation[] {
|
||||
return listStages().filter((definition) => definition.stageId !== "brainstorm").map((definition) => {
|
||||
const path = definition.artifactLocation.replace(/\/$/, "");
|
||||
return {
|
||||
stage: definition.stageId,
|
||||
label: definition.stageId === "plan" ? "Brainstorm / Plan" : definition.label,
|
||||
label: definition.label,
|
||||
path,
|
||||
kind: definition.artifactLocation.endsWith("/") ? "directory" : "file",
|
||||
};
|
||||
@@ -72,6 +75,7 @@ function stageLocations(): ConventionalLocation[] {
|
||||
function conventionalLocations(): ConventionalLocation[] {
|
||||
return [
|
||||
...stageLocations(),
|
||||
{ stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" },
|
||||
{ stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" },
|
||||
{ stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" },
|
||||
];
|
||||
|
||||
@@ -83,6 +83,20 @@ The shared ViewHeader sits directly above plugin content, so the body needs top
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Strategy is one durable upstream anchor; keep it as a compact row while repeatable workflow outputs retain collection cards. */
|
||||
.ce-group[data-layout="singleton"] {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 0.3fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: var(--space-lg);
|
||||
padding-block: var(--space-sm);
|
||||
}
|
||||
|
||||
.ce-group[data-layout="singleton"] .ce-artifact-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ce-group[data-empty="true"] {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -179,6 +193,11 @@ The shared ViewHeader sits directly above plugin content, so the body needs top
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.ce-view[data-mobile="true"] .ce-group[data-layout="singleton"] {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.ce-view[data-mobile="true"] .ce-groups {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -177,23 +177,23 @@ function nextPipelineStageId(stageId: string): string | undefined {
|
||||
|
||||
type RailStatus = "Not started" | "Active" | "Needs input" | "Complete";
|
||||
|
||||
function artifactForStage(stageId: string, groups: CeArtifactGroup[]): CeArtifactEntry | undefined {
|
||||
const groupId = stageId === "brainstorm" ? "plan" : stageId;
|
||||
const entries = groups.find((group) => group.stage === groupId)?.entries ?? [];
|
||||
function artifactsForStage(stageId: string, groups: CeArtifactGroup[]): CeArtifactEntry[] {
|
||||
const entries = groups.find((group) => group.stage === stageId)?.entries ?? [];
|
||||
if (stageId === "brainstorm") {
|
||||
return entries.find((entry) => (
|
||||
const unifiedEntries = groups.find((group) => group.stage === "plan")?.entries ?? [];
|
||||
return [...entries, ...unifiedEntries.filter((entry) => (
|
||||
entry.kind === "artifact"
|
||||
&& entry.artifactContract === "ce-unified-plan/v1"
|
||||
&& (entry.artifactReadiness === "requirements-only" || entry.artifactReadiness === "implementation-ready")
|
||||
));
|
||||
&& (entry.artifactReadiness === "requirements-only" || !entry.productContractSource || entry.productContractSource === "ce-brainstorm")
|
||||
))];
|
||||
}
|
||||
if (stageId === "plan") {
|
||||
return entries.find((entry) => (
|
||||
return entries.filter((entry) => (
|
||||
entry.kind === "artifact"
|
||||
&& (entry.artifactReadiness === "implementation-ready" || !entry.artifactReadiness)
|
||||
));
|
||||
}
|
||||
return entries.find((entry) => entry.kind === "artifact");
|
||||
return entries.filter((entry) => entry.kind === "artifact");
|
||||
}
|
||||
|
||||
function railStatus(stageId: string, sessions: CeSession[], artifact?: CeArtifactEntry): RailStatus {
|
||||
@@ -206,7 +206,10 @@ function railStatus(stageId: string, sessions: CeSession[], artifact?: CeArtifac
|
||||
|
||||
/**
|
||||
* FNXC:CompoundEngineeringUI 2026-07-10-12:00:
|
||||
* Operators need the five-stage compounding pipeline as the overview's primary navigation. Each stage shows actionable state and its latest durable artifact, while Debug remains a separate investigation action because it is not pipeline progression.
|
||||
* Operators need the five-stage compounding loop as the overview's primary navigation. Stages report repeatable run and artifact collections instead of implying one permanent completion; Strategy remains the upstream singleton anchor, while Debug remains a separate investigation action because it is not pipeline progression.
|
||||
*
|
||||
* FNXC:CompoundEngineeringUI 2026-07-10-23:40:
|
||||
* Compound Engineering follows the upstream repeatable loop: brainstorm requirements, plan implementation, work, review, compound, then repeat with better context. Brainstorm and Plan therefore expose collection counts and latest activity over multiple unified-plan files rather than a single artifact slot.
|
||||
*/
|
||||
function PipelineOverview({
|
||||
groups,
|
||||
@@ -240,8 +243,10 @@ function PipelineOverview({
|
||||
</header>
|
||||
<ol className="ce-stage-rail">
|
||||
{stages.map((stage) => {
|
||||
const artifact = artifactForStage(stage.stageId, groups);
|
||||
const latestSession = sessions.find((session) => session.stage === stage.stageId);
|
||||
const artifacts = artifactsForStage(stage.stageId, groups);
|
||||
const artifact = artifacts[0];
|
||||
const stageSessions = sessions.filter((session) => session.stage === stage.stageId);
|
||||
const latestSession = stageSessions[0];
|
||||
const status = railStatus(stage.stageId, sessions, artifact);
|
||||
const Icon = resolveIcon(stage.icon);
|
||||
return (
|
||||
@@ -257,7 +262,9 @@ function PipelineOverview({
|
||||
<span className="ce-stage-icon"><Icon size={17} aria-hidden="true" /></span>
|
||||
<span className="ce-stage-copy">
|
||||
<strong>{stage.label}</strong>
|
||||
<span className="ce-stage-status">{status}</span>
|
||||
<span className="ce-stage-status">
|
||||
{status} · {stageSessions.length} {stageSessions.length === 1 ? "run" : "runs"} · {artifacts.length} {artifacts.length === 1 ? "artifact" : "artifacts"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{artifact?.kind === "artifact" ? (
|
||||
@@ -370,8 +377,9 @@ function StageGroup({
|
||||
openFile?: PluginDashboardViewContext["openFile"];
|
||||
}) {
|
||||
const empty = group.entries.length === 0;
|
||||
const singleton = group.stage === "strategy";
|
||||
return (
|
||||
<section className="ce-group card" data-testid="ce-group" data-stage={group.stage} data-empty={empty ? "true" : "false"}>
|
||||
<section className="ce-group card" data-testid="ce-group" data-stage={group.stage} data-layout={singleton ? "singleton" : "collection"} data-empty={empty ? "true" : "false"}>
|
||||
<header className="ce-group-header">
|
||||
<h3>{group.label}</h3>
|
||||
<span className="ce-group-count">{group.entries.length}</span>
|
||||
@@ -397,6 +405,33 @@ function StageGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function artifactDisplayGroups(groups: CeArtifactGroup[]): CeArtifactGroup[] {
|
||||
const conventionalBrainstorms = groups.find((group) => group.stage === "brainstorm");
|
||||
return groups.flatMap((group) => {
|
||||
if (group.stage === "brainstorm") return [];
|
||||
if (group.stage !== "plan") return [group];
|
||||
const brainstorms = group.entries.filter((entry) => (
|
||||
entry.kind === "artifact"
|
||||
&& entry.artifactContract === "ce-unified-plan/v1"
|
||||
&& entry.artifactReadiness === "requirements-only"
|
||||
));
|
||||
const plans = group.entries.filter((entry) => (
|
||||
entry.kind === "error"
|
||||
|| (entry.artifactReadiness !== "requirements-only")
|
||||
));
|
||||
return [
|
||||
{
|
||||
...(conventionalBrainstorms ?? group),
|
||||
stage: "brainstorm",
|
||||
label: "Brainstorms",
|
||||
present: Boolean(conventionalBrainstorms?.present || brainstorms.length > 0),
|
||||
entries: [...(conventionalBrainstorms?.entries ?? []), ...brainstorms],
|
||||
},
|
||||
{ ...group, label: "Plans", entries: plans },
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
|
||||
const projectId = readProjectId(props);
|
||||
const { mobile, active } = useViewportMode();
|
||||
@@ -647,7 +682,7 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
|
||||
|
||||
{result && hasAnything ? (
|
||||
<div className="ce-groups" data-partial={isPartial ? "true" : "false"}>
|
||||
{result.groups.map((group) => (
|
||||
{artifactDisplayGroups(result.groups).map((group) => (
|
||||
<StageGroup
|
||||
key={group.stage}
|
||||
group={group}
|
||||
|
||||
@@ -130,6 +130,29 @@ describe("CompoundEngineeringView", () => {
|
||||
// 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);
|
||||
expect(document.querySelector('.ce-groups [data-stage="strategy"]')).toHaveAttribute("data-layout", "singleton");
|
||||
});
|
||||
|
||||
it("presents brainstorm and plan artifacts as repeatable collections", async () => {
|
||||
listArtifacts.mockResolvedValue(
|
||||
makeResult({
|
||||
plan: [
|
||||
{ kind: "artifact", id: "plan:docs/plans/requirements.md", stage: "plan", path: "docs/plans/requirements.md", name: "requirements.md", size: 5, updatedAt: 3, artifactContract: "ce-unified-plan/v1", artifactReadiness: "requirements-only", productContractSource: "ce-brainstorm" },
|
||||
{ kind: "artifact", id: "plan:docs/plans/ready.md", stage: "plan", path: "docs/plans/ready.md", name: "ready.md", size: 5, updatedAt: 2, artifactContract: "ce-unified-plan/v1", artifactReadiness: "implementation-ready", productContractSource: "ce-brainstorm" },
|
||||
{ kind: "artifact", id: "plan:docs/plans/legacy.md", stage: "plan", path: "docs/plans/legacy.md", name: "legacy.md", size: 5, updatedAt: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-summary");
|
||||
const brainstorms = document.querySelector('.ce-groups [data-stage="brainstorm"]')!;
|
||||
const plans = document.querySelector('.ce-groups [data-stage="plan"]')!;
|
||||
expect(brainstorms).toHaveTextContent("Brainstorms");
|
||||
expect(brainstorms.querySelectorAll('[data-testid="ce-artifact"]')).toHaveLength(1);
|
||||
expect(plans).toHaveTextContent("Plans");
|
||||
expect(plans.querySelectorAll('[data-testid="ce-artifact"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("opens an artifact in the built-in file viewer via context.openFile", async () => {
|
||||
|
||||
@@ -138,13 +138,18 @@ export function createSessionRoutes(): PluginRouteDefinition[] {
|
||||
{
|
||||
method: "GET",
|
||||
path: "/sessions",
|
||||
description: "List CE sessions (optionally filtered by status/stage).",
|
||||
description: "List CE sessions (optionally filtered by project/status/stage).",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
recoverStaleSessionsForContext(ctx, { reason: "route" });
|
||||
const query = (req as RouteRequest).query ?? {};
|
||||
const status = asCeSessionStatus(typeof query.status === "string" ? query.status : undefined);
|
||||
const stage = typeof query.stage === "string" ? query.stage : undefined;
|
||||
const sessions = getCeSessionStore(ctx).list({ status, stage });
|
||||
const projectId = typeof query.projectId === "string" ? query.projectId : undefined;
|
||||
/*
|
||||
FNXC:CompoundEngineering 2026-07-10-23:40:
|
||||
Dashboard session collections must be scoped at the route/store boundary so resume, URL restoration, stage state, and history cannot expose another project's Compound Engineering runs.
|
||||
*/
|
||||
const sessions = getCeSessionStore(ctx).list({ status, stage, projectId });
|
||||
return { status: 200, body: { sessions } };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -215,7 +215,7 @@ export class CeSessionStore {
|
||||
return row ? rowToSession(row) : undefined;
|
||||
}
|
||||
|
||||
list(filter: { status?: CeSessionStatus; stage?: string } = {}): CeSession[] {
|
||||
list(filter: { status?: CeSessionStatus; stage?: string; projectId?: string } = {}): CeSession[] {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (filter.status) {
|
||||
@@ -226,6 +226,10 @@ export class CeSessionStore {
|
||||
clauses.push("stage = ?");
|
||||
params.push(filter.stage);
|
||||
}
|
||||
if (filter.projectId) {
|
||||
clauses.push("projectId = ?");
|
||||
params.push(filter.projectId);
|
||||
}
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM ce_sessions ${where} ORDER BY updatedAt DESC, id`)
|
||||
|
||||
Reference in New Issue
Block a user