Closes #2248. Completes SEP-2640 support across all three clients, and closes the one obligation #2234 deliberately left open. - `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML frontmatter against the entry the listing advertised, field by field. No digest can cover this: a digest is taken over the bytes the server served, so it proves the file was not altered in transit and says nothing about whether the listing described it honestly. Needs a real YAML parser; `yaml` was already a root dependency, so this adds no package — but it does newly put it on core/'s import graph, hence the three bundler `external` lists. - `resources/directory/read`: result schemas defined against the normative text, `InspectorClient.readResourceDirectory`, a Directory section on the Skills screen, and `--method resources/directory/read` in the CLI. The client refuses the call locally when the server did not declare `directoryRead`, which is the SEP's MUST NOT. A child the directory lists but the entry does not is marked `not listed` rather than merged into the manifest — the SEP calls a directory result a live observation and forbids treating it as extending the manifest. - CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus `--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a violation. Reads are sequential; a conforming manifest may declare 512 entries. - TUI: a Skills pane, shown only when the server declares the extension. Each row carries its conformance verdict as a glyph as well as a colour, since the pane is read over ssh and through script(1). Enter verifies. - `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording it per file: it says the session's authorization expired, so absorbing it would report N identical read failures and swallow the error the TUI and the web commands key off to reauthorize. Two open questions settled, both in code comments where they will be found: `skills/get` carries no caching attributes because SEP-2640 leaves the question open in as many words, so requiring them would fail a conforming server; and there is no `PagedSkillsState` because every consumer of this list is a whole-catalog verdict — computed over page one of three, "this server's skills conform" is not merely partial but wrong. The fixture grows two skills, both for checks that were otherwise undemonstrable: `lying-listing` (listing and file disagree, digest still verifies) and `stale-manifest` (serves a file its manifest does not declare). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc Signed-off-by: cliffhall <cliff@futurescale.com>
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
/**
|
|
* ManagedSkillsState: holds the full skill list (SEP-2640), in sync with the
|
|
* server. Loaded on connect, cleared on disconnect, re-walked on refresh.
|
|
*
|
|
* Deliberately NOT a `ManagedListState` subclass, despite the family
|
|
* resemblance. That base is built around two things the Skills extension does
|
|
* not have: a top-level `ServerCapabilities` key to gate on (skills is a
|
|
* *server-declared extension*, read from `capabilities.extensions`), and a
|
|
* per-list `list_changed` notification to debounce and turn into a sidebar
|
|
* indicator (SEP-2640 defines none). Subclassing would mean widening the base's
|
|
* capability gate and inventing a list-changed event nothing dispatches — two
|
|
* changes to shared machinery to serve one caller. The cursor walk below is the
|
|
* only behavior actually shared, and it is nine lines.
|
|
*
|
|
* The walk is done here rather than through an SDK `listAll*` verb for the same
|
|
* reason the request is a plain `client.request`: the SDK has no high-level verb
|
|
* for a consumer-owned extension method, so there is no cache-aware wrapper to
|
|
* delegate to and no `cacheMode` to honor.
|
|
*
|
|
* ⚠️ **There is deliberately no `PagedSkillsState`, so the `paginatedLists`
|
|
* server setting does not apply to Skills (#2248).** Tools, prompts and
|
|
* resources each have a paged counterpart that setting switches them to; skills
|
|
* does not, and the reason is not that the walk is cheap.
|
|
*
|
|
* It is that **every consumer of this list is a whole-catalog verdict.** The
|
|
* Skills screen's conformance summary, and the CLI's `--verify` exit code, are
|
|
* statements about the catalog: "this server's skills conform". Computed over
|
|
* page one of three, that statement is *wrong* — it reports a clean catalog
|
|
* while the tampered digest sits on page three, and reports it with exactly the
|
|
* confidence of a real pass. Paging the other lists costs a reader some rows;
|
|
* paging this one would make the tool's own output untrue. The setting exists
|
|
* to let a user watch a server's pagination work, and this list's page count is
|
|
* surfaced instead (`getPagination`, rendered by both the web screen and the
|
|
* TUI pane), which serves that purpose without staking a verdict on a partial
|
|
* read.
|
|
*
|
|
* The cost argument, which is the one #2248 asked about, points the same way
|
|
* and is secondary: SEP-2640 makes a listing entry a *complete* manifest —
|
|
* verbatim frontmatter and the full `resources` set with digests — precisely so
|
|
* that "a host that pages through the listing has, in that one pass, everything
|
|
* it needs … there is no second round-trip per skill". A full walk is the
|
|
* access pattern the wire format was designed for. Revisit if a real server
|
|
* turns up whose catalog makes the walk painful; the guards this walk already
|
|
* carries (`SKILLS_MAX_PAGES`, the repeated-cursor check) are what bound it
|
|
* until then.
|
|
*/
|
|
|
|
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
|
import { SKILLS_LIST_METHOD, type SkillEntry } from "../skillsSchemas.js";
|
|
import { LIST_MAX_PAGES, isClientDecodeRejection } from "../listSalvage.js";
|
|
import { isTerminalStatus } from "../types.js";
|
|
import type { RequestMetadata } from "../types.js";
|
|
import { TypedEventTarget } from "../typedEventTarget.js";
|
|
|
|
export interface SkillsPaginationState {
|
|
/** Pages walked on the last completed refresh (a one-page list is 1). */
|
|
pageCount: number;
|
|
}
|
|
|
|
export interface ManagedSkillsStateEventMap {
|
|
skillsChange: SkillEntry[];
|
|
paginationChange: SkillsPaginationState;
|
|
/** The last walk's failure, or `null` once one succeeds. */
|
|
errorChange: Error | null;
|
|
}
|
|
|
|
/**
|
|
* Thrown when a server hands back a cursor it already handed back, which would
|
|
* otherwise walk forever. Surfaced as the store's error so the panel reports the
|
|
* server bug instead of hanging — the same "report, don't swallow" posture the
|
|
* conformance checks take.
|
|
*/
|
|
export const REPEATED_CURSOR_MESSAGE =
|
|
"Server repeated a pagination cursor in skills/list; stopped to avoid an infinite walk.";
|
|
|
|
/**
|
|
* Page cap for the `skills/list` walk. Mirrors `LIST_MAX_PAGES`, the bound the
|
|
* SDK's aggregate verbs and the #1909 salvage re-walks already use, so the two
|
|
* pagination paths in this repo cannot drift to different limits.
|
|
*/
|
|
export const SKILLS_MAX_PAGES = LIST_MAX_PAGES;
|
|
|
|
/** The error a walk raises when it hits {@link SKILLS_MAX_PAGES}. */
|
|
export const SKILLS_PAGE_LIMIT_MESSAGE = `skills/list exceeded ${SKILLS_MAX_PAGES} pages without the server's pagination converging`;
|
|
|
|
export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEventMap> {
|
|
private skills: SkillEntry[] = [];
|
|
private pageCount = 0;
|
|
private error: Error | null = null;
|
|
private client: InspectorClientProtocol | null = null;
|
|
private unsubscribe: (() => void) | null = null;
|
|
// Overlap guard, held as the generation whose walk is in flight (or `null`).
|
|
// See `refresh` for why this is not a boolean.
|
|
private runningGeneration: number | null = null;
|
|
// Session counter, advanced by `reset` (disconnect) and by `destroy`. A walk
|
|
// captures it and abandons its writes when it no longer matches.
|
|
private generation = 0;
|
|
|
|
constructor(client: InspectorClientProtocol) {
|
|
super();
|
|
this.client = client;
|
|
const onConnect = (): void => {
|
|
// No caller to await the connect-time load, so its rejection is caught
|
|
// here rather than left to become an unhandled rejection. Not a swallow:
|
|
// `refresh` has already recorded the failure via `setError`, and the
|
|
// panel renders it.
|
|
void this.refresh().catch(() => {});
|
|
};
|
|
const onStatusChange = (): void => {
|
|
if (isTerminalStatus(this.client?.getStatus())) {
|
|
this.reset();
|
|
}
|
|
};
|
|
this.client.addEventListener("connect", onConnect);
|
|
this.client.addEventListener("statusChange", onStatusChange);
|
|
this.unsubscribe = () => {
|
|
if (this.client) {
|
|
this.client.removeEventListener("connect", onConnect);
|
|
this.client.removeEventListener("statusChange", onStatusChange);
|
|
}
|
|
this.client = null;
|
|
};
|
|
}
|
|
|
|
/** Defensive copy of the current list. */
|
|
getSkills(): SkillEntry[] {
|
|
return [...this.skills];
|
|
}
|
|
|
|
getPagination(): SkillsPaginationState {
|
|
return { pageCount: this.pageCount };
|
|
}
|
|
|
|
/** The last walk's failure, or `null` when it succeeded. */
|
|
getError(): Error | null {
|
|
return this.error;
|
|
}
|
|
|
|
// Compared by identity rather than message: two distinct failures with the
|
|
// same text are still two events, and a re-render on a repeat failure is
|
|
// cheap next to silently coalescing them.
|
|
private setError(value: Error | null): void {
|
|
if (this.error === value) return;
|
|
this.error = value;
|
|
this.dispatchTypedEvent("errorChange", value);
|
|
}
|
|
|
|
private reset(): void {
|
|
// Advance the session first, so an in-flight walk's continuation sees a
|
|
// stale generation and leaves the cleared state alone.
|
|
this.generation += 1;
|
|
this.skills = [];
|
|
this.pageCount = 0;
|
|
this.dispatchTypedEvent("skillsChange", this.getSkills());
|
|
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
|
// A disconnect ends the session the error belonged to — a stale
|
|
// "couldn't load skills" must not outlive it into the next connect.
|
|
this.setError(null);
|
|
}
|
|
|
|
/**
|
|
* Walk every page of `skills/list` and commit the result.
|
|
*
|
|
* A failure is recorded as observable state (`getError`) AND re-thrown: the
|
|
* state drives the panel's error rendering, while the rejection is what a
|
|
* caller's auth-recovery wrapper keys off to detect a 401 and start a
|
|
* re-authorization. The connect-time load, which has no such caller, catches
|
|
* it above.
|
|
*/
|
|
async refresh(metadata?: RequestMetadata): Promise<SkillEntry[]> {
|
|
const client = this.client;
|
|
if (!client || client.getStatus() !== "connected") return this.getSkills();
|
|
// A server that never declared the extension answers `skills/list` with
|
|
// -32601, which would spam the console for a question we already know the
|
|
// answer to. An empty list is the right semantics.
|
|
if (!client.getSkillsExtension()) {
|
|
this.applyPages([], 0);
|
|
return this.getSkills();
|
|
}
|
|
// The session this walk belongs to. A disconnect, a `destroy()`, or a
|
|
// reconnect on the same client advances it, and every write below is
|
|
// gated on it still being current — otherwise a slow walk's continuation
|
|
// repopulates a store that has already been cleared, or delivers the
|
|
// previous session's skills into the next one.
|
|
const generation = this.generation;
|
|
const isCurrent = () => this.generation === generation;
|
|
// The overlap guard is per SESSION, not a bare boolean. A boolean would
|
|
// stay set while a walk from a dead session was still awaiting the server,
|
|
// so the reconnect's own load would return here as a no-op and never be
|
|
// retried — permanently, if that stale request never settles. Keying it on
|
|
// the generation lets a new session start immediately, and the `finally`
|
|
// below only clears the guard it actually set.
|
|
if (this.runningGeneration === generation) return this.getSkills();
|
|
this.runningGeneration = generation;
|
|
try {
|
|
const collected: SkillEntry[] = [];
|
|
const seen = new Set<string>();
|
|
let cursor: string | undefined;
|
|
let pages = 0;
|
|
for (;;) {
|
|
const page = await client.listSkills(cursor, metadata);
|
|
if (!isCurrent()) return this.getSkills();
|
|
collected.push(...page.skills);
|
|
pages += 1;
|
|
if (page.nextCursor === undefined) break;
|
|
// Two distinct runaway shapes, and the repeated-cursor guard only
|
|
// catches one: a server stuck on a single cursor. A server that hands
|
|
// back an endlessly *unique* cursor walks forever and grows
|
|
// `collected` without bound, so the page cap is what stops it. The cap
|
|
// raises rather than truncating, for the reason `listPaginationExceeded`
|
|
// states: returning what we have would present a partial list as a
|
|
// complete one.
|
|
if (pages >= SKILLS_MAX_PAGES) {
|
|
throw new Error(SKILLS_PAGE_LIMIT_MESSAGE);
|
|
}
|
|
if (seen.has(page.nextCursor)) {
|
|
throw new Error(REPEATED_CURSOR_MESSAGE);
|
|
}
|
|
seen.add(page.nextCursor);
|
|
cursor = page.nextCursor;
|
|
}
|
|
this.setError(null);
|
|
this.applyPages(collected, pages);
|
|
return this.getSkills();
|
|
} catch (err) {
|
|
// Still re-thrown even when the session moved on — the caller's
|
|
// auth-recovery wrapper keys off the rejection — but the *state* is left
|
|
// alone, so a dead session's failure cannot surface in the live one.
|
|
if (isCurrent()) {
|
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
this.setError(error);
|
|
// Attribute the failure to the response it came from, so the Protocol
|
|
// entry stops rendering a rejected result as a clean success (#1953).
|
|
// Must happen in this catch, while the correlation window the client
|
|
// documents is still valid — and ONLY for a decode rejection, which is
|
|
// what `isClientDecodeRejection` separates from a request that never
|
|
// produced a response at all. Same handling every managed list gets.
|
|
if (isClientDecodeRejection(err)) {
|
|
client.markResponseRejected?.(SKILLS_LIST_METHOD, error.message);
|
|
}
|
|
}
|
|
throw err;
|
|
} finally {
|
|
// Only the walk that set the guard may clear it: a stale session's
|
|
// `finally` must not release the guard a live session is holding.
|
|
if (this.runningGeneration === generation) {
|
|
this.runningGeneration = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private applyPages(skills: SkillEntry[], pageCount: number): void {
|
|
this.skills = skills;
|
|
this.pageCount = pageCount;
|
|
this.dispatchTypedEvent("skillsChange", this.getSkills());
|
|
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
|
}
|
|
|
|
/** Unsubscribe from the client and drop the list; idempotent. */
|
|
destroy(): void {
|
|
this.generation += 1;
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = null;
|
|
this.skills = [];
|
|
this.pageCount = 0;
|
|
this.error = null;
|
|
}
|
|
}
|