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.
- flexitracker-0.2.1.dev0/.gitignore +20 -0
- flexitracker-0.2.1.dev0/PKG-INFO +48 -0
- flexitracker-0.2.1.dev0/README.md +40 -0
- flexitracker-0.2.1.dev0/install/README.md +38 -0
- flexitracker-0.2.1.dev0/install/flexitracker.service +12 -0
- flexitracker-0.2.1.dev0/install/install.ps1 +35 -0
- flexitracker-0.2.1.dev0/install/install.sh +35 -0
- flexitracker-0.2.1.dev0/pyinstaller_entry.py +15 -0
- flexitracker-0.2.1.dev0/pyproject.toml +41 -0
- flexitracker-0.2.1.dev0/src/flexitracker/__init__.py +9 -0
- flexitracker-0.2.1.dev0/src/flexitracker/__main__.py +6 -0
- flexitracker-0.2.1.dev0/src/flexitracker/_backend.py +16 -0
- flexitracker-0.2.1.dev0/src/flexitracker/cli.py +295 -0
- flexitracker-0.2.1.dev0/src/flexitracker/config.py +108 -0
- flexitracker-0.2.1.dev0/src/flexitracker/core.py +29 -0
- flexitracker-0.2.1.dev0/src/flexitracker/idle.py +97 -0
- flexitracker-0.2.1.dev0/src/flexitracker/outbox.py +109 -0
- flexitracker-0.2.1.dev0/src/flexitracker/sender.py +97 -0
- flexitracker-0.2.1.dev0/src/flexitracker/state_machine.py +177 -0
- flexitracker-0.2.1.dev0/src/flexitracker/version.py +26 -0
- flexitracker-0.2.1.dev0/tests/test_config.py +43 -0
- flexitracker-0.2.1.dev0/tests/test_conformance.py +82 -0
- flexitracker-0.2.1.dev0/tests/test_idle.py +9 -0
- flexitracker-0.2.1.dev0/tests/test_outbox.py +72 -0
- flexitracker-0.2.1.dev0/tests/test_sender_body.py +50 -0
- flexitracker-0.2.1.dev0/tests/vectors/01-activation-debounced-backdated.json +12 -0
- flexitracker-0.2.1.dev0/tests/vectors/02-idle-backdated-to-last-input.json +14 -0
- flexitracker-0.2.1.dev0/tests/vectors/03-short-break-absorbed.json +13 -0
- flexitracker-0.2.1.dev0/tests/vectors/04-brief-activity-debounced.json +10 -0
- flexitracker-0.2.1.dev0/tests/vectors/05-sustained-return-backdated.json +13 -0
- flexitracker-0.2.1.dev0/tests/vectors/06-heartbeats-while-active.json +15 -0
- flexitracker-0.2.1.dev0/tests/vectors/07-recover-after-sudden-shutdown.json +12 -0
- flexitracker-0.2.1.dev0/tests/vectors/08-locked-session-goes-idle.json +14 -0
- flexitracker-0.2.1.dev0/tests/vectors/09-recover-anchors-later-evidence.json +12 -0
- flexitracker-0.2.1.dev0/tests/vectors/10-suspend-longer-closes-span.json +14 -0
- flexitracker-0.2.1.dev0/tests/vectors/11-suspend-with-immediate-wake.json +14 -0
- flexitracker-0.2.1.dev0/tests/vectors/12-suspend-shorter-absorbed.json +13 -0
- flexitracker-0.2.1.dev0/tests/vectors/13-backwards-clock-watermark.json +15 -0
- flexitracker-0.2.1.dev0/tests/vectors/14-small-forward-clock-absorbed.json +13 -0
- flexitracker-0.2.1.dev0/tests/vectors/15-recover-from-active-matches-loop.json +14 -0
- flexitracker-0.2.1.dev0/tests/vectors/16-explicit-close-no-double-close.json +15 -0
- flexitracker-0.2.1.dev0/tests/vectors/17-freshness-independent-of-poll.json +11 -0
- flexitracker-0.2.1.dev0/tests/vectors/18-short-reading-pause.json +14 -0
- flexitracker-0.2.1.dev0/tests/vectors/19-presence-must-be-sustained.json +13 -0
- flexitracker-0.2.1.dev0/tests/vectors/20-gap-kills-pending-return.json +12 -0
- flexitracker-0.2.1.dev0/tests/vectors/21-idle-backdating-exact.json +15 -0
- flexitracker-0.2.1.dev0/tests/vectors/22-min-inactivity-exact-boundary.json +14 -0
- flexitracker-0.2.1.dev0/tests/vectors/23-large-timestamps.json +12 -0
- flexitracker-0.2.1.dev0/tests/vectors/24-all-kinds-lowercase.json +16 -0
- 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,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)
|