fix(instrument-repos): import every instrument a repository provides - #1485
Merged
Conversation
Three defects made repo import report an incomplete and internally inconsistent
set of instruments. For one repo: 9 directories on GitHub, 8 in the repo list,
4 in the "View instruments" dialog.
**Series and file instruments were never imported.** `discoverInstrumentDirs`
scanned a hardcoded `['forms', 'interactive']`, so `lib/series` and `lib/file`
were skipped before discovery. Nothing was logged because nothing was attempted,
even though both are real kinds in `$InstrumentKind` with a directory in the
built-in library.
Series carry an ordering constraint scalars do not: `validateSeriesInstrument`
rejects a series unless every instrument it references is already stored. The
category list is now ordered so `series` is scanned last and the scalars a repo
provides are created first. A series referencing a *different* repository still
fails — that needs a second resolution pass after all repos are imported, and is
documented as a known limitation rather than half-built here.
**A lost insert race silently dropped instruments.** `create` checks
`instrumentModel.exists({ id })` and then inserts, which is not atomic. Two
concurrent imports of an instrument provided by more than one repo both cleared
the check; the loser got a driver-level unique-constraint error instead of the
`ConflictException` that the caller knows how to recover an id from, so the
instrument was dropped from that repo's `instrumentIds` entirely. The insert now
re-checks existence on failure and reports the same conflict, which is exact
(the insert failed and the row is present) and avoids coupling to Prisma error
codes. Anything else still propagates.
The conflict message is now produced in one place, since
`InstrumentReposService` parses the id back out of it.
**The dialog hid all but the latest edition.** The repo table counts
`instrumentIds.length` while the dialog used `useInstrumentInfoQuery()` with no
arguments, where `allEditions` defaults to false and results are keyed by
instrument name, keeping only the highest edition. A page whose purpose is
auditing what a repository contributed now asks for every edition.
Tests: `discoverInstrumentDirs` gains coverage for all four categories, for the
series-last ordering, and for ignoring an index-less directory; `create` gains
coverage for the race and for not masking unrelated insert failures. The two
category tests and the race test were each confirmed to fail before the fix.
No e2e test accompanies this. `SetupService.seedDefaultInstrumentRepo` skips
seeding when `NODE_ENV=test` because the suite must not reach GitHub, so the e2e
environment contains no repo-sourced instruments and neither code path is
reachable. Covering it needs a mockable GitHub layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gdevenyi
requested changes
Aug 4, 2026
gdevenyi
left a comment
Contributor
There was a problem hiding this comment.
Thanks — the three root causes are well diagnosed, the discovery ordering is sound, and the race fix with its fail-first tests is solid. Two things before this lands:
- The "View instruments" dialog can still disagree with the table's count, in exactly the case your conflict-handling fix creates.
apps/web/src/routes/_app/admin/instrument-repos/index.tsx(~line 278) filters the dialog byinstrument.sourceRepo?.id === repoToView.id, but provenance is only tagged on instruments an import actually created (tagInstruments(createdIds, ...)). An instrument recovered through the conflict path — the six multi-repo names from your bug #2 — joins the repo'sinstrumentIds, which the table counts, whilesourceRepokeeps pointing at whichever repo created it first, so the dialog omits it. A manually-uploaded instrument a repo also provides behaves the same way. Filtering by membership —repoToView.instrumentIds.includes(instrument.id)— derives both the count and the list from the same field. KeepallEditions: truealongside it, since the default response drops the lower-edition ids entirely. .agents/docs/architecture/instrument-pipeline.md(which this PR edits) still says the placement instruction inpackages/instrument-guidelines/AGENTS.mdis coupled todiscoverInstrumentDirs— change one and you must change the other. This PR changes discovery to scanlib/fileandlib/series, but guidelines rule 5 still only tells authors where to put forms and interactive tasks, even though the same document fully specs authoring FILE and SERIES instruments. Either extend rule 5 to name all four directories (that file ships as a published npm package, so treat the edit as a release), or add a line to the pipeline doc recording that the guidelines deliberately name only the two original directories.
The absence of an e2e test is fine as argued — the NODE_ENV=test guard in SetupService.seedDefaultInstrumentRepo and the header comment in testing/src/specs/admin-instrument-repos.spec.ts both confirm the import path is unreachable in that environment.
If you hand this to Claude Code, Opus 5 is the right size — two small but cross-workspace edits (web route, architecture doc, possibly a published package) that need repo judgment rather than pure mechanics.
Reviewed at commit ef5da95.
…mport-completeness
`odc-instruments/SKILL.md` still told agents that discovery scans only `lib/forms` and `lib/interactive` and silently skips `lib/file` and `lib/series` — the exact behaviour this branch changes. Left as-is it would actively mislead: an agent would conclude a missing series was expected rather than a bug. Replaces it with what is now true, including the ordering constraint and the cross-repository limitation, so the caveat travels with the capability rather than living only in the architecture doc. Found by applying the review note on #1482 (a fixed defect still listed as known in `odc-debugging/SKILL.md`) to this branch's own doc surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes three defects that made instrument repository import report an incomplete
and internally inconsistent set of instruments.
Observed on
DouglasNeuroInformatics/ODC_Restricted_Instruments: 9 formdirectories on GitHub, 8 in the repo list column, 4 in the "View
instruments" dialog. Three different numbers, three different causes.
1. Series and file instruments were never imported
discoverInstrumentDirsscanned a hardcoded list:lib/series/andlib/file/were skipped before discovery. Nothing appeared inthe logs because nothing was attempted — the instruments were never bundled, so
there was no error to report. Both are real kinds (
$InstrumentKindis['FILE', 'FORM', 'INTERACTIVE', 'SERIES']) with a directory in the built-inlibrary, so a repo laid out the same way as
packages/instrument-librarysilentlylost half its categories.
Ordering is now load-bearing
Series carry a constraint scalars do not.
validateSeriesInstrumentresolvesevery item to a stored instrument and rejects the whole series if any is missing:
The category list is ordered so
seriesis scanned last, and the doc commentstates that callers must import in the returned order.
importInstrumentsalready iterates sequentially and awaits each create, so the scalars a repo
provides exist by the time its series are reached.
Known limitation, deliberately not fixed here
A series referencing an instrument from a different repository still fails,
because nothing resolves dependencies across repos or retries afterwards —
whether it works depends on the order repos were added. Making that reliable
needs a second resolution pass once every repo is imported, which is a design
change rather than a bug fix. It is now documented in
.agents/docs/architecture/instrument-pipeline.mdinstead of being half-built.2. A lost insert race silently dropped instruments
Seen for six instruments, all of them names provided by more than one repository.
createguards withinstrumentModel.exists({ id })and then inserts — notatomic. Two concurrent imports both clear the guard, and the loser gets a
driver-level unique-constraint error rather than the
ConflictExceptionthatimportInstrumentFromDirknows how to recover an existing id from. So instead ofbeing associated with the repo that provides it, the instrument was dropped from
that repo's
instrumentIdsentirely.The insert now re-checks existence when it fails and reports the same conflict:
This is exact rather than heuristic — the insert failed and the row is now
present means someone else won the race — and it avoids coupling the service to
Prisma error codes. Any other failure still propagates unchanged.
The conflict message now has a single source (
instrumentExistsConflict), sinceInstrumentReposServiceparses the id back out of it and the two throw sitesmust not drift.
A genuinely silent path is now loud
If that id regex ever fails to match,
importInstrumentFromDirreturnednulland
importInstrumentsdidif (!result) continue;with no log abovedebug—an instrument vanishing from a repo's list with no trace at default log level.
That branch now logs an error.
3. The dialog hid all but the latest edition
The repo table counts
row.instrumentIds.length— the true stored count — whilethe dialog was populated by
useInstrumentInfoQuery()with no arguments. Thatdefaults
allEditionstofalse, andfindInfothen keys results by instrumentname, keeping only the highest edition:
8 instruments at two editions each collapse to 4 rows. Nothing was missing from
the database — the dialog wasn't asking for it. A page titled "Instruments in
<repo>", whose purpose is auditing what a repository contributed, now requestsevery edition. This affects only the dialog; the table count is unchanged.
Tests
discoverInstrumentDirshad no coverage at all. It now has three tests: all fourcategories are discovered,
seriescomes after every scalar, and a directorywithout an index file is ignored.
creategains two: a lost insert race isreported as a conflict, and an unrelated insert failure is not masked.
The two category tests and the race test were each confirmed to fail before the
fix and pass after.
Full suite: 417 passed, 1 skipped.
tscandeslintclean forapps/apiandapps/web.Why there is no e2e test
SetupService.seedDefaultInstrumentReporeturns early whenNODE_ENV=test,because the suite must not reach out to GitHub — a constraint the existing
admin-instrument-repos.spec.tsdocuments in a header comment. The e2eenvironment therefore contains no instrument repository and no repo-sourced
instrument, so neither the import path nor the dialog is reachable. Covering this
properly needs a mockable GitHub layer, which would unlock e2e coverage for the
whole import feature and is worth doing separately.
Note on merge order
This branches from
mainand touchesinstrument-repos.service.ts, which #1482also modifies (its error-logging change is ~30 lines from this one's conflict
handling). They are separate hunks and should merge cleanly, but #1482 is the
production-breakage fix and is worth landing first.
🤖 Generated with Claude Code