Skip to content

fix(scanner): read recording.db, the format the recorder writes - #17

Merged
abrichr merged 1 commit into
mainfrom
fix/scanner-recording-db
Aug 28, 2026
Merged

fix(scanner): read recording.db, the format the recorder writes#17
abrichr merged 1 commit into
mainfrom
fix/scanner-recording-db

Conversation

@abrichr

@abrichr abrichr commented Aug 28, 2026

Copy link
Copy Markdown
Member

What was broken

openadapt-viewer catalog scan found zero recordings on a machine full of them.

The scanner globbed capture.db and queried a capture table and an events table. openadapt-capture PR #28 replaced that bespoke format on 2026-07-17 with a SQLAlchemy database named recording.db. Nothing the current recorder writes matched the glob.

Against the two recordings openadapt-capture commits under examples/captures, on main:

FOUND: 0 []
--- pointing it straight at a real recording directory ---
Warning: Could not read capture.db: no such table: capture
created_at= 1787888945.5  duration= None  frames= None  events= None  task= None  meta= {}

It was not a one-word fix

Renaming the glob would have registered directories with no date, no duration and no counts, which is the second line above. The formats share no table and no column:

pre-#28 capture.db current recording.db
capture.started_at recording.timestamp
capture.ended_at gone; derive from the newest event
capture.screen_width / screen_height recording.monitor_width / monitor_height
events action_event
screenshots/*.png screenshot rows holding png_data blobs

What changed

scanner.py reads the current schema:

  • recording for the start timestamp, task description, platform and display geometry, matching what CaptureSession.load selects
  • action_event rows for the event count, which is what events counted
  • screenshot rows for the frame count, falling back to screenshots/*.png only when the database cannot answer
  • the newest timestamp across the event tables for the duration, because there is no ended_at column

It reads with sqlite3 rather than importing CaptureSession. Indexing needs two row counts and a few scalars; installing openadapt-capture for them would pull mss, sounddevice, soundfile, matplotlib, sqlalchemy, alembic, numpy and the platform accessibility stack into an HTML generator. Anything that needs decoded frames should import CaptureSession instead of extending the scanner.

cli.py also fixes catalog register, which raised TypeError for every directory that got past the path check. Recording names the field id, register_recording takes recording_id, and TypeError is not in the except clause there, so the command ended in a traceback.

Legacy captures are not supported, on purpose

openadapt-capture's own scripts/migrate_legacy_capture.py says it plainly: "CaptureSession.load reads only recording.db. Legacy captures are therefore unloadable by current code." The migration exists, and screenshots.yml already reports a capture.db directory as unusable rather than missing.

So a legacy directory now prints the conversion command and is skipped, and _extract_recording_info raises rather than returning a shell:

Warning: /tmp/.../old-rec holds the legacy capture.db format, which the current
viewer cannot read. Convert it with openadapt-capture:
python scripts/migrate_legacy_capture.py <src> <dest>

A directory holding both is indexed from recording.db with no warning, so converting in place works.

Proof against the real recordings

Not a fixture written here. openadapt-viewer catalog scan --capture-dir <openadapt-capture>/examples/captures, then catalog list --json:

[
  {"id": "turn-off-nightshift", "created_at": 1767229200.0, "duration_seconds": 19.0,
   "frame_count": 20, "event_count": 20,
   "task_description": "Synthetic display-setting documentation fixture",
   "metadata": {"platform": "synthetic", "screen_width": 960, "screen_height": 540,
                "pixel_ratio": 1.0}},
  {"id": "demo_new", "created_at": 1767225600.0, "duration_seconds": 13.0,
   "frame_count": 14, "event_count": 14,
   "task_description": "Synthetic calculator documentation fixture",
   "metadata": {"platform": "synthetic", "screen_width": 960, "screen_height": 540,
                "pixel_ratio": 1.0}}
]

The counts match the table in that repository's examples/captures/README.md: 20 frames and 20 actions for turn-off-nightshift, 14 and 14 for demo_new.

Tests

tests/test_scanner_recording_db.py reads those two committed recordings. 17 of its 21 tests fail against the scanner on main and all 21 pass here. Every expectation is queried from the recording.db the scanner read, written out in the test rather than shared with the scanner, so it asserts the catalog reports what the file says.

test.yml now sparse-checks out openadapt-capture/examples/captures and points the suite at it, the way screenshots.yml already does. Without that the whole module would skip and verify nothing, so one test fails under GITHUB_ACTIONS when the recordings are absent. Tonight a PR with 197 passing tests shipped a real break because every test stubbed the boundary under test; a recording.db built here to match this repository's idea of the schema would have proved only that the reader agrees with the test author.

tests/test_screenshot_generation.py::test_captures_exist asserted on capture.db at the checkout root. Both halves were stale: the captures live under examples/captures, and the file is recording.db.

Not fixed here

viewers/benchmark/real_data_loader.py reads the same legacy schema. It also needs an episodes.json that no committed recording carries, so there is no real input to verify a fix against. Left alone rather than changed blind.

🤖 Generated with Claude Code

`catalog scan` globbed `capture.db` and queried a `capture` table and an
`events` table. openadapt-capture PR #28 replaced that bespoke format on
2026-07-17 with a SQLAlchemy database named `recording.db`. Nothing a user
records today matched the glob, so the scanner found zero recordings on a
machine full of them, and the catalog could not be populated at all.

The rename alone would not have fixed it. The two formats share no table and
no column:

  capture.started_at        -> recording.timestamp
  capture.ended_at          -> (gone; derive from the newest event)
  capture.screen_width      -> recording.monitor_width
  capture.screen_height     -> recording.monitor_height
  events                    -> action_event
  screenshots/*.png         -> screenshot rows holding png_data blobs

Pointed straight at a current recording, the old reader logged "no such table:
capture" and registered an entry with the directory mtime as its date and None
for duration, frames and events. So this reads the current schema:

* `recording` for the start timestamp, task description, platform and display
  geometry, matching what `CaptureSession.load` selects;
* `action_event` rows for the event count, which is what `events` counted;
* `screenshot` rows for the frame count, since frames are blobs now, falling
  back to screenshots/*.png only when the database cannot answer;
* the newest timestamp across the event tables for the duration, because there
  is no ended_at column.

Legacy `capture.db` is not read. openadapt-capture's own
`scripts/migrate_legacy_capture.py` states that current code cannot load it,
and the screenshots workflow already treats it as unusable. A legacy directory
now prints the conversion command instead of registering a shell with no date
and no counts, and `_extract_recording_info` raises rather than returning one.

Also fixes `catalog register`, which raised TypeError for every directory that
got past the path check: `Recording` names the field `id` and
`register_recording` takes `recording_id`, and TypeError is not in the except
clause there.

The tests read the two recordings openadapt-capture commits under
examples/captures, which CI now checks out. Building a recording.db here to
match this repository's idea of the schema would only prove the reader agrees
with the test author, which is how the drift went unnoticed for six weeks.

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

Copy link
Copy Markdown
Contributor

📸 Generated Screenshots Preview

Screenshots have been generated. Download the artifacts to preview them.

Generated Files:

  • demo_new_controls.png (0.10 MB)
  • demo_new_events.png (0.09 MB)
  • demo_new_full.png (0.12 MB)
  • turn-off-nightshift_controls.png (0.10 MB)
  • turn-off-nightshift_events.png (0.10 MB)
  • turn-off-nightshift_full.png (0.12 MB)

🔗 Download screenshots artifact

@abrichr
abrichr merged commit 897e5b9 into main Aug 28, 2026
5 checks passed
@abrichr
abrichr deleted the fix/scanner-recording-db branch August 28, 2026 04:01
abrichr added a commit that referenced this pull request Aug 28, 2026
`load_real_capture_data` opened `<recording>/capture.db` and ran
`SELECT * FROM capture LIMIT 1`, then read `started_at`, `ended_at`,
`screen_width` and `screen_height`. openadapt-capture PR #28 replaced that
format on 2026-07-17 with `recording.db`, a SQLAlchemy database whose
`recording` table shares no column with it:

  capture.started_at      -> recording.timestamp
  capture.ended_at        -> (gone; derive from the newest event)
  capture.screen_width    -> recording.monitor_width
  capture.screen_height   -> recording.monitor_height
  events                  -> action_event
  screenshots/*.png       -> screenshot rows holding png_data blobs

So the benchmark viewer raised FileNotFoundError on every recording the
current recorder has produced. #17 fixed this in the catalog scanner and
left this file alone, because the loader also requires an `episodes.json`
that no committed recording carries.

There is now one reader. `openadapt_viewer/recording_db.py` owns the
filename, the column names, the derived end time and the refusal to read a
legacy capture; the scanner and the benchmark loader both go through it.
Adding a second, divergent translator here would have put format knowledge
in two places in a repository that does not own the format. Legacy support
is deliberately absent for the same reason: openadapt-capture ships
`scripts/migrate_legacy_capture.py` and states that current code cannot load
a legacy capture, so a `capture.db` directory now raises LegacyCaptureError
carrying the conversion command, matching what the scanner prints.

The run's `config` gains `frame_count`, `event_count` and
`task_description`, counted from the database. They are what makes a loaded
run checkable against its source file rather than only against itself.

tests/test_real_data_loader.py loads the two recordings openadapt-capture
commits under examples/captures and asserts the values the loader reports,
queried from the same file. Twenty of its twenty-one tests fail without this
change.

It does not verify the episodes half, and says so in its own docstring. The
loader's `episodes.json` dialect has string `steps`, an `episode_id` like
`episode_001` and a `screenshots.key_frames` list. openadapt-ml's pipeline
serialises `EpisodeExtractionResult`, whose `steps` are objects, whose
`episode_id` is a UUID, and which has no `screenshots` key. No producer
writes the dialect this parser reads, so the fixture used is the one already
committed here, and every assertion that matters is made against
recording.db instead.

examples/capture.db goes with it: a zero-byte file, committed, named after
the retired format and read by nothing. sqlite3.connect creates a database
at any path it is handed, which is how a file like that appears. Both
lookups here use is_file() before connecting.
abrichr added a commit that referenced this pull request Aug 28, 2026
The first pass verified everything against openadapt-viewer==0.2.0 from PyPI
and documented two bugs that main has already fixed. #15 replaced the
hardcoded DEFAULT_CAPTURE_PATH with $OPENADAPT_CAPTURE_RECORDING, and #17 and
#19 moved the readers from the pre-#28 capture.db to recording.db. Reciting
the wheel's behaviour also tripped tests/test_no_hardcoded_paths.py, which
bans an absolute home path in any tracked file including documentation.

Everything is now re-run against an editable install of this branch: the demo
screenshot, the component signatures in docs/COMPONENTS.md, the benchmark
invocation, and the offline behaviour with cdn.jsdelivr.net aborted in a
headless browser.

The legacy-capture path replaces the stale hardcoded-path bullet, because it is
a live failure: LegacyCaptureError subclasses FileNotFoundError, so the
fallback in generate_benchmark_html catches it, load_benchmark_data returns a
run with zero tasks, and the CLI prints "Generated:". The migration command
that recording_db raises never reaches the person who needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
abrichr added a commit that referenced this pull request Aug 28, 2026
* docs: rewrite the README around one worked example

The old README opened with six bolded-lead feature bullets, then documented
capabilities this package does not have. The audio-transcript section described
a feature of openadapt-capture: the string "transcript" appears nowhere in
openadapt-viewer's source or in the 0.2.0 wheel. The four README screenshots
were produced by scripts/generate_readme_screenshots.py, which calls
openadapt_capture.visualize.html.create_html, so they showed another package's
output with captions pointing at a transcript panel that is not in the images.
The synthetic-demo section told the reader to open synthetic_demo_viewer.html
and linked SYNTHETIC_DEMOS_EXPLAINED.md; neither file exists in the repository.
SEARCH_FUNCTIONALITY.md was linked at the root and lives under docs/.

Everything in the new file was run against openadapt-viewer==0.2.0 installed
from PyPI into an empty venv, and the pasted output is that run's output. The
component signatures move to docs/COMPONENTS.md, read out of the installed
package with inspect.signature rather than copied from the source tree.

The screenshot is the real output of `openadapt-viewer demo`, regenerated by
scripts/generate_demo_screenshot.py.

Four behaviours that the old README's "works offline, no server required" claim
covered up are now written down: the page fetches Alpine from jsdelivr and the
task list does not render without it; `benchmark` with no --data resolves an
absolute path on one developer's machine; the capture viewer emits
repo-relative href/src for episode_timeline.css and .js; and __version__ still
reports 0.1.0.

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

* docs: describe main, not the released 0.2.0

The first pass verified everything against openadapt-viewer==0.2.0 from PyPI
and documented two bugs that main has already fixed. #15 replaced the
hardcoded DEFAULT_CAPTURE_PATH with $OPENADAPT_CAPTURE_RECORDING, and #17 and
#19 moved the readers from the pre-#28 capture.db to recording.db. Reciting
the wheel's behaviour also tripped tests/test_no_hardcoded_paths.py, which
bans an absolute home path in any tracked file including documentation.

Everything is now re-run against an editable install of this branch: the demo
screenshot, the component signatures in docs/COMPONENTS.md, the benchmark
invocation, and the offline behaviour with cdn.jsdelivr.net aborted in a
headless browser.

The legacy-capture path replaces the stale hardcoded-path bullet, because it is
a live failure: LegacyCaptureError subclasses FileNotFoundError, so the
fallback in generate_benchmark_html catches it, load_benchmark_data returns a
run with zero tasks, and the CLI prints "Generated:". The migration command
that recording_db raises never reaches the person who needs it.

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

* docs: drop two capability claims the generator does not implement

The offline section said benchmark --standalone embeds Plotly. The flag is
dead: cli.py passes it to generate_benchmark_html, which passes it into
_generate_viewer_html, whose body never reads it. PageBuilder is built with
include_alpine=True and no include_plotly, so the Plotly branch never runs
and the two renderings are byte identical. Claiming an offline escape hatch
inside the section about not being offline-safe is the worst place for it.

dark_mode is stored in PageBuilder.__init__ and never read again. The sun
button in the header is what switches the palette. Say that instead.

Add the screenshot-path bullet: the benchmark viewer writes a real
recording's screenshots as absolute local paths, so mailing that file loses
the images. Only demo inlines them as data URIs.

The demo's pass and fail values come from an unseeded random.random(), so
90.0% is not impossible, only unlikely. Soften the caption.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.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.

1 participant