Skip to content

feat(api): add device-scoped bucket and query routing - #200

Draft
TimeToBuildBob wants to merge 21 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/device-addressing-9a85
Draft

TimeToBuildBob wants to merge 21 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/device-addressing-9a85

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Continue ActivityWatch/activitywatch#302 with Python HTTP integration over draft ActivityWatch/aw-core#183 at 4ac214618dd9e93cc8e054b4e797ad70df96f641 (Git-pinned and locked). Includes #183 local discovery commits rebased on master; that PR remains independently mergeable.

  • Device-scoped bucket CRUD, events/count/single and bulk delete, heartbeat, JSON/CSV export and query routes reuse existing validation/authentication through immutable request-local API views.
  • Normalize server UUID and local to one stored scope; preserve peer ownership. Legacy POST is local-only, alias-aware, and rejects local/global routing disagreement. Other unique global-name compatibility remains; ambiguity is 409.
  • Query cache includes scope; heartbeat state uses device + canonical bucket ID, with insert/delete/metadata invalidation.
  • Complete device catalogue, grouped metadata/export and grouped import with whole-envelope identity/event preflight. Export substitutes source UUID for local sentinel; imported source history remains peer-owned elsewhere. Preserve metadata data.
  • Device import merges existing same-device buckets and skips duplicate events (timestamp, duration, canonical data), matching Rust. An alias of stored history merges into that row; it does not create a sibling. Legacy /import still rejects existing exact names. Rollback deletes only buckets this call created.
  • Grouped export streams events into a private tempfile from a dedicated read-only SQLite snapshot before HTTP success. The API write lock is held only to flush and pin that snapshot, so later writes are not blocked for the duration of staging. No live database cursor survives response iteration.
  • SQLite worker-thread requests use thread-owned connections, closed at teardown, while sharing write serialization and API caches. Never disable SQLite thread checks or swap the shared API's datastore.
  • Unsupported scoped Memory/Peewee requests return 501. Authentication and host checks remain in force.

Verification

Initial 4 HTTP contract tests failed on missing routes. Additional regressions reproduced metadata loss, unsafe legacy POST acknowledgement, catalogue/import error-path gaps, per-bucket snapshot commits, request-thread SQLite 500s, reject-on-existing device re-import, and export holding the write lock before repair.

  • 19 file-backed SQLite HTTP contract tests, included in make test, including re-import dedup/alias merge, concurrent write during staged export, real concurrent WAL writer exclusion, staging failure before success, real request-thread export and parallel same-name device isolation.
  • Broad server slice: 143 passed via repository make test (live-client test_client.py and test_integration.py excluded). Repository mypy 31 files passed.
  • Core snapshot follow-up at 4ac2146: 392 passed / 5 skipped, repository mypy 37 files passed (using its prescribed stubs/import flags).
  • Ruff, Black and whitespace passed; commits use normal hooks.

A same-version PyPI aw-core installation survived the first Poetry install despite the Git lock. Removed only aw-core from the isolated venv, reinstalled and verified direct_url.json identifies the exact Git pin before the no-PYTHONPATH tests. Subsequent pin update to 4ac2146 is locked in poetry.lock.

Explicit draft boundaries

  • Not complete cross-backend identity acceptance; no Rust duplicate-name guards removed. No production services/databases changed; no watcher adoption or sync-v2 import enabled.
  • SQLite is the only device-routed Python backend; unsupported ones return 501.
  • Individual groups reuse Python rollback for newly created buckets; earlier successful groups may remain after a runtime failure.
  • Event streaming retains the backend's existing time-range semantics. Unbounded archive/timestamp fidelity remains acceptance work. Legacy JSON/CSV export implementations remain separate.
  • Full shared HTTP and backend-specific migration/same-name acceptance remains, including privacy and read-reader refresh boundaries.
  • Peer hostname is a deterministic bucket-metadata representative, not a latest-name registry.
  • Draft dependency must land and be replaced by an upstream release/pin before shipping. Local passes are not current-head CI or a released outcome.

Mirror aw-server-rust's /api/0/devices endpoint in Python aw-server.
Returns the local device id (UUID, stable across restarts) and hostname.
Paired with aw-core#177 which adds device_id to the SQLite schema.

Part of ActivityWatch/activitywatch#302 (bucket identity unification).

Git-Session-Id: 02ce
…stability

Git-Session-Id: aa774620-bec5-581f-adf4-2c8cc3cf4eb6
Git-Session-Id: 931478ca-2d2b-48c5-bb99-0bb5ad608478
Comment thread tests/test_device_addressing.py Fixed
Comment thread tests/test_device_addressing.py Fixed
@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The follow-up appears safe to merge; the previous duplicate-import issue is fixed.

Summary

Adds device-scoped bucket and query routes, grouped import/export, and request-owned SQLite connections.

  • Callers can choose a device before working with its buckets.
  • The server lists, exports, and imports buckets grouped by device.
  • SQLite requests use their own connection on worker threads.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    Request[HTTP request] --> Connection[Request-owned SQLite connection]
    Connection --> Scope[Device-scoped API view]
    Scope --> Bucket[Bucket and event operations]
    Scope --> Query[Scope-keyed query cache]
    Connection --> Export[Read-only export snapshot]
    Export --> File[Staged download]
Loading

Reviews (3) · Last reviewed commit: "fix(api): skip duplicate new events with..." · Reviewed by Greptile

Git-Session-Id: 931478ca-2d2b-48c5-bb99-0bb5ad608478
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

The previous format failure is superseded: 0a4ded7 passes format and lint; its remaining checks were still running when inspected. The two CodeQL findings flag DELETE requests inside test assertions (tests/test_device_addressing.py); those calls are still present on this head.

The implementation session is actively using this branch/worktree, so I left it untouched and sent the owner the two exact findings and the minimal repair (execute each DELETE before asserting its response status). No merge or CI-green claim; this remains a draft.

Git-Session-Id: 931478ca-2d2b-48c5-bb99-0bb5ad608478
Device import now deduplicates existing same-device events, matching
Rust, and merges aliases into the stored row instead of rejecting.
Grouped export pins a read-only SQLite snapshot so staging no longer
holds the API write lock. Pins aw-core#183 at 4ac2146.

Git-Session-Id: aeaa4ffa-df33-4fe0-8487-f35c72996602
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw_server/api.py Outdated
Comment on lines +265 to +269
new_events = [
event
for event in incoming
if self._import_event_identity(event) not in existing
]

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 Imports keep duplicate new events

When an existing bucket receives two identical new events in one device import, _merge_imported_events keeps both. Each event is checked against the same existing set, which contains only stored events. Both copies reach create_events, so overlapping source histories can inflate event counts and activity totals.

Add each accepted event's identity to the set before checking the next event.

Suggested change
new_events = [
event
for event in incoming
if self._import_event_identity(event) not in existing
]
new_events = []
for event in incoming:
identity = self._import_event_identity(event)
if identity not in existing:
existing.add(identity)
new_events.append(event)

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.

Fixed in 0278a28.

_merge_imported_events compared each incoming event only against stored history, so two identical new events in one batch both reached create_events. Accepted identities are now recorded as they land. Regression test_import_skips_duplicate_new_events_in_one_batch failed at count 3 before the set update and passes at 2 after. The two CodeQL DELETE-in-assert findings are split so the request runs before the status check.

Existing-bucket merge compared each incoming event only against stored
history, so two identical new events in one envelope both reached
create_events. Record accepted identities as they land, and execute
DELETE requests before asserting their status in the HTTP tests.

Git-Session-Id: 72dcece2-03b4-59da-979e-db600720e05b
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Git-Session-Id: 0dc232f4-6721-4652-9e55-bd51fe8eec69
Existing-bucket merge compared each incoming event only against stored
history, so two identical new events in one envelope both reached
create_events. Record accepted identities as they land, and execute
DELETE requests before asserting their status in the HTTP tests.

Git-Session-Id: 72dcece2-03b4-59da-979e-db600720e05b
Git-Session-Id: 0dc232f4-6721-4652-9e55-bd51fe8eec69
Git-Session-Id: 0dc232f4-6721-4652-9e55-bd51fe8eec69
Git-Session-Id: 0dc232f4-6721-4652-9e55-bd51fe8eec69
Git-Session-Id: c2d218fa-690b-4e77-96e3-959267ad65f3
Git-Session-Id: c2d218fa-690b-4e77-96e3-959267ad65f3
Comment thread tests/test_device_backend_contract.py Fixed
Comment thread tests/test_device_backend_contract.py Fixed
Git-Session-Id: c2d218fa-690b-4e77-96e3-959267ad65f3
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Integrated #201 in d6e9d4c, retaining the existing same-batch import dedup repair. Current aa99cfc pins/locks integrated core 26001d3; verified installed direct_url.json, no PYTHONPATH. tests/test_device_backend_contract.py is included in make test: set AW_RUST_CONTRACT_BINARY to run identical real-loopback HTTP fixtures on both backends. Eight shared cases pass, including Python→Rust and Rust→Python idempotent grouped transfer, aliases/query/export, heartbeat state, UUID/local equivalence and forged legacy ownership. They exposed two repaired gaps: core alias-query existence and naive created timestamps causing Rust import 422. New creation is aware UTC; grouped export normalizes historical naive metadata without rewriting SQL (regression verifies this). Package slice 156 passed; mypy32, Ruff/Black/hooks pass. Both Rust name guards remain and full same-name acceptance is unfinished; no production migration, watcher/V2 rollout or release claim.

…meout

The macOS Build job times out waiting for the Python backend's /info
while the server banner has printed; the loop discarded every response
and exception, so the failure was undiagnosable.

Git-Session-Id: 422c02db-c672-451c-95ff-206cb6ce083b
HTTPServer.server_bind calls socket.getfqdn on the bind address before
listening. On macOS runners that lookup outlives the 20s readiness
deadline, so the server log stops after the Flask banner and /info times
out. Stub getfqdn in the test subprocess only; reproduced locally by
forcing a slow getfqdn via sitecustomize (red before, green after).

Git-Session-Id: 422c02db-c672-451c-95ff-206cb6ce083b
Git-Session-Id: c81281b2-9cbe-4e63-a85e-e227184324d1
Git-Session-Id: c81281b2-9cbe-4e63-a85e-e227184324d1
Git-Session-Id: c81281b2-9cbe-4e63-a85e-e227184324d1
Git-Session-Id: 914f36a8-05d5-4b6a-865c-3aaf7e1995f8
logs = []
running = {}

def start(backend, label, restart=False):
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.

2 participants