mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
feat(slices,ui): typed slice-tag preference + Explorer refresh + queue DESC sort
V0.3.1 slice 17 founder-walk-workspace-state-correctness. Three
correctness fixes from the 2026-05-11 founder walk (items 3, 8, 10).
(1) Item 3 — qitems-per-slice filter (slice-indexer matchQitems):
pre-fix unioned `body LIKE` / `tags LIKE` on
[sliceName, railItem, missionId]. The missionId substring term
over-matched: a qitem tagged only `mission:<id>` appeared under
EVERY slice in that mission. Fix: when at least one qitem has the
typed `slice:<sliceName>` tag, the missionId substring term is
dropped — the typed tag is the authoritative slice membership
signal. Substring fallback (including missionId) preserved when
no typed-tag rows exist so legacy corpora don't regress.
(2) Item 8 — Explorer auto-show:
- new POST /api/slices/refresh route invokes indexer.invalidate()
to drop both listing + detail caches
- new useRefreshSlices mutation hook posts to /refresh then
invalidates the slices + files react-query caches
- ProjectTreeView gains a refresh button (RefreshCw lucide icon
with spin-while-pending) on its header
- useSlices + useFilesList opt into refetchOnWindowFocus so the
common "switched away to mkdir + came back" path picks up new
folders without manual click
(3) Item 10 — slice queue descending order:
ScopeQueueRollup sorts qitemIds DESC by tsCreated (with qitem-id
fallback before queueItemsById is loaded, since the id encodes the
timestamp prefix). Latest qitem at top.
Tests:
- New `when typed slice:<name> tag matches exist, mission-tagged-only
qitems are NOT included` (red→green proof of the over-match fix)
- New `preserves legacy substring fallback (including mission body)
when NO typed slice: tag exists` (HG-2 — no regression for legacy
corpora)
- New POST /api/slices/refresh tests (cache reset + 503 envelope)
slice-indexer 27/27 + slices-routes 21/21 PASS. Full daemon suite
3351 passed / 1 skipped / 0 failed on clean re-run (initial 6
failures were flaky 5s createDaemon timeouts that resolved on
re-run — same pattern banked during slice 05). UI typecheck on
touched files clean; pre-existing tanstack-react-table errors in
SpecsTable + TopologyTableView unrelated.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
3c75cb2282
commit
1341572c89
8 files changed
+233
-12
No files matched your search
@@ -407,21 +407,51 @@ export class SliceIndexer {
|
||||
}
|
||||
|
||||
private matchQitems(sliceName: string, railItem: string | null, missionId: string | null): string[] {
|
||||
// Strategy: union qitems whose body or tags mention the slice id,
|
||||
// rail item, or mission id. Body matching preserves the legacy corpus;
|
||||
// tags make the mission-aware default workspace precise for new work.
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (founder
|
||||
// item 3): the previous implementation unioned substring matches on
|
||||
// [sliceName, railItem, missionId]. The missionId term over-matched
|
||||
// — every qitem tagged `mission:<id>` appeared under EVERY slice in
|
||||
// that mission. Fix: when typed `slice:<name>` tag rows exist for
|
||||
// this slice, the missionId substring term is dropped from the
|
||||
// union; the typed tag is the authoritative slice membership signal.
|
||||
// Substring fallback (including the missionId term) is preserved for
|
||||
// slices whose qitem corpus pre-dates the typed-tag convention so
|
||||
// legacy mission-aware workspaces don't regress.
|
||||
const ids = new Set<string>();
|
||||
const terms = Array.from(new Set([sliceName, railItem, missionId].filter((v): v is string => !!v)));
|
||||
try {
|
||||
for (const term of terms) {
|
||||
// 1. Typed-tag matches: tags JSON contains `slice:<sliceName>` literal.
|
||||
// Both the `"slice:..."` JSON-array form and the comma-separated
|
||||
// form (legacy CLI tags) are caught by a wildcard on the slice id.
|
||||
let typedTagMatchCount = 0;
|
||||
try {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items WHERE tags LIKE ? LIMIT 500`,
|
||||
).all(`%slice:${sliceName}%`) as Array<{ qitem_id: string }>;
|
||||
for (const r of rows) ids.add(r.qitem_id);
|
||||
typedTagMatchCount = rows.length;
|
||||
} catch {
|
||||
// queue_items present but `tags` column missing (older test
|
||||
// harness): fall through to substring path with full term set.
|
||||
}
|
||||
|
||||
// 2. Substring matches. Terms are chosen so the missionId term
|
||||
// is INCLUDED only when no typed-tag rows exist (legacy
|
||||
// compatibility); otherwise the slice-specific terms (sliceName +
|
||||
// railItem) drive matching. railItem stays in both branches
|
||||
// because it's slice-scoped, not mission-scoped.
|
||||
const substringTerms = (typedTagMatchCount > 0
|
||||
? [sliceName, railItem]
|
||||
: [sliceName, railItem, missionId]
|
||||
).filter((v): v is string => !!v);
|
||||
for (const term of Array.from(new Set(substringTerms))) {
|
||||
try {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items WHERE body LIKE ? OR tags LIKE ? LIMIT 500`
|
||||
`SELECT qitem_id FROM queue_items WHERE body LIKE ? OR tags LIKE ? LIMIT 500`,
|
||||
).all(`%${term}%`, `%${term}%`) as Array<{ qitem_id: string }>;
|
||||
for (const r of rows) ids.add(r.qitem_id);
|
||||
} catch {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`
|
||||
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`,
|
||||
).all(`%${term}%`) as Array<{ qitem_id: string }>;
|
||||
for (const r of rows) ids.add(r.qitem_id);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,18 @@ export function slicesRoutes(): Hono {
|
||||
});
|
||||
});
|
||||
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (founder
|
||||
// item 8 — Explorer auto-show): explicit cache invalidation surface.
|
||||
// POST /api/slices/refresh drops both indexer caches so newly-created
|
||||
// slice / mission folders are picked up without a daemon restart.
|
||||
// Registered BEFORE the dynamic /:name routes so it isn't shadowed.
|
||||
app.post("/refresh", (c) => {
|
||||
const deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
|
||||
deps.indexer.invalidate();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// 2) Proof asset serving — registered BEFORE /:name to keep /:name from
|
||||
// eating /proof-asset paths. Hono's :wildcard matches a single
|
||||
// segment; we parse the rest of the path manually for nested
|
||||
|
||||
@@ -281,6 +281,67 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
|
||||
expect(slice.qitemIds.sort()).toEqual(["q-by-mission-body", "q-by-slice-tag"]);
|
||||
});
|
||||
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder item 3.
|
||||
// The over-match bug: a qitem tagged ONLY mission:<missionId>
|
||||
// (no `slice:` tag, no slice-name body mention) was returning
|
||||
// under EVERY slice in that mission because matchQitems unioned
|
||||
// the missionId substring term across all slices. Fix: when at
|
||||
// least one qitem has the typed `slice:<sliceName>` tag, the
|
||||
// missionId substring term is dropped from the union so
|
||||
// mission-tagged-only qitems no longer pollute the slice's queue.
|
||||
// Substring fallback is preserved for slices without typed-tag
|
||||
// qitems (legacy corpus compatibility — HG-2).
|
||||
it("when typed slice:<name> tag matches exist, mission-tagged-only qitems are NOT included", () => {
|
||||
// Production shape: slices/missions root with a mission folder
|
||||
// containing the slice. missionId resolves to the mission folder
|
||||
// name; railItem defaults to missionId when frontmatter doesn't
|
||||
// specify one. The over-match bug was about the missionId
|
||||
// substring term polluting per-slice queue results.
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "release-fake", "slices"), "fake-slice-17", {
|
||||
// Explicit rail-item disambiguates from missionId so the fix's
|
||||
// "drop missionId, keep railItem" branch can be exercised
|
||||
// without the railItem-equals-missionId collision.
|
||||
"README.md": "---\nstatus: active\nrail-item: WALK-17\n---\n# Fake 17\n",
|
||||
});
|
||||
insertQitem(db, {
|
||||
qitemId: "q-typed-slice-tag",
|
||||
body: "Body without slice name.",
|
||||
tags: ["slice:fake-slice-17"],
|
||||
});
|
||||
insertQitem(db, {
|
||||
qitemId: "q-mission-tag-only",
|
||||
body: "Body without slice name.",
|
||||
tags: ["mission:release-fake"],
|
||||
});
|
||||
insertQitem(db, {
|
||||
qitemId: "q-by-slice-name-body",
|
||||
body: "fake-slice-17 mention in body.",
|
||||
tags: [],
|
||||
});
|
||||
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
|
||||
const slice = indexer.get("fake-slice-17")!;
|
||||
expect(slice.qitemIds.sort()).toEqual(["q-by-slice-name-body", "q-typed-slice-tag"]);
|
||||
expect(slice.qitemIds).not.toContain("q-mission-tag-only");
|
||||
});
|
||||
|
||||
it("preserves legacy substring fallback (including mission body) when NO typed slice: tag exists", () => {
|
||||
// When no qitem has the typed `slice:<name>` tag, the indexer
|
||||
// falls back to the pre-fix three-term substring union so older
|
||||
// dogfood corpora keep matching the way they did before this
|
||||
// slice landed.
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "legacy-mission", "slices"), "legacy-slice", {
|
||||
"README.md": "---\nstatus: active\nrail-item: LEGACY-RAIL\n---\n",
|
||||
});
|
||||
insertQitem(db, { qitemId: "q-legacy-by-mission", body: "advance the legacy-mission mission", tags: [] });
|
||||
insertQitem(db, { qitemId: "q-legacy-by-name", body: "legacy-slice work item", tags: [] });
|
||||
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
|
||||
const slice = indexer.get("legacy-slice")!;
|
||||
// Both match: mission body via missionId substring, name body via sliceName substring.
|
||||
expect(slice.qitemIds.sort()).toEqual(["q-legacy-by-mission", "q-legacy-by-name"]);
|
||||
});
|
||||
|
||||
it("returns empty qitem set when queue_items table is absent", () => {
|
||||
// Re-create db without queue_items to simulate the test-harness gap.
|
||||
const bareDb = createDb();
|
||||
|
||||
@@ -319,4 +319,43 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
expect(res.headers.get("content-type")).toBe("image/png");
|
||||
});
|
||||
});
|
||||
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (founder
|
||||
// item 8 — Explorer auto-show). POST /api/slices/refresh drops both
|
||||
// indexer caches so newly-created slice / mission folders show up
|
||||
// without a daemon restart.
|
||||
describe("POST /api/slices/refresh", () => {
|
||||
it("returns 200 + drops the listing cache so freshly-created slices appear", async () => {
|
||||
writeSlice(slicesRoot, "before-slice", { "README.md": "---\n---\n" });
|
||||
// Prime the cache via a list call
|
||||
const first = await app.request("/api/slices");
|
||||
const firstList = (await first.json()) as { slices: Array<{ name: string }> };
|
||||
expect(firstList.slices.map((s) => s.name)).toEqual(["before-slice"]);
|
||||
|
||||
// Add another slice on disk AFTER cache primed
|
||||
writeSlice(slicesRoot, "after-slice", { "README.md": "---\n---\n" });
|
||||
// Without refresh, the cached list should still be stale
|
||||
const stale = await app.request("/api/slices");
|
||||
const staleList = (await stale.json()) as { slices: Array<{ name: string }> };
|
||||
expect(staleList.slices.map((s) => s.name).sort()).toEqual(["before-slice"]);
|
||||
|
||||
// Trigger refresh
|
||||
const refresh = await app.request("/api/slices/refresh", { method: "POST" });
|
||||
expect(refresh.status).toBe(200);
|
||||
const refreshBody = (await refresh.json()) as { ok: boolean };
|
||||
expect(refreshBody.ok).toBe(true);
|
||||
|
||||
// Now the list call picks up the new slice
|
||||
const fresh = await app.request("/api/slices");
|
||||
const freshList = (await fresh.json()) as { slices: Array<{ name: string }> };
|
||||
expect(freshList.slices.map((s) => s.name).sort()).toEqual(["after-slice", "before-slice"]);
|
||||
});
|
||||
|
||||
it("returns 503 when indexer is not wired", async () => {
|
||||
const bareApp = new Hono();
|
||||
bareApp.route("/api/slices", slicesRoutes());
|
||||
const res = await bareApp.request("/api/slices/refresh", { method: "POST" });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useSlices, type SliceListEntry } from "../../hooks/useSlices.js";
|
||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { useSlices, useRefreshSlices, type SliceListEntry } from "../../hooks/useSlices.js";
|
||||
import { useWorkspaceName } from "../../hooks/useWorkspaceName.js";
|
||||
import {
|
||||
useMissionDiscovery,
|
||||
@@ -28,6 +28,31 @@ import {
|
||||
type ProjectSliceRow,
|
||||
} from "../../lib/project-mission-state.js";
|
||||
|
||||
function ProjectTreeRefreshHeader() {
|
||||
const refresh = useRefreshSlices();
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 pb-2 border-b border-stone-200">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.16em] text-stone-500">
|
||||
Project
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="project-tree-refresh"
|
||||
title="Refresh slice + file caches"
|
||||
onClick={() => refresh.mutate()}
|
||||
disabled={refresh.isPending}
|
||||
className="flex items-center gap-1 px-1 py-0.5 text-on-surface-variant hover:text-stone-900 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${refresh.isPending ? "animate-spin" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-mono text-[9px] uppercase tracking-wide">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type GroupedMission = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -234,6 +259,17 @@ export function ProjectTreeView() {
|
||||
|
||||
return (
|
||||
<div data-testid="project-tree-view" className="flex-1 overflow-y-auto py-2">
|
||||
{/*
|
||||
V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
|
||||
item 8 (Explorer auto-show). Manual refresh button drops the
|
||||
daemon-side indexer cache and react-query slice/file caches so
|
||||
newly-created slice / mission folders appear without restarting
|
||||
the daemon. Window-focus refetch in useSlices / useFilesList
|
||||
handles the common "switched away to mkdir + came back" case;
|
||||
this button is the explicit fallback when window-focus doesn't
|
||||
fire (e.g., a fast operator who never blurs the tab).
|
||||
*/}
|
||||
<ProjectTreeRefreshHeader />
|
||||
<ul>
|
||||
<li data-testid="project-workspace-node">
|
||||
<button
|
||||
|
||||
@@ -266,7 +266,20 @@ function ScopeQueueRollup({
|
||||
queueItemsById: Map<string, QueueItemDetail>;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
if (qitemIds.length === 0) {
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
|
||||
// item 10 (slice queue descending order). Latest qitem at top. Prefer
|
||||
// tsCreated when the loaded detail is available; fall back to the
|
||||
// qitem-id (which encodes a timestamp prefix `qitem-YYYYMMDD...`) so
|
||||
// sort works before queueItemsById has finished loading.
|
||||
const sortedQitemIds = [...qitemIds].sort((a, b) => {
|
||||
const itemA = queueItemsById.get(a);
|
||||
const itemB = queueItemsById.get(b);
|
||||
const tsA = itemA?.tsCreated ?? a;
|
||||
const tsB = itemB?.tsCreated ?? b;
|
||||
if (tsA === tsB) return 0;
|
||||
return tsA < tsB ? 1 : -1; // DESC
|
||||
});
|
||||
if (sortedQitemIds.length === 0) {
|
||||
return <EmptyState label="NO QITEMS" description="No queue items are indexed for this scope." variant="card" testId="scope-queue-empty" />;
|
||||
}
|
||||
return (
|
||||
@@ -277,7 +290,7 @@ function ScopeQueueRollup({
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="divide-y divide-outline-variant border border-outline-variant">
|
||||
{qitemIds.map((qitemId) => {
|
||||
{sortedQitemIds.map((qitemId) => {
|
||||
const item = queueItemsById.get(qitemId);
|
||||
return (
|
||||
<li key={qitemId} className="bg-white/35 backdrop-blur-sm">
|
||||
|
||||
@@ -73,6 +73,11 @@ export function useFilesList(root: string | null, path: string | null) {
|
||||
queryFn: () => fetchList(root!, path ?? ""),
|
||||
enabled: !!root,
|
||||
staleTime: 15_000,
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
|
||||
// item 8 (Explorer auto-show): refetch on window focus so new files
|
||||
// / folders created while the operator was off-tab appear without a
|
||||
// manual refresh click.
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// instead of the raw 503.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export type SliceStatus = "active" | "done" | "blocked" | "draft";
|
||||
export type SliceFilter = "all" | "active" | "done" | "blocked";
|
||||
@@ -81,6 +81,31 @@ export function useSlices(filter: SliceFilter, boundToWorkflow: BoundToWorkflowF
|
||||
],
|
||||
queryFn: () => fetchSlicesList(filter, boundToWorkflow),
|
||||
staleTime: 30_000,
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
|
||||
// item 8 (Explorer auto-show): refetch on window focus so an operator
|
||||
// who switches away to `mkdir slices/...` and comes back sees the
|
||||
// new folder without manually clicking refresh.
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
|
||||
// item 8 (Explorer auto-show). Mutation hook for the Explorer header's
|
||||
// manual refresh button: POSTs to /api/slices/refresh to drop the
|
||||
// daemon-side indexer cache, then invalidates the react-query slices
|
||||
// + files caches so the next render hits the fresh data.
|
||||
export function useRefreshSlices() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/slices/refresh", { method: "POST" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as { ok: boolean };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["slices"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user