flexitracker 0.2.1.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. flexitracker-0.2.1.dev0/.gitignore +20 -0
  2. flexitracker-0.2.1.dev0/PKG-INFO +48 -0
  3. flexitracker-0.2.1.dev0/README.md +40 -0
  4. flexitracker-0.2.1.dev0/install/README.md +38 -0
  5. flexitracker-0.2.1.dev0/install/flexitracker.service +12 -0
  6. flexitracker-0.2.1.dev0/install/install.ps1 +35 -0
  7. flexitracker-0.2.1.dev0/install/install.sh +35 -0
  8. flexitracker-0.2.1.dev0/pyinstaller_entry.py +15 -0
  9. flexitracker-0.2.1.dev0/pyproject.toml +41 -0
  10. flexitracker-0.2.1.dev0/src/flexitracker/__init__.py +9 -0
  11. flexitracker-0.2.1.dev0/src/flexitracker/__main__.py +6 -0
  12. flexitracker-0.2.1.dev0/src/flexitracker/_backend.py +16 -0
  13. flexitracker-0.2.1.dev0/src/flexitracker/cli.py +295 -0
  14. flexitracker-0.2.1.dev0/src/flexitracker/config.py +108 -0
  15. flexitracker-0.2.1.dev0/src/flexitracker/core.py +29 -0
  16. flexitracker-0.2.1.dev0/src/flexitracker/idle.py +97 -0
  17. flexitracker-0.2.1.dev0/src/flexitracker/outbox.py +109 -0
  18. flexitracker-0.2.1.dev0/src/flexitracker/sender.py +97 -0
  19. flexitracker-0.2.1.dev0/src/flexitracker/state_machine.py +177 -0
  20. flexitracker-0.2.1.dev0/src/flexitracker/version.py +26 -0
  21. flexitracker-0.2.1.dev0/tests/test_config.py +43 -0
  22. flexitracker-0.2.1.dev0/tests/test_conformance.py +82 -0
  23. flexitracker-0.2.1.dev0/tests/test_idle.py +9 -0
  24. flexitracker-0.2.1.dev0/tests/test_outbox.py +72 -0
  25. flexitracker-0.2.1.dev0/tests/test_sender_body.py +50 -0
  26. flexitracker-0.2.1.dev0/tests/vectors/01-activation-debounced-backdated.json +12 -0
  27. flexitracker-0.2.1.dev0/tests/vectors/02-idle-backdated-to-last-input.json +14 -0
  28. flexitracker-0.2.1.dev0/tests/vectors/03-short-break-absorbed.json +13 -0
  29. flexitracker-0.2.1.dev0/tests/vectors/04-brief-activity-debounced.json +10 -0
  30. flexitracker-0.2.1.dev0/tests/vectors/05-sustained-return-backdated.json +13 -0
  31. flexitracker-0.2.1.dev0/tests/vectors/06-heartbeats-while-active.json +15 -0
  32. flexitracker-0.2.1.dev0/tests/vectors/07-recover-after-sudden-shutdown.json +12 -0
  33. flexitracker-0.2.1.dev0/tests/vectors/08-locked-session-goes-idle.json +14 -0
  34. flexitracker-0.2.1.dev0/tests/vectors/09-recover-anchors-later-evidence.json +12 -0
  35. flexitracker-0.2.1.dev0/tests/vectors/10-suspend-longer-closes-span.json +14 -0
  36. flexitracker-0.2.1.dev0/tests/vectors/11-suspend-with-immediate-wake.json +14 -0
  37. flexitracker-0.2.1.dev0/tests/vectors/12-suspend-shorter-absorbed.json +13 -0
  38. flexitracker-0.2.1.dev0/tests/vectors/13-backwards-clock-watermark.json +15 -0
  39. flexitracker-0.2.1.dev0/tests/vectors/14-small-forward-clock-absorbed.json +13 -0
  40. flexitracker-0.2.1.dev0/tests/vectors/15-recover-from-active-matches-loop.json +14 -0
  41. flexitracker-0.2.1.dev0/tests/vectors/16-explicit-close-no-double-close.json +15 -0
  42. flexitracker-0.2.1.dev0/tests/vectors/17-freshness-independent-of-poll.json +11 -0
  43. flexitracker-0.2.1.dev0/tests/vectors/18-short-reading-pause.json +14 -0
  44. flexitracker-0.2.1.dev0/tests/vectors/19-presence-must-be-sustained.json +13 -0
  45. flexitracker-0.2.1.dev0/tests/vectors/20-gap-kills-pending-return.json +12 -0
  46. flexitracker-0.2.1.dev0/tests/vectors/21-idle-backdating-exact.json +15 -0
  47. flexitracker-0.2.1.dev0/tests/vectors/22-min-inactivity-exact-boundary.json +14 -0
  48. flexitracker-0.2.1.dev0/tests/vectors/23-large-timestamps.json +12 -0
  49. flexitracker-0.2.1.dev0/tests/vectors/24-all-kinds-lowercase.json +16 -0
  50. flexitracker-0.2.1.dev0/uv.lock +170 -0
@@ -0,0 +1,20 @@
1
+ # Node / Cloudflare
2
+ node_modules/
3
+ .wrangler/
4
+ dist/
5
+ *.log
6
+
7
+ # Python daemon (uv / build / freeze artifacts)
8
+ .venv/
9
+ __pycache__/
10
+ *.pyc
11
+ daemon-py/build/
12
+ *.spec
13
+
14
+ # Local simulation state
15
+ .dev-state/
16
+ *.local
17
+
18
+ # Screenshots / MCP artifacts
19
+ .playwright-mcp/
20
+ /*.png
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: flexitracker
3
+ Version: 0.2.1.dev0
4
+ Summary: FlexiTracker activity daemon — pure-Python.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+
9
+ # FlexiTracker daemon (Python)
10
+
11
+ The FlexiTracker activity daemon: a pure-Python program that captures idle/active
12
+ transitions and ships back-dated events to the backend. Pure stdlib, no compiled
13
+ extension, so it installs with `uv` into a user profile — no admin rights and no
14
+ compiler — which is what lets it run on managed machines that block unsigned
15
+ executables. OS idle detection is done through `ctypes` (Windows
16
+ `GetLastInputInfo`, Linux XScreenSaver).
17
+
18
+ ## Install
19
+
20
+ Recommended (all platforms):
21
+
22
+ ```bash
23
+ uv tool install flexitracker
24
+ ```
25
+
26
+ Or, on a machine that allows executables, download the standalone
27
+ `flexitracker` / `flexitracker.exe` from the GitHub **Releases** page (it bundles
28
+ its own Python runtime). See `install/README.md` for per-OS auto-start and the
29
+ Windows SmartScreen trust step.
30
+
31
+ ## Use
32
+
33
+ ```bash
34
+ flexitracker configure --key <ACCESS_KEY> # authorize this machine, then self-test
35
+ flexitracker test # connectivity check, sends no data
36
+ flexitracker # run the daemon
37
+ ```
38
+
39
+ ## Develop
40
+
41
+ ```bash
42
+ uv sync
43
+ uv run pytest # unit tests + the 24 behavioural vectors (tests/vectors/)
44
+ ```
45
+
46
+ The behavioural vectors in `tests/vectors/` are the oracle for the state machine
47
+ (back-dating, suspend reconciliation, the emit watermark, the return-to-work
48
+ clock). Any change to that logic must be reflected in a vector.
@@ -0,0 +1,40 @@
1
+ # FlexiTracker daemon (Python)
2
+
3
+ The FlexiTracker activity daemon: a pure-Python program that captures idle/active
4
+ transitions and ships back-dated events to the backend. Pure stdlib, no compiled
5
+ extension, so it installs with `uv` into a user profile — no admin rights and no
6
+ compiler — which is what lets it run on managed machines that block unsigned
7
+ executables. OS idle detection is done through `ctypes` (Windows
8
+ `GetLastInputInfo`, Linux XScreenSaver).
9
+
10
+ ## Install
11
+
12
+ Recommended (all platforms):
13
+
14
+ ```bash
15
+ uv tool install flexitracker
16
+ ```
17
+
18
+ Or, on a machine that allows executables, download the standalone
19
+ `flexitracker` / `flexitracker.exe` from the GitHub **Releases** page (it bundles
20
+ its own Python runtime). See `install/README.md` for per-OS auto-start and the
21
+ Windows SmartScreen trust step.
22
+
23
+ ## Use
24
+
25
+ ```bash
26
+ flexitracker configure --key <ACCESS_KEY> # authorize this machine, then self-test
27
+ flexitracker test # connectivity check, sends no data
28
+ flexitracker # run the daemon
29
+ ```
30
+
31
+ ## Develop
32
+
33
+ ```bash
34
+ uv sync
35
+ uv run pytest # unit tests + the 24 behavioural vectors (tests/vectors/)
36
+ ```
37
+
38
+ The behavioural vectors in `tests/vectors/` are the oracle for the state machine
39
+ (back-dating, suspend reconciliation, the emit watermark, the return-to-work
40
+ clock). Any change to that logic must be reflected in a vector.
@@ -0,0 +1,38 @@
1
+ # Installing the FlexiTracker daemon
2
+
3
+ The daemon is a pure-Python program. The **recommended** install on every OS is
4
+ with [uv](https://docs.astral.sh/uv/) — no administrator rights, no compiler, and
5
+ it works on managed machines that block unsigned executables.
6
+
7
+ ## Recommended: uv (all platforms)
8
+
9
+ ```bash
10
+ uv tool install flexitracker
11
+ flexitracker configure --key <YOUR_ACCESS_KEY>
12
+ flexitracker test
13
+ ```
14
+
15
+ Then enable auto-start on login:
16
+
17
+ - **Linux:** `./install.sh` (installs a systemd *user* service).
18
+ - **Windows:** `powershell -ExecutionPolicy Bypass -File install.ps1` (registers a
19
+ login task).
20
+
21
+ ## Alternative: standalone executable (machines that allow exes)
22
+
23
+ If your machine permits running executables, download the standalone
24
+ `flexitracker.exe` (Windows) or `flexitracker` (Linux) from the project's GitHub
25
+ **Releases** page — it bundles its own Python runtime, so nothing else is needed.
26
+
27
+ ```bat
28
+ flexitracker.exe configure --key <YOUR_ACCESS_KEY>
29
+ flexitracker.exe test
30
+ ```
31
+
32
+ ### Windows SmartScreen (unsigned executable)
33
+
34
+ The standalone executable is **not code-signed**, so Windows SmartScreen may warn
35
+ on first run. To allow it: click **More info → Run anyway**, or right-click the
36
+ file → **Properties** → tick **Unblock** → **OK**. If your organization blocks
37
+ unsigned executables entirely, use the **uv** path above instead — it does not run
38
+ an executable.
@@ -0,0 +1,12 @@
1
+ [Unit]
2
+ Description=FlexiTracker activity daemon
3
+ After=graphical-session.target
4
+
5
+ [Service]
6
+ # Runs the entrypoint that `uv tool install flexitracker` puts on PATH.
7
+ ExecStart=%h/.local/bin/flexitracker
8
+ Restart=on-failure
9
+ RestartSec=10
10
+
11
+ [Install]
12
+ WantedBy=default.target
@@ -0,0 +1,35 @@
1
+ # FlexiTracker (Windows) — install the daemon into your user profile with uv
2
+ # (no admin, no compiler) and register a login task so it starts on sign-in.
3
+ #
4
+ # This is the recommended path on managed machines that block unsigned
5
+ # executables but permit a user-scope Python toolchain. If your machine allows
6
+ # executables, you can instead download the standalone flexitracker.exe from the
7
+ # GitHub Releases page (see install/README.md for the SmartScreen trust step).
8
+
9
+ $ErrorActionPreference = "Stop"
10
+
11
+ if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
12
+ Write-Error "uv is required: https://docs.astral.sh/uv/ (run: irm https://astral.sh/uv/install.ps1 | iex)"
13
+ exit 1
14
+ }
15
+
16
+ Write-Host "Installing flexitracker with uv…"
17
+ uv tool install --upgrade flexitracker
18
+
19
+ $bin = (Get-Command flexitracker -ErrorAction SilentlyContinue).Source
20
+ if (-not $bin) { $bin = Join-Path $env:USERPROFILE ".local\bin\flexitracker.exe" }
21
+
22
+ # Authorize this machine if not already configured.
23
+ $cfg = Join-Path $env:APPDATA ".config\flexitracker\config.toml"
24
+ if (-not (Test-Path $cfg)) {
25
+ Write-Host "`nAuthorize this machine (paste the access key from the web app):"
26
+ & $bin configure
27
+ }
28
+
29
+ # Register a login task that starts the daemon windowless.
30
+ $action = New-ScheduledTaskAction -Execute $bin
31
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
32
+ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -Hidden
33
+ Register-ScheduledTask -TaskName "FlexiTracker" -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null
34
+
35
+ Write-Host "`nInstalled and registered to start on login. Check with: flexitracker test"
@@ -0,0 +1,35 @@
1
+ #!/bin/sh
2
+ # FlexiTracker (Linux) — install the daemon into your user profile with uv
3
+ # (no admin, no compiler) and enable it as a systemd user service on login.
4
+ set -e
5
+
6
+ if ! command -v uv >/dev/null 2>&1; then
7
+ echo "uv is required: https://docs.astral.sh/uv/ (curl -LsSf https://astral.sh/uv/install.sh | sh)" >&2
8
+ exit 1
9
+ fi
10
+
11
+ echo "Installing flexitracker with uv…"
12
+ uv tool install --upgrade flexitracker
13
+
14
+ BIN="$HOME/.local/bin/flexitracker"
15
+ if [ ! -x "$BIN" ]; then
16
+ # Fall back to whatever uv put on PATH.
17
+ BIN="$(command -v flexitracker || echo "$BIN")"
18
+ fi
19
+
20
+ # Authorize this machine if not already configured.
21
+ if [ ! -f "$HOME/.config/flexitracker/config.toml" ]; then
22
+ echo
23
+ echo "Authorize this machine (paste the access key from the web app):"
24
+ "$BIN" configure
25
+ fi
26
+
27
+ # Install + enable the user service.
28
+ mkdir -p "$HOME/.config/systemd/user"
29
+ cp "$(dirname "$0")/flexitracker.service" "$HOME/.config/systemd/user/flexitracker.service"
30
+ systemctl --user daemon-reload
31
+ systemctl --user enable --now flexitracker.service
32
+
33
+ echo
34
+ echo "Installed and started. Check with: flexitracker test"
35
+ echo "Auto-starts on login. (You may need: loginctl enable-linger \"$USER\")"
@@ -0,0 +1,15 @@
1
+ """Entry point for the frozen single-file executable (PyInstaller/Nuitka).
2
+
3
+ The wheel installs a `flexitracker` console script; the frozen exe needs a plain
4
+ top-level script to freeze. Build (see release.yml):
5
+
6
+ uv run --group build pyinstaller --onefile --name flexitracker \
7
+ --paths src --console pyinstaller_entry.py
8
+ """
9
+
10
+ import sys
11
+
12
+ from flexitracker.cli import main
13
+
14
+ if __name__ == "__main__":
15
+ sys.exit(main())
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "flexitracker"
3
+ # Version is derived from the git tag by hatch-vcs at build time, so the tag IS
4
+ # the version — the release tag and the wheel can never drift. Untagged builds
5
+ # get a development version (which `flexitracker --version` reports as 0.0.0).
6
+ dynamic = ["version"]
7
+ description = "FlexiTracker activity daemon — pure-Python."
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ # Pure-Python, near-stdlib on purpose: no compiled extension so `uv tool install`
12
+ # needs no C toolchain and no admin rights (see openspec/specs/daemon-parity).
13
+ # OS idle/lock detection uses ctypes (stdlib); HTTP uses urllib (stdlib);
14
+ # TOML read uses tomllib (stdlib, 3.11+); TOML write is a tiny local emitter.
15
+ dependencies = []
16
+
17
+ [project.scripts]
18
+ flexitracker = "flexitracker.cli:main"
19
+
20
+ [dependency-groups]
21
+ dev = ["pytest>=8"]
22
+ # Freezing tool for the standalone single-file executable (GitHub Release asset,
23
+ # for machines that permit executables). Not a runtime dependency; used only by
24
+ # the release build. See daemon-py/pyinstaller_entry.py and release.yml.
25
+ build = ["pyinstaller>=6"]
26
+
27
+ [build-system]
28
+ requires = ["hatchling", "hatch-vcs"]
29
+ build-backend = "hatchling.build"
30
+
31
+ # Version from the VCS tag. `root = ".."` points setuptools-scm at the repo root,
32
+ # since this package lives in a subdirectory (its own pyproject would otherwise
33
+ # not find the repo's .git and would fall back). `no-local-version` keeps the
34
+ # version PyPI-acceptable (no +local segment); `fallback_version` lets a checkout
35
+ # with no tags/history still build.
36
+ [tool.hatch.version]
37
+ source = "vcs"
38
+ raw-options = { root = "..", local_scheme = "no-local-version", fallback_version = "0.0.0" }
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/flexitracker"]
@@ -0,0 +1,9 @@
1
+ """FlexiTracker activity daemon — the pure-Python daemon.
2
+
3
+ Captures idle/active transitions and ships back-dated events to the backend.
4
+ The trickiest behaviour (back-dating, suspend reconciliation, the emit watermark)
5
+ is pinned by the behavioural vectors in `tests/vectors/`.
6
+
7
+ The version is derived from the git tag by hatch-vcs at build time; see
8
+ `version.get_version()` (reports 0.0.0 for a development build).
9
+ """
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,16 @@
1
+ """Backend URL baked into the release build.
2
+
3
+ Mirrors the Rust `option_env!("FLEXITRACKER_BACKEND_URL")`: the release pipeline
4
+ rewrites `BAKED_BACKEND_URL` (from the PROD_BASE_URL variable) so a user runs only
5
+ `flexitracker configure --key <KEY>`. The `FLEXITRACKER_BACKEND_URL` environment
6
+ variable overrides it, and `--backend-url` overrides both.
7
+ """
8
+
9
+ import os
10
+
11
+ # Rewritten at release time. Empty in source so self-hosters must pass --backend-url.
12
+ BAKED_BACKEND_URL = "https://flexitracker.vuorinet.net"
13
+
14
+
15
+ def default_backend_url() -> str:
16
+ return os.environ.get("FLEXITRACKER_BACKEND_URL") or BAKED_BACKEND_URL
@@ -0,0 +1,295 @@
1
+ """CLI + daemon loop.
2
+
3
+ Fail-fast: unexpected conditions exit with a clear message rather than being
4
+ silently absorbed. Subcommands: `configure` (authorize + self-test), `test`
5
+ (connectivity check, sends no data), and the default daemon run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import socket
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from ._backend import default_backend_url
19
+ from .config import Config
20
+ from .core import machine_descriptor
21
+ from .idle import Sample, SimulatedIdle, platform_source
22
+ from .outbox import Outbox
23
+ from .sender import SenderError, fetch_thresholds, post_batch, whoami
24
+ from .state_machine import Persisted, StateMachine, Tick
25
+
26
+ HELP = """flexitracker — activity tracking daemon
27
+
28
+ USAGE:
29
+ flexitracker configure [--key KEY] [--backend-url URL] Authorize this machine
30
+ flexitracker test Check connectivity (sends no data)
31
+ flexitracker [OPTIONS] Run the daemon
32
+
33
+ OPTIONS:
34
+ --key, --account-key KEY Per-machine access key (saved to config)
35
+ --backend-url URL Backend base URL (defaults to the built-in one)
36
+ --config PATH Config file path (default: ~/.config/flexitracker/config.toml)
37
+ --simulate Post a synthetic day through the real pipeline and exit
38
+ --once Take a single reading, flush, and exit
39
+ --check Alias for `test`
40
+ -V, --version Print version
41
+ -h, --help Print this help"""
42
+
43
+
44
+ def now_ms() -> int:
45
+ return int(time.time() * 1000)
46
+
47
+
48
+ def _os_name() -> str:
49
+ # Match the Rust std::env::consts::OS values used on the wire.
50
+ return {"linux": "linux", "win32": "windows", "darwin": "macos"}.get(
51
+ sys.platform, sys.platform
52
+ )
53
+
54
+
55
+ def machine_desc() -> dict:
56
+ hostname = os.environ.get("COMPUTERNAME") or os.environ.get("HOSTNAME") or socket.gethostname() or "unknown"
57
+ return machine_descriptor(hostname, _os_name())
58
+
59
+
60
+ class Args:
61
+ def __init__(self) -> None:
62
+ self.cmd = "daemon" # daemon | configure | test
63
+ self.account_key: Optional[str] = None
64
+ self.backend_url: Optional[str] = None
65
+ self.config_path: Optional[Path] = None
66
+ self.simulate = False
67
+ self.once = False
68
+
69
+
70
+ def parse_args(argv: list) -> Args:
71
+ a = Args()
72
+ it = iter(argv)
73
+
74
+ def take(flag: str) -> str:
75
+ try:
76
+ return next(it)
77
+ except StopIteration:
78
+ raise ValueError(f"{flag} requires a value")
79
+
80
+ for arg in it:
81
+ if arg == "configure":
82
+ a.cmd = "configure"
83
+ elif arg in ("test", "--check"):
84
+ a.cmd = "test"
85
+ elif arg in ("--account-key", "--key"):
86
+ a.account_key = take(arg)
87
+ elif arg == "--backend-url":
88
+ a.backend_url = take(arg)
89
+ elif arg == "--config":
90
+ a.config_path = Path(take(arg))
91
+ elif arg == "--simulate":
92
+ a.simulate = True
93
+ elif arg == "--once":
94
+ a.once = True
95
+ elif arg in ("--version", "-V"):
96
+ from .version import get_version
97
+
98
+ print(f"flexitracker {get_version()}")
99
+ sys.exit(0)
100
+ elif arg in ("--help", "-h"):
101
+ print(HELP)
102
+ sys.exit(0)
103
+ else:
104
+ raise ValueError(f"unknown argument: {arg}")
105
+ return a
106
+
107
+
108
+ def prompt(msg: str) -> str:
109
+ sys.stdout.write(msg)
110
+ sys.stdout.flush()
111
+ return sys.stdin.readline().strip()
112
+
113
+
114
+ def self_test(cfg: Config) -> int:
115
+ print(f"Contacting {cfg.backend_url} …")
116
+ try:
117
+ w = whoami(cfg.backend_url, cfg.access_key)
118
+ except SenderError as e:
119
+ print(f"error: {e}", file=sys.stderr)
120
+ return 1
121
+ print(" ✓ Reachable")
122
+ print(f" ✓ Key valid — account: {w.email}")
123
+ if w.machine_label:
124
+ print(f' ✓ This machine: "{w.machine_label}"')
125
+ if w.active:
126
+ print(" ✓ Account active — no activity data was sent.")
127
+ return 0
128
+ print(f"error: account is {w.status} — not active yet (nothing was sent)", file=sys.stderr)
129
+ return 1
130
+
131
+
132
+ def flush(cfg: Config, ob: Outbox) -> None:
133
+ batch = ob.next_batch()
134
+ if batch is None:
135
+ return
136
+ try:
137
+ post_batch(cfg.backend_url, cfg.access_key, batch)
138
+ ob.ack()
139
+ except SenderError as e:
140
+ print(f"flush deferred ({ob.pending_len()} pending): {e}", file=sys.stderr)
141
+
142
+
143
+ def load_state(path: Path) -> Optional[Persisted]:
144
+ try:
145
+ data = json.loads(path.read_text(encoding="utf-8"))
146
+ except (FileNotFoundError, json.JSONDecodeError):
147
+ return None
148
+ return Persisted(
149
+ reported_state=data.get("reported_state", "Idle"),
150
+ last_active_time=data.get("last_active_time"),
151
+ last_heartbeat=data.get("last_heartbeat"),
152
+ pending_active_since=data.get("pending_active_since"),
153
+ last_emitted_ts=data.get("last_emitted_ts"),
154
+ last_seen_wall=data.get("last_seen_wall"),
155
+ )
156
+
157
+
158
+ def save_state(path: Path, p: Persisted) -> None:
159
+ # Temp + rename: this file is the sole basis for reconstructing the end of a
160
+ # span after an ungraceful shutdown, so a torn write would corrupt exactly
161
+ # the record that exists to survive one.
162
+ data = {
163
+ "reported_state": p.reported_state,
164
+ "last_active_time": p.last_active_time,
165
+ "last_heartbeat": p.last_heartbeat,
166
+ "pending_active_since": p.pending_active_since,
167
+ "last_emitted_ts": p.last_emitted_ts,
168
+ "last_seen_wall": p.last_seen_wall,
169
+ }
170
+ tmp = path.with_suffix(".tmp")
171
+ tmp.write_text(json.dumps(data), encoding="utf-8")
172
+ os.replace(tmp, path)
173
+
174
+
175
+ def simulate(cfg: Config, ob: Outbox) -> int:
176
+ n = now_ms()
177
+ day = n - (n % 86_400_000)
178
+ h = 3_600_000
179
+ events = [
180
+ {"ts": day + 8 * h, "kind": "active"},
181
+ {"ts": day + 10 * h, "kind": "idle"},
182
+ {"ts": day + 13 * h, "kind": "active"},
183
+ {"ts": day + 16 * h, "kind": "idle"},
184
+ ]
185
+ ob.append(events)
186
+ try:
187
+ post_batch(cfg.backend_url, cfg.access_key, ob.next_batch())
188
+ ob.ack()
189
+ except SenderError as e:
190
+ print(f"error: simulate post failed: {e}", file=sys.stderr)
191
+ return 1
192
+ print(f"simulated day posted ({len(events)} events)")
193
+ return 0
194
+
195
+
196
+ def run(argv: list) -> int:
197
+ try:
198
+ args = parse_args(argv)
199
+ except ValueError as e:
200
+ print(HELP)
201
+ print(f"error: {e}", file=sys.stderr)
202
+ return 1
203
+
204
+ config_path = args.config_path or Config.default_path()
205
+
206
+ try:
207
+ cfg = Config.load(config_path)
208
+ except Exception: # noqa: BLE001 — any load failure falls back to a fresh config (mirrors the Rust `.ok().unwrap_or_else`)
209
+ cfg = Config()
210
+
211
+ if args.account_key:
212
+ cfg.access_key = args.account_key
213
+ if args.backend_url:
214
+ cfg.backend_url = args.backend_url
215
+ if not cfg.backend_url:
216
+ cfg.backend_url = default_backend_url()
217
+
218
+ if args.cmd == "configure":
219
+ if not cfg.access_key:
220
+ cfg.access_key = prompt("Paste your machine access key: ")
221
+ if not cfg.access_key:
222
+ print("error: no access key provided", file=sys.stderr)
223
+ return 1
224
+ if not cfg.backend_url:
225
+ print("error: no backend url (pass --backend-url; releases have one built in)", file=sys.stderr)
226
+ return 1
227
+ cfg.save(config_path)
228
+ print(f"Saved config to {config_path}.")
229
+ return self_test(cfg)
230
+
231
+ if args.cmd == "test":
232
+ if not cfg.access_key or not cfg.backend_url:
233
+ print("error: not configured — run `flexitracker configure --key <KEY>` first", file=sys.stderr)
234
+ return 1
235
+ return self_test(cfg)
236
+
237
+ # Daemon
238
+ if not cfg.access_key or not cfg.backend_url:
239
+ print("error: missing access key or backend url (run `flexitracker configure --key <KEY>`)", file=sys.stderr)
240
+ return 1
241
+ cfg.save(config_path)
242
+
243
+ # Refresh thresholds; fall back to cached/defaults offline.
244
+ try:
245
+ cfg.thresholds = fetch_thresholds(cfg.backend_url, cfg.access_key, cfg.thresholds.poll_sec)
246
+ cfg.save(config_path)
247
+ except SenderError as e:
248
+ print(f"warning: using cached thresholds ({e})", file=sys.stderr)
249
+ thresholds = cfg.thresholds.to_thresholds()
250
+
251
+ state_path = config_path.with_name("state.json")
252
+ outbox_path = config_path.with_name("outbox.json")
253
+ ob = Outbox.open(outbox_path)
254
+ dropped = ob.trim_expired(now_ms())
255
+ if dropped > 0:
256
+ print(f"dropped {dropped} outbox event(s) older than the backend edit window", file=sys.stderr)
257
+ ob.set_machine(machine_desc())
258
+
259
+ if args.simulate:
260
+ return simulate(cfg, ob)
261
+
262
+ persisted = load_state(state_path) or Persisted()
263
+ sm = StateMachine(thresholds, persisted)
264
+
265
+ ob.append(sm.recover(now_ms()))
266
+ save_state(state_path, sm.p)
267
+ flush(cfg, ob)
268
+
269
+ try:
270
+ source = platform_source()
271
+ except OSError as e:
272
+ print(f"error: idle source: {e}", file=sys.stderr)
273
+ return 1
274
+
275
+ last_mono: Optional[float] = None
276
+ while True:
277
+ s: Sample = source.sample()
278
+ mono_now = time.monotonic()
279
+ mono_elapsed_ms = None if last_mono is None else int((mono_now - last_mono) * 1000)
280
+ last_mono = mono_now
281
+ events = sm.step(
282
+ Tick(now=now_ms(), idle_ms=s.idle_ms, locked=s.locked, mono_elapsed_ms=mono_elapsed_ms)
283
+ )
284
+ ob.append(events)
285
+ save_state(state_path, sm.p)
286
+ flush(cfg, ob)
287
+ if args.once:
288
+ return 0
289
+ time.sleep(thresholds.poll_ms / 1000)
290
+
291
+
292
+ def main(argv: Optional[list] = None) -> int:
293
+ if argv is None:
294
+ argv = sys.argv[1:]
295
+ return run(argv)