fix(catalog): survive an unreachable registry, and ask for the gap - #3299
Conversation
Three things a search that goes wrong could not do. A registry the network cannot reach emptied the catalog. The cache read returned undefined past its 24h TTL, so an expired entry and no entry at all were indistinguishable, and one timeout against the registry host reported the whole catalog as unreachable while a usable copy sat on disk. Freshness is now the caller's decision: a fresh entry still skips the network, a stale one is served once revalidation fails, and only a name never fetched on this machine can still fail outright. The gap-report channel was never offered at the moment it is for. `feedback --search-miss` names moves the catalog does not have, which is the one signal install counts cannot produce, and it was documented only in a skill file and a help screen, neither of them open when a query comes back wrong. `catalog --query` now prints the line pre-filled, and every --json envelope carries it as `report_gap`, on hits as well as on zero results: the reports worth having come from searches that returned plausible items where none of them did the job. The registry skill owns `hyperframes catalog` and never mentioned the channel at all; it does now, alongside what a stale-served registry means. The CLI skill gains the three commands nothing named, and says plainly which two entries in --help are not for agents to run. Test plan: 8 new tests in remote.test.ts, 3 of which fail without the cache change; 4 new in catalog.test.ts pinning report_gap and the shell-escaping of a non-ASCII query. Full CLI suite: 2632 passed, 3 pre-existing failures (2 transcribe, 1 hostname-dependent) unchanged from the base commit. Both output paths exercised against the real CLI.
jrusso1020
left a comment
There was a problem hiding this comment.
The cache fix is the right shape and I'd approve it on the merits. I'm withholding the stamp for one reason: Typecheck is a required context, it is red at 306ab10, and this PR is what turned it red. Everything else below is either a second thing worth fixing while you're in there, or a note on what I checked so you know what this review does and does not cover.
Blocking: the new test files don't typecheck
Three errors, all in files this PR adds or edits:
src/commands/catalog.test.ts(358,21): error TS2339: Property 'report_gap' does not exist on type 'Envelope'.
src/commands/catalog.test.ts(371,21): error TS2339: Property 'report_gap' does not exist on type 'Envelope'.
src/registry/remote.test.ts(36,58): error TS2344: Type '"fetch"' does not satisfy the constraint '"undefined" | "length" | ...'
Both are small:
catalog.test.ts:173— the localEnvelopeinterface never got the new field. Addingreport_gap: string;alongsideshowncloses it, and non-optional is the accurate model: both envelope-shaped emit sites are insideif (query)branches, and every one of them sets it.remote.test.ts:36— the explicit return annotationPromise<ReturnType<typeof vi.spyOn<typeof globalThis, "fetch">>>is the only thing failing; the twovi.spyOn(globalThis, "fetch")calls on lines 37 and 42 typecheck fine, because there the key is inferred from the actual object rather than asserted againstkeyof typeof globalThis. Simplest fix is to drop the annotation and let the return type infer. If you want it written down,MockInstance<typeof fetch>fromvitestsays the same thing without thekeyofconstraint.
Worth naming the reason this got past a careful test plan: vitest transpiles without typechecking, so "2632 passed" and tsc are genuinely independent signals and the suite can be fully green while the package does not compile. bun run typecheck in packages/cli before the next push should be the whole of it.
The docs claim an offline capability the code does not have
skills/hyperframes-registry/SKILL.md adds:
A registry the CLI cannot reach does not empty the catalog: [...] so search and install still work against the last copy on disk. An item never fetched on this machine is the only thing a network failure can genuinely block.
The second sentence isn't right, and the PR body carries the same claim ("Only discovery and re-install degrade gracefully").
fetchItemFile is unchanged and item files are never cached — you say so yourself under "Not covered". Following it into installer.ts:152-160, the fetch is unconditional: the only branch that skips it is the local-edits guard (!force && existsSync(destPath) && hasLocalEdits(...)), which exists to protect a file you changed, not to serve one from disk. So a re-install of an item this machine installed yesterday still calls fetchItemFile for every file and still fails offline. What the cached item manifest buys is the file list, not the file contents — the install fails a step later, not less often.
So the accurate line is that discovery degrades gracefully and install does not. That distinction is worth getting exactly right in this file specifically, because it is the text an agent loads and acts on: as written it will tell someone to run add while offline, which fails at precisely the moment the PR is trying to make things better. The degenerate exception (every file locally edited, so every file is preserved and nothing is fetched) isn't worth documenting.
Nit: the truthiness test moved from the payload to the envelope
Narrow, and not what's blocking — but it is a behaviour change the diff introduces rather than a pre-existing wart, so it's worth a line.
Before, readCache returned entry.data and both callers tested the data: if (cached) return cached;. Now readCacheEntry returns the whole entry and the callers test the envelope: if (cached && isFresh(cached)) return cached.data;. Any parseable file with a numeric fetchedAt is truthy regardless of what data holds, so a cache file whose payload is null now short-circuits and returns null where it previously fell through to the network. In fetchItemManifest that hands back null typed as RegistryItem.
Reachable only if a registry ever serves a literal null body with a 200, or a cache file is hand-edited, so it's a hardening note rather than a bug I can demonstrate in the wild. One line in readCacheEntry covers it:
if (entry.data === undefined || entry.data === null) return undefined;Fixing it also fails in the direction you care about here — toward "go ask the network" rather than toward "the catalog is empty".
What I checked and what held
- The cache mechanism, read against
67edb01. Splitting the read from the freshness decision is the correct fix for the described failure, and the three preserved contracts are genuinely preserved:fetchRegistryManifeststill returnsundefinedwith no cache,fetchItemManifeststill throws, andskipCachestill forces revalidation. Reading the cache file even underskipCacheis a deliberate consequence of the stale fallback, not an oversight. - "3 of 8 fail against the unpatched
remote.ts" — traced, and it lands on exactly three. The stale-serve test on each fetcher plus theskipCachefallback flip; the other five pass either way. I verified this by reading both revisions rather than by executing the suite, so treat it as corroboration of your claim, not as an independent run. - The stale tests assert the fetch was actually attempted (
expect(fetchSpy).toHaveBeenCalledTimes(1)). That is the check that stops a fallback assertion from passing when the clock never moved, and it is usually the missing one. The mocked rejection not being a realAbortSignal.timeoutdoesn't weaken anything here, since the handler is a barecatchand cannot distinguish sources. report_gapcoverage, by enumerating emit sites rather than trusting the summary. ThreeJSON.stringifysites incatalog.ts; the two query-bearing envelopes both carry the field, and the third is the query-less plain listing that the comment correctly leaves alone. "Every--jsonsearch envelope" checks out.- The escaping actually pins its own regex flag.
["\\$]is exactly the set that stays special inside double quotes, and the test payload carries four distinct metacharacters plus a repeated"— so dropping the/g` and escaping only the first match fails the assertion. That is the row that usually slips through when a test reaches for a single hostile token. The command is built and printed, never handed to a shell, which is the right call.
Other CI signals, since neither is yours to fix
Analyze (python)is red at this head and green at67edb01, and the PR changes zero.pyfiles. It is not in the required set. I'd re-run it rather than assume, but I see no mechanism by which this diff causes it.Tests on windows-latestis still pending and is required, so it owes a result regardless.
Scope: I read registry/remote.ts and commands/catalog.ts in full at both revisions, registry/remote.test.ts in full, the changed regions of catalog.test.ts, installer.ts around the install path, and all three docs files. I did not run the suite locally.
Happy to re-check quickly once the typecheck fixes are up — the substantive review above already carries, so it should just be confirming the three errors are gone.
— Rames Jusso
Review follow-ups on the three points raised. The package did not compile. `Envelope` in catalog.test.ts never gained `report_gap`, and the explicit return annotation on `staleAfterPriming` asserted "fetch" against `keyof typeof globalThis`, which does not hold even though the `vi.spyOn` calls themselves infer fine. `report_gap` is non-optional because both envelope-shaped emit sites sit inside `if (query)` branches and both set it; the annotation is now `MockInstance<typeof fetch>`, which says the same thing without the keyof constraint. Worth naming why a green suite hid this: vitest transpiles without typechecking, so the two signals are independent, and the pre-commit typecheck hook covers core, studio and scripts but not packages/cli -- which is why the commit passed locally and CI did not. The registry skill claimed more than the code delivers. Item files are never cached and `installer.ts` fetches every one of them unconditionally (the only skip is the local-edits guard, which protects a file you changed rather than serving one from disk), so a re-install offline still fails -- a step later, not less often. The doc now says discovery degrades gracefully and `add` does not, because that file is what an agent loads and acts on, and as written it would have sent someone to run `add` offline. An empty cached payload is a miss, not an answer. The callers now test the entry rather than the payload, so a parseable file with a numeric fetchedAt and a null body would short-circuit the fetch and hand `null` back typed as RegistryItem. Rejecting it in readCacheEntry keeps the miss failing toward "go ask the network". Test plan: 1 new test pinning the empty-payload guard, verified to fail with the guard removed. remote.test.ts 9, catalog.test.ts 27, both green. `bun run typecheck` in packages/cli is clean, which is the check that was red.
|
All three addressed in TypecheckFixed, and
Worth adding to your point about vitest transpiling without typechecking: the pre-commit The offline claimYou're right and I've rewritten it. I followed The skill now says discovery degrades gracefully and
Your framing of why it matters in this file specifically is the part I got wrong when writing it, and it's now the reason the line is phrased as an instruction rather than a description. The null payloadTaken, with a test. Agreed it's a hardening note rather than a demonstrable bug, but you're right that the truthiness target moved from the payload to the envelope in this diff, so it's mine to close.
|
The nudge added earlier in this branch only printed once the on-device tier had answered, on the reasoning that a thin word-match result is explainable while a thin meaning-match result is a real gap. That reasoning silences the line in the only case that actually occurs. The on-device tier needs a consented 33 MB download, and under --json the offer is not even printed, so an agent run ranks on `words` unless it explicitly passed --on-device --yes. Every catalog gap reported since the channel opened came from the word tier; none came from on-device. Gating the prompt on on-device therefore guaranteed it would never be seen by the population that files these reports, and the accompanying skill text went further and told agents outright not to report a word-tier miss -- so an agent handed `report_gap` with `--tier words` would have followed the docs straight into discarding it. The nudge now prints on both tiers with the tier that actually answered, and both skills say to report whenever nothing fits rather than to hold out for a tier that is usually off. The tier still rides along in the report, which is what keeps a vocabulary miss distinguishable from a meaning miss when these are read -- that distinction belongs in the analysis, not in a gate that suppresses the data. Test plan: 2 new tests pinning the nudge on each tier, including the `--tier words` token. catalog.test.ts 29, remote.test.ts 9, both green; packages/cli typecheck clean. Confirmed against the real CLI: a word-tier query now prints the pre-filled command with --tier words.
What
Three fixes around a catalog search that goes wrong.
catalog --queryhands back the gap-report command, pre-filled, and--jsoncarries it as a newreport_gapfield./hyperframes-registrydocuments the gap channel and the stale-serve behaviour;/hyperframes-clinames three commands it never mentioned, and marks two that agents should not run.Why
The cache fix.
readCachereturnedundefinedpast its 24h TTL, which made "expired entry" and "no entry at all" indistinguishable to both callers. One timeout against the registry host therefore reported the entire catalog as unreachable, while a perfectly usable copy sat on disk. Reports from the field describe exactly this: a fetch that timed out on every item, and an author who hand-wrote captions, a chart and a wipe rather than install the blocks that already existed locally. The cost is silent, because an author who works around the catalog leaves no trace in it.The gap report.
feedback --search-missexists to name moves the catalog does not have. It is the only signal that can describe a move nobody could install, which is precisely the thing install counts can never show. It was documented in a skill file and printed byfeedback --help, and neither of those is open at the moment a query comes back wrong. The command was reachable only by an author who already remembered it existed.The docs.
/hyperframes-registryownshyperframes catalogand never mentioned the gap channel, so the skill an agent actually loads for catalog work routed past it. Separately, a few CLI commands appear in--helpand in no skill at all, and two more (events, and the deprecatedcheckaliases) look useful but waste a turn.How
registry/remote.ts—readCachebecomesreadCacheEntry+isFresh, so freshness is the caller's decision rather than a property of the read. Both fetchers keep their fast path (fresh entry, no network) and gain a fallback (stale entry once the fetch throws). One change, both call sites, since both routed through the same read.Deliberately preserved:
fetchRegistryManifeststill returnsundefinedwhen the network fails and nothing was ever cached.fetchItemManifeststill throws in that case, so callers can still tell "offline" from "no such item".skipCacheforces revalidation but does not forbid the stale fallback. "Check for something newer" and "rather have nothing than this" are different requests, and only the first one is ever made.commands/catalog.ts— asearchMissCommand(query, tier)helper builds the line with shell metacharacters escaped. It is printed on zero results, printed after an on-device search that returned hits, and carried in every--jsonsearch envelope. Only the command is built; sending it stays a separate deliberate act, so plaincatalog --querykeeps its promise that the query text never leaves the machine.The nudge prints on both tiers, with whichever one answered. It was on-device-only at first, on the reasoning that a thin word-match result is explainable while a thin meaning-match result is a real gap. That reasoning was wrong in practice: the on-device tier needs a consented 33 MB download and is not even offered under
--json, so an agent run ranks onwordsunless it passed--on-device --yes. Every catalog gap reported since the channel opened came from the word tier and none from on-device, so gating on on-device guaranteed the line would never reach the population that files these reports. The accompanying skill text went further and told agents not to report a word-tier miss at all, which would have sent an agent holdingreport_gap --tier wordsstraight into discarding it.The tier still rides along in the report, which is what keeps a vocabulary miss distinguishable from a meaning miss. That distinction belongs in the analysis, not in a gate that suppresses the data.
The
--jsonfield is unconditional on both tiers, because an agent judging the hits useless needs the command already in the envelope by the time it makes that call.Test plan
packages/cli/src/registry/remote.test.tsis new: 8 tests covering the fresh path, the stale fallback on both fetchers, the stale fallback underskipCache, a successful refetch winning over the cache, and the two unchanged failure contracts. Verified that 3 of them fail against the unpatchedremote.tsand the other 5 pass, so the suite is pinned to the behaviour change rather than to the implementation. The two stale tests assert the failing fetch was actually attempted, otherwise they would pass even if the clock never moved.4 tests added to
catalog.test.ts:report_gappresent on a search with hits, the tier named correctly on the word tier, a CJK query surviving intact, and shell metacharacters escaped.Exercised against the real CLI, both paths:
--jsonon a real query returnsreport_gapalongsidetierandresults.Full CLI suite: 2632 passed. 3 failures remain (2 in
transcribe, 1 hostname-dependent intelemetryIdentity); all 3 were confirmed failing on the base commit with this branch stashed, so they are pre-existing and untouched here.Not covered
fetchItemFileis unchanged. Item files are written straight to the destination and were never cached, so installing an item this machine has never seen still needs the network. Only discovery and re-install degrade gracefully.npxcache can still run an older CLI than the version requested, which makes newer catalog flags look like they do not exist. That is not fixable from inside this repo and needs its own approach.lint/checkas installed) are a separate seam: the fix there is gating the registry in CI with the linter we already have, not a change to the fetch path.