Skip to content

fix(drivers): make version switching work, and reversible#675

Merged
frahlg merged 2 commits into
masterfrom
674-driver-version-switching
Jul 26, 2026
Merged

fix(drivers): make version switching work, and reversible#675
frahlg merged 2 commits into
masterfrom
674-driver-version-switching

Conversation

@frahlg

@frahlg frahlg commented Jul 26, 2026

Copy link
Copy Markdown
Member

Closes #674.

Four faults, each hidden behind the one in front of it. The first one meant nothing from the signed channel could be installed at all.

Nothing signed could be installed

parseCatalogEntry matched only a DRIVER table written inline. Signed artifacts do not write one: tools/ftw_repository.py builds the table as a local and assigns it twice, before the source loads and again after, so a driver cannot overwrite the identity it was signed under. That left DRIVER = __sourceful_ftw_metadata, which the pattern misses.

Every signed artifact therefore parsed as empty metadata, and validateLuaArtifact refused it:

driver metadata id/version @, want ferroamp@1.1.1

Update failed identically. Nobody had noticed, because nothing reached that far.

The Versions panel rendered nothing

GET /versions answers with VersionCandidate: {repository_id, driver:{version,…}, installed?}. The picker read .version off the outer object, so if (!version) return dropped every row. Its install call also omitted repository_id, which the endpoint requires with a 400.

The test meant to cover this matched the source with a regex — assert.match(render, /body && body\.available/) — so it stayed green against an empty panel. The suite now drives the real code with the payload the API really returns.

No way back from the first install

Installing a channel version over a bundled driver leaves PreviousInstalledPath empty, and the bundled copy is not an install to activate by version. Rollback refused outright. That is the case that matters most: installing over the bundled driver is the first thing anyone does when trying a new one.

Rollback now removes the managed entry, which is what makes core resolve the bundled copy again, and the restart points config at the bundled path rather than the symlink it just deleted.

The row spoke our vocabulary, not the operator's

It read BUNDLED · V1.0.0 beside Update, Rollback and Versions — where Rollback is a subset of Versions, and bundled/managed/local describes our storage, not their situation.

Every driver runs locally. What matters is which version runs and what you can switch to. The row now reads:

v1.0.0   official, shipped with this build · verified on hardware   [Update to v1.1.1] [Versions]
           v1.1.1   untested   [Use this]

verification_status has been in the catalog all along and this view never showed it, so you could update onto an untested driver without being told. Rollback is gone; stepping back is picking an older row. After a switch, Undo puts the old version back — activating it when it is retained, rolling back when it is the bundled copy — and re-arms the switch so the next attempt is one click.

An override still gets the list, since seeing what else exists is the point when you are testing your own driver, but its buttons say Download rather than Use this: a local file keeps winning either way.

Verified

Ran the full cycle against the real channel on a clean state: bundled v1.0.0 → fetch and switch to v1.1.1 → undo back to v1.0.0 → switch again, confirming telemetry each time. Both Go fixes have tests that fail without them. make verify clean; 125 web tests, full Go suite.

🤖 Generated with Claude Code

Four faults, each hidden behind the one in front of it.

The signed channel could not install anything at all. parseCatalogEntry only
matched a DRIVER table written inline, but tools/ftw_repository.py assigns it
from a local -- twice, so a driver cannot overwrite the identity it was signed
under. Every signed artifact therefore parsed as empty metadata and
validateLuaArtifact refused it with "driver metadata id/version @, want
ferroamp@1.1.1". Update failed the same way; nobody noticed because nothing
reached that far.

The Versions panel from #672 rendered zero rows. GET /versions answers with
VersionCandidate -- {repository_id, driver:{version}, installed?} -- and the
picker read .version off the outer object, so the guard dropped every row. Its
install call also omitted repository_id, which the endpoint requires. The test
that was meant to cover this matched the source with a regex, so it passed
against an empty panel.

Rolling back the first managed install was refused: PreviousInstalledPath is
empty, and the bundled driver is not an install to step back to. That is the
common case -- installing over the bundled driver is the first thing anyone
does when trying a new one. Rollback now removes the managed entry, which is
what makes core resolve the bundled copy again, and the restart points config
at the bundled path rather than the symlink it just deleted.

The row itself said BUNDLED · V1.0.0 beside three buttons, where Rollback was a
subset of Versions and bundled/managed/local is our vocabulary. Every driver
runs locally; what matters is which version runs and what you can switch to. So
the row now reads "v1.0.0 · official, shipped with this build · verified on
hardware", the catalog's verification_status finally appears where updates are
chosen, and one Versions list replaces Rollback. After a switch, Undo puts the
old version back -- activating it when it is retained, rolling back when it is
the bundled copy -- and re-arms the switch so trying again is one click.

The web tests now drive the real code with the payload the API really returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole diff. Four faults, each fix lands where the fault is, and the tests now drive the real code instead of regex-matching the source (that the old web suite stayed green against an empty panel is the tell it was worth rewriting).

Correctness looks right:

  • extractDriverBlock keeps the inline path, then follows DRIVER = <alias> to the local <alias> = {…} it names, and a dangling alias (DRIVER = missing_table) stays empty rather than borrowing a nearby table. The three catalog tests cover exactly those cases.
  • Rollback-from-first-install deactivates and points targetPath at the bundled DriverDir when InstalledPath == "", consistent across manager.go and api_device_repository.go, with a test on the fall-back-to-bundled case. That's the case that actually matters (installing over the bundled driver is the first thing anyone does).

Security screen, clean:

  • No secrets or keys in the diff.
  • The dynamic regex is regexp.QuoteMeta'd, so a driver-controlled alias name can't inject a pattern.
  • The web picker renders manifest fields via createElement/textContent, never innerHTML, and there's a test asserting exactly that, so remote (signed but still remote) manifest input stays text.
  • No new runtime deps (the ed25519/httptest imports are test-only) and no new network destinations.

Not blocking, just noting: extractDriverBlock reads the first DRIVER = <alias> while the signed artifact assigns it twice (before and after source load). Same local both times today, and metadata is validated against the signed manifest anyway, so a first-vs-last mismatch just fails the install rather than smuggling an identity past validation. Worth knowing if the generator ever assigns different tables.

Safe to merge from my read. CI is green across go test+vet, web, and the driver contract.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26937e0e79

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/internal/driverrepo/manager.go Outdated
Comment on lines +621 to +624
if err := m.Deactivate(logicalPath); err != nil {
return state.DriverRepoInstall{}, err
}
return state.DriverRepoInstall{LogicalPath: logicalPath, DriverID: current.DriverID}, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the managed artifact when bundled rollback fails

When the first managed install is rolled back and restarting or validating the bundled driver fails, this deactivation leaves no active install for handleDeviceRepositoryRollback's recovery path: that handler calls Rollback again to roll forward, but the second call now fails at ActiveDriverRepoInstall. Because Registry.Restart has already removed the managed driver before attempting the failed bundled add, the request returns an error with the driver stopped instead of restoring the previously working managed artifact.

Useful? React with 👍 / 👎.

Comment thread web/settings/tabs/devices.js Outdated
Comment on lines +95 to +97
function push(row) {
if (!row.version || seen[row.version]) return;
seen[row.version] = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve repository and hash variants in version rows

When two enabled repositories publish different artifacts with the same version, deduplicating solely by row.version hides all but the first candidate. This can even present an uninstalled artifact as selectable while suppressing the active same-version artifact from a later repository, causing the button to install the wrong candidate; the backend explicitly distinguishes these cases by repository and SHA and requires the SHA when activating ambiguous versions, so the row key must retain that identity.

Useful? React with 👍 / 👎.

…ent undo

An external review found that removing the Rollback button left no lasting way
back. /versions lists signed and retained artifacts; the bundled copy is
neither, so once a channel version was installed the only path back lived in an
Undo button that vanished when the panel closed.

Worse, the rollback-to-bundled path added in the previous commit could take a
driver offline. Not every driver the channel offers is bundled, and pointing
the restart at a file that is not there makes Registry.Restart remove the
working instance before Add fails. The recovery path then called Rollback a
second time, which had already deactivated the only active row and had nothing
left to restore -- the "sql: no rows in result set" seen while testing.

So going back to the bundled copy is now its own operation rather than a step
inside Rollback. UseBundled checks the bundled file exists before deactivating
anything, and returns the artifact it replaced so the caller can put it back if
the bundled driver fails to start. Rollback returns to refusing a first
install, which keeps its own recovery path intact. The version list carries a
standing "the copy shipped with this build" row, so the way back survives
closing the panel.

Also from the review: the dedupe key was the version alone, which dropped one
of two repositories publishing the same version with different hashes -- the
hash is the artifact's identity, so it is the key. A row fetched from the
channel now records that it is on disk, so a second attempt activates it
instead of downloading again and failing offline. Undo picks its route from
what was running rather than from a version number, since a managed artifact of
the same version can sit on disk and differ in whether it may control anything.

The summary line above the panel is rebuilt from the catalog after a switch
rather than edited in place. Which version runs, its provenance, how well
tested it is and whether it may control anything are all properties of the
artifact now running; the telemetry-only badge in particular differs between
versions of one driver, and it was stale in both directions.

The DOM stub in the tests answered null from querySelector, which let a test
pass while the code it covered did nothing. It resolves properly now, verified
by breaking markRunning and watching the test fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@frahlg
frahlg merged commit a9847e8 into master Jul 26, 2026
11 checks passed
@frahlg
frahlg deleted the 674-driver-version-switching branch July 26, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Driver versions: one list, easy to swap, easy to swap back

2 participants