mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
shell-redesign V1 attempt-3 Phase 5 P5-5 + P5-6: filesystem mission discovery + live PROGRESS.md status
Per project-tree.md L132–L133 + SC-26: mission status derives from
PROGRESS.md frontmatter (file-system-as-truth pattern). Phase 3 stubbed
parseMissionStatus(); Phase 5 wires the live filesystem walk + fetch.
Both ride on the existing /api/files/list + /api/files/read daemon
routes (verified at HEAD f1b91ff via packages/daemon/src/routes/files.ts);
NO new daemon endpoint, NO schema migration, NO new event type. SC-29
hard line preserved beyond the Phase 4 ConfigStore allowlist exception.
NEW packages/ui/src/hooks/useMissionDiscovery.ts:
- useMissionDiscovery() resolves workspace.root → /api/files/roots
match (exact-equal preferred, deepest path-prefix fallback) →
/api/files/list?root=&path=missions → directory entries (filtered
to type==='dir').
- Returns { missions, unavailable, isLoading, hint }. Unavailable cases
surface a hint string explaining the operator action needed
(configure workspace.root, register OPENRIG_FILES_ALLOWLIST root,
upgrade to v0.3.0 daemon for /api/config support).
NEW packages/ui/src/hooks/useMissionProgressStatus.ts:
- useMissionProgressStatus(root, missionPath) wraps existing useFilesRead
to fetch <missionPath>/PROGRESS.md and parse via parseMissionStatus.
- TanStack Query supplies the cache layer; the daemon's mtime metadata
in /api/files/read response satisfies project-tree.md L132 mtime-cache
invariant.
- Returns { status, isLoading, unavailable, mtime }; "unknown" + 404
treated as quiet degrade (mission directory may exist without
PROGRESS.md yet).
REFACTORED packages/ui/src/components/project/ProjectTreeView.tsx:
- ProjectTreeView consumes useMissionDiscovery + new
LiveMissionStatusBadge wrapper (uses useMissionProgressStatus).
- When discovery returns missions: render disk-discovered missions; for
each, attach matching railItem-grouped slices; orphaned slices fall
through to an "Unsorted" mission node.
- When discovery is unavailable: render a small italic
"Filesystem mission discovery unavailable; showing railItem grouping"
hint + the legacy railItem grouping (Phase 3 fallback preserved
intact). data-testid='project-discovery-degraded' for tests.
- Existing Phase 3 A5 no-workspace empty-state preserved verbatim.
NEW packages/ui/test/p5-mission-discovery.test.tsx (10 tests):
- parseMissionStatus invariants (active / shipped / blocked variants
+ null/empty/malformed → unknown).
- ProjectTreeView with workspace.root in allowlist: filesystem missions
surface as tree nodes; file-type entries filtered out.
- Fallback to railItem grouping when allowlist doesn't contain
workspace.root, OR allowlist is empty; degraded hint visible.
- A5 no-workspace empty-state preserved when workspace.root unset.
- Slice-to-mission matching by railItem; orphans → Unsorted node.
Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest full UI suite: 648/650 PASS (pre-existing library-review
Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
233aeb258a
commit
919a428ee9
4 files changed
+639
-28
No files matched your search
@@ -1,25 +1,38 @@
|
||||
// V1 attempt-3 Phase 3 — Project tree per project-tree.md L13–L46 + SC-24.
|
||||
//
|
||||
// Workspace > mission > slice tree. V1 groups slices by railItem (the
|
||||
// existing slice rail-item is the closest analog to "mission" in the
|
||||
// current data model); workspace level is the single openrig-work
|
||||
// surface. Phase 5 polish + filesystem mission discovery is the V2 path.
|
||||
// V1 attempt-3 Phase 5 P5-5 + P5-6: filesystem-based mission discovery via
|
||||
// useMissionDiscovery (walks workspace.root/missions/ over /api/files/list)
|
||||
// and live MissionStatusBadge derived from PROGRESS.md frontmatter via
|
||||
// useMissionProgressStatus (over /api/files/read). When the allowlist
|
||||
// doesn't expose workspace.root, the tree falls back to the legacy
|
||||
// railItem-grouped slice listing.
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useSlices } from "../../hooks/useSlices.js";
|
||||
import { useSlices, type SliceListEntry } from "../../hooks/useSlices.js";
|
||||
import { useWorkspaceName } from "../../hooks/useWorkspaceName.js";
|
||||
import {
|
||||
useMissionDiscovery,
|
||||
type DiscoveredMission,
|
||||
} from "../../hooks/useMissionDiscovery.js";
|
||||
import { useMissionProgressStatus } from "../../hooks/useMissionProgressStatus.js";
|
||||
import { MissionStatusBadge, type MissionStatus } from "../MissionStatusBadge.js";
|
||||
|
||||
type SliceRow = { name: string; displayName: string; status: string };
|
||||
|
||||
type GroupedMission = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: MissionStatus;
|
||||
slices: Array<{ name: string; displayName: string; status: string }>;
|
||||
slices: SliceRow[];
|
||||
// P5-5: filesystem-discovered missions carry root + path so the live
|
||||
// PROGRESS.md status fetcher knows where to read.
|
||||
fsRoot?: string;
|
||||
fsPath?: string;
|
||||
};
|
||||
|
||||
function deriveStatus(slices: Array<{ status: string }>): MissionStatus {
|
||||
function deriveStatusFromSlices(slices: Array<{ status: string }>): MissionStatus {
|
||||
if (slices.length === 0) return "unknown";
|
||||
if (slices.some((s) => s.status === "blocked")) return "blocked";
|
||||
if (slices.some((s) => s.status === "active")) return "active";
|
||||
@@ -27,21 +40,40 @@ function deriveStatus(slices: Array<{ status: string }>): MissionStatus {
|
||||
return "active";
|
||||
}
|
||||
|
||||
/** Live mission-status badge: when the mission was discovered on disk,
|
||||
* fetches PROGRESS.md frontmatter; otherwise falls back to the heuristic
|
||||
* derived from constituent slices' statuses. */
|
||||
function LiveMissionStatusBadge({ mission }: { mission: GroupedMission }) {
|
||||
const live = useMissionProgressStatus(mission.fsRoot ?? null, mission.fsPath ?? null);
|
||||
// When filesystem-discovered AND the read succeeded, prefer the live status;
|
||||
// otherwise the slice-derived fallback.
|
||||
const status =
|
||||
mission.fsRoot && !live.unavailable && !live.isLoading ? live.status : mission.status;
|
||||
return (
|
||||
<MissionStatusBadge
|
||||
status={status}
|
||||
testId={`project-mission-${mission.id}-badge`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectTreeView() {
|
||||
const { data: slicesResp } = useSlices("all");
|
||||
const workspace = useWorkspaceName();
|
||||
const discovery = useMissionDiscovery();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
||||
workspace: true,
|
||||
});
|
||||
const toggle = (k: string) =>
|
||||
setExpanded((p) => ({ ...p, [k]: !p[k] }));
|
||||
|
||||
const missions = useMemo<GroupedMission[]>(() => {
|
||||
// useSlices returns SliceListResponse | SlicesUnavailable; the unavailable
|
||||
// case (slices_root_not_configured) lacks the `slices` field — guard for it.
|
||||
const list = slicesResp && "slices" in slicesResp ? slicesResp.slices : [];
|
||||
const buckets = new Map<string, GroupedMission["slices"]>();
|
||||
for (const s of list) {
|
||||
const sliceList: SliceListEntry[] =
|
||||
slicesResp && "slices" in slicesResp ? slicesResp.slices : [];
|
||||
|
||||
// Group slices by railItem (the "mission" label they self-report).
|
||||
const slicesByRailItem = useMemo(() => {
|
||||
const buckets = new Map<string, SliceRow[]>();
|
||||
for (const s of sliceList) {
|
||||
const key = s.railItem ?? "unsorted";
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key)!.push({
|
||||
@@ -50,13 +82,51 @@ export function ProjectTreeView() {
|
||||
status: s.status,
|
||||
});
|
||||
}
|
||||
return Array.from(buckets.entries()).map(([k, slices]) => ({
|
||||
return buckets;
|
||||
}, [sliceList]);
|
||||
|
||||
// P5-5: when filesystem mission discovery is available, surface the
|
||||
// disk-discovered missions and attach their slices via railItem match.
|
||||
// Otherwise fall back to railItem grouping alone.
|
||||
const missions = useMemo<GroupedMission[]>(() => {
|
||||
if (!discovery.unavailable && discovery.missions.length > 0) {
|
||||
const orphanSlices: SliceRow[] = [];
|
||||
const consumedKeys = new Set<string>();
|
||||
const discovered: GroupedMission[] = discovery.missions.map((m: DiscoveredMission) => {
|
||||
const matchedSlices = slicesByRailItem.get(m.name) ?? [];
|
||||
if (matchedSlices.length > 0) consumedKeys.add(m.name);
|
||||
return {
|
||||
id: m.name,
|
||||
label: m.name,
|
||||
status: deriveStatusFromSlices(matchedSlices), // baseline; live overrides via badge
|
||||
slices: matchedSlices,
|
||||
fsRoot: m.root,
|
||||
fsPath: m.path,
|
||||
};
|
||||
});
|
||||
// Any slice whose railItem doesn't match a disk mission goes into
|
||||
// "unsorted" so it's still reachable.
|
||||
for (const [railItem, slices] of slicesByRailItem.entries()) {
|
||||
if (!consumedKeys.has(railItem)) orphanSlices.push(...slices);
|
||||
}
|
||||
if (orphanSlices.length > 0) {
|
||||
discovered.push({
|
||||
id: "unsorted",
|
||||
label: "Unsorted",
|
||||
status: deriveStatusFromSlices(orphanSlices),
|
||||
slices: orphanSlices,
|
||||
});
|
||||
}
|
||||
return discovered;
|
||||
}
|
||||
// Fallback: railItem grouping only (Phase 3 behavior).
|
||||
return Array.from(slicesByRailItem.entries()).map(([k, slices]) => ({
|
||||
id: k,
|
||||
label: k === "unsorted" ? "Unsorted" : k,
|
||||
status: deriveStatus(slices),
|
||||
status: deriveStatusFromSlices(slices),
|
||||
slices,
|
||||
}));
|
||||
}, [slicesResp]);
|
||||
}, [discovery.unavailable, discovery.missions, slicesByRailItem]);
|
||||
|
||||
// A5 bounce-fix: replace hardcoded "openrig-work" with live-wired
|
||||
// workspace name from ConfigStore. When unset/unreachable: render an
|
||||
@@ -123,6 +193,15 @@ export function ProjectTreeView() {
|
||||
</button>
|
||||
{expanded.workspace ? (
|
||||
<ul className="ml-4 border-l border-stone-200">
|
||||
{discovery.unavailable && discovery.hint ? (
|
||||
<li
|
||||
data-testid="project-discovery-degraded"
|
||||
className="px-2 py-1 font-mono text-[9px] text-on-surface-variant italic"
|
||||
title={discovery.hint}
|
||||
>
|
||||
Filesystem mission discovery unavailable; showing railItem grouping.
|
||||
</li>
|
||||
) : null}
|
||||
{missions.length === 0 ? (
|
||||
<li className="px-2 py-1 font-mono text-[10px] text-on-surface-variant italic">
|
||||
No missions yet.
|
||||
@@ -144,22 +223,28 @@ export function ProjectTreeView() {
|
||||
<span className="font-mono text-[11px] text-stone-900 flex-1 truncate">
|
||||
{m.label}
|
||||
</span>
|
||||
<MissionStatusBadge status={m.status} testId={`project-mission-${m.id}-badge`} />
|
||||
<LiveMissionStatusBadge mission={m} />
|
||||
</button>
|
||||
{expanded[`mission-${m.id}`] ? (
|
||||
<ul className="ml-4 border-l border-stone-200">
|
||||
{m.slices.map((s) => (
|
||||
<li key={s.name}>
|
||||
<Link
|
||||
to="/project/slice/$sliceId"
|
||||
params={{ sliceId: s.name }}
|
||||
data-testid={`project-slice-${s.name}`}
|
||||
className="block px-2 py-0.5 font-mono text-xs text-on-surface hover:text-stone-900 hover:bg-surface-low truncate"
|
||||
>
|
||||
{s.displayName}
|
||||
</Link>
|
||||
{m.slices.length === 0 ? (
|
||||
<li className="px-2 py-0.5 font-mono text-[10px] text-on-surface-variant italic">
|
||||
No slices.
|
||||
</li>
|
||||
))}
|
||||
) : (
|
||||
m.slices.map((s) => (
|
||||
<li key={s.name}>
|
||||
<Link
|
||||
to="/project/slice/$sliceId"
|
||||
params={{ sliceId: s.name }}
|
||||
data-testid={`project-slice-${s.name}`}
|
||||
className="block px-2 py-0.5 font-mono text-xs text-on-surface hover:text-stone-900 hover:bg-surface-low truncate"
|
||||
>
|
||||
{s.displayName}
|
||||
</Link>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// V1 attempt-3 Phase 5 P5-5 — Filesystem-based Project tree mission discovery.
|
||||
//
|
||||
// Walks `<workspace.root>/missions/` via the existing /api/files/list daemon
|
||||
// route (no new daemon endpoint per SC-29). Returns the list of mission
|
||||
// directories so ProjectTreeView can render them as tree nodes alongside
|
||||
// (or instead of) the railItem-grouped slice fallback. Per project-tree.md
|
||||
// L13–L46 the Project tree shape is workspace > mission > slice; the
|
||||
// canonical mission unit is a directory under workspace.root/missions/.
|
||||
//
|
||||
// Resolution chain:
|
||||
// 1. useSettings → workspace.root (absolute path)
|
||||
// 2. useFilesRoots → list of (name, path) allowlist roots
|
||||
// 3. find allowlist root whose `path` is a prefix of workspace.root
|
||||
// (exact-equal preferred; "the workspace root" is the typical
|
||||
// registration case)
|
||||
// 4. compute relative path from root → workspace.root/missions
|
||||
// 5. useFilesList(rootName, relPath) → mission directory entries
|
||||
//
|
||||
// When no suitable root is registered (operator hasn't added workspace.root
|
||||
// to OPENRIG_FILES_ALLOWLIST), returns { unavailable: true } so the UI can
|
||||
// degrade to the legacy railItem-grouped slice listing without crashing.
|
||||
|
||||
import { useFilesRoots, useFilesList } from "./useFiles.js";
|
||||
import { useWorkspaceName } from "./useWorkspaceName.js";
|
||||
|
||||
export interface DiscoveredMission {
|
||||
/** Mission directory name (e.g., "recursive-self-improvement-v2"). */
|
||||
name: string;
|
||||
/** Allowlist root name to use with /api/files/* endpoints. */
|
||||
root: string;
|
||||
/** Path under root for this mission directory (e.g., "missions/foo"). */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface UseMissionDiscoveryResult {
|
||||
missions: DiscoveredMission[];
|
||||
/** True when allowlist has no root containing workspace.root. */
|
||||
unavailable: boolean;
|
||||
/** True while any underlying query is in flight. */
|
||||
isLoading: boolean;
|
||||
/** Hint text for the empty-state when unavailable. */
|
||||
hint: string | null;
|
||||
}
|
||||
|
||||
/** Returns true when `parent` is a path-prefix of `child`, treating both as
|
||||
* absolute filesystem paths. Trailing slashes are tolerated; segment
|
||||
* boundaries enforced (so "/work" is NOT a prefix of "/workspace"). */
|
||||
function isPathPrefix(parent: string, child: string): boolean {
|
||||
const p = parent.replace(/\/+$/, "");
|
||||
const c = child.replace(/\/+$/, "");
|
||||
if (c === p) return true;
|
||||
return c.startsWith(p + "/");
|
||||
}
|
||||
|
||||
/** Compute the relative path inside a root: child = "<root>/<rel>" → "<rel>". */
|
||||
function relativeUnder(rootPath: string, absChild: string): string {
|
||||
const p = rootPath.replace(/\/+$/, "");
|
||||
const c = absChild.replace(/\/+$/, "");
|
||||
if (c === p) return "";
|
||||
if (c.startsWith(p + "/")) return c.slice(p.length + 1);
|
||||
return c;
|
||||
}
|
||||
|
||||
export function useMissionDiscovery(): UseMissionDiscoveryResult {
|
||||
const workspace = useWorkspaceName();
|
||||
const rootsQuery = useFilesRoots();
|
||||
const rootsResp = rootsQuery.data;
|
||||
|
||||
// Resolution: pick the best-matching root for workspace.root, if any.
|
||||
let chosenRoot: { name: string; path: string } | null = null;
|
||||
if (workspace.root && rootsResp && "roots" in rootsResp) {
|
||||
// Exact match preferred.
|
||||
const exact = rootsResp.roots.find((r) => r.path === workspace.root);
|
||||
if (exact) {
|
||||
chosenRoot = exact;
|
||||
} else {
|
||||
// Otherwise pick the deepest root that is a prefix of workspace.root
|
||||
// (handles operator who registered ~/code as a root with workspace.root
|
||||
// = ~/code/projects/openrig-work).
|
||||
const prefixed = rootsResp.roots
|
||||
.filter((r) => workspace.root && isPathPrefix(r.path, workspace.root))
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
const first = prefixed[0];
|
||||
if (first) chosenRoot = first;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the missions/ relative path from the chosen root.
|
||||
const missionsRelPath =
|
||||
chosenRoot && workspace.root
|
||||
? (() => {
|
||||
const rel = relativeUnder(chosenRoot.path, workspace.root);
|
||||
return rel ? `${rel}/missions` : "missions";
|
||||
})()
|
||||
: null;
|
||||
|
||||
const listQuery = useFilesList(
|
||||
chosenRoot ? chosenRoot.name : null,
|
||||
missionsRelPath,
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
workspace.isLoading ||
|
||||
rootsQuery.isLoading ||
|
||||
(chosenRoot !== null && listQuery.isLoading);
|
||||
|
||||
// Unavailable cases:
|
||||
// - Settings unreachable (legacy v0.2.0 daemon without /api/config).
|
||||
// - No workspace.root configured.
|
||||
// - No allowlist root contains workspace.root (operator must register).
|
||||
if (!workspace.settingsAvailable && !workspace.isLoading) {
|
||||
return {
|
||||
missions: [],
|
||||
unavailable: true,
|
||||
isLoading,
|
||||
hint: "Daemon does not expose /api/config (likely v0.2.0). Upgrade to v0.3.0+ for live mission discovery.",
|
||||
};
|
||||
}
|
||||
if (!workspace.root && !workspace.isLoading) {
|
||||
return {
|
||||
missions: [],
|
||||
unavailable: true,
|
||||
isLoading,
|
||||
hint: "Configure a workspace root: rig config set workspace.root <path>",
|
||||
};
|
||||
}
|
||||
if (!chosenRoot && !rootsQuery.isLoading) {
|
||||
return {
|
||||
missions: [],
|
||||
unavailable: true,
|
||||
isLoading,
|
||||
hint: `No allowlist root contains workspace.root (${workspace.root}). Set OPENRIG_FILES_ALLOWLIST=<name>:<path> to register.`,
|
||||
};
|
||||
}
|
||||
|
||||
// listQuery may be in flight or errored; treat error as unavailable so the
|
||||
// UI falls back gracefully without throwing.
|
||||
if (listQuery.isError) {
|
||||
return {
|
||||
missions: [],
|
||||
unavailable: true,
|
||||
isLoading,
|
||||
hint: `Could not list ${missionsRelPath} under ${chosenRoot?.name}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = listQuery.data?.entries ?? [];
|
||||
const missions: DiscoveredMission[] = entries
|
||||
.filter((e) => e.type === "dir")
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
root: chosenRoot!.name,
|
||||
path: missionsRelPath ? `${missionsRelPath}/${e.name}` : e.name,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
missions,
|
||||
unavailable: false,
|
||||
isLoading,
|
||||
hint: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// V1 attempt-3 Phase 5 P5-6 — MissionStatusBadge live PROGRESS.md fetch.
|
||||
//
|
||||
// Fetches `<missionPath>/PROGRESS.md` via the existing /api/files/read
|
||||
// daemon route (no new daemon endpoint per SC-29) and parses the
|
||||
// frontmatter `status:` field via parseMissionStatus(). Per project-tree.md
|
||||
// L132–L133: "status derives from PROGRESS.md frontmatter or top-level
|
||||
// status: field. … Driver implements via a thin parser that reads
|
||||
// PROGRESS.md once per mission node load (NOT on every render); cache
|
||||
// invalidation on file mtime change."
|
||||
//
|
||||
// TanStack Query supplies the cache layer; staleTime + the underlying
|
||||
// /api/files/read response's `mtime` field together give us mtime-aware
|
||||
// caching: queries refetch when invalidated, and the daemon's mtime
|
||||
// metadata lets the UI show a fresh-vs-stale indicator if needed.
|
||||
|
||||
import { useFilesRead } from "./useFiles.js";
|
||||
import { parseMissionStatus, type MissionStatus } from "../components/MissionStatusBadge.js";
|
||||
|
||||
export interface UseMissionProgressStatusResult {
|
||||
status: MissionStatus;
|
||||
isLoading: boolean;
|
||||
/** When true, PROGRESS.md does not exist for this mission (or the read
|
||||
* failed); status is "unknown" and the UI may surface a hint. */
|
||||
unavailable: boolean;
|
||||
/** mtime of PROGRESS.md when known; null otherwise. Useful for fresh
|
||||
* indicators or invalidation hooks. */
|
||||
mtime: string | null;
|
||||
}
|
||||
|
||||
export function useMissionProgressStatus(
|
||||
root: string | null,
|
||||
missionPath: string | null,
|
||||
): UseMissionProgressStatusResult {
|
||||
const progressPath =
|
||||
root && missionPath ? `${missionPath}/PROGRESS.md` : null;
|
||||
const readQuery = useFilesRead(root, progressPath);
|
||||
|
||||
if (!root || !missionPath) {
|
||||
return { status: "unknown", isLoading: false, unavailable: true, mtime: null };
|
||||
}
|
||||
|
||||
if (readQuery.isLoading) {
|
||||
return { status: "unknown", isLoading: true, unavailable: false, mtime: null };
|
||||
}
|
||||
|
||||
if (readQuery.isError || !readQuery.data) {
|
||||
// 404 / unreadable PROGRESS.md = unknown; degrade quietly.
|
||||
return { status: "unknown", isLoading: false, unavailable: true, mtime: null };
|
||||
}
|
||||
|
||||
return {
|
||||
status: parseMissionStatus(readQuery.data.content),
|
||||
isLoading: false,
|
||||
unavailable: false,
|
||||
mtime: readQuery.data.mtime ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// V1 attempt-3 Phase 5 P5-5 + P5-6 — filesystem-based mission discovery +
|
||||
// MissionStatusBadge live PROGRESS.md fetch.
|
||||
//
|
||||
// Both features ride on the existing /api/files/list + /api/files/read
|
||||
// daemon routes (no new daemon endpoints; SC-29 honored). When the
|
||||
// allowlist doesn't expose workspace.root, the tree falls back to the
|
||||
// legacy railItem-grouped slice listing — also tested here.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
Outlet,
|
||||
} from "@tanstack/react-router";
|
||||
import { parseMissionStatus } from "../src/components/MissionStatusBadge.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// parseMissionStatus — unit-level (Phase 3 already covered most paths;
|
||||
// adding a few P5-6 path-specific cases for invariant guard).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("parseMissionStatus (P5-6 invariants)", () => {
|
||||
it("parses status:active from PROGRESS.md frontmatter", () => {
|
||||
expect(
|
||||
parseMissionStatus("---\nname: Mission X\nstatus: active\n---\n# body"),
|
||||
).toBe("active");
|
||||
});
|
||||
|
||||
it("parses status:shipped variants", () => {
|
||||
expect(parseMissionStatus("---\nstatus: shipped\n---")).toBe("shipped");
|
||||
expect(parseMissionStatus("---\nstatus: complete\n---")).toBe("shipped");
|
||||
expect(parseMissionStatus("---\nstatus: completed\n---")).toBe("shipped");
|
||||
expect(parseMissionStatus("---\nstatus: done\n---")).toBe("shipped");
|
||||
});
|
||||
|
||||
it("parses status:blocked variants", () => {
|
||||
expect(parseMissionStatus("---\nstatus: blocked\n---")).toBe("blocked");
|
||||
expect(parseMissionStatus("---\nstatus: stalled\n---")).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns 'unknown' on missing/empty/malformed input", () => {
|
||||
expect(parseMissionStatus(null)).toBe("unknown");
|
||||
expect(parseMissionStatus(undefined)).toBe("unknown");
|
||||
expect(parseMissionStatus("")).toBe("unknown");
|
||||
expect(parseMissionStatus("# no frontmatter")).toBe("unknown");
|
||||
expect(parseMissionStatus("---\nfoo: bar\n---")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// useMissionDiscovery — integration via ProjectTreeView (the real consumer).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
import { ProjectTreeView } from "../src/components/project/ProjectTreeView.js";
|
||||
|
||||
interface RenderTreeOpts {
|
||||
// Settings response: workspace.root absolute path. Pass null to render
|
||||
// the unset/unreachable empty-state.
|
||||
workspaceRoot: string | null;
|
||||
settingsAvailable?: boolean;
|
||||
// Files API mocks.
|
||||
roots: Array<{ name: string; path: string }>;
|
||||
// Map "<root>:<path>" → directory entries.
|
||||
listings?: Record<string, Array<{ name: string; type: "dir" | "file" }>>;
|
||||
// Map "<root>:<path>" → file content.
|
||||
reads?: Record<string, { content: string; mtime?: string }>;
|
||||
// useSlices response.
|
||||
slices?: Array<{ name: string; displayName: string; railItem: string | null; status: string; rawStatus: string | null; qitemCount: number; hasProofPacket: boolean; lastActivityAt: string | null }>;
|
||||
}
|
||||
|
||||
function setupFetch(opts: RenderTreeOpts) {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
// /api/config (settings)
|
||||
if (url.includes("/api/config")) {
|
||||
if (opts.settingsAvailable === false) {
|
||||
return new Response("not implemented", { status: 404 });
|
||||
}
|
||||
const settings: Record<string, { value: unknown }> = {};
|
||||
if (opts.workspaceRoot !== null) {
|
||||
settings["workspace.root"] = { value: opts.workspaceRoot };
|
||||
}
|
||||
return new Response(JSON.stringify({ settings }), { status: 200 });
|
||||
}
|
||||
// /api/files/roots
|
||||
if (url.includes("/api/files/roots")) {
|
||||
return new Response(JSON.stringify({ roots: opts.roots }), { status: 200 });
|
||||
}
|
||||
// /api/files/list?root=<name>&path=<rel>
|
||||
if (url.includes("/api/files/list")) {
|
||||
const u = new URL(url, "http://localhost");
|
||||
const root = u.searchParams.get("root") ?? "";
|
||||
const path = u.searchParams.get("path") ?? "";
|
||||
const key = `${root}:${path}`;
|
||||
const entries = opts.listings?.[key] ?? [];
|
||||
return new Response(
|
||||
JSON.stringify({ root, path, entries: entries.map((e) => ({ ...e, size: null, mtime: null })) }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
// /api/files/read?root=<name>&path=<rel>
|
||||
if (url.includes("/api/files/read")) {
|
||||
const u = new URL(url, "http://localhost");
|
||||
const root = u.searchParams.get("root") ?? "";
|
||||
const path = u.searchParams.get("path") ?? "";
|
||||
const key = `${root}:${path}`;
|
||||
const data = opts.reads?.[key];
|
||||
if (!data) return new Response("not found", { status: 404 });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
root,
|
||||
path,
|
||||
absolutePath: `/${root}/${path}`,
|
||||
content: data.content,
|
||||
mtime: data.mtime ?? "2026-05-06T18:00:00Z",
|
||||
contentHash: "deadbeef",
|
||||
size: data.content.length,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
// /api/slices?filter=...
|
||||
if (url.includes("/api/slices")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
slices: opts.slices ?? [],
|
||||
totalCount: opts.slices?.length ?? 0,
|
||||
filter: "all",
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response("[]");
|
||||
});
|
||||
}
|
||||
|
||||
function renderTree(opts: RenderTreeOpts): ReturnType<typeof render> {
|
||||
setupFetch(opts);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> });
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: () => <ProjectTreeView />,
|
||||
});
|
||||
const fallbackRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "$",
|
||||
component: () => null,
|
||||
});
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([indexRoute, fallbackRoute]),
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
|
||||
it("filesystem-discovered missions surface as tree nodes when allowlist exposes workspace root", async () => {
|
||||
const { findByTestId } = renderTree({
|
||||
workspaceRoot: "/Users/admin/.openrig/workspace",
|
||||
roots: [{ name: "workspace", path: "/Users/admin/.openrig/workspace" }],
|
||||
listings: {
|
||||
"workspace:missions": [
|
||||
{ name: "rsi-v2", type: "dir" },
|
||||
{ name: "shell-redesign-v1", type: "dir" },
|
||||
{ name: "README.md", type: "file" }, // file → filtered out
|
||||
],
|
||||
},
|
||||
reads: {
|
||||
"workspace:missions/rsi-v2/PROGRESS.md": {
|
||||
content: "---\nstatus: active\n---\n# RSI v2",
|
||||
},
|
||||
"workspace:missions/shell-redesign-v1/PROGRESS.md": {
|
||||
content: "---\nstatus: shipped\n---\n# Shell V1",
|
||||
},
|
||||
},
|
||||
slices: [],
|
||||
});
|
||||
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
|
||||
expect(await findByTestId("project-mission-shell-redesign-v1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("file-type entries under missions/ are not surfaced as missions", async () => {
|
||||
const { findByTestId, queryByTestId } = renderTree({
|
||||
workspaceRoot: "/Users/admin/.openrig/workspace",
|
||||
roots: [{ name: "workspace", path: "/Users/admin/.openrig/workspace" }],
|
||||
listings: {
|
||||
"workspace:missions": [
|
||||
{ name: "rsi-v2", type: "dir" },
|
||||
{ name: "README.md", type: "file" },
|
||||
],
|
||||
},
|
||||
slices: [],
|
||||
});
|
||||
await findByTestId("project-mission-rsi-v2");
|
||||
expect(queryByTestId("project-mission-README.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to railItem grouping when no allowlist root contains workspace.root", async () => {
|
||||
const { findByTestId } = renderTree({
|
||||
workspaceRoot: "/Users/admin/.openrig/workspace",
|
||||
roots: [{ name: "elsewhere", path: "/Users/admin/code/elsewhere" }],
|
||||
slices: [
|
||||
{
|
||||
name: "slice-a",
|
||||
displayName: "Slice A",
|
||||
railItem: "rsi-v2",
|
||||
status: "active",
|
||||
rawStatus: "active",
|
||||
qitemCount: 1,
|
||||
hasProofPacket: false,
|
||||
lastActivityAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await findByTestId("project-discovery-degraded")).toBeTruthy();
|
||||
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to railItem grouping when allowlist is empty", async () => {
|
||||
const { findByTestId } = renderTree({
|
||||
workspaceRoot: "/Users/admin/.openrig/workspace",
|
||||
roots: [],
|
||||
slices: [
|
||||
{
|
||||
name: "slice-x",
|
||||
displayName: "Slice X",
|
||||
railItem: null,
|
||||
status: "active",
|
||||
rawStatus: "active",
|
||||
qitemCount: 1,
|
||||
hasProofPacket: false,
|
||||
lastActivityAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await findByTestId("project-discovery-degraded")).toBeTruthy();
|
||||
expect(await findByTestId("project-mission-unsorted")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("workspace.root unconfigured renders the no-workspace empty-state (Phase 3 A5 behavior preserved)", async () => {
|
||||
const { findByTestId } = renderTree({
|
||||
workspaceRoot: null,
|
||||
roots: [],
|
||||
slices: [],
|
||||
});
|
||||
expect(await findByTestId("project-no-workspace")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("matches slices to filesystem missions by railItem; orphans go to 'unsorted'", async () => {
|
||||
const { findByTestId, queryByTestId } = renderTree({
|
||||
workspaceRoot: "/Users/admin/.openrig/workspace",
|
||||
roots: [{ name: "workspace", path: "/Users/admin/.openrig/workspace" }],
|
||||
listings: {
|
||||
"workspace:missions": [{ name: "rsi-v2", type: "dir" }],
|
||||
},
|
||||
reads: {
|
||||
"workspace:missions/rsi-v2/PROGRESS.md": { content: "---\nstatus: active\n---" },
|
||||
},
|
||||
slices: [
|
||||
{
|
||||
name: "slice-rsi",
|
||||
displayName: "RSI Slice",
|
||||
railItem: "rsi-v2",
|
||||
status: "active",
|
||||
rawStatus: "active",
|
||||
qitemCount: 0,
|
||||
hasProofPacket: false,
|
||||
lastActivityAt: null,
|
||||
},
|
||||
{
|
||||
name: "slice-orphan",
|
||||
displayName: "Orphan Slice",
|
||||
railItem: "no-such-mission",
|
||||
status: "active",
|
||||
rawStatus: "active",
|
||||
qitemCount: 0,
|
||||
hasProofPacket: false,
|
||||
lastActivityAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
|
||||
expect(await findByTestId("project-mission-unsorted")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user