Skip to content

feat(usb-drive, admin): multi-USB drive API for VxAdmin#8057

Draft
eventualbuddha wants to merge 33 commits intomainfrom
brian/multi-usb-api
Draft

feat(usb-drive, admin): multi-USB drive API for VxAdmin#8057
eventualbuddha wants to merge 33 commits intomainfrom
brian/multi-usb-api

Conversation

@eventualbuddha
Copy link
Copy Markdown
Contributor

@eventualbuddha eventualbuddha commented Mar 4, 2026

Overview

Refs #7897

Adds a multi-USB drive API to libs/usb-drive and migrates VxAdmin to use it. This is groundwork for supporting multiple simultaneous USB drives in VxAdmin, which will be needed for Election Archiving & Backup and multi-USB CVR imports.

Kooha-2026-03-04-16-45-39.webm
❯ lt usb-drive/*
drwxr-xr-x  - vx  4 Mar 16:51  usb-drive/sdb
drwxr-xr-x  - vx  4 Mar 16:51 ├──  mock-usb-data
drwxr-xr-x  - vx  4 Mar 16:51 │   └──  claremont_2025-claremont-municipal-election_6f825e2c0a
.rw-r--r-- 20 vx  4 Mar 16:50 └──  mock-usb-state.json
drwxr-xr-x  - vx  4 Mar 16:51  usb-drive/sdc
.rw-r--r-- 19 vx  4 Mar 16:50 └──  mock-usb-state.json
drwxr-xr-x  - vx  4 Mar 16:50  usb-drive/sdd
drwxr-xr-x  - vx  4 Mar 16:50 ├──  mock-usb-data
.rw-r--r-- 19 vx  4 Mar 16:50 └──  mock-usb-state.json

Testing Plan

  • Test mock USB drives in dev dock.
  • Test pass-through USB devices are detected.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a multi-USB-drive API to libs/usb-drive and migrates VxAdmin to use it, as groundwork for supporting multiple simultaneous USB drives. The new MultiUsbDrive interface tracks all connected drives, auto-mounts FAT32 partitions, and handles per-drive eject/format with action locking. A UsbDriveAdapter bridges back to the single-drive UsbDrive interface for existing consumers in other apps.

Changes:

  • libs/usb-drive: New multi_usb_drive.ts with MultiUsbDrive interface and detectMultiUsbDrive() factory; usb_drive_adapter.ts bridges to legacy UsbDrive; block_devices.ts gains getAllUsbDrives() and createBlockDeviceChangeWatcher(); mocks added; mount.sh updated to derive mount point from device name.
  • apps/admin/backend: buildApp() now accepts MultiUsbDrive; new getUsbDrives()/ejectUsbDrive({ driveDevPath })/formatUsbDrive({ driveDevPath }) API endpoints.
  • apps/admin/frontend: AppContext now carries usbDrives: UsbDriveInfo[]; a getUsbDriveStatus() helper converts to the legacy UsbDriveStatus shape for components not yet multi-drive aware.

Reviewed changes

Copilot reviewed 49 out of 49 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
libs/usb-drive/src/multi_usb_drive.ts Core new multi-drive implementation with auto-mount, per-drive eject/format, and action locking
libs/usb-drive/src/usb_drive_adapter.ts Adapter from MultiUsbDrive to UsbDrive for backward compatibility
libs/usb-drive/src/usb_drive.ts Refactored to delegate to detectMultiUsbDrive + createUsbDriveAdapter
libs/usb-drive/src/block_devices.ts Adds getAllUsbDrives() (disk→partition hierarchy) and createBlockDeviceChangeWatcher()
libs/usb-drive/src/mocks/memory_multi_usb_drive.ts New mock for MultiUsbDrive for tests
libs/usb-drive/src/mocks/file_usb_drive.ts New createMockFileMultiUsbDrive() backed by state file
libs/usb-drive/src/index.ts Exports new public types and factories
libs/usb-drive/scripts/mount.sh Mount point now derived from device name for multi-drive support
apps/admin/backend/src/app.ts Migrated to MultiUsbDrive; new API routes for multi-drive operations
apps/admin/backend/src/server.ts Updated startup to use MultiUsbDrive
apps/admin/frontend/src/contexts/app_context.ts Context updated to use usbDrives: UsbDriveInfo[]
apps/admin/frontend/src/get_usb_drive_status.ts New helper to convert UsbDriveInfo[] to legacy UsbDriveStatus
apps/admin/frontend/src/api.ts New getUsbDrives, updated ejectUsbDrive/formatUsbDrive
Various frontend screens/components Updated to use usbDrives from context + getUsbDriveStatus helper
Various backend test files Updated to use MockMultiUsbDrive and new API shapes

@eventualbuddha eventualbuddha force-pushed the brian/multi-usb-api branch 2 times, most recently from 7ab5b39 to 744e595 Compare March 12, 2026 18:28
@eventualbuddha eventualbuddha requested a review from Copilot March 12, 2026 18:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 54 out of 54 changed files in this pull request and generated 6 comments.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 55 out of 55 changed files in this pull request and generated 6 comments.

Comment on lines +496 to +499
// Refetch when the first drive's mount state changes (inserted/ejected/removed)
queryKey(usbDrives: UsbDriveInfo[]): QueryKey {
const mountType = usbDrives[0]?.partitions[0]?.mount.type ?? 'no_drive';
return ['listPotentialElectionPackagesOnUsbDrive', mountType];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ironically, Copilot suggested that I be less inclusive than when it was just usbDrives[0]. I'm inclined to go back to just doing the full object. @copilot, please just change this to:

     return ['listPotentialElectionPackagesOnUsbDrive', usbDrives[0]];

Comment on lines +236 to +241
driveAction.set(driveDevPath, 'ejecting');
await logger.logAsCurrentRole(LogEventId.UsbDriveEjectInit);
try {
const disk = cachedDrives.find((d) => d.devPath === driveDevPath);
assert(disk, `Drive not found: ${driveDevPath}`);

Copy link
Copy Markdown

Copilot AI commented Mar 17, 2026

@eventualbuddha I've opened a new pull request, #8122, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown

Copilot AI commented Mar 17, 2026

@eventualbuddha I've opened a new pull request, #8123, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown

Copilot AI commented Mar 17, 2026

@eventualbuddha I've opened a new pull request, #8124, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown

Copilot AI commented Mar 17, 2026

@eventualbuddha I've opened a new pull request, #8125, to work on those changes. Once the pull request is ready, I'll request review from you.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 17, 2026

CLA assistant check
All committers have signed the CLA.

eventualbuddha and others added 8 commits March 17, 2026 15:09
Look for more kinds of drives than simply those that show up as
/dev/sd*. Also changes the mount point to include the partition name so
multiple partitions may be mounted simultaneously.
- Add `MultiUsbDrive` interface with `getDrives()`, `mountDrive()`,
  `ejectDrive()`, `formatDrive()`, and `sync()` methods
- Add `UsbDriveInfo` and `UsbPartitionInfo` types for the per-drive state
- Add `detectMultiUsbDrive(logger)` factory that polls block devices and
  auto-mounts/unmounts partitions on insertion/ejection
- Add `createMockMultiUsbDrive()` for testing; uses `makeTemporaryDirectory`
  from `@votingworks/fixtures` to back inserted drives with a real directory
- Export `VX_USB_LABEL_REGEXP` from this module (moved from usb_drive.ts)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `createMockFileMultiUsbDrive()` that implements `MultiUsbDrive` using
  files on disk (analogous to the existing `MockFileUsbDrive`)
- Add `addMockDrive()` / `listMockDrives()` / `removeMockDriveDir()` helpers
  to manage simulated drive slots in the filesystem mock root
- Update `getMockFileUsbDriveHandler()` to accept an optional `diskName` to
  target a specific slot; defaults to the first slot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `createUsbDriveAdapter(multiUsbDrive, getDriveDevPath)` that
  implements the single-drive `UsbDrive` interface on top of `MultiUsbDrive`,
  enabling backward-compatible consumers (Exporter, listDirectoryOnUsbDrive,
  createSystemCallApi) without modification
- Refactor `detectUsbDrive` to build on `detectMultiUsbDrive` + adapter;
  preserve identical observable behavior for all existing callers
- Add `detectOrMockMultiUsbDrive` factory for callers that need the full
  multi-drive API with file-mock support in non-production environments
- Update `cli.ts` watch command to use the new `onChange` option shape
- Export `MultiUsbDrive`, `UsbDriveAdapter`, and multi-USB mocks from
  the package index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
eventualbuddha and others added 25 commits March 17, 2026 15:09
- Update `getUsbDriveStatus` to return `DevDockUsbDriveInfo[]` (with
  `devPath` and `status`) instead of a single `DevDockUsbDriveStatus`
- Add `insertUsbDrive` and `removeUsbDrive` API methods keyed by `devPath`
- Add `addUsbDriveSlot` and `removeUsbDriveSlot` API methods to manage
  the number of simulated drives
- Initialize with a default drive slot on startup
- Poll USB drive status every second in the dev-dock frontend
- Add per-drive insert/eject controls in the dev-dock UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace single `UsbDrive` with `MultiUsbDrive` in backend `buildApp`
- Replace `getUsbDriveStatus`/`ejectUsbDrive`/`formatUsbDrive` with
  multi-drive equivalents: `getUsbDrives`, `ejectUsbDrive(driveDevPath)`,
  `formatUsbDrive(driveDevPath)`
- Add `get_usb_drive_status` frontend utility to derive per-component
  USB status from the list of `UsbDriveInfo` objects
- Update navigation, settings, diagnostics, and export screens to use
  the new API; settings screen shows each drive with its own eject button
- Update dev-dock mock client usage in frontend to match new API shape
- Fix `FormatUsbButtonProps` to accept a simpler `formatUsbDriveMutation`
  type (removes the explicit `UseMutationResult` annotation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ileUsbDrive` factory

Consistent naming: all four USB drive mocks are now factory functions.
- `createMockUsbDrive`(): single-drive test mock (mockFunction-based)
- `createMockMultiUsbDrive`(): multi-drive test mock (mockFunction-based)
- `createMockFileUsbDrive`(): single-drive dev mock (file-backed)
- `createMockFileMultiUsbDrive`(): multi-drive dev mock (file-backed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getUsbDriveStatus was collapsing 'unmounted' and 'unmounting' into
'ejected'. Only 'unmounting' means an eject is in progress; a bare
'unmounted' partition has no eject history (the MultiUsbDrive API
does not expose ejectState). Returning 'ejected' here caused non-FAT32
drives (which are never auto-mounted) to always display as ejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The bad_format check only tested fstype !== 'vfat', so a FAT16
partition (fstype='vfat', fsver='FAT16') was not flagged. This mirrors
the isFat32Partition check in multi_usb_drive.ts which requires both
fstype === 'vfat' AND fsver === 'FAT32'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getAllUsbDrives only iterated over disk-typed udev entries, silently
dropping any partition that appeared without a corresponding disk entry.
This is observed with some USB card readers. For such orphan partitions,
derive the disk path by stripping the partition suffix and synthesize a
UsbDiskDeviceInfo entry, matching the old getUsbDriveDeviceInfo behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ejectState only ever stored `true` values; a Set<string> expresses the
intent more clearly and removes the possibility of a false value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Calling assert() in a UI event handler crashes the app if triggered
without a devPath. Replace with a no-op guard that returns ok(), matching
the pattern already used in navigation_screen.tsx for eject.

Also fix render_in_app_context.tsx test helper: fsver was '32' instead
of 'FAT32', which caused the new fsver check to flag mock mounted drives
as bad_format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
….length)

The pattern `devPath.slice('/dev/'.length)` was repeated 6 times across
two files to extract the disk name from a device path. Using
`basename(devPath)` from node:path is both clearer and safe against
paths that don't start with '/dev/'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lector

The adapter was hardcoded to always select '/dev/sdb', so usbDrive.eject()
and usbDrive.format() would unconditionally call ejectDrive/formatDrive
even when no drive was inserted. Use drives[0]?.devPath instead so the
adapter returns no_drive and is a no-op for eject/format when the drive
list is empty, matching the real detectUsbDrive behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After the issue-1 fix (unmounted→no_drive), there was no way to distinguish
a newly-inserted-but-not-yet-mounted partition from one that was explicitly
ejected by the user. Add `{ type: 'ejected' }` to UsbPartitionMount and
update computeMount to return it when ejectState tracks the drive.

This makes getUsbDriveStatus correctly return `ejected` for user-ejected
drives while still returning `no_drive` for unmounted non-ejected ones.

Also fixes mock_api_client.ts using fsver '32' instead of 'FAT32', which
caused the FAT32 check added in issue 2 to misidentify mounted drives as
bad_format in tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…kagesOnUsbDrive query

Replace the full UsbDriveInfo[] array as the React Query key with a compact
string derived from the first drive's first partition mount type. Using large
objects as query keys is verbose and harder to debug. The derived mount type
string ('mounted', 'unmounted', 'ejected', 'no_drive') still captures all
state transitions that should trigger a refetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Now that MultiUsbDrive exposes { type: 'ejected' } on partitions
belonging to ejected drives, the adapter no longer needs a separate
boolean to remember whether eject was called. The explicit mount
type is unambiguous and avoids the per-adapter stale-state issue
noted in review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ions

This matches the behavior of UsbDriveAdapter, which also returns bad_format
for drives with no partitions. A present drive with no partitions is
unformatted, which the user should be prompted to format — not silently
treated as absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, a disk whose partitions all lived outside /media (or were LVM,
etc.) was added to the result with partitions: []. Downstream code treats an
empty-partition disk as unformatted and may prompt formatting. Now the disk
is excluded entirely, consistent with the intent of isDataUsbDrive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- fix(admin): update ClientAppRoot to use usbDrives instead of
  deprecated usbDriveStatus in AppContext (fixes TypeScript error that
  caused admin-frontend build and integration-testing to fail)
- fix(usb-drive): add test for orphan partition that fails
  isDataUsbDrive to reach 100% branch coverage
Can't test that something is unmounted if it gets auto-mounted.
Co-authored-by: eventualbuddha <1938+eventualbuddha@users.noreply.github.com>
Co-authored-by: eventualbuddha <1938+eventualbuddha@users.noreply.github.com>
Co-authored-by: eventualbuddha <1938+eventualbuddha@users.noreply.github.com>
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.

4 participants