Repository navigation
Change how hostname/devices work #302
Description
Activity
Another thing, we might want to make buckets user-specific on platforms which have multi-user support.
See this forum post
https://forum.activitywatch.net/t/add-windows-username-to-bucket/374/2One problem i have (and maybe its related to this):
I have two buckets, on is [name-of-my-macbook].local and the other [name-of-my-macbook].fritz.box
I guess the issue is the following: when i start my macbook with lan-connection plugged in, i get another host name (and also another Bucket, if i understand that right) as i get when im connected via wifi. Am i able to avoid that?
Thx so much. You made a great tool!
I also had this problem and this helped me: #554 (comment)
Reacted by ueli kSome concrete evidence on where this stands, from investigating a multi-device sync failure (ActivityWatch/aw-server-rust#682, #683, ActivityWatch/aw-android#272).
Bullet-by-bullet status in the code today
"Every device/installation should have a unique ID" — done.
aw-server/src/device_id.rs, surfaced at/api/0/info."Special hostname that defaults to the local instance" — implemented, zero callers. Both servers have the
!localsentinel (aw-server-rust/aw-server/src/endpoints/bucket.rs,aw-server/aw_server/api.py), and it is the only code path anywhere that writes a device ID onto a bucket:if bucket.hostname == "!local" { bucket.hostname = gethostname()...; bucket.data.insert("device_id".to_string(), state.device_id.clone().into()); }
Grepping the whole bundle for
!localreturns only those two server implementations — no watcher uses it. On a long-running instance of mine:0/32 buckets have data.device_id"Move the hostname into the
datafield" — not done.hostnameis still a column onBucket.The practical consequence
device_ididentifies the instance but never the data, so every consumer falls back to hostname. That is not abstract — it is the direct cause of a cluster of bugs we just worked through:- aw-sync names sync-folder directories
{hostname}/{device_id}/, and derives import provenance from the bucket'shostnamefield, not from thedevice_idsitting right there in the path. - When aw-android started sanitizing its device name (
"POCO F8 Ultra"→poco_f8_ultra, needed becausebucket_newnow rejects hostnames containing whitespace), the device's sync identity forked into two folders holding the samedevice_id. Nothing could reconcile them, and importing both silently truncated ~5 years of history — aw-sync: duplicate folders for one device_id silently truncate history on pull aw-server-rust#683. - Any way to rename the host? I want to merge my devices aw-android#133 ("any way to rename the host?") is the same defect from the user's side.
- aw-webui's Raw Data page has device-ID UI and a "device known by several IDs" consistency check that can never fire, because
data.device_idis always absent and collapses to hostname — Buckets.vue rendersID: undefinedonce data.device_id is populated; device-ID check is dead code aw-webui#982.
One trap for whoever implements the next step
The tempting one-liner — stamp
data.device_idunconditionally inbucket_new— is wrong.AwClient::create_bucketPOSTs to that same endpoint (aw-client-rust/src/lib.rs:117), so a sync pull creating an imported peer bucket would be stamped with the local device ID. That is precisely the bug class ActivityWatch/aw-server-rust#650 just fixed for$aw.sync.origin, which was being written on push-staging as well as on import and therefore meant nothing.A safer shape, roughly in the staging spirit of ActivityWatch/aw-server-rust#649:
- Keep the server stamping only on
!local. - Have aw-sync stamp
data.device_idon imported buckets from the peer directory name it already knows — sync is the one component that always has the right answer. - Migrate watchers to
!localopportunistically. - Only then make device_id authoritative for provenance.
The ordering matters: make it present before making it authoritative. Right now step 4 looks like a small change and is actually a migration, because there is no data to migrate from.
Happy to be wrong about the direction — mainly wanted the "implemented but inert" state recorded, since from the outside it looks like this bullet is done.
- aw-sync names sync-folder directories
Proposal: bucket identity =
(device_id, id), hostname out of the IDThis closes bullets 2 and 3 of this issue, and it is the end state the sync work in #1445 has been converging on. Erik's framing: "getting rid of hostnames in bucket-ids altogether, buckets unique on (device_id, bucket_id) instead of current bucket_id which includes hostname in id (not reliable)."
Why now
The sync v2 design puts every bucket at
devices/{device_id}/buckets/{bucket_uid}/with its publishedidin a manifest — the folder is already keyed(device_id, bucket). What stops the local server from being the same is one constraint:buckets.name TEXT UNIQUE. Because of it, an imported peer bucket has to be renamed to avoid colliding with the local one, which is the entire reason-synced-from-<origin>exists, which is the root of ActivityWatch/aw-server-rust#649, #692, #694, the hostname fork in ActivityWatch/aw-android#272, and the reason the multi-device view can't include browser buckets.The code is waiting for it.
aw-webui/src/queries.ts:98builds'aw-watcher-window_' + hostin the frontend and prefix-matchesfind_bucket("…_")when the hostname is unknown, with a comment abouthostvshost.local.multideviceQuery's own comments: picked in the order of the hostnames array; only supports desktop; doesn't support browser buckets due to the 'unknown' hostname.stores/buckets.tscarries// TODO: Include consideration of device_id UUIDtwice. And the!localsentinel — implemented on both servers, zero callers — is the watcher-side half of this, never adopted because nothing needed it.The model
- Identity:
(device_id, id).idis the local, hostname-free name:aw-watcher-window,aw-watcher-web-firefox,aw-stopwatch. hostnameis metadata on the device, not the bucket. Devices can have several over time; the current one is a display label.- Provenance is the
device_idcolumn.device_id != localis "derived from a peer" — no name substring, no$aw.sync.originneeded for that purpose.
What changes, by layer
1. Datastore (rust
aw-datastoreand pythonaw-core, in lockstep). Addbuckets.device_id;UNIQUE(name)→UNIQUE(device_id, name). Migration: every existing row getsdevice_id = local instance id, except rows whose id carries-synced-from-<origin>→device_idfrom$aw.sync.origin(trustworthy since ActivityWatch/aw-server-rust#650). Do not rewritename. Existingaw-watcher-window_erb-m2staysaw-watcher-window_erb-m2withdevice_id = local; only newly created buckets are clean. Names converge over time; the display layer strips a suffix that matches one of the device's known hostnames.2. REST API. Canonical:
/api/0/devices/{device_id}/buckets/{id}. Alias: today's/api/0/buckets/{id}= the local device's bucket — so every existing watcher and script keeps working. A compatibility resolver for one release: anidending in_<a hostname this device has had>resolves to(local, stripped);…-synced-from-<X>resolves to(X, stripped). New:GET /api/0/devices→[{device_id, hostnames, first_seen, last_seen, local}]— the peer catalogue the Raw Data page and sync status both need anyway.On the stamping hazard I raised earlier in this thread: it dissolves. Plain
POST /buckets/{id}always stampsdevice_id = local(it must, for uniqueness); imports go through the device-scoped path and never hit it.3. Watchers. Send
id = "aw-watcher-window",hostname = "!local". That gives!localits first callers, and it is opportunistic — an un-updated watcher sendingaw-watcher-window_<host>still lands as(local, that-name).4. aw-query.
query_bucket("aw-watcher-window")= local device by default;query_bucket("aw-watcher-window", device=<id>)for a peer;find_buckets(type=…, device=…)replaces prefix-matchingfind_bucket, which is what the webui means when it prefix-matches. The multi-device query becomes "for each selected device,query_bucket(id, device=d)" — no hostname array, browser buckets included because they are(device, aw-watcher-web-firefox), nounknownexclusion.5. aw-webui.
/activity/{host}/→/activity/{device_id}/, hostname rendered as label from/api/0/devices.queries.tsstops string-building IDs.bucketsByDevicebecomes real (it already groups by device; todaydevice_idalways falls back to hostname — ActivityWatch/aw-webui#982). The "multidevice" checkbox becomes a device selector with overlap priority order.6. Sync v2. This is where it pays off: import writes
(device_id, id)directly. No-synced-from-bucket is ever created again. ActivityWatch/aw-server-rust#694 (derived = read-only) is adevice_id != localcheck. #692 evaporates — the hostname is not in the id. ActivityWatch/aw-server-rust#649 stage 3 is just the column.Sequencing — the one thing that must be decided now
The datastore change must land before v2's import step. If v2 imports into the old model first, every imported bucket carries a suffix that then has to be migrated a second time. It can follow the manifest and segment writer, which never touch the local bucket table. So the v2 sequence in #1445 becomes:
SyncReport(ActivityWatch/aw-server-rust#699) → manifest → segment writer →(device_id, name)identity → reader + import → derived/read-only → legacy retirement.API/query/webui changes can trail the datastore change — old string IDs keep resolving through the alias.
Honest cost
The schema change is the one non-additive step in the whole programme. Category rules, saved queries and external scripts store full bucket-ID strings — the compatibility resolver covers them for a release, and not rewriting names means nothing users see changes until they opt in. The real coordination cost is python and rust datastores moving in lockstep, since the bundle still defaults to aw-server-python; a migrated db must be readable by both. That is an argument for the bundle defaulting to rust first, or for doing the python side as a strict mirror in the same week.
Concrete first PR (can start after the v0.14.0 cut, independent of sync):
buckets.device_idcolumn +UNIQUE(device_id, name)+ migration + stamp-on-create + the/api/0/devicesendpoint, in both servers, with the compatibility resolver. Additive on disk because names are untouched. Everything above builds on it.cc @TimeToBuildBob — this is the identity work R2 was pointing at, made explicit; it is the step to plan for right after the v0.14.0 queue clears.
- Identity:
Acknowledged.
(device_id, id)is the end state. The 2026-08-22 staged plan (Stages 0–2, Stage 3 optional per ActivityWatch/aw-server-rust#649) is superseded: v2 import needs the datastore unique-constraint change or every imported bucket migrates twice.Parked until the v0.14.0 desktop cut. No PR this session.
First PR (after the cut, independent of sync)
Paired, same week, both servers — rust
aw-datastoreNEWEST_DB_VERSION6→7 as the spec, pythonaw-coresqliteLATEST_VERSION1→2 as the strict mirror. Python's identifier column isid, rust's isname; the constraint isUNIQUE(device_id, <identifier>)in both. HTTP contract must match. Do not make "bundle defaults to rust" a prerequisite of this PR.Scope, matching the proposal:
buckets.device_idcolumn- drop single-column uniqueness on the identifier
- do not rewrite names
- stamp
device_id = localonPOST /api/0/buckets/{id} GET /api/0/devices- compatibility resolver on the alias path
Migration: do not put
$aw.sync.originindevice_id$aw.sync.originis a hostname.aw-sync/tests/sync.rsasserts it matches the source hostname;get_or_create_sync_bucketfalls back tobucket.hostname. Writing it intobuckets.device_idputs hostname instability back into the identity column — the ActivityWatch/aw-server-rust#683 class of bug.For the first PR, stamp every existing row
device_id = local instance id, including-synced-from-rows. Names stay unique because the suffix is still in the id, so the new unique constraint holds. Recovering a peer UUID belongs to v2 import, which already has the peer'sdevice_idfrom the folder path (the trap in the earlier comment on this issue). There is no device catalogue to join against untilGET /api/0/devicesexists, and that catalogue would be derived from this column — circular.Resolver must be bidirectional before any watcher moves
If a watcher starts minting
aw-watcher-windowwhileaw-watcher-window_<host>holds the history,find_bucketexact-matches the new empty bucket and query-driven views lose history, while aw-webui keeps concatenating_<host>and never sees the new one.So the resolver needs both directions, for one release:
- suffixed lookup → stripped row, if that's what exists
- stripped lookup → suffixed row matching a hostname this device has had, if that's what exists
Watcher
!local/ hostname-free ids stay out of the first PR. The unused!localexists-check-before-substitution bug (pythonapi.pyreturns already-exists before substituting) is a prerequisite of watcher adoption, not of the column.Sequence (locked to #1445)
ActivityWatch/aw-server-rust#699 → manifest → segment writer → this PR → reader+import (writes
(device_id, id)directly) → ActivityWatch/aw-server-rust#694.Local task
aw-bucket-id-device-identity-unificationretargeted from "waiting on design review" to "waiting on the v0.14.0 desktop tag".- added a commit that references this issue
on Sep 17, 2026 Pointer so this doesn't float free: the aw-android sync-identity fork now has an open stopgap PR —
ActivityWatch/aw-android#273(repairs the already-forked SAF folders + unsanitizedbuckets.hostnamerows on devices that hit v0.14.2b1). It is not an alternative to the(device_id, id)work here: the schema migration below is additive (stampdevice_id = local, do not rewrite names) and does not reconcile damage already on disk. Erik flagged the PR as possibly-misguided-at-942-lines; the disposition is either a trimmed stopgap merge or folding the reconciliation into this issue's first PR. Either way the first PR here is unchanged.Phase 2.2 Python mirror PRs are now open:
- feat(datastore): add device_id column, v1→v2 migration (UNIQUE(device_id, id)) aw-core#177 —
device_idcolumn in SQLite storage (LATEST_VERSION 1→2), v1→v2 migration, updated CRUD methods - feat(api): add GET /api/0/devices endpoint (bucket identity phase 2.2) aw-server#183 —
GET /api/0/devicesendpoint (mirrors aw-server-rust), returns stable UUID + hostname
These mirror ActivityWatch/aw-server-rust#786 for the Python stack. Both PRs are paired and should be reviewed/merged together.
- feat(datastore): add device_id column, v1→v2 migration (UNIQUE(device_id, id)) aw-core#177 —
- added a commit that references this issue
on Oct 7, 2026
A few things we should consider changing:
/api/0/infoand use it during bucket creation. Although defaulting to the local value seems like better API ergonomics (and would maybe make migration easier).datafield of buckets. This would impose specific semantics on the data field of buckets (which we currently lack) so might need further discussion (i.e. which data attributes are reserved for internal use in aw-server?)Originally discussed here: ActivityWatch/aw-server-rust#61 (comment)