fix: address Copilot review round 13 on #2251
- SkillsScreen: put the SESSION in the invalidation key. This screen stays
mounted across a disconnect, so content alone cannot tell server A's entry
from an identical-looking one on server B — A's in-flight verification
could land afterwards and report `verified` for a file never read from B,
and a retained batch entry could leave B's Verify all disabled.
`useInspectorStores` now exposes a `sessionNonce`, bumped on both create
and destroy so it never repeats across a reconnect, and App keys on
`${activeServerId}:${sessionNonce}` — the server id alone would repeat,
which is one of the crossings this exists to prevent.
- SkillsScreen: match the selection by `skillUriIdentity`, in both the
lookup and the NavLink active check. A refresh that canonicalizes
`skill://demo/%53KILL.md` names the same skill, and the detail pane was
emptying out because the server changed its spelling — the last raw-string
URI comparison left after round 12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
bd28a36f23
commit
4d7dd180d9
9 changed files with 135 additions and 7 deletions
|
|
@ -459,6 +459,7 @@ function App() {
|
||||||
tasks,
|
tasks,
|
||||||
refreshTasks,
|
refreshTasks,
|
||||||
clearCompletedTasks,
|
clearCompletedTasks,
|
||||||
|
sessionNonce,
|
||||||
skills,
|
skills,
|
||||||
skillsPageCount,
|
skillsPageCount,
|
||||||
skillsLoadError,
|
skillsLoadError,
|
||||||
|
|
@ -1826,6 +1827,10 @@ function App() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const skillsPanelProps: SkillsPanelProps = {
|
const skillsPanelProps: SkillsPanelProps = {
|
||||||
|
// Server id AND per-connect nonce: the id alone would repeat on a
|
||||||
|
// reconnect to the same server, which is one of the crossings this key
|
||||||
|
// exists to prevent.
|
||||||
|
skillsSessionKey: `${activeServerId ?? ""}:${sessionNonce}`,
|
||||||
skills,
|
skills,
|
||||||
skillsPageCount,
|
skillsPageCount,
|
||||||
skillsLoadError,
|
skillsLoadError,
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ const readFixtureFile = vi.fn(async (uri: string) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseProps: SkillsScreenProps = {
|
const baseProps: SkillsScreenProps = {
|
||||||
|
sessionKey: "session-1",
|
||||||
skills: ALL_SKILLS,
|
skills: ALL_SKILLS,
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
ui: EMPTY_SKILLS_UI,
|
ui: EMPTY_SKILLS_UI,
|
||||||
|
|
@ -718,6 +719,67 @@ describe("SkillsScreen", () => {
|
||||||
expect(verifyAll()).toBeDisabled();
|
expect(verifyAll()).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discards a verification that lands after the session changed", async () => {
|
||||||
|
// This screen stays mounted across a disconnect, so content alone does not
|
||||||
|
// tell server A's entry from an identical-looking one on server B. Without
|
||||||
|
// the session in the key, A's in-flight read would land and report
|
||||||
|
// `verified` for a file that was never read from B.
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let release: ((value: { text: string }) => void) | undefined;
|
||||||
|
const onReadSkillFile = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<{ text: string }>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<SkillsScreen
|
||||||
|
{...baseProps}
|
||||||
|
sessionKey="server-a:1"
|
||||||
|
onReadSkillFile={onReadSkillFile}
|
||||||
|
skills={[CLEAN_SKILL]}
|
||||||
|
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||||
|
|
||||||
|
// Same entry, different session.
|
||||||
|
rerender(
|
||||||
|
<SkillsScreen
|
||||||
|
{...baseProps}
|
||||||
|
sessionKey="server-b:2"
|
||||||
|
onReadSkillFile={onReadSkillFile}
|
||||||
|
skills={[CLEAN_SKILL]}
|
||||||
|
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
release?.({ text: SELF_TEXT });
|
||||||
|
expect(screen.queryByText("verified")).not.toBeInTheDocument();
|
||||||
|
// ...and the batch guard did not carry over either.
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Verify all/ }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the selection when a refresh canonicalizes the skill's URI", async () => {
|
||||||
|
// The selection is stored as the URI the list gave us, so a server that
|
||||||
|
// re-spells it must not empty the detail pane for the same skill.
|
||||||
|
renderWithMantine(
|
||||||
|
<SkillsScreen
|
||||||
|
{...baseProps}
|
||||||
|
skills={[CLEAN_SKILL]}
|
||||||
|
ui={{
|
||||||
|
...EMPTY_SKILLS_UI,
|
||||||
|
selectedSkillUri: "skill://data-analysis/%53KILL.md",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("skill-detail")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Select a skill to view details"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the SKILL.md preview on demand", async () => {
|
it("shows the SKILL.md preview on demand", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(<ControlledSkillsScreen />);
|
renderWithMantine(<ControlledSkillsScreen />);
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,14 @@ interface FetchedEntryState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsScreenProps {
|
export interface SkillsScreenProps {
|
||||||
|
/**
|
||||||
|
* Identity of the connected session. Part of the invalidation key below, so
|
||||||
|
* a verification still in flight when the user switches servers cannot land
|
||||||
|
* afterwards and report a verdict for an identical-looking entry on the new
|
||||||
|
* one — this screen stays mounted across a disconnect, so content alone does
|
||||||
|
* not distinguish the two.
|
||||||
|
*/
|
||||||
|
sessionKey: string;
|
||||||
skills: SkillEntry[];
|
skills: SkillEntry[];
|
||||||
/** Pages the last `skills/list` walk took; shown so pagination is visible. */
|
/** Pages the last `skills/list` walk took; shown so pagination is visible. */
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
|
|
@ -320,6 +328,7 @@ function shortDigest(digest: string | undefined): string {
|
||||||
* the Inspector fetches only what the user asks it to.
|
* the Inspector fetches only what the user asks it to.
|
||||||
*/
|
*/
|
||||||
export function SkillsScreen({
|
export function SkillsScreen({
|
||||||
|
sessionKey,
|
||||||
skills,
|
skills,
|
||||||
pageCount,
|
pageCount,
|
||||||
loadError,
|
loadError,
|
||||||
|
|
@ -378,10 +387,15 @@ export function SkillsScreen({
|
||||||
);
|
);
|
||||||
}, [skills, search]);
|
}, [skills, search]);
|
||||||
|
|
||||||
const selected = useMemo(
|
// Matched by IDENTITY, like every other URI comparison here: a refresh that
|
||||||
() => skills.find((skill) => skill.uri === selectedSkillUri),
|
// canonicalizes `skill://demo/%53KILL.md` to `skill://demo/SKILL.md` names
|
||||||
[skills, selectedSkillUri],
|
// the same skill, and the detail pane must not empty out because the server
|
||||||
);
|
// changed its spelling.
|
||||||
|
const selected = useMemo(() => {
|
||||||
|
if (selectedSkillUri === undefined) return undefined;
|
||||||
|
const wanted = skillUriIdentity(selectedSkillUri);
|
||||||
|
return skills.find((skill) => skillUriIdentity(skill.uri) === wanted);
|
||||||
|
}, [skills, selectedSkillUri]);
|
||||||
|
|
||||||
const issues = useMemo(
|
const issues = useMemo(
|
||||||
() => (selected ? checkSkillConformance(selected) : []),
|
() => (selected ? checkSkillConformance(selected) : []),
|
||||||
|
|
@ -406,8 +420,9 @@ export function SkillsScreen({
|
||||||
// not. A primitive string, because `useValueChange` compares with `Object.is`
|
// not. A primitive string, because `useValueChange` compares with `Object.is`
|
||||||
// and a fresh object every render would loop.
|
// and a fresh object every render would loop.
|
||||||
const manifestKey = useMemo(
|
const manifestKey = useMemo(
|
||||||
() => (selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")),
|
() =>
|
||||||
[selected, selectedSkillUri],
|
`${sessionKey}\n${selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")}`,
|
||||||
|
[selected, selectedSkillUri, sessionKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
|
// Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
|
||||||
|
|
@ -623,7 +638,11 @@ export function SkillsScreen({
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={skill.uri}
|
key={skill.uri}
|
||||||
active={skill.uri === selectedSkillUri}
|
active={
|
||||||
|
selectedSkillUri !== undefined &&
|
||||||
|
skillUriIdentity(skill.uri) ===
|
||||||
|
skillUriIdentity(selectedSkillUri)
|
||||||
|
}
|
||||||
label={skillDisplayName(skill)}
|
label={skillDisplayName(skill)}
|
||||||
description={skill.uri}
|
description={skill.uri}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,7 @@ const appsArgs: AppsPanelProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const skillsArgs: SkillsPanelProps = {
|
const skillsArgs: SkillsPanelProps = {
|
||||||
|
skillsSessionKey: "story-session",
|
||||||
skills: [],
|
skills: [],
|
||||||
skillsPageCount: 0,
|
skillsPageCount: 0,
|
||||||
skillsUi: EMPTY_SKILLS_UI,
|
skillsUi: EMPTY_SKILLS_UI,
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ function makeProps(...overrides: PropOverrides[]): InspectorViewProps {
|
||||||
...mergeBundle("apps", overrides),
|
...mergeBundle("apps", overrides),
|
||||||
},
|
},
|
||||||
skills: {
|
skills: {
|
||||||
|
skillsSessionKey: "test-session",
|
||||||
skills: [],
|
skills: [],
|
||||||
skillsPageCount: 0,
|
skillsPageCount: 0,
|
||||||
skillsUi: EMPTY_SKILLS_UI,
|
skillsUi: EMPTY_SKILLS_UI,
|
||||||
|
|
|
||||||
|
|
@ -456,6 +456,7 @@ export function InspectorView({
|
||||||
onRefreshApps,
|
onRefreshApps,
|
||||||
} = appsPanel;
|
} = appsPanel;
|
||||||
const {
|
const {
|
||||||
|
skillsSessionKey,
|
||||||
skills,
|
skills,
|
||||||
skillsPageCount,
|
skillsPageCount,
|
||||||
skillsLoadError,
|
skillsLoadError,
|
||||||
|
|
@ -1055,6 +1056,7 @@ export function InspectorView({
|
||||||
onSortChange: setConsoleSort,
|
onSortChange: setConsoleSort,
|
||||||
};
|
};
|
||||||
const skillsScreenProps = {
|
const skillsScreenProps = {
|
||||||
|
sessionKey: skillsSessionKey,
|
||||||
skills,
|
skills,
|
||||||
pageCount: skillsPageCount,
|
pageCount: skillsPageCount,
|
||||||
loadError: skillsLoadError,
|
loadError: skillsLoadError,
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,12 @@ export interface AppsPanelProps {
|
||||||
|
|
||||||
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
|
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
|
||||||
export interface SkillsPanelProps {
|
export interface SkillsPanelProps {
|
||||||
|
/**
|
||||||
|
* Identity of the connected session. Part of the screen's invalidation key,
|
||||||
|
* so async verification state can never cross a connection — see
|
||||||
|
* `UseInspectorStoresResult.sessionNonce`.
|
||||||
|
*/
|
||||||
|
skillsSessionKey: string;
|
||||||
skills: SkillEntry[];
|
skills: SkillEntry[];
|
||||||
/** Pages the last `skills/list` walk took. */
|
/** Pages the last `skills/list` walk took. */
|
||||||
skillsPageCount: number;
|
skillsPageCount: number;
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,21 @@ describe("useInspectorStores", () => {
|
||||||
expect(h.api().stores).not.toBe(first);
|
expect(h.api().stores).not.toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("advances the session nonce on both create and destroy", () => {
|
||||||
|
// It names one connected session and must never repeat across a
|
||||||
|
// reconnect — `SkillsScreen` keys async verification state on it, and a
|
||||||
|
// repeated value would let one session's result land in another.
|
||||||
|
const h = harness();
|
||||||
|
const seen = new Set<number>([h.api().sessionNonce]);
|
||||||
|
h.run((api) => api.createStores(client(), fetchLogOptions));
|
||||||
|
seen.add(h.api().sessionNonce);
|
||||||
|
h.run((api) => api.destroyStores());
|
||||||
|
seen.add(h.api().sessionNonce);
|
||||||
|
h.run((api) => api.createStores(client(), fetchLogOptions));
|
||||||
|
seen.add(h.api().sessionNonce);
|
||||||
|
expect(seen.size).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
it("destroys and clears on destroyStores", () => {
|
it("destroys and clears on destroyStores", () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
h.run((api) => api.createStores(client(), fetchLogOptions));
|
h.run((api) => api.createStores(client(), fetchLogOptions));
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,17 @@ export type FetchLogOptions = Pick<
|
||||||
export interface UseInspectorStoresResult {
|
export interface UseInspectorStoresResult {
|
||||||
/** The live stores, or `null` before the first connect / after teardown. */
|
/** The live stores, or `null` before the first connect / after teardown. */
|
||||||
stores: InspectorStores | null;
|
stores: InspectorStores | null;
|
||||||
|
/**
|
||||||
|
* Bumped on every `createStores` **and** every `destroyStores`, so it names
|
||||||
|
* one connected session and never repeats across a reconnect.
|
||||||
|
*
|
||||||
|
* Screens that hold async state keyed by *content* need this in the key:
|
||||||
|
* `SkillsScreen` stays mounted across a disconnect, so a verification still
|
||||||
|
* in flight for server A could otherwise land after a switch to server B and
|
||||||
|
* report a verdict for an identical-looking entry that was never read from B
|
||||||
|
* (#2234).
|
||||||
|
*/
|
||||||
|
sessionNonce: number;
|
||||||
/**
|
/**
|
||||||
* Build a fresh set of stores for `client`, tearing down whatever set is
|
* Build a fresh set of stores for `client`, tearing down whatever set is
|
||||||
* live first. Stable, so callers need no dependency on the current stores.
|
* live first. Stable, so callers need no dependency on the current stores.
|
||||||
|
|
@ -162,6 +173,9 @@ export function useInspectorStores({
|
||||||
paginatedLists,
|
paginatedLists,
|
||||||
}: UseInspectorStoresParams): UseInspectorStoresResult {
|
}: UseInspectorStoresParams): UseInspectorStoresResult {
|
||||||
const [stores, setStores] = useState<InspectorStores | null>(null);
|
const [stores, setStores] = useState<InspectorStores | null>(null);
|
||||||
|
// See `sessionNonce` above. A counter rather than the store object's identity
|
||||||
|
// because it has to be usable as part of a string key.
|
||||||
|
const [sessionNonce, setSessionNonce] = useState(0);
|
||||||
// Mirrors `stores` so `destroyStores` can read the live set without taking a
|
// Mirrors `stores` so `destroyStores` can read the live set without taking a
|
||||||
// dependency on it — which is what keeps every caller's callback stable.
|
// dependency on it — which is what keeps every caller's callback stable.
|
||||||
const storesRef = useRef<InspectorStores | null>(null);
|
const storesRef = useRef<InspectorStores | null>(null);
|
||||||
|
|
@ -177,6 +191,7 @@ export function useInspectorStores({
|
||||||
storesRef.current = null;
|
storesRef.current = null;
|
||||||
fetchLogRef.current = null;
|
fetchLogRef.current = null;
|
||||||
setStores(null);
|
setStores(null);
|
||||||
|
setSessionNonce((n) => n + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createStores = useCallback(
|
const createStores = useCallback(
|
||||||
|
|
@ -214,6 +229,7 @@ export function useInspectorStores({
|
||||||
storesRef.current = next;
|
storesRef.current = next;
|
||||||
fetchLogRef.current = fetchRequestLogState;
|
fetchLogRef.current = fetchRequestLogState;
|
||||||
setStores(next);
|
setStores(next);
|
||||||
|
setSessionNonce((n) => n + 1);
|
||||||
},
|
},
|
||||||
[destroyStores],
|
[destroyStores],
|
||||||
);
|
);
|
||||||
|
|
@ -340,6 +356,7 @@ export function useInspectorStores({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stores,
|
stores,
|
||||||
|
sessionNonce,
|
||||||
createStores,
|
createStores,
|
||||||
destroyStores,
|
destroyStores,
|
||||||
fetchLogRef,
|
fetchLogRef,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue