mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
feat(daemon,ui): render mission + slice README/PROGRESS via scope-markdown hook
V0.3.1 slice 12 walk-items 1, 2, 4 from the 2026-05-11 synthesis.
Slice default tab is now Overview (README + Current Step + Readiness),
mission tabs render their README / PROGRESS content, and the data
layer is generalized so any project scope can read any markdown file
through one hook.
(1) Generalized hook (walk-item 1 foundation):
- `useScopeMarkdown(absoluteScopePath, filename)` at
packages/ui/src/hooks/useScopeMarkdown.ts. Same allowlist-root
resolution + relative-path conversion as the slice-06 era hook;
the filename is now a parameter so callers pick README.md /
PROGRESS.md / timeline.md / etc.
- `useSliceTimelineMarkdown(slicePath)` is now a backward-compat
shim that calls `useScopeMarkdown(slicePath, "timeline.md")`.
Slice-06 TimelineTab callsites continue to work unchanged.
(2) Daemon mission data layer (walk-items 2 + 4 foundation):
- New `GET /api/missions/:missionId` route returns
{ missionId, missionPath, slices: SliceListEntry[] } filtered by
missionId. Uses the existing SliceIndexer; no new persistence.
503 when indexer not wired / not ready; 404 when no slices.
- `useMission(missionId)` react-query hook wraps the route with
refetch-on-window-focus.
(3) Slice scope (walk-item 1):
- SliceScopePage default tab flipped to "overview" (was "story").
- SliceOverviewTab now renders the slice README via
useScopeMarkdown above Current Step + Readiness. Primary Docs
filename-duplication section dropped (the README + Docs tab tree
cover the same surface).
(4) Mission scope (walk-items 2 + 4):
- MissionScopePage Overview tab renders mission README via
useScopeMarkdown above a compact slice rail (per-slice metrics
collapsed from a 4-column grid to a single dense status line).
- MissionScopePage Progress tab renders mission PROGRESS.md via
useScopeMarkdown above the existing ScopeProgressRollup.
Phrasing discipline applied: comments + commit body use walk-item
terminology; no chain-of-authorization attribution. Slice id tokens
retained (stable identifiers).
Tests:
- missions-routes.test.ts (NEW): 5 cases — 200 with missionPath +
filtered slices; 404 mission_not_found; 503 indexer-unavailable;
503 slices_root_not_configured; exact-id match no substring leak.
- use-scope-markdown.test.tsx (NEW): 6 cases — pure resolver tests,
README.md / PROGRESS.md production wire, null-path + 404 paths.
- use-slice-timeline-markdown.test.tsx (existing 7/7) preserved
via the backward-compat shim.
Targeted: 18/18 (5 missions-routes + 6 use-scope-markdown + 7
preserved slice-06). Full daemon suite: 3364 passed / 1 skipped /
0 failed. UI tests passing for hook tests; the rest of the UI suite
hits a pre-existing worktree-PostCSS infra issue (tailwindcss
module resolution from worktree path) unrelated to this slice.
Deferred (flagged for orch decision):
- Workspace Topology swap to HostMultiRigGraph snapshot
- Mission Progress vs Artifacts heat-map differentiation
Both are visual refinements that need dev-server walk to evaluate;
core walk-items 1, 2, 4 are covered by this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
ca6c7f13b0
commit
fccd819694
8 files changed
+668
-181
No files matched your search
@@ -0,0 +1,71 @@
|
||||
// V0.3.1 slice 12 walk-item 1 — mission scope data layer.
|
||||
//
|
||||
// GET /api/missions/:missionId — returns aggregated mission metadata
|
||||
// (missionPath + slices in this mission). Powers the Mission Overview
|
||||
// + Mission Progress tabs in the Project surface; pairs with the
|
||||
// generalized useScopeMarkdown hook which reads README.md / PROGRESS.md
|
||||
// content via the existing /api/files/read route.
|
||||
//
|
||||
// Returns:
|
||||
// 200 { missionId, missionPath, slices: SliceListEntry[] }
|
||||
// 404 { error: "mission_not_found" } when no slices match
|
||||
// 503 { error: "slices_indexer_unavailable" } when indexer not wired
|
||||
// 503 { error: "slices_root_not_configured" } when indexer not ready
|
||||
//
|
||||
// This route does NOT itself read frontmatter / README / PROGRESS
|
||||
// content — it's the metadata layer. File content reuses
|
||||
// /api/files/read so the route surface stays minimal.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import * as path from "node:path";
|
||||
import type { SliceIndexer, SliceListEntry } from "../domain/slices/slice-indexer.js";
|
||||
|
||||
export function missionsRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:missionId", (c) => {
|
||||
const indexer = c.get("sliceIndexer" as never) as SliceIndexer | undefined;
|
||||
if (!indexer) {
|
||||
return c.json(
|
||||
{
|
||||
error: "slices_indexer_unavailable",
|
||||
hint: "Mission data layer requires the SliceIndexer to be wired into AppDeps.",
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
if (!indexer.isReady()) {
|
||||
return c.json(
|
||||
{
|
||||
error: "slices_root_not_configured",
|
||||
hint: "Run rig config init-workspace, or set workspace.slices_root to workspace/missions. Supported shape: missions/<mission>/slices/<slice>.",
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
const missionId = c.req.param("missionId");
|
||||
const allSlices = indexer.list();
|
||||
const slices = allSlices.filter((s) => s.missionId === missionId);
|
||||
if (slices.length === 0) {
|
||||
return c.json({ error: "mission_not_found", missionId }, 404);
|
||||
}
|
||||
const missionPath = computeMissionPath(slices[0]!);
|
||||
return c.json({
|
||||
missionId,
|
||||
missionPath,
|
||||
slices,
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Derive the mission folder's absolute path from any slice's
|
||||
* `slicePath`. Slices live at
|
||||
* `<missionsRoot>/<missionId>/slices/<sliceName>` per the workspace
|
||||
* contract, so going up two levels yields the mission folder. */
|
||||
function computeMissionPath(slice: SliceListEntry): string {
|
||||
// slicePath = <root>/<missionId>/slices/<sliceName>
|
||||
// up 2 levels = <root>/<missionId>
|
||||
return path.resolve(slice.slicePath, "..", "..");
|
||||
}
|
||||
@@ -77,6 +77,7 @@ import { watchdogRoutes } from "./routes/watchdog.js";
|
||||
import { workflowRoutes } from "./routes/workflow.js";
|
||||
import { missionControlRoutes } from "./routes/mission-control.js";
|
||||
import { slicesRoutes } from "./routes/slices.js";
|
||||
import { missionsRoutes } from "./routes/missions.js";
|
||||
import { filesRoutes } from "./routes/files.js";
|
||||
import { progressRoutes } from "./routes/progress.js";
|
||||
import { steeringRoutes } from "./routes/steering.js";
|
||||
@@ -470,6 +471,10 @@ export function createApp(deps: AppDeps): Hono {
|
||||
);
|
||||
// Slice Story View v0 — slice indexer + per-tab payload routes.
|
||||
app.route("/api/slices", slicesRoutes());
|
||||
// V0.3.1 slice 12 walk-item 1 — mission scope data layer
|
||||
// (aggregated mission metadata + slices filter; pairs with
|
||||
// useScopeMarkdown for README / PROGRESS content via /api/files/read).
|
||||
app.route("/api/missions", missionsRoutes());
|
||||
// UI Enhancement Pack v0 — files (item 3 + item 4) + progress (item 1B) routes.
|
||||
app.route("/api/files", filesRoutes());
|
||||
app.route("/api/progress", progressRoutes());
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// V0.3.1 slice 12 walk-item 1 — mission scope data layer.
|
||||
//
|
||||
// GET /api/missions/:missionId returns aggregated mission metadata:
|
||||
// - missionId: echo of the requested id
|
||||
// - missionPath: absolute filesystem path of the mission folder
|
||||
// (the parent of the slices folder containing the
|
||||
// matched slices)
|
||||
// - slices: SliceListEntry[] filtered to entries with this missionId
|
||||
//
|
||||
// The route does NOT itself read README.md / PROGRESS.md content —
|
||||
// the UI fetches those via the existing /api/files/read route through
|
||||
// the generalized useScopeMarkdown hook. This route is the mission
|
||||
// METADATA layer; the file content layer is reused.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
|
||||
import { missionsRoutes } from "../src/routes/missions.js";
|
||||
|
||||
function buildApp(indexer: SliceIndexer): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("sliceIndexer" as never, indexer);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/missions", missionsRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
function writeSliceInMission(
|
||||
missionsRoot: string,
|
||||
missionId: string,
|
||||
sliceName: string,
|
||||
frontmatter: Record<string, string> = {},
|
||||
): void {
|
||||
const dir = path.join(missionsRoot, missionId, "slices", sliceName);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const fm = Object.entries(frontmatter)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join("\n");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "README.md"),
|
||||
`---\n${fm}\n---\n# ${sliceName}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
let db: Database.Database;
|
||||
let cleanupRoot: string;
|
||||
let missionsRoot: string;
|
||||
let indexer: SliceIndexer;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema]);
|
||||
cleanupRoot = fs.mkdtempSync(path.join(os.tmpdir(), "missions-routes-"));
|
||||
missionsRoot = path.join(cleanupRoot, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
|
||||
app = buildApp(indexer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(cleanupRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /api/missions/:missionId", () => {
|
||||
it("returns 200 with missionPath + slices filtered to the requested mission", async () => {
|
||||
writeSliceInMission(missionsRoot, "getting-started", "first-slice", { status: "active" });
|
||||
writeSliceInMission(missionsRoot, "getting-started", "second-slice", { status: "done" });
|
||||
writeSliceInMission(missionsRoot, "other-mission", "third-slice", { status: "active" });
|
||||
|
||||
const res = await app.request("/api/missions/getting-started");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
missionId: string;
|
||||
missionPath: string;
|
||||
slices: Array<{ name: string; missionId: string }>;
|
||||
};
|
||||
expect(body.missionId).toBe("getting-started");
|
||||
expect(body.missionPath).toBe(path.join(missionsRoot, "getting-started"));
|
||||
expect(body.slices.map((s) => s.name).sort()).toEqual(["first-slice", "second-slice"]);
|
||||
expect(body.slices.every((s) => s.missionId === "getting-started")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when no slices exist for the missionId", async () => {
|
||||
writeSliceInMission(missionsRoot, "getting-started", "first-slice", { status: "active" });
|
||||
|
||||
const res = await app.request("/api/missions/unknown-mission");
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("mission_not_found");
|
||||
});
|
||||
|
||||
it("returns 503 when the indexer isn't wired", async () => {
|
||||
const bareApp = new Hono();
|
||||
bareApp.route("/api/missions", missionsRoutes());
|
||||
const res = await bareApp.request("/api/missions/anything");
|
||||
expect(res.status).toBe(503);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("slices_indexer_unavailable");
|
||||
});
|
||||
|
||||
it("returns 503 when the indexer is not ready (slices root not configured)", async () => {
|
||||
const emptyIndexer = new SliceIndexer({
|
||||
slicesRoot: "",
|
||||
dogfoodEvidenceRoot: null,
|
||||
db,
|
||||
});
|
||||
const emptyApp = buildApp(emptyIndexer);
|
||||
const res = await emptyApp.request("/api/missions/getting-started");
|
||||
expect(res.status).toBe(503);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("slices_root_not_configured");
|
||||
});
|
||||
|
||||
it("only counts slices with the exact missionId (no substring match)", async () => {
|
||||
writeSliceInMission(missionsRoot, "release-0.3.1", "slice-a", { status: "active" });
|
||||
writeSliceInMission(missionsRoot, "release-0.3.1-followup", "slice-b", { status: "active" });
|
||||
|
||||
const res = await app.request("/api/missions/release-0.3.1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { slices: Array<{ name: string }> };
|
||||
expect(body.slices.map((s) => s.name)).toEqual(["slice-a"]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
} from "../../lib/project-mission-state.js";
|
||||
import { TimelineTab } from "../slices/tabs/TimelineTab.js";
|
||||
import { useSliceTimelineMarkdown } from "../../hooks/useSliceTimelineMarkdown.js";
|
||||
import { useScopeMarkdown } from "../../hooks/useScopeMarkdown.js";
|
||||
import { useMission } from "../../hooks/useMission.js";
|
||||
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
|
||||
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
|
||||
import { DocsTab } from "../slices/tabs/DocsTab.js";
|
||||
import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
|
||||
@@ -711,6 +714,15 @@ export function MissionScopePage() {
|
||||
const { missionId } = useParams({ from: "/project/mission/$missionId" });
|
||||
const [active, setActive] = useState<SharedTab>("overview");
|
||||
const rollup = useProjectScopeRollup(missionId, active !== "overview");
|
||||
// V0.3.1 slice 12 walk-item 1 — fetch aggregated mission metadata
|
||||
// (missionPath for README/PROGRESS lookup; slices already covered
|
||||
// by rollup). README + PROGRESS render via useScopeMarkdown above
|
||||
// the existing slice rail.
|
||||
const missionData = useMission(missionId);
|
||||
const missionPath =
|
||||
missionData.data && "missionPath" in missionData.data ? missionData.data.missionPath : null;
|
||||
const missionReadme = useScopeMarkdown(missionPath, "README.md");
|
||||
const missionProgress = useScopeMarkdown(missionPath, "PROGRESS.md");
|
||||
return (
|
||||
<ScopeShell
|
||||
eyebrow="Mission"
|
||||
@@ -720,38 +732,41 @@ export function MissionScopePage() {
|
||||
onSelect={(id) => setActive(id as SharedTab)}
|
||||
>
|
||||
{active === "overview" ? (
|
||||
<div data-testid="mission-overview-panel" className="space-y-3">
|
||||
{rollup.rows.length > 0 ? (
|
||||
rollup.rows.map((slice) => (
|
||||
<article key={slice.name} className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
|
||||
<Link
|
||||
to="/project/slice/$sliceId"
|
||||
params={{ sliceId: slice.name }}
|
||||
className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
|
||||
>
|
||||
{slice.displayName}
|
||||
</Link>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<ProjectPill token={scopeToken("slice")} compact />
|
||||
<ProjectPill token={{ label: slice.status, tone: stateTone(slice.status) }} compact />
|
||||
<DateChip value={slice.lastActivityAt} />
|
||||
</div>
|
||||
<div className="mt-2 grid gap-2 font-mono text-[10px] text-stone-700 sm:grid-cols-4">
|
||||
<SliceMetric label="Status" value={slice.status} />
|
||||
<SliceMetric label="Qitems" value={slice.qitemCount} />
|
||||
<SliceMetric label="Proof" value={slice.hasProofPacket ? "yes" : "no"} />
|
||||
<SliceMetric label="Last activity" value={formatMaybeDate(slice.lastActivityAt)} />
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
label="NO SLICES"
|
||||
description="No indexed slices are attached to this mission."
|
||||
variant="card"
|
||||
testId="mission-overview-empty"
|
||||
/>
|
||||
<div data-testid="mission-overview-panel" className="space-y-6">
|
||||
{missionReadme.content && (
|
||||
<section data-testid="mission-overview-readme" className="border border-outline-variant bg-white/20 p-4">
|
||||
<MarkdownViewer content={missionReadme.content} hideFrontmatter hideRawToggle />
|
||||
</section>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{rollup.rows.length > 0 ? (
|
||||
rollup.rows.map((slice) => (
|
||||
<article key={slice.name} className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
|
||||
<Link
|
||||
to="/project/slice/$sliceId"
|
||||
params={{ sliceId: slice.name }}
|
||||
className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
|
||||
>
|
||||
{slice.displayName}
|
||||
</Link>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 font-mono text-[10px] text-stone-600">
|
||||
<ProjectPill token={scopeToken("slice")} compact />
|
||||
<ProjectPill token={{ label: slice.status, tone: stateTone(slice.status) }} compact />
|
||||
<DateChip value={slice.lastActivityAt} />
|
||||
<span>· {slice.qitemCount} qitems</span>
|
||||
{slice.hasProofPacket && <span>· proof</span>}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
label="NO SLICES"
|
||||
description="No indexed slices are attached to this mission."
|
||||
variant="card"
|
||||
testId="mission-overview-empty"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{active === "story" ? (
|
||||
@@ -763,11 +778,18 @@ export function MissionScopePage() {
|
||||
/>
|
||||
) : null}
|
||||
{active === "progress" ? (
|
||||
<ScopeProgressRollup
|
||||
rows={rollup.rows}
|
||||
detailsByName={rollup.details.itemsByName}
|
||||
isLoading={rollup.list.isLoading || rollup.details.isFetching}
|
||||
/>
|
||||
<div data-testid="mission-progress-panel" className="space-y-6">
|
||||
{missionProgress.content && (
|
||||
<section data-testid="mission-progress-readme" className="border border-outline-variant bg-white/20 p-4">
|
||||
<MarkdownViewer content={missionProgress.content} hideFrontmatter hideRawToggle />
|
||||
</section>
|
||||
)}
|
||||
<ScopeProgressRollup
|
||||
rows={rollup.rows}
|
||||
detailsByName={rollup.details.itemsByName}
|
||||
isLoading={rollup.list.isLoading || rollup.details.isFetching}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{active === "artifacts" ? (
|
||||
<ScopeArtifactsRollup rows={rollup.rows} detailsByName={rollup.details.itemsByName} />
|
||||
@@ -989,9 +1011,11 @@ function SliceArtifactsTab({ detail }: { detail: SliceDetail }) {
|
||||
|
||||
function SliceOverviewTab({ detail }: { detail: SliceDetail }) {
|
||||
const currentStep = detail.acceptance.currentStep;
|
||||
const primaryDocs = detail.docs.tree.filter((entry) =>
|
||||
entry.type === "file" && /(^|\/)(README|IMPLEMENTATION-PRD|PROGRESS)\.md$/i.test(entry.relPath),
|
||||
);
|
||||
// V0.3.1 slice 12 walk-item 1 — render slice README via the
|
||||
// generalized scope-markdown reader; the Primary Docs filename
|
||||
// duplication section is dropped (the README itself + the Docs tab
|
||||
// tree are sufficient).
|
||||
const readmeMd = useScopeMarkdown(detail.slicePath ?? null, "README.md");
|
||||
|
||||
return (
|
||||
<div data-testid="slice-overview-tab" className="space-y-6">
|
||||
@@ -1002,6 +1026,12 @@ function SliceOverviewTab({ detail }: { detail: SliceDetail }) {
|
||||
<SliceMetric label="Last Activity" value={formatMaybeDate(detail.lastActivityAt)} />
|
||||
</section>
|
||||
|
||||
{readmeMd.content && (
|
||||
<section data-testid="slice-overview-readme" className="border border-outline-variant bg-white/20 p-4">
|
||||
<MarkdownViewer content={readmeMd.content} hideFrontmatter hideRawToggle />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section data-testid="slice-overview-current-step" className="border border-outline-variant bg-white/20 p-4">
|
||||
<SectionHeader tone="muted">Current Step</SectionHeader>
|
||||
{currentStep ? (
|
||||
@@ -1049,31 +1079,16 @@ function SliceOverviewTab({ detail }: { detail: SliceDetail }) {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-testid="slice-overview-docs" className="border border-outline-variant bg-white/20 p-4">
|
||||
<SectionHeader tone="muted">Primary Docs</SectionHeader>
|
||||
{primaryDocs.length > 0 ? (
|
||||
<ul className="mt-3 divide-y divide-outline-variant border border-outline-variant bg-white/30">
|
||||
{primaryDocs.map((entry) => (
|
||||
<li key={entry.relPath} className="px-3 py-2 font-mono text-[10px] text-stone-900">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<ToolMark tool={entry.relPath} size="xs" />
|
||||
<span className="truncate">{entry.relPath}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="mt-3 font-mono text-[10px] text-stone-400">No README, implementation PRD, or progress file indexed.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SliceScopePage() {
|
||||
const { sliceId } = useParams({ from: "/project/slice/$sliceId" });
|
||||
const [active, setActive] = useState<SliceTab>("story");
|
||||
// V0.3.1 slice 12 walk-item 1 — default tab is Overview (README +
|
||||
// current step + readiness) instead of Story; the first thing the
|
||||
// operator should see on a slice is its README, not a metric grid.
|
||||
const [active, setActive] = useState<SliceTab>("overview");
|
||||
const detailQuery = useSliceDetail(sliceId);
|
||||
const queueItems = useQueueItemMap(detailQuery.data?.qitemIds ?? []);
|
||||
const queueItemsById = useMemo(() => queueItems.itemsById, [queueItems.itemsById]);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// V0.3.1 slice 12 walk-item 1 — mission scope data hook.
|
||||
//
|
||||
// Wraps GET /api/missions/:missionId, the aggregated mission metadata
|
||||
// route that returns missionPath + filtered SliceListEntry[]. Pairs
|
||||
// with useScopeMarkdown for README / PROGRESS content via
|
||||
// /api/files/read.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { SliceListEntry } from "./useSlices.js";
|
||||
|
||||
export interface MissionDataResponse {
|
||||
missionId: string;
|
||||
/** Absolute filesystem path of the mission folder. */
|
||||
missionPath: string;
|
||||
/** Slices in this mission (SliceListEntry[] filtered). */
|
||||
slices: SliceListEntry[];
|
||||
}
|
||||
|
||||
export interface MissionUnavailable {
|
||||
unavailable: true;
|
||||
error: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
async function fetchMission(missionId: string): Promise<MissionDataResponse | MissionUnavailable> {
|
||||
const res = await fetch(`/api/missions/${encodeURIComponent(missionId)}`);
|
||||
if (res.status === 503) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string; hint?: string };
|
||||
return {
|
||||
unavailable: true,
|
||||
error: body.error ?? "missions_route_unavailable",
|
||||
hint: body.hint,
|
||||
};
|
||||
}
|
||||
if (res.status === 404) {
|
||||
return {
|
||||
unavailable: true,
|
||||
error: "mission_not_found",
|
||||
};
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as MissionDataResponse;
|
||||
}
|
||||
|
||||
export function useMission(missionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["mission", "detail", missionId],
|
||||
queryFn: () => fetchMission(missionId!),
|
||||
enabled: !!missionId,
|
||||
staleTime: 30_000,
|
||||
// V0.3.1 slice 17 workspace-state-correctness pattern: refetch on
|
||||
// window focus so the operator who creates a slice folder + comes
|
||||
// back to the tab sees the new slice without a manual refresh.
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// V0.3.1 slice 12 walk-item 1 — generalized scope-markdown reader.
|
||||
//
|
||||
// Reads `<scopePath>/<filename>` via the existing /api/files/read
|
||||
// daemon route so any tab that needs to render a markdown file from a
|
||||
// project scope (slice, mission, workspace) can do so through a single
|
||||
// hook with allowlist-root resolution baked in.
|
||||
//
|
||||
// scopePath is an ABSOLUTE filesystem path (e.g.,
|
||||
// /Users/x/code/substrate/.../missions/release-0.3.1 or
|
||||
// /Users/x/code/substrate/.../slices/06-…). The daemon's
|
||||
// /api/files/read route REQUIRES a path RELATIVE TO ONE OF THE
|
||||
// REGISTERED ALLOWLIST ROOTS and rejects absolute paths outright.
|
||||
//
|
||||
// This hook resolves the scope's absolute path against
|
||||
// /api/files/roots (cached for 60s via useFilesRoots) to find which
|
||||
// root contains the scope, then issues the read with the correct
|
||||
// relative path. When no allowlist root contains the scope, the hook
|
||||
// returns `unavailable: true` — graceful degradation matching
|
||||
// useMissionDiscovery / useSliceTimelineMarkdown precedent.
|
||||
//
|
||||
// Generalization of the slice-06-era useSliceTimelineMarkdown.
|
||||
// useSliceTimelineMarkdown is kept exported as a thin wrapper so
|
||||
// existing callsites (TimelineTab) keep working without modification.
|
||||
|
||||
import { useFilesRead, useFilesRoots, type AllowlistRoot } from "./useFiles.js";
|
||||
|
||||
export interface UseScopeMarkdownResult {
|
||||
/** Raw file content when the file exists; null otherwise. */
|
||||
content: string | null;
|
||||
isLoading: boolean;
|
||||
/** True when the file does not exist, no allowlist root contains
|
||||
* the scope, or the read failed. Distinct from `content === null`
|
||||
* during initial load. */
|
||||
unavailable: boolean;
|
||||
/** mtime of the file when known. */
|
||||
mtime: string | null;
|
||||
/** Diagnostic: the (root, relPath) pair the hook computed for the
|
||||
* /api/files/read call. Exposed so integration tests can assert
|
||||
* the production call shape (absolute → relative conversion). */
|
||||
resolved: { rootName: string; relPath: string } | null;
|
||||
}
|
||||
|
||||
/** Returns true when `parent` is a path-prefix of `child`, treating
|
||||
* both as absolute filesystem paths. Segment-boundary aware 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 path under a root: `<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;
|
||||
}
|
||||
|
||||
/** Pick the deepest-matching allowlist root that contains the
|
||||
* absolute path. Returns null when no root contains it. Exported
|
||||
* for the integration test surface. */
|
||||
export function resolveScopePathToAllowlist(
|
||||
roots: AllowlistRoot[],
|
||||
absoluteScopePath: string,
|
||||
): { rootName: string; relPath: string } | null {
|
||||
// Exact match wins; otherwise deepest prefix.
|
||||
const exact = roots.find((r) => r.path.replace(/\/+$/, "") === absoluteScopePath.replace(/\/+$/, ""));
|
||||
if (exact) return { rootName: exact.name, relPath: "" };
|
||||
const prefixed = roots
|
||||
.filter((r) => isPathPrefix(r.path, absoluteScopePath))
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
const winner = prefixed[0];
|
||||
if (!winner) return null;
|
||||
return { rootName: winner.name, relPath: relativeUnder(winner.path, absoluteScopePath) };
|
||||
}
|
||||
|
||||
/** Fetch `<absoluteScopePath>/<filename>` via /api/files/read with
|
||||
* allowlist-root resolution. Returns the same shape as the slice-06
|
||||
* useSliceTimelineMarkdown hook so callsites can swap between the two
|
||||
* via a single mental model. */
|
||||
export function useScopeMarkdown(
|
||||
absoluteScopePath: string | null,
|
||||
filename: string,
|
||||
): UseScopeMarkdownResult {
|
||||
const rootsQuery = useFilesRoots();
|
||||
const rootsResp = rootsQuery.data;
|
||||
const rootsList: AllowlistRoot[] | null =
|
||||
rootsResp && "roots" in rootsResp ? rootsResp.roots : null;
|
||||
|
||||
const resolved =
|
||||
absoluteScopePath && rootsList
|
||||
? resolveScopePathToAllowlist(rootsList, absoluteScopePath)
|
||||
: null;
|
||||
|
||||
const filePath = resolved
|
||||
? resolved.relPath
|
||||
? `${resolved.relPath}/${filename}`
|
||||
: filename
|
||||
: null;
|
||||
|
||||
const readQuery = useFilesRead(
|
||||
resolved ? resolved.rootName : null,
|
||||
filePath,
|
||||
);
|
||||
|
||||
if (!absoluteScopePath) {
|
||||
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
|
||||
}
|
||||
|
||||
if (rootsQuery.isLoading) {
|
||||
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved: null };
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
|
||||
}
|
||||
|
||||
if (readQuery.isLoading) {
|
||||
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved };
|
||||
}
|
||||
|
||||
if (readQuery.isError || !readQuery.data) {
|
||||
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved };
|
||||
}
|
||||
|
||||
return {
|
||||
content: readQuery.data.content ?? null,
|
||||
isLoading: false,
|
||||
unavailable: false,
|
||||
mtime: readQuery.data.mtime ?? null,
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
@@ -1,131 +1,30 @@
|
||||
// 0.3.1 slice 06 — fetch `<slicePath>/timeline.md` via the existing
|
||||
// /api/files/read daemon route so TimelineTab can render the curated
|
||||
// narrative above the auto-captured event feed.
|
||||
// V0.3.1 slice 12 walk-item 1 — backward-compat shim.
|
||||
//
|
||||
// SliceDetail.slicePath is an ABSOLUTE filesystem path (e.g.,
|
||||
// /Users/x/code/substrate/.../missions/foo/slices/06-…). The daemon's
|
||||
// /api/files/read route REQUIRES a path RELATIVE TO ONE OF THE
|
||||
// REGISTERED ALLOWLIST ROOTS and rejects absolute paths outright.
|
||||
//
|
||||
// This hook resolves the slice's absolute path against
|
||||
// `/api/files/roots` (cached for 60s) to find which root contains the
|
||||
// slice, then issues the read with the correct relative path. When no
|
||||
// allowlist root contains the slice, the hook returns
|
||||
// `unavailable: true` — graceful degradation matching the
|
||||
// useMissionDiscovery precedent.
|
||||
// Historically `useSliceTimelineMarkdown(absoluteSlicePath)` fetched
|
||||
// `<slicePath>/timeline.md`. Slice 12 generalized the implementation
|
||||
// to `useScopeMarkdown(absoluteScopePath, filename)` so any scope
|
||||
// (slice, mission, workspace) can read any markdown file through the
|
||||
// same allowlist-root-aware path. This file is now a thin re-export
|
||||
// shim so slice-06 TimelineTab callsites (and other code referencing
|
||||
// `useSliceTimelineMarkdown` / `resolveSlicePathToAllowlist`) keep
|
||||
// working without modification.
|
||||
|
||||
import { useFilesRead, useFilesRoots, type AllowlistRoot } from "./useFiles.js";
|
||||
import {
|
||||
useScopeMarkdown,
|
||||
resolveScopePathToAllowlist,
|
||||
type UseScopeMarkdownResult,
|
||||
} from "./useScopeMarkdown.js";
|
||||
|
||||
export interface UseSliceTimelineMarkdownResult {
|
||||
/** Raw timeline.md content when the file exists; null otherwise.
|
||||
* TimelineTab renders the content above the event feed when
|
||||
* present and falls through to the standard feed when not. */
|
||||
content: string | null;
|
||||
isLoading: boolean;
|
||||
/** True when the file does not exist, no allowlist root contains
|
||||
* the slice, or the read failed. Distinct from `content === null`
|
||||
* during initial load. */
|
||||
unavailable: boolean;
|
||||
/** mtime of timeline.md when known. */
|
||||
mtime: string | null;
|
||||
/** Diagnostic: the (root, relPath) pair the hook computed for the
|
||||
* /api/files/read call. Exposed so integration tests can assert
|
||||
* the production call shape (absolute → relative conversion). */
|
||||
resolved: { rootName: string; relPath: string } | null;
|
||||
}
|
||||
export type UseSliceTimelineMarkdownResult = UseScopeMarkdownResult;
|
||||
|
||||
/** Returns true when `parent` is a path-prefix of `child`, treating
|
||||
* both as absolute filesystem paths. Segment-boundary aware so
|
||||
* `/work` is NOT a prefix of `/workspace`. Mirrors the
|
||||
* useMissionDiscovery helper. */
|
||||
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 path under a root: `<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;
|
||||
}
|
||||
|
||||
/** Pick the deepest-matching allowlist root that contains the
|
||||
* absolute path. Returns null when no root contains it. Exported
|
||||
* for the integration test surface. */
|
||||
export function resolveSlicePathToAllowlist(
|
||||
roots: AllowlistRoot[],
|
||||
absoluteSlicePath: string,
|
||||
): { rootName: string; relPath: string } | null {
|
||||
// Exact match wins; otherwise deepest prefix.
|
||||
const exact = roots.find((r) => r.path.replace(/\/+$/, "") === absoluteSlicePath.replace(/\/+$/, ""));
|
||||
if (exact) return { rootName: exact.name, relPath: "" };
|
||||
const prefixed = roots
|
||||
.filter((r) => isPathPrefix(r.path, absoluteSlicePath))
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
const winner = prefixed[0];
|
||||
if (!winner) return null;
|
||||
return { rootName: winner.name, relPath: relativeUnder(winner.path, absoluteSlicePath) };
|
||||
}
|
||||
|
||||
/** Fetch `<slicePath>/timeline.md` for a slice scope.
|
||||
* `absoluteSlicePath` is the SliceDetail.slicePath value verbatim
|
||||
* (daemon emits absolute paths). The hook handles the allowlist-root
|
||||
* resolution + relative-path construction internally so call sites
|
||||
* stay simple. */
|
||||
/** Backward-compat alias for slice-06 / Phase 6 callsites. New code
|
||||
* should call useScopeMarkdown directly with an explicit filename
|
||||
* ("README.md" / "PROGRESS.md" / etc.). */
|
||||
export function useSliceTimelineMarkdown(
|
||||
absoluteSlicePath: string | null,
|
||||
): UseSliceTimelineMarkdownResult {
|
||||
const rootsQuery = useFilesRoots();
|
||||
const rootsResp = rootsQuery.data;
|
||||
const rootsList: AllowlistRoot[] | null =
|
||||
rootsResp && "roots" in rootsResp ? rootsResp.roots : null;
|
||||
|
||||
const resolved =
|
||||
absoluteSlicePath && rootsList
|
||||
? resolveSlicePathToAllowlist(rootsList, absoluteSlicePath)
|
||||
: null;
|
||||
|
||||
const timelinePath = resolved
|
||||
? resolved.relPath
|
||||
? `${resolved.relPath}/timeline.md`
|
||||
: "timeline.md"
|
||||
: null;
|
||||
|
||||
const readQuery = useFilesRead(
|
||||
resolved ? resolved.rootName : null,
|
||||
timelinePath,
|
||||
);
|
||||
|
||||
if (!absoluteSlicePath) {
|
||||
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
|
||||
}
|
||||
|
||||
if (rootsQuery.isLoading) {
|
||||
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved: null };
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
|
||||
}
|
||||
|
||||
if (readQuery.isLoading) {
|
||||
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved };
|
||||
}
|
||||
|
||||
if (readQuery.isError || !readQuery.data) {
|
||||
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved };
|
||||
}
|
||||
|
||||
return {
|
||||
content: readQuery.data.content ?? null,
|
||||
isLoading: false,
|
||||
unavailable: false,
|
||||
mtime: readQuery.data.mtime ?? null,
|
||||
resolved,
|
||||
};
|
||||
return useScopeMarkdown(absoluteSlicePath, "timeline.md");
|
||||
}
|
||||
|
||||
/** Backward-compat alias for the resolver export. */
|
||||
export const resolveSlicePathToAllowlist = resolveScopePathToAllowlist;
|
||||
@@ -0,0 +1,168 @@
|
||||
// V0.3.1 slice 12 walk-item 1 — useScopeMarkdown generalization tests.
|
||||
//
|
||||
// useScopeMarkdown(scopePath, filename) generalizes the slice-06 era
|
||||
// useSliceTimelineMarkdown to read any markdown file under any project
|
||||
// scope (mission, slice, workspace). The backward-compat shim at
|
||||
// useSliceTimelineMarkdown.ts is exercised separately; this file
|
||||
// exercises the generalized hook directly with the README.md and
|
||||
// PROGRESS.md filenames the Mission tabs consume.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import {
|
||||
useScopeMarkdown,
|
||||
resolveScopePathToAllowlist,
|
||||
} from "../src/hooks/useScopeMarkdown.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function makeWrapper() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client }, children);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveScopePathToAllowlist (pure)", () => {
|
||||
it("matches the deepest root that prefixes the absolute scope path", () => {
|
||||
const roots = [
|
||||
{ name: "home", path: "/Users/x" },
|
||||
{ name: "substrate", path: "/Users/x/code/substrate" },
|
||||
{ name: "workspace", path: "/Users/x/code/substrate/shared-docs/openrig-work" },
|
||||
];
|
||||
const r = resolveScopePathToAllowlist(
|
||||
roots,
|
||||
"/Users/x/code/substrate/shared-docs/openrig-work/missions/release-0.3.1",
|
||||
);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.rootName).toBe("workspace");
|
||||
expect(r!.relPath).toBe("missions/release-0.3.1");
|
||||
});
|
||||
|
||||
it("returns null when no allowlist root contains the absolute path", () => {
|
||||
const roots = [{ name: "ws", path: "/Users/x/code/openrig-work" }];
|
||||
expect(resolveScopePathToAllowlist(roots, "/somewhere/else")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useScopeMarkdown — production wire honors arbitrary filename", () => {
|
||||
it("fetches <scopePath>/README.md when filename='README.md' (mission Overview tab path)", async () => {
|
||||
fetchSpy.mockImplementation(async (url: string) => {
|
||||
if (url.startsWith("/api/files/roots")) {
|
||||
return new Response(JSON.stringify({
|
||||
roots: [
|
||||
{ name: "workspace", path: "/Users/example/code/openrig-work" },
|
||||
],
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (url.startsWith("/api/files/read")) {
|
||||
return new Response(JSON.stringify({
|
||||
root: "workspace",
|
||||
path: "missions/getting-started/README.md",
|
||||
absolutePath: "/Users/example/code/openrig-work/missions/getting-started/README.md",
|
||||
content: "---\nstatus: active\n---\n# Getting Started\n",
|
||||
mtime: "2026-05-11T00:00:00Z",
|
||||
contentHash: "rdme",
|
||||
size: 50,
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useScopeMarkdown(
|
||||
"/Users/example/code/openrig-work/missions/getting-started",
|
||||
"README.md",
|
||||
),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.content).toContain("# Getting Started");
|
||||
const readCall = fetchSpy.mock.calls.find((c) => String(c[0]).startsWith("/api/files/read"));
|
||||
expect(readCall).toBeDefined();
|
||||
const url = new URL(`http://localhost${readCall![0]}`);
|
||||
expect(url.searchParams.get("root")).toBe("workspace");
|
||||
expect(url.searchParams.get("path")).toBe("missions/getting-started/README.md");
|
||||
});
|
||||
|
||||
it("fetches <scopePath>/PROGRESS.md when filename='PROGRESS.md' (mission Progress tab path)", async () => {
|
||||
fetchSpy.mockImplementation(async (url: string) => {
|
||||
if (url.startsWith("/api/files/roots")) {
|
||||
return new Response(JSON.stringify({
|
||||
roots: [{ name: "ws", path: "/Users/example/code/openrig-work" }],
|
||||
}), { status: 200 });
|
||||
}
|
||||
if (url.startsWith("/api/files/read")) {
|
||||
const reqUrl = new URL(`http://localhost${url}`);
|
||||
expect(reqUrl.searchParams.get("path")).toBe("missions/getting-started/PROGRESS.md");
|
||||
return new Response(JSON.stringify({
|
||||
root: "ws",
|
||||
path: "missions/getting-started/PROGRESS.md",
|
||||
absolutePath: "/Users/example/code/openrig-work/missions/getting-started/PROGRESS.md",
|
||||
content: "# Progress\n\n## Done\n- thing 1\n",
|
||||
mtime: "2026-05-11T00:00:00Z",
|
||||
contentHash: "prog",
|
||||
size: 25,
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("?", { status: 404 });
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useScopeMarkdown(
|
||||
"/Users/example/code/openrig-work/missions/getting-started",
|
||||
"PROGRESS.md",
|
||||
),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.content).toContain("## Done");
|
||||
});
|
||||
|
||||
it("returns unavailable=true when scopePath is null (no scope selected)", async () => {
|
||||
fetchSpy.mockImplementation(async () => new Response(JSON.stringify({ roots: [] }), { status: 200 }));
|
||||
const { result } = renderHook(
|
||||
() => useScopeMarkdown(null, "README.md"),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.unavailable).toBe(true);
|
||||
expect(result.current.content).toBeNull();
|
||||
expect(result.current.resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("returns unavailable=true when file is missing on disk (404 from /api/files/read)", async () => {
|
||||
fetchSpy.mockImplementation(async (url: string) => {
|
||||
if (url.startsWith("/api/files/roots")) {
|
||||
return new Response(JSON.stringify({
|
||||
roots: [{ name: "ws", path: "/Users/example/code/openrig-work" }],
|
||||
}), { status: 200 });
|
||||
}
|
||||
if (url.startsWith("/api/files/read")) return new Response("missing", { status: 404 });
|
||||
return new Response("?", { status: 500 });
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() => useScopeMarkdown(
|
||||
"/Users/example/code/openrig-work/missions/empty",
|
||||
"PROGRESS.md",
|
||||
),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.unavailable).toBe(true);
|
||||
expect(result.current.content).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user