Repository navigation
RFC: named instance profiles — testing-root fallback in aw-core#152; remaining PRs need the same rule #1399
Description
Activity
@TimeToBuildBob One thing I don't love about this is the shared config files and shared database directory (becomes more difficult to clean up a specific profile). I'd have thought maybe the dirs would get retrieved with a
activitywatch-testing/debugoractivitywatch-researchappname or something so that storage and config is in separate dirs for different profiles (more akin to what browsers like Chrome and Firefox do). Also improves portability.Seen. Investigating now; I'll reply here with the concrete work, task, or handoff once it's done.
You're right — suffixing filenames inside a shared directory is weaker isolation than separate directories per profile. The current RFC keeps everything under
~/.config/activitywatch/and~/.local/share/activitywatch/with-testing/-researchsuffixes because that's the smallest change from today's--testingboolean, but it does mean a user running three profiles still has one crowded config dir and one datastore dir.Using a per-profile appname (
activitywatch-testing,activitywatch-research, etc.) would give true directory isolation, make cleanup trivial (rm -rf ~/.local/share/activitywatch-research/), and match the Chrome/Firefox model you mentioned. The tradeoff is that it touchesaw_core.dirsmore deeply — today bothget_config_dir()andget_data_dir()hardcodeappname="activitywatch", and a few tools (the migrator, log setup, the tray icon) assume a single appname for path construction.Two options:
- Keep the RFC as-is (suffix within shared dirs) for Step 1, treat separate directories as a follow-up that changes
aw_core.dirsand the path helpers. - Revise Step 1 now to make
appnameprofile-aware, soget_config_dir(profile)andget_data_dir(profile)return separate roots from the start. This is more work upfront but avoids baking the shared-dir assumption into the first PR.
My bias is toward (2) if you think the cleaner isolation is worth the extra scope — it's a better foundation and the path-helper changes are localized. But I don't want to expand the PR without your call.
Either way, I'll hold on Steps 2–7 until you confirm the direction.
- Keep the RFC as-is (suffix within shared dirs) for Step 1, treat separate directories as a follow-up that changes
- added a commit that references this issue
on Aug 20, 2026 aw-core layer done: ActivityWatch/aw-core#149 (force-updated to the per-profile appname approach you described).
What shipped:
_get_appname()inaw_core/dirs.pyreadsAW_PROFILEand returnsactivitywatch-<profile>or bareactivitywatch. All four dir functions (get_data_dir,get_config_dir,get_cache_dir,get_log_dir) pass this name to platformdirs, so every module using those functions gets a fully isolated directory root with no other changes. 11 new tests verify the disjoint-dirs invariant for default/testing/research.Next layers (separate PRs, will follow once aw-core#149 merges):
- aw-server-rust:
OnceLock<String> PROFILE+dirs.rsroot suffix - aw-tauri: same
dirs.rschange - aw-qt / launchers:
--profile NAMEflag setsAW_PROFILEbefore spawning children
- aw-server-rust:
- added a commit that references this issue
on Aug 20, 2026 @TimeToBuildBob continue
aw-server-rust layer done: ActivityWatch/aw-server-rust#652.
What shipped:
--profile NAMEflag onaw-server-rust;--testingstays as alias for--profile testingstatic mut TESTING: bool(unsafe) replaced withOnceLock<String> PROFILEdb_path(profile)→sqlite.db/sqlite-<profile>.db(default maps to existing unsuffixed path, testing maps to existing-testingpath, no migration)- Config file:
config.toml/config-<profile>.toml— same rule - Log filename:
<module>-<profile>_<ts>.logfor non-default profiles validate_profile()in dirs.rs (lowercase alnum +-_, max 32 chars, starts with alnum) — same spec as the Python side- Developer-mode behaviour (Rocket debug, permissive CORS) stays gated on
profile == "testing"specifically, not on "non-default" - Android call-sites updated to
"default" - New tests: suffix-rule invariant (default/testing/research), and validate_profile
What's still needed (blocked on aw-core PyPI release for Python downstream):
- aw-server (Python):
--profile, config section lookup, Settings, dev-mode split - aw-client:
ActivityWatchClient(profile=...), config sections, persistqueue filename - aw-qt:
--profile, single-instance lock suffix, passing flag through to spawned modules
PyPI latest is 0.5.9 (2022) — a new aw-core release is the gate for those three.
aw-tauri (
dirs.rsequivalent) can follow aw-server-rust independently whenever convenient.Python server/client layer done:
- feat(profile): add --profile flag and port/settings isolation aw-server#167 —
--profile NAME,--testingalias,AW_PROFILEexported before config load, settings suffix,/api/0/inforeportsprofile - feat(profile): add profile= to ActivityWatchClient and --profile CLI aw-client#118 —
ActivityWatchClient(profile=...),--profileCLI, persistqueue suffix, rustconfig-<profile>.tomlAPI-key lookup
--testingis unchanged (port 5666, existing-testingfilenames). Named profiles take port from their own isolated config; no hash-to-port table (research bakes 5667 in phase 5).Default profile does not set
AW_PROFILE=default. aw-core#149 suffixes any non-empty value, so that would resolve toactivitywatch-defaultand orphan an existing install. Default leaves the env unset.Testing-root discrepancy (called out, not papered over): python aw-core#149 maps
AW_PROFILE=testing→activitywatch-testing. The rust isolation branch keeps testing on the bareactivitywatchroot so existingsqlite-testing.dbfiles are not orphaned. These PRs follow the already-merged python dirs contract. Unifying the rust testing root is a follow-up on that isolation PR.Also noticed: aw-qt#128 currently
export_profile("default")which would hit theactivitywatch-defaultfootgun once aw-core#149 is in the tree. Python side unsets instead; aw-qt should match. Won't rebuild #128 otherwise.- feat(profile): add --profile flag and port/settings isolation aw-server#167 —
- added a commit that references this issue
on Aug 25, 2026 Revise Step 1 now to make appname profile-aware, so get_config_dir(profile) and get_data_dir(profile) return separate roots from the start. This is more work upfront but avoids baking the shared-dir assumption into the first PR.
Yes!
Already went with option (2) — the per-profile appname approach — before you replied. Current state:
- aw-core#149 —
_get_appname(profile)→activitywatch-<profile>, fully isolated dir roots via platformdirs. 11 tests covering default/testing/research invariants. - aw-server-rust#652 —
--profile NAME,OnceLock<String> PROFILE,sqlite-<profile>.db,config-<profile>.toml, developer-mode stays gated onprofile == "testing"specifically. - aw-server#167 —
--profile NAME,AW_PROFILEexported before config load,/api/0/inforeports profile. - aw-client#118 —
ActivityWatchClient(profile=...), persistqueue suffix,--profileCLI.
Remaining: aw-qt (
--profile, single-instance lock suffix, propagating the flag to spawned children) — I'll pick that up once the Python layers above are confirmed green. The Python downstream (aw-server, aw-client, aw-qt) is also blocked on a new aw-core PyPI release, since they pin from PyPI andaw_core.profileisn't published yet.One heads-up noted in my last comment: aw-qt currently calls
export_profile("default")which would resolve toactivitywatch-default(wrong, orphans the existing install). Python unsetsAW_PROFILEfor default; aw-qt will need to match. I'll fix that in the aw-qt PR.- aw-core#149 —
Correction to my previous comment: the concern about
export_profile("default")was stale. aw-qt#128 merged at 11:59 already handles this correctly —export_profilewith the default profile callsos.environ.pop("AW_PROFILE", None)rather than settingAW_PROFILE=default, so theactivitywatch-defaultfootgun does not exist in the merged code. The aw-qt layer is clean, no follow-up needed there.Current open state:
- aw-server-rust#652 — CI green, awaiting review
- aw-server#167 — CI green, awaiting review
- aw-client#118 — CI green, awaiting review
- Python downstream (aw-server, aw-client) still blocked on aw-core PyPI release
aw-cli layer done: ActivityWatch/aw-core#151.
What shipped:
--profile NAMEadded to theawCLI group;--testingstays as an alias. The group callback setsAW_PROFILEbefore any subcommand runs, soget_log_dir()and friends resolve to the profile-specific directory automatically (no extra threading needed). Theqtsubcommand now forwards--profile NAMEto aw-qt.find_oldest_logdrops itstestingparameter — with per-profile appname dirs the filename filter is obsolete. 6 new tests.Current state:
Step Repo Status 1 — aw-core profile primitives #149 ✅ merged 3 — aw-server #167 🔄 open, CI green 4 — aw-client #118 🔄 open, CI green 5 — aw-qt aw-qt#128 ✅ merged 6 — aw-cli #151 🆕 just opened 7 — aw-server-rust aw-server-rust#652 🔄 open, CI green Steps 3, 4, and 7 are gated on aw-core reaching PyPI — that's the only remaining sequencing constraint.
- added a commit that references this issue
on Aug 25, 2026 3 remaining items
Decision received — not merging the four PRs as-is.
Rule I'll implement identically in python and rust: use
activitywatch-testing/if it exists; else stay on the sharedactivitywatch/root (old paths, old filenames) if legacy testing artifacts are present; else createactivitywatch-testing/. Bare filenames inside isolated profile roots;-testingsuffixes only in legacy mode.Python half: ActivityWatch/aw-core#152 amends merged #149 with that fallback (dirs + datastore + log filenames). Local tests green; CI running.
Merge order unchanged from your comment: aw-core#152 + PyPI release → aw-server-rust#652 → aw-server#167 (pin bump, un-skip dir-isolation test) → aw-client#118 → aw-tauri#241.
Next from me: same fallback on #652 (
set_profilebeforesetup_logger, bare filenames in profile roots). I cannot cut the PyPI release — that's the remaining maintainer gate after #152 lands. #167/#118/#241 stay open; they need the new rule before merge.Rust half of the testing-root fallback is on ActivityWatch/aw-server-rust#652 (
4e555f2). Same three-step rule as aw-core#152, plusset_profilebeforesetup_loggerand bare filenames in isolated roots.Still open after that, in the merge order you set: aw-core#152 + PyPI release → #652 → aw-server#167 (pin bump) → aw-client#118 (API-key lookup + unprovisioned-profile warning) → aw-tauri#241 (git-pin + profile API call sites). I cannot cut the PyPI release.
- added a commit that references this issue
on Aug 29, 2026 Client half of the testing-root rule is on ActivityWatch/aw-client#118 (
8f45af7).Rust API-key lookup now uses the same three-step rule as aw-core#152 / aw-server-rust#652: isolated roots read bare
config.toml; legacy testing keepsconfig-testing.tomlon the shared root. Named profiles no longer look atconfig-<profile>.toml. Unprovisioned named profiles warn on the silent[server]/5600 fallback, matching aw-server#167.Still do not merge #167/#118/#652/#241 as-is. Maintainer order unchanged: aw-core#152 + PyPI → #652 → #167 → #118 → #241.
- added a commit that references this issue
on Aug 30, 2026 - added a commit that references this issue
on Aug 31, 2026 Stack complete. Final state:
- aw-core 0.5.18 on PyPI (2026-09-03): profile dir suffixing (Automate collection of statistics into spreadsheet #149), testing-root fallback per the ruling above ([bug] [investigate] False-positive virus archive scan results #152), aw-cli --profile (Best way to pull data by Window Title or App Name? #151), plus Can't open dashboard on Mint KDE 18.2 #150/AFK watcher incorrectly logging AFK time #153
- aw-server-rust#652, aw-server#167, aw-client#118, aw-tauri#241 (+ pin/call-site follow-ups AFK watcher counting superhot.exe as afk time. #244/v0.8.0b7 doesn't start on Windows 10 #248), aw-qt#128: all merged
- aw-server#169 merged just now: aw-core floor raised to ^0.5.18 so data isolation cannot silently be a no-op; the dir-isolation test is un-skipped and passing
Every component now implements the same testing-root rule (verified: identical marker lists and precedence in python and rust), and named profiles get real directory isolation end to end.
Remaining follow-ups, tracked but non-blocking:
- aw-server writes
settings-<profile>.json/ reads[server-<profile>]sections inside already-isolated roots — diverges from the bare-filenames contract (noted on aw-server#169) - Named profiles default to port 5600 and collide with a running default instance; the trays should assign and persist a free port on profile creation
- aw-tauri single-instance on macOS/Windows keys off the bundle id, so named profiles can't run alongside the default instance there (Linux works via per-profile D-Bus ids)
Closing — thanks @TimeToBuildBob for the fast turnaround on the ruling.
Confirmed. Thanks for carrying the release and final verification through. The named-profile stack is complete; I won't reopen this RFC for the three non-blocking follow-ups.
- added a commit that references this issue
on Sep 8, 2026
STATUS 2026-08-28 — remaining merge stack (no new ping)
Do not rebuild. Remaining work is maintainer merge plus one design confirmation on the rust testing root.
aw-serveris not a tracked repo).activitywatch-research).testingstill shares theactivitywatchroot (legacysqlite-testing.db). Isolation commitsd7f8db2..7d75747are on this PR, not a follow-up. Last Erik comment 2026-08-26 ("fix it").Ask: merge #167, #118, #652, #241. On #652, confirm rust
testingstaying on the shared root vs matching python'sactivitywatch-testing. After #652, do not expect a second root-isolation PR. Python runtime isolation still needs an aw-core PyPI release containing #149 (PyPI is still 0.5.17).No new GitHub ping before 2026-09-02 unless you reply.
Summary
Replace the two-valued
testing: boolthat selects which ActivityWatch instance to run with a named instance profile, so that more than two instances can coexist on one machine.Today
--testingpicks between exactly two instances. The profile name would drive both the config section and the datastore/logfile suffix, with"default"and"testing"resolving to precisely the paths and sections that exist today.Motivation
A third parallel instance is genuinely needed.
A "research" build of ActivityWatch used in a study at Lund University must not share a datastore with a participant's personal ActivityWatch, or personal activity leaks into the research data. Today the only mitigation is asking participants to quit their normal ActivityWatch first — which relies on the participant, is easy to get wrong, and silently produces contaminated data when it goes wrong.
More generally, a profile mechanism lets a developer run prod + testing + research side by side, and gives a clean answer for any packaged/derived build that wants its own instance.
This is adjacent to, but distinct from, #75 ("doesn't behave well on multiuser systems"): #75 is about several users on one machine, this is about several instances for one user.
Current state
The port is already fully config-driven (it comes from the config section), while the datastore suffix is derived from the boolean rather than from config:
aw-server/aw_server/main.pyconfigsection = "server" if not args.testing else "server-testing"aw-server/aw_server/config.pyport = "5600"in[server],port = "5666"in[server-testing]aw-server/aw_server/settings.py"settings.json" if not testing else "settings-testing.json"aw-client/aw_client/client.py_config["server" if not testing else "server-testing"], same forclientaw-client/aw_client/client.py(RequestQueue)"-testing" if client.testing else ""in the persistqueue filenameaw-qt/aw_qt/config.pyconfig["aw-qt" if not testing else "aw-qt-testing"],config-testing.toml,server-testingaw-qt/aw_qt/main.pysuffix = "-testing" if testing else ""for the single-instance lockaw-core/aw_datastore/storages/peewee.py"-testing" if testing else ""in the db filenameaw-core/aw_datastore/storages/sqlite.pyself.sid + ("-testing" if testing else "")aw-core/aw_datastore/migration.pypeewee_type + ("-testing" if datastore.testing else "")aw-core/aw_core/log.py("testing_" if testing else "")in the logfile nameProposed design
A profile is a name identifying a parallel, fully isolated instance. Two derivations, applied everywhere:
So:
defaultsqlite.v1.db[server]aw-server_<ts>.logtestingsqlite-testing.v1.db[server-testing]aw-server_testing_<ts>.logresearchsqlite-research.v1.db[server-research]aw-server_research_<ts>.logThe first two rows are exactly what exists today — the legacy layout is the special case where
defaultmaps to the empty suffix. No existing database, config file or logfile is renamed, moved or orphaned, and no migration is required. That is a hard requirement of this proposal; any variant that needs to rename user databases should be rejected.Backwards compatibility
--testingstays, as an alias for--profile testing.testing=True/Falsestays working in every Python API, resolving to thetesting/defaultprofiles.profilewins and a warning is logged — a legacy alias must never silently redirect an explicitly requested profile to a different datastore.AbstractStorage.testingis kept as a property derived from.profile(with a setter), so third-party code reading or assigning it keeps working.Validation
The profile name reaches the filesystem as part of a filename, so it is validated: lowercase ASCII alphanumerics plus
-and_, max 32 chars, must start with a letter or digit. That excludes path separators,.., whitespace and empty names. Lowercase-only is deliberate —Researchandresearchwould otherwise be two config sections sharing one file on a case-insensitive filesystem.testingis overloaded — the profile must not inherit all of itWorth calling out explicitly, because it is the main design subtlety. In aw-server,
testingcurrently means two different things at once:json_provider_class.compact = not testing,debug=testing(Flask debug), extra permissive CORS in_config_cors, and bucket deletion allowed without?force=1(rest.py).Only (1) generalises. A
researchprofile is a production instance and must not get Flask debug mode, permissive CORS, or unguarded bucket deletion. The proposal is therefore that the developer-mode behaviours stay gated onprofile == "testing"specifically, not on "profile is not default".aw_core.profile.is_testing_profile()exists for exactly that, so the distinction is explicit at each call site rather than implied.(aw-server-rust does the same thing in
main.rs:if !testing && cfg!(debug_assertions) { testing = true; }— debug builds force testing mode.)Ports
The port is already per-section, so
[server-research]can set its own. Open question for arbitrary profiles: what should happen when no[server-<profile>]section exists? Options are (a) hard error telling the user to add the section, (b) inherit[server]but require--port, or (c) derive a port from the name. I lean (a) — explicit, and it cannot silently collide with the running default instance on 5600. Happy to follow your preference.Rollout ordering
These are separate repos with separate release cadences, and aw-server/aw-client/aw-qt consume aw-core from PyPI (
aw-core = "^0.5.8", currently locked at 0.5.16). So the work has to land bottom-up, one release at a time:aw_core.profileuntil then.--profile, config section lookup,Settings, and splitting instance-selection from developer-mode as described above.ActivityWatchClient(profile=...), config sections, the persistqueue filename,aw-clientCLI.--profile,aw-qt-<profile>config section, single-instance lock suffix, and passing--profilethrough to the modules it spawns. Needs 3 and 4 released first, since it spawns them.aw_cli, lives in the aw-core repo) —--profilefor log lookup and for theqtsubcommand. Deliberately not done in step 1: theqtsubcommand forwards the flag to aw-qt, so it is blocked on step 5.Steps 3, 4 and 5 are each independently backwards compatible, so they don't have to ship together — an aw-qt that doesn't yet know about profiles simply keeps passing
--testing.aw-server-rust
Checked: it has the equivalent flag and would need the same treatment, in
aw-server/src/:main.rs:--testingflag, plusif !testing && cfg!(debug_assertions) { testing = true; }config.rs: a globalTESTINGstatic withset_testing/is_testing,testing: boolonAWConfig(serde(skip)), andget_config_pathchoosingconfig.tomlvsconfig-testing.tomldirs.rs:db_path(testing)choosingsqlite.dbvssqlite-testing.dbSame suffix rule applies cleanly (
sqlite-research.db,config-research.toml). The global mutableTESTINGstatic is the awkward part and probably wants to become a profile string on the config. I'd treat this as a separate follow-up rather than blocking the Python side on it — but the two must agree on the naming scheme, which is the reason to settle the scheme here first.Out of scope
First PR
ActivityWatch/aw-core#149 implements step 1 only:
aw_core.profile, the datastore suffix across all three storages,migration.py, and logfile naming — all backwards compatible, with tests covering thatdefaultresolves to the legacy unsuffixed path,testingto the legacy-testingpath, and a custom profile to its own.Does this direction look right before I take it up the stack?