Skip to content

RFC: named instance profiles — testing-root fallback in aw-core#152; remaining PRs need the same rule #1399

Description

@TimeToBuildBob

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.

Step PR Status
1 — aw-core dirs aw-core#149 merged 2026-08-20
6 — aw-cli aw-core#151 merged 2026-08-25
5 — aw-qt aw-qt#128 merged 2026-08-25
3 — aw-server aw-server#167 OPEN, CI green. Not on the decision dashboard (aw-server is not a tracked repo).
4 — aw-client aw-client#118 OPEN, CI green. Same dashboard hole.
7 — aw-server-rust aw-server-rust#652 OPEN, CI green. Code isolates non-testing profiles via sibling appname (activitywatch-research). testing still shares the activitywatch root (legacy sqlite-testing.db). Isolation commits d7f8db2..7d75747 are on this PR, not a follow-up. Last Erik comment 2026-08-26 ("fix it").
aw-tauri aw-tauri#241 OPEN, CI green.

Ask: merge #167, #118, #652, #241. On #652, confirm rust testing staying on the shared root vs matching python's activitywatch-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: bool that selects which ActivityWatch instance to run with a named instance profile, so that more than two instances can coexist on one machine.

Today --testing picks 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:

Location Today
aw-server/aw_server/main.py configsection = "server" if not args.testing else "server-testing"
aw-server/aw_server/config.py port = "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 for client
aw-client/aw_client/client.py (RequestQueue) "-testing" if client.testing else "" in the persistqueue filename
aw-qt/aw_qt/config.py config["aw-qt" if not testing else "aw-qt-testing"], config-testing.toml, server-testing
aw-qt/aw_qt/main.py suffix = "-testing" if testing else "" for the single-instance lock
aw-core/aw_datastore/storages/peewee.py "-testing" if testing else "" in the db filename
aw-core/aw_datastore/storages/sqlite.py self.sid + ("-testing" if testing else "")
aw-core/aw_datastore/migration.py peewee_type + ("-testing" if datastore.testing else "")
aw-core/aw_core/log.py ("testing_" if testing else "") in the logfile name

Proposed design

A profile is a name identifying a parallel, fully isolated instance. Two derivations, applied everywhere:

suffix(profile)         = ""            if profile == "default" else "-" + profile
config_section(base, p) = base + suffix(p)

So:

profile db file config section logfile
default sqlite.v1.db [server] aw-server_<ts>.log
testing sqlite-testing.v1.db [server-testing] aw-server_testing_<ts>.log
research sqlite-research.v1.db [server-research] aw-server_research_<ts>.log

The first two rows are exactly what exists today — the legacy layout is the special case where default maps 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

  • --testing stays, as an alias for --profile testing.
  • testing=True/False stays working in every Python API, resolving to the testing/default profiles.
  • Where both are given and disagree, profile wins and a warning is logged — a legacy alias must never silently redirect an explicitly requested profile to a different datastore.
  • AbstractStorage.testing is 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 — Research and research would otherwise be two config sections sharing one file on a case-insensitive filesystem.

testing is overloaded — the profile must not inherit all of it

Worth calling out explicitly, because it is the main design subtlety. In aw-server, testing currently means two different things at once:

  1. which instance — config section, database, settings file, logfile; and
  2. developer mode — 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 research profile 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 on profile == "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:

  1. aw-core — the profile primitives, the datastore suffix, logfile naming. (PR below.)
  2. aw-core release to PyPI. Everything above is blocked on this — the downstream repos cannot even import aw_core.profile until then.
  3. aw-server — --profile, config section lookup, Settings, and splitting instance-selection from developer-mode as described above.
  4. aw-client — ActivityWatchClient(profile=...), config sections, the persistqueue filename, aw-client CLI.
  5. aw-qt — --profile, aw-qt-<profile> config section, single-instance lock suffix, and passing --profile through to the modules it spawns. Needs 3 and 4 released first, since it spawns them.
  6. aw-cli (aw_cli, lives in the aw-core repo) — --profile for log lookup and for the qt subcommand. Deliberately not done in step 1: the qt subcommand forwards the flag to aw-qt, so it is blocked on step 5.
  7. aw-server-rust — separate follow-up, see below.

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: --testing flag, plus if !testing && cfg!(debug_assertions) { testing = true; }
  • config.rs: a global TESTING static with set_testing/is_testing, testing: bool on AWConfig (serde(skip)), and get_config_path choosing config.toml vs config-testing.toml
  • dirs.rs: db_path(testing) choosing sqlite.db vs sqlite-testing.db

Same suffix rule applies cleanly (sqlite-research.db, config-research.toml). The global mutable TESTING static 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

  • Renaming or migrating any existing data. Explicitly a non-goal.
  • A UI for switching profiles.
  • Per-profile watcher management beyond passing the flag through.

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 that default resolves to the legacy unsuffixed path, testing to the legacy -testing path, and a custom profile to its own.

Does this direction look right before I take it up the stack?

Activity

  1. ErikBjare commented on Aug 18, 2026

    @ErikBjare
    Member

    @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/debug or activitywatch-research appname 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.

  2. TimeToBuildBob commented on Aug 18, 2026

    @TimeToBuildBob
    ContributorAuthor

    Seen. Investigating now; I'll reply here with the concrete work, task, or handoff once it's done.

  3. TimeToBuildBob commented on Aug 18, 2026

    @TimeToBuildBob
    ContributorAuthor

    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/-research suffixes because that's the smallest change from today's --testing boolean, 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 touches aw_core.dirs more deeply — today both get_config_dir() and get_data_dir() hardcode appname="activitywatch", and a few tools (the migrator, log setup, the tray icon) assume a single appname for path construction.

    Two options:

    1. Keep the RFC as-is (suffix within shared dirs) for Step 1, treat separate directories as a follow-up that changes aw_core.dirs and the path helpers.
    2. 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.

    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.

  4. TimeToBuildBob commented on Aug 20, 2026

    @TimeToBuildBob
    ContributorAuthor

    aw-core layer done: ActivityWatch/aw-core#149 (force-updated to the per-profile appname approach you described).

    What shipped: _get_appname() in aw_core/dirs.py reads AW_PROFILE and returns activitywatch-<profile> or bare activitywatch. 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.rs root suffix
    • aw-tauri: same dirs.rs change
    • aw-qt / launchers: --profile NAME flag sets AW_PROFILE before spawning children
  5. ErikBjare commented on Aug 23, 2026

    @ErikBjare
    Member
  6. TimeToBuildBob commented on Aug 23, 2026

    @TimeToBuildBob
    ContributorAuthor

    aw-server-rust layer done: ActivityWatch/aw-server-rust#652.

    What shipped:

    • --profile NAME flag on aw-server-rust; --testing stays as alias for --profile testing
    • static mut TESTING: bool (unsafe) replaced with OnceLock<String> PROFILE
    • db_path(profile) → sqlite.db / sqlite-<profile>.db (default maps to existing unsuffixed path, testing maps to existing -testing path, no migration)
    • Config file: config.toml / config-<profile>.toml — same rule
    • Log filename: <module>-<profile>_<ts>.log for 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.rs equivalent) can follow aw-server-rust independently whenever convenient.

  7. TimeToBuildBob commented on Aug 25, 2026

    @TimeToBuildBob
    ContributorAuthor

    Python server/client layer done:

    --testing is unchanged (port 5666, existing -testing filenames). 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 to activitywatch-default and 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 bare activitywatch root so existing sqlite-testing.db files 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 the activitywatch-default footgun once aw-core#149 is in the tree. Python side unsets instead; aw-qt should match. Won't rebuild #128 otherwise.

  8. ErikBjare commented on Aug 25, 2026

    @ErikBjare
    Member

    #1399 (comment)

    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!

  9. TimeToBuildBob commented on Aug 25, 2026

    @TimeToBuildBob
    ContributorAuthor

    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 on profile == "testing" specifically.
    • aw-server#167 — --profile NAME, AW_PROFILE exported before config load, /api/0/info reports profile.
    • aw-client#118 — ActivityWatchClient(profile=...), persistqueue suffix, --profile CLI.

    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 and aw_core.profile isn't published yet.

    One heads-up noted in my last comment: aw-qt currently calls export_profile("default") which would resolve to activitywatch-default (wrong, orphans the existing install). Python unsets AW_PROFILE for default; aw-qt will need to match. I'll fix that in the aw-qt PR.

  10. TimeToBuildBob commented on Aug 25, 2026

    @TimeToBuildBob
    ContributorAuthor

    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_profile with the default profile calls os.environ.pop("AW_PROFILE", None) rather than setting AW_PROFILE=default, so the activitywatch-default footgun 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
  11. TimeToBuildBob commented on Aug 25, 2026

    @TimeToBuildBob
    ContributorAuthor

    aw-cli layer done: ActivityWatch/aw-core#151.

    What shipped: --profile NAME added to the aw CLI group; --testing stays as an alias. The group callback sets AW_PROFILE before any subcommand runs, so get_log_dir() and friends resolve to the profile-specific directory automatically (no extra threading needed). The qt subcommand now forwards --profile NAME to aw-qt. find_oldest_log drops its testing parameter — 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.

  12. 3 remaining items

  13. TimeToBuildBob commented on Aug 29, 2026

    @TimeToBuildBob
    ContributorAuthor

    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 shared activitywatch/ root (old paths, old filenames) if legacy testing artifacts are present; else create activitywatch-testing/. Bare filenames inside isolated profile roots; -testing suffixes 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_profile before setup_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.

  14. TimeToBuildBob commented on Aug 29, 2026

    @TimeToBuildBob
    ContributorAuthor

    Rust half of the testing-root fallback is on ActivityWatch/aw-server-rust#652 (4e555f2). Same three-step rule as aw-core#152, plus set_profile before setup_logger and 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.

  15. TimeToBuildBob commented on Aug 29, 2026

    @TimeToBuildBob
    ContributorAuthor

    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 keeps config-testing.toml on the shared root. Named profiles no longer look at config-<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.

  16. ErikBjare commented on Sep 3, 2026

    @ErikBjare
    Member

    Stack complete. Final state:

    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:

    1. 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)
    2. 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
    3. 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.

  17. TimeToBuildBob commented on Sep 3, 2026

    @TimeToBuildBob
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions