Skip to content

fix(desktop): bind screenshot capture to the server entry id, not the local one - #80

Merged
bladehell-ai merged 1 commit into
developfrom
fix/desktop-screenshots-bound-to-local-entry-id
Aug 21, 2026
Merged

fix(desktop): bind screenshot capture to the server entry id, not the local one#80
bladehell-ai merged 1 commit into
developfrom
fix/desktop-screenshots-bound-to-local-entry-id

Conversation

@bladehell-ai

Copy link
Copy Markdown
Collaborator

Symptom

Screenshots stopped reaching the dashboard while time sync stayed healthy. On 2026-08-20, 17 users tracked 5–8 hours each and 3 had any screenshot at all. Each install stopped on its own date, which made it look like an intermittent per-machine problem.

Evidence (production)

Screenshots arriving within 60s of capture (created_at - captured_at):

Date live total
2026-08-10 1613 1692
2026-08-11 1665 1677
2026-08-12 1627 1639
2026-08-13 47 292
2026-08-14 0 106
2026-08-18 0 835
2026-08-19 2 1025

The live path died the exact day the offline-first refactor reached production. Everything after it dribbles in through the offline queue: mean capture→arrival lag 15–42 hours, with 833 shots in the last 7 days taking over 24h.

All 17 users were sending heartbeats up to the current minute — that ruled out connectivity, auth, sync and the S3 pipeline, and isolated the fault to the screenshot path.

Root cause

startTimer() makes no network call any more, so currentEntry.id is the local SQLite id local-<ts>-<rand>, and it was passed straight to screenshotService.start(). POST /screenshots/presign validates time_entry_id as a uuid and firstOrFail()s it against time_entries, so every live screenshot 422'd, exhausted its 3 retries and fell into the offline queue.

rebindEntryId() was written for exactly this and survived the refactor — but its only caller, reconcileTimerState(), was deleted with it. Its docblock still named that caller.

Why the queued fallback then lost them for good: resolution reads timer_sessions.server_entry_id, but the 05:00 purge (24h) and clearForLogout() delete that row with no knowledge of the queue's separate database. With a 15–42h backlog lag, shots routinely outlived their own session row. The idempotency_key fallback could not help — the queued key is the shot's dedupe key, never the session's. Unresolvable items are held, not dropped, so they accumulated behind ORDER BY priority DESC, id ASC LIMIT 500 and were finally deleted silently, with their image files, by the 7-day TTL sweep.

Changes

  • SessionSyncWorker fires onSessionConfirmed(localId, serverEntryId) once per session, on the transition from "server has never seen this" to "id known"; index.js wires it to screenshotService.rebindEntryId(). This is the only signal that can un-break a session started offline, since capture is bound before any server id exists.
  • liveCaptureEntryId() seeds start() from an already-known server id — covers restore-after-restart, project switch, and any session older than one sync cycle.
  • Queued screenshots record session_uuid alongside the per-shot idempotency_key; add() persists it and _resolveEntryId() resolves through it first.
  • purgeConfirmed() takes a keep-list, fed by offlineQueue.referencedSessionKeys().
  • A flush that holds screenshots now logs the count, escalating to console.error past 50 — the failure mode above was completely silent.

Tests

desktop/test/screenshot-entry-id-binding.test.js — 12 new tests covering the rebind callback (fires once, never re-fires, survives a throwing handler), the index.js wiring, session_uuid persistence and resolution precedence, and the purge keep-list.

46 suites / 796 tests pass.

Docs

  • bugs/desktop-screenshots-bound-to-local-entry-id.md (+ index row in bugs/README.md)
  • CLAUDE.md — the capture-binding rule, the session_uuid requirement, and the purge keep-list

Reviewer notes

  • currentEntry.id is deliberately not mutated — only the capture service is rebound. Local SQLite operations key off it and would break.
  • The session_uuid widening is safe for heartbeats: their idempotency_key already is the session uuid, so preferring session_uuid only widens what can resolve.
  • Rollout only restores the live path for new desktop builds. Screenshots already stranded in users' queues are unrecoverable where their session row is gone.

🤖 Generated with Claude Code

… local one

Screenshots stopped reaching the dashboard while time sync stayed healthy. On
2026-08-20 seventeen users tracked 5-8 hours each and three had any screenshot
at all. Live uploads (arrival within 60s of capture) went 1627/1639 on Aug 12
to 0/835 on Aug 18 -- the offline-first rollout landed on Aug 13.

`startTimer()` makes no network call any more, so `currentEntry.id` is the local
SQLite id `local-<ts>-<rand>`, and it was passed straight to
`screenshotService.start()`. `POST /screenshots/presign` validates
`time_entry_id` as a uuid and looks it up in `time_entries`, so every live shot
422'd, exhausted its retries and fell into the offline queue. `rebindEntryId()`
was written for exactly this case and survived the refactor, but its only caller
-- `reconcileTimerState()` -- was deleted with it.

The queued fallback then lost them for good. Resolution reads
`timer_sessions.server_entry_id`, but the 05:00 purge and `clearForLogout()`
delete that row with no knowledge of the queue's separate database, and with a
15-42 hour backlog lag shots routinely outlived their own session. The
`idempotency_key` fallback could not help: the queued key is the shot's dedupe
key, never the session's. Unresolvable items are held rather than dropped, so
they piled up behind `LIMIT 500` and were finally deleted -- silently, with
their image files -- by the 7-day TTL sweep. Heartbeats were unaffected because
they are priority 1 and carry the session uuid, which is what isolated the fault.

- `SessionSyncWorker` fires `onSessionConfirmed(localId, serverEntryId)` once per
  session, on the transition to a known server id; index.js rebinds capture to it
- `liveCaptureEntryId()` seeds `start()` from an already-known server id, covering
  restore-after-restart and project switch
- queued screenshots record `session_uuid`; `add()` persists it and
  `_resolveEntryId()` resolves through it first
- `purgeConfirmed()` takes a keep-list fed by `offlineQueue.referencedSessionKeys()`
- a flush that holds screenshots now logs it, escalating past 50

12 regression tests in desktop/test/screenshot-entry-id-binding.test.js
(46 suites / 796 tests pass). Full write-up in
bugs/desktop-screenshots-bound-to-local-entry-id.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bladehell-ai
bladehell-ai merged commit 9b614e8 into develop Aug 21, 2026
5 checks passed
@bladehell-ai
bladehell-ai deleted the fix/desktop-screenshots-bound-to-local-entry-id branch August 21, 2026 08:03
@bladehell-ai
bladehell-ai restored the fix/desktop-screenshots-bound-to-local-entry-id branch August 21, 2026 09:15
@bladehell-ai
bladehell-ai deleted the fix/desktop-screenshots-bound-to-local-entry-id branch August 21, 2026 09:15
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