flexitracker 0.3.0__tar.gz → 0.4.0__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.3.0 → flexitracker-0.4.0}/PKG-INFO +7 -5
- {flexitracker-0.3.0 → flexitracker-0.4.0}/README.md +6 -4
- flexitracker-0.4.0/install/README.md +95 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/pyproject.toml +6 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/cli.py +43 -9
- flexitracker-0.4.0/src/flexitracker/logging_setup.py +64 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/outbox.py +6 -1
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/sender.py +9 -1
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_cli.py +19 -1
- flexitracker-0.4.0/tests/test_logging_setup.py +83 -0
- flexitracker-0.3.0/install/README.md +0 -43
- flexitracker-0.3.0/install/install.ps1 +0 -35
- flexitracker-0.3.0/install/install.sh +0 -35
- {flexitracker-0.3.0 → flexitracker-0.4.0}/.gitignore +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/install/flexitracker.service +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/pyinstaller_entry.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/__init__.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/__main__.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/_backend.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/config.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/core.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/idle.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/login.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/state_machine.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/version.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_config.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_conformance.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_idle.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_login.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_outbox.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_sender_body.py +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/01-activation-debounced-backdated.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/02-idle-backdated-to-last-input.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/03-short-break-absorbed.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/04-brief-activity-debounced.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/05-sustained-return-backdated.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/06-heartbeats-while-active.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/07-recover-after-sudden-shutdown.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/08-locked-session-goes-idle.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/09-recover-anchors-later-evidence.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/10-suspend-longer-closes-span.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/11-suspend-with-immediate-wake.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/12-suspend-shorter-absorbed.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/13-backwards-clock-watermark.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/14-small-forward-clock-absorbed.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/15-recover-from-active-matches-loop.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/16-explicit-close-no-double-close.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/17-freshness-independent-of-poll.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/18-short-reading-pause.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/19-presence-must-be-sustained.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/20-gap-kills-pending-return.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/21-idle-backdating-exact.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/22-min-inactivity-exact-boundary.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/23-large-timestamps.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/24-all-kinds-lowercase.json +0 -0
- {flexitracker-0.3.0 → flexitracker-0.4.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flexitracker
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: FlexiTracker activity daemon — pure-Python.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -23,10 +23,12 @@ Recommended (all platforms):
|
|
|
23
23
|
uv tool install flexitracker
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Or, on a machine that allows executables, download the standalone
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
Or, on a machine that allows executables, download the standalone build from
|
|
27
|
+
the GitHub **Releases** page (it bundles its own Python runtime): one binary
|
|
28
|
+
on Linux, or on Windows two — a console one for `login`/`test` and a
|
|
29
|
+
windowless one for auto-start. See `install/README.md` for per-OS auto-start
|
|
30
|
+
(a single copy-pasteable command, no installer script) and the Windows
|
|
31
|
+
SmartScreen trust step.
|
|
30
32
|
|
|
31
33
|
## Use
|
|
32
34
|
|
|
@@ -15,10 +15,12 @@ Recommended (all platforms):
|
|
|
15
15
|
uv tool install flexitracker
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Or, on a machine that allows executables, download the standalone
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
Or, on a machine that allows executables, download the standalone build from
|
|
19
|
+
the GitHub **Releases** page (it bundles its own Python runtime): one binary
|
|
20
|
+
on Linux, or on Windows two — a console one for `login`/`test` and a
|
|
21
|
+
windowless one for auto-start. See `install/README.md` for per-OS auto-start
|
|
22
|
+
(a single copy-pasteable command, no installer script) and the Windows
|
|
23
|
+
SmartScreen trust step.
|
|
22
24
|
|
|
23
25
|
## Use
|
|
24
26
|
|
|
@@ -0,0 +1,95 @@
|
|
|
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 and installer
|
|
6
|
+
scripts alike.
|
|
7
|
+
|
|
8
|
+
Every auto-start step below is a single command you run yourself and can read
|
|
9
|
+
before pasting — **no installer script (PowerShell, VBScript, or shell) is
|
|
10
|
+
downloaded or executed for you.** This also renders on the web app's Machines
|
|
11
|
+
tab, matching your detected OS, with a copy button.
|
|
12
|
+
|
|
13
|
+
## Recommended: uv (all platforms)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv tool install flexitracker
|
|
17
|
+
flexitracker login
|
|
18
|
+
flexitracker test
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`login` opens your browser to authorize the machine — sign in, approve, and the
|
|
22
|
+
key lands in the config automatically; you never see or paste it. On a
|
|
23
|
+
headless or scripted box, use `flexitracker login --key <YOUR_ACCESS_KEY>`
|
|
24
|
+
with a key from the web UI's "Add machine" button instead.
|
|
25
|
+
|
|
26
|
+
`uv tool install` places two commands on `PATH`: the console `flexitracker`
|
|
27
|
+
used above (for `login`/`test`, where you want to see the output), and a
|
|
28
|
+
second, windowless `flexitracker-daemon` command for auto-start below — it runs
|
|
29
|
+
the identical daemon with no console window.
|
|
30
|
+
|
|
31
|
+
### Auto-start on login
|
|
32
|
+
|
|
33
|
+
- **Windows** — run this once in `cmd.exe` or PowerShell (no admin rights
|
|
34
|
+
needed; it only touches your own user's registry):
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v FlexiTracker /t REG_SZ /d "\"%USERPROFILE%\.local\bin\flexitracker-daemon.exe\"" /f
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
To undo it: `reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v FlexiTracker /f`
|
|
41
|
+
|
|
42
|
+
- **Linux** — systemd *user* service:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
mkdir -p ~/.config/systemd/user
|
|
46
|
+
cp flexitracker.service ~/.config/systemd/user/
|
|
47
|
+
systemctl --user daemon-reload
|
|
48
|
+
systemctl --user enable --now flexitracker.service
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
(`flexitracker.service` is the unit file in this directory — it's a plain
|
|
52
|
+
declarative unit, not a script.) Check status/logs with
|
|
53
|
+
`systemctl --user status flexitracker` / `journalctl --user -u flexitracker -f`.
|
|
54
|
+
If the machine doesn't keep a session open past logout, also run
|
|
55
|
+
`loginctl enable-linger "$USER"` once so the user service can still start.
|
|
56
|
+
To undo it: `systemctl --user disable --now flexitracker.service`.
|
|
57
|
+
|
|
58
|
+
## Alternative: standalone executables (machines that allow exes)
|
|
59
|
+
|
|
60
|
+
If your machine permits running executables, download from the project's
|
|
61
|
+
GitHub **Releases** page — it bundles its own Python runtime, so nothing else
|
|
62
|
+
is needed. Windows ships **two** executables; use the right one for the job:
|
|
63
|
+
|
|
64
|
+
| File | Use it for |
|
|
65
|
+
|------|------------|
|
|
66
|
+
| `flexitracker-windows-x86_64.exe` | `login` and `test`, run from a terminal — it has a console, so you see the output. |
|
|
67
|
+
| `flexitracker-daemon-windows-x86_64.exe` | Auto-start only — a windowless build with no console window, ever. |
|
|
68
|
+
|
|
69
|
+
Linux has one binary, `flexitracker-linux-x86_64`, used for everything.
|
|
70
|
+
|
|
71
|
+
```bat
|
|
72
|
+
flexitracker-windows-x86_64.exe login
|
|
73
|
+
flexitracker-windows-x86_64.exe test
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Auto-start on login (standalone executable)
|
|
77
|
+
|
|
78
|
+
Save `flexitracker-daemon-windows-x86_64.exe` somewhere permanent (e.g.
|
|
79
|
+
`%LOCALAPPDATA%\flexitracker\flexitracker-daemon.exe`), then register it the
|
|
80
|
+
same way as the `uv` path, pointed at that path instead:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v FlexiTracker /t REG_SZ /d "\"<path to flexitracker-daemon-windows-x86_64.exe>\"" /f
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
On Linux, use the same `flexitracker.service` + `systemctl --user` steps
|
|
87
|
+
above, pointing `ExecStart` at wherever you saved `flexitracker-linux-x86_64`.
|
|
88
|
+
|
|
89
|
+
### Windows SmartScreen (unsigned executables)
|
|
90
|
+
|
|
91
|
+
Both standalone executables are **not code-signed**, so Windows SmartScreen may
|
|
92
|
+
warn on first run. To allow one: click **More info → Run anyway**, or
|
|
93
|
+
right-click the file → **Properties** → tick **Unblock** → **OK**. If your
|
|
94
|
+
organization blocks unsigned executables entirely, use the **uv** path above
|
|
95
|
+
instead — it does not run a downloaded executable.
|
|
@@ -17,6 +17,12 @@ dependencies = []
|
|
|
17
17
|
[project.scripts]
|
|
18
18
|
flexitracker = "flexitracker.cli:main"
|
|
19
19
|
|
|
20
|
+
# Windows-subsystem launcher (no console ever) for `uv tool install`, used only
|
|
21
|
+
# for the auto-started background daemon. `login`/`test` use the console
|
|
22
|
+
# `flexitracker` entry point above so their output is visible.
|
|
23
|
+
[project.gui-scripts]
|
|
24
|
+
flexitracker-daemon = "flexitracker.cli:main"
|
|
25
|
+
|
|
20
26
|
[dependency-groups]
|
|
21
27
|
dev = ["pytest>=8"]
|
|
22
28
|
# Freezing tool for the standalone single-file executable (GitHub Release asset,
|
|
@@ -9,6 +9,7 @@ sends no data), and the default daemon run.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import logging
|
|
12
13
|
import os
|
|
13
14
|
import socket
|
|
14
15
|
import sys
|
|
@@ -18,12 +19,16 @@ from typing import Optional
|
|
|
18
19
|
|
|
19
20
|
from ._backend import default_backend_url
|
|
20
21
|
from .config import Config
|
|
21
|
-
from .core import machine_descriptor
|
|
22
|
+
from .core import EventKind, machine_descriptor
|
|
22
23
|
from .idle import Sample, platform_source
|
|
24
|
+
from .logging_setup import LOGGER_NAME, configure_logging
|
|
23
25
|
from .login import LoginError, browser_login
|
|
24
26
|
from .outbox import Outbox
|
|
25
27
|
from .sender import SenderError, fetch_thresholds, post_batch, whoami
|
|
26
28
|
from .state_machine import Persisted, StateMachine, Tick
|
|
29
|
+
from .version import get_version
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
27
32
|
|
|
28
33
|
HELP = """flexitracker — activity tracking daemon
|
|
29
34
|
|
|
@@ -100,8 +105,6 @@ def parse_args(argv: list) -> Args:
|
|
|
100
105
|
elif arg == "--once":
|
|
101
106
|
a.once = True
|
|
102
107
|
elif arg in ("--version", "-V"):
|
|
103
|
-
from .version import get_version
|
|
104
|
-
|
|
105
108
|
print(f"flexitracker {get_version()}")
|
|
106
109
|
sys.exit(0)
|
|
107
110
|
elif arg in ("--help", "-h"):
|
|
@@ -138,7 +141,17 @@ def flush(cfg: Config, ob: Outbox) -> None:
|
|
|
138
141
|
post_batch(cfg.backend_url, cfg.access_key, batch)
|
|
139
142
|
ob.ack()
|
|
140
143
|
except SenderError as e:
|
|
141
|
-
|
|
144
|
+
logger.warning("flush deferred (%d pending): %s", ob.pending_len(), e)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def log_events(events: list) -> None:
|
|
148
|
+
"""INFO for a work/idle span start or end; DEBUG for a heartbeat, so a
|
|
149
|
+
default-level log stays small no matter how long the daemon runs."""
|
|
150
|
+
for e in events:
|
|
151
|
+
if e["kind"] == EventKind.HEARTBEAT:
|
|
152
|
+
logger.debug("heartbeat ts=%s", e["ts"])
|
|
153
|
+
else:
|
|
154
|
+
logger.info("%s ts=%s", e["kind"], e["ts"])
|
|
142
155
|
|
|
143
156
|
|
|
144
157
|
def load_state(path: Path) -> Optional[Persisted]:
|
|
@@ -253,12 +266,29 @@ def run(argv: list) -> int:
|
|
|
253
266
|
return 1
|
|
254
267
|
cfg.save(config_path)
|
|
255
268
|
|
|
269
|
+
configure_logging(config_path)
|
|
270
|
+
machine = machine_desc()
|
|
271
|
+
logger.info(
|
|
272
|
+
"flexitracker %s starting (backend=%s, config=%s)", get_version(), cfg.backend_url, config_path
|
|
273
|
+
)
|
|
274
|
+
logger.info("machine: hostname=%s os=%s", machine["hostname"], machine["os"])
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
return _run_daemon(args, cfg, config_path, machine)
|
|
278
|
+
except Exception:
|
|
279
|
+
# No console to see a traceback on under the windowed/no-console build
|
|
280
|
+
# — the rotating log file is the only place this can land.
|
|
281
|
+
logger.exception("daemon loop crashed")
|
|
282
|
+
return 1
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _run_daemon(args: Args, cfg: Config, config_path: Path, machine: dict) -> int:
|
|
256
286
|
# Refresh thresholds; fall back to cached/defaults offline.
|
|
257
287
|
try:
|
|
258
288
|
cfg.thresholds = fetch_thresholds(cfg.backend_url, cfg.access_key, cfg.thresholds.poll_sec)
|
|
259
289
|
cfg.save(config_path)
|
|
260
290
|
except SenderError as e:
|
|
261
|
-
|
|
291
|
+
logger.warning("using cached thresholds (%s)", e)
|
|
262
292
|
thresholds = cfg.thresholds.to_thresholds()
|
|
263
293
|
|
|
264
294
|
state_path = config_path.with_name("state.json")
|
|
@@ -266,8 +296,8 @@ def run(argv: list) -> int:
|
|
|
266
296
|
ob = Outbox.open(outbox_path)
|
|
267
297
|
dropped = ob.trim_expired(now_ms())
|
|
268
298
|
if dropped > 0:
|
|
269
|
-
|
|
270
|
-
ob.set_machine(
|
|
299
|
+
logger.warning("dropped %d outbox event(s) older than the backend edit window", dropped)
|
|
300
|
+
ob.set_machine(machine)
|
|
271
301
|
|
|
272
302
|
if args.simulate:
|
|
273
303
|
return simulate(cfg, ob)
|
|
@@ -275,25 +305,29 @@ def run(argv: list) -> int:
|
|
|
275
305
|
persisted = load_state(state_path) or Persisted()
|
|
276
306
|
sm = StateMachine(thresholds, persisted)
|
|
277
307
|
|
|
278
|
-
|
|
308
|
+
recovered = sm.recover(now_ms())
|
|
309
|
+
log_events(recovered)
|
|
310
|
+
ob.append(recovered)
|
|
279
311
|
save_state(state_path, sm.p)
|
|
280
312
|
flush(cfg, ob)
|
|
281
313
|
|
|
282
314
|
try:
|
|
283
315
|
source = platform_source()
|
|
284
316
|
except OSError as e:
|
|
285
|
-
|
|
317
|
+
logger.error("idle source: %s", e)
|
|
286
318
|
return 1
|
|
287
319
|
|
|
288
320
|
last_mono: Optional[float] = None
|
|
289
321
|
while True:
|
|
290
322
|
s: Sample = source.sample()
|
|
323
|
+
logger.debug("sample idle_ms=%s locked=%s", s.idle_ms, s.locked)
|
|
291
324
|
mono_now = time.monotonic()
|
|
292
325
|
mono_elapsed_ms = None if last_mono is None else int((mono_now - last_mono) * 1000)
|
|
293
326
|
last_mono = mono_now
|
|
294
327
|
events = sm.step(
|
|
295
328
|
Tick(now=now_ms(), idle_ms=s.idle_ms, locked=s.locked, mono_elapsed_ms=mono_elapsed_ms)
|
|
296
329
|
)
|
|
330
|
+
log_events(events)
|
|
297
331
|
ob.append(events)
|
|
298
332
|
save_state(state_path, sm.p)
|
|
299
333
|
flush(cfg, ob)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Rotating file logging for the background daemon loop.
|
|
2
|
+
|
|
3
|
+
Only the daemon loop uses this — `login`, `test`, `--version`, `--help`, and
|
|
4
|
+
`--simulate` stay on plain `print()`, since those are explicit, human-invoked,
|
|
5
|
+
foreground commands, not the silent background loop this exists for.
|
|
6
|
+
|
|
7
|
+
INFO records lifecycle events only (startup, the detected machine descriptor,
|
|
8
|
+
work/idle span start+end). Per-tick sampling and heartbeats are DEBUG-only, so
|
|
9
|
+
a default-level log stays small no matter how long the daemon runs. A
|
|
10
|
+
`StreamHandler` on `stderr` is added only when `stderr` is actually attached
|
|
11
|
+
(interactive `login`/`test`/foreground runs) — a `--windowed` frozen build has
|
|
12
|
+
no console, so `sys.stderr` is `None` there and must not be touched.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import logging.handlers
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
LOGGER_NAME = "flexitracker"
|
|
25
|
+
|
|
26
|
+
# ~8MB worst case (current + 3 backups) — generous for INFO-level lifecycle
|
|
27
|
+
# logging across many days, bounded enough to never become a disk concern.
|
|
28
|
+
MAX_BYTES = 2_000_000
|
|
29
|
+
BACKUP_COUNT = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def configure_logging(
|
|
33
|
+
config_path: Path,
|
|
34
|
+
level: Optional[str] = None,
|
|
35
|
+
max_bytes: int = MAX_BYTES,
|
|
36
|
+
backup_count: int = BACKUP_COUNT,
|
|
37
|
+
) -> logging.Logger:
|
|
38
|
+
"""Attach a rotating file handler (plus an optional stderr mirror) to the
|
|
39
|
+
module logger. Idempotent: a second call is a no-op so re-entrant daemon
|
|
40
|
+
code paths and tests can call it freely."""
|
|
41
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
42
|
+
if logger.handlers:
|
|
43
|
+
return logger
|
|
44
|
+
|
|
45
|
+
resolved_level = (level or os.environ.get("FLEXITRACKER_LOG_LEVEL") or "INFO").upper()
|
|
46
|
+
logger.setLevel(resolved_level)
|
|
47
|
+
|
|
48
|
+
log_path = config_path.with_name("flexitracker.log")
|
|
49
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
file_handler = logging.handlers.RotatingFileHandler(
|
|
51
|
+
log_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
|
|
52
|
+
)
|
|
53
|
+
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
54
|
+
logger.addHandler(file_handler)
|
|
55
|
+
|
|
56
|
+
# None under a --windowed frozen build (no console attached) — never a
|
|
57
|
+
# closed/broken stream, so this is the correct, sufficient guard.
|
|
58
|
+
if sys.stderr is not None:
|
|
59
|
+
stream_handler = logging.StreamHandler(sys.stderr)
|
|
60
|
+
stream_handler.setLevel(logging.WARNING)
|
|
61
|
+
stream_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
62
|
+
logger.addHandler(stream_handler)
|
|
63
|
+
|
|
64
|
+
return logger
|
|
@@ -9,10 +9,15 @@ either implementation can resume the other's outbox (proven by the harness).
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import logging
|
|
12
13
|
import os
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Optional
|
|
15
16
|
|
|
17
|
+
from .logging_setup import LOGGER_NAME
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
20
|
+
|
|
16
21
|
# Events older than the backend's edit window are rejected on arrival; holding
|
|
17
22
|
# them only grows the file.
|
|
18
23
|
MAX_EVENT_AGE_MS = 120 * 86_400_000
|
|
@@ -49,7 +54,7 @@ class Outbox:
|
|
|
49
54
|
except OSError:
|
|
50
55
|
pass
|
|
51
56
|
extra = f" (previous contents kept at {aside})" if moved else ""
|
|
52
|
-
|
|
57
|
+
logger.warning("outbox unreadable (%s); starting with an empty queue%s", e, extra)
|
|
53
58
|
return cls(path, 0, [], None)
|
|
54
59
|
return cls(
|
|
55
60
|
path,
|
|
@@ -14,6 +14,14 @@ from dataclasses import dataclass
|
|
|
14
14
|
from typing import Optional
|
|
15
15
|
|
|
16
16
|
from .config import ThresholdCfg
|
|
17
|
+
from .version import get_version
|
|
18
|
+
|
|
19
|
+
# Cloudflare's bot-fight mode blocks urllib's default "Python-urllib/x.y" User-
|
|
20
|
+
# Agent outright (HTTP 403, body "error code: 1010") on every route, Access-
|
|
21
|
+
# bypassed or not — it's a zone-level WAF check, unrelated to Access. Every
|
|
22
|
+
# real request this client makes needs a non-default UA or it never leaves
|
|
23
|
+
# the edge.
|
|
24
|
+
USER_AGENT = f"flexitracker-daemon/{get_version()}"
|
|
17
25
|
|
|
18
26
|
|
|
19
27
|
class SenderError(Exception):
|
|
@@ -22,7 +30,7 @@ class SenderError(Exception):
|
|
|
22
30
|
|
|
23
31
|
def _request(url: str, key: Optional[str], method: str, body: Optional[dict]) -> dict:
|
|
24
32
|
data = None
|
|
25
|
-
headers = {}
|
|
33
|
+
headers = {"user-agent": USER_AGENT}
|
|
26
34
|
if key:
|
|
27
35
|
headers["authorization"] = f"Bearer {key}"
|
|
28
36
|
if body is not None:
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
2
4
|
|
|
3
5
|
from flexitracker import cli as cli_mod
|
|
4
|
-
from flexitracker.cli import parse_args, run
|
|
6
|
+
from flexitracker.cli import log_events, parse_args, run
|
|
5
7
|
from flexitracker.config import Config
|
|
6
8
|
from flexitracker.login import LoginError
|
|
7
9
|
|
|
@@ -80,3 +82,19 @@ def test_login_browser_failure_writes_no_config(tmp_path, monkeypatch):
|
|
|
80
82
|
rc = run(["login", "--backend-url", "https://backend.example", "--config", str(config_path)])
|
|
81
83
|
assert rc == 1
|
|
82
84
|
assert not config_path.exists()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_log_events_logs_spans_at_info_and_heartbeat_at_debug(caplog):
|
|
88
|
+
events = [
|
|
89
|
+
{"ts": 1000, "kind": "active"},
|
|
90
|
+
{"ts": 2000, "kind": "heartbeat"},
|
|
91
|
+
{"ts": 3000, "kind": "idle"},
|
|
92
|
+
]
|
|
93
|
+
with caplog.at_level(logging.DEBUG, logger="flexitracker"):
|
|
94
|
+
log_events(events)
|
|
95
|
+
info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
|
|
96
|
+
debug_messages = [r.message for r in caplog.records if r.levelno == logging.DEBUG]
|
|
97
|
+
assert any("active" in m for m in info_messages)
|
|
98
|
+
assert any("idle" in m for m in info_messages)
|
|
99
|
+
assert any("heartbeat" in m for m in debug_messages)
|
|
100
|
+
assert not any("heartbeat" in m for m in info_messages)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from flexitracker.logging_setup import LOGGER_NAME, configure_logging
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(autouse=True)
|
|
9
|
+
def _reset_logger():
|
|
10
|
+
# configure_logging is idempotent (no-op once handlers exist), so each
|
|
11
|
+
# test needs a clean slate rather than reusing the process-wide singleton.
|
|
12
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
13
|
+
logger.handlers.clear()
|
|
14
|
+
logger.setLevel(logging.NOTSET)
|
|
15
|
+
yield
|
|
16
|
+
logger.handlers.clear()
|
|
17
|
+
logger.setLevel(logging.NOTSET)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _log_path(tmp_path):
|
|
21
|
+
return tmp_path / "flexitracker.log"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_writes_to_a_rotating_file_next_to_config(tmp_path):
|
|
25
|
+
config_path = tmp_path / "config.toml"
|
|
26
|
+
logger = configure_logging(config_path)
|
|
27
|
+
logger.info("hello")
|
|
28
|
+
for h in logger.handlers:
|
|
29
|
+
h.flush()
|
|
30
|
+
assert "hello" in _log_path(tmp_path).read_text(encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_default_level_is_info_debug_is_suppressed(tmp_path):
|
|
34
|
+
config_path = tmp_path / "config.toml"
|
|
35
|
+
logger = configure_logging(config_path)
|
|
36
|
+
logger.info("span start")
|
|
37
|
+
logger.debug("heartbeat tick")
|
|
38
|
+
for h in logger.handlers:
|
|
39
|
+
h.flush()
|
|
40
|
+
text = _log_path(tmp_path).read_text(encoding="utf-8")
|
|
41
|
+
assert "span start" in text
|
|
42
|
+
assert "heartbeat tick" not in text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_env_var_raises_level_to_debug(tmp_path, monkeypatch):
|
|
46
|
+
monkeypatch.setenv("FLEXITRACKER_LOG_LEVEL", "DEBUG")
|
|
47
|
+
config_path = tmp_path / "config.toml"
|
|
48
|
+
logger = configure_logging(config_path)
|
|
49
|
+
logger.debug("heartbeat tick")
|
|
50
|
+
for h in logger.handlers:
|
|
51
|
+
h.flush()
|
|
52
|
+
assert "heartbeat tick" in _log_path(tmp_path).read_text(encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_rotates_past_the_size_limit(tmp_path):
|
|
56
|
+
config_path = tmp_path / "config.toml"
|
|
57
|
+
logger = configure_logging(config_path, max_bytes=500, backup_count=2)
|
|
58
|
+
for i in range(200):
|
|
59
|
+
logger.info("padding line number %d to force rotation", i)
|
|
60
|
+
assert _log_path(tmp_path).exists()
|
|
61
|
+
assert (tmp_path / "flexitracker.log.1").exists()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_no_stderr_handler_when_stderr_is_absent(tmp_path, monkeypatch):
|
|
65
|
+
# Mirrors a --windowed frozen build, where sys.stderr is None.
|
|
66
|
+
monkeypatch.setattr("flexitracker.logging_setup.sys.stderr", None)
|
|
67
|
+
config_path = tmp_path / "config.toml"
|
|
68
|
+
logger = configure_logging(config_path)
|
|
69
|
+
assert len(logger.handlers) == 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_stderr_handler_present_when_stderr_is_attached(tmp_path):
|
|
73
|
+
config_path = tmp_path / "config.toml"
|
|
74
|
+
logger = configure_logging(config_path)
|
|
75
|
+
assert len(logger.handlers) == 2
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_second_call_is_a_no_op(tmp_path):
|
|
79
|
+
config_path = tmp_path / "config.toml"
|
|
80
|
+
configure_logging(config_path)
|
|
81
|
+
handlers_after_first = list(logging.getLogger(LOGGER_NAME).handlers)
|
|
82
|
+
configure_logging(config_path)
|
|
83
|
+
assert logging.getLogger(LOGGER_NAME).handlers == handlers_after_first
|
|
@@ -1,43 +0,0 @@
|
|
|
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 login
|
|
12
|
-
flexitracker test
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
`login` opens your browser to authorize the machine — sign in, approve, and the
|
|
16
|
-
key lands in the config automatically; you never see or paste it. On a
|
|
17
|
-
headless or scripted box, use `flexitracker login --key <YOUR_ACCESS_KEY>`
|
|
18
|
-
with a key from the web UI's "Add machine" button instead.
|
|
19
|
-
|
|
20
|
-
Then enable auto-start on login:
|
|
21
|
-
|
|
22
|
-
- **Linux:** `./install.sh` (installs a systemd *user* service).
|
|
23
|
-
- **Windows:** `powershell -ExecutionPolicy Bypass -File install.ps1` (registers a
|
|
24
|
-
login task).
|
|
25
|
-
|
|
26
|
-
## Alternative: standalone executable (machines that allow exes)
|
|
27
|
-
|
|
28
|
-
If your machine permits running executables, download the standalone
|
|
29
|
-
`flexitracker.exe` (Windows) or `flexitracker` (Linux) from the project's GitHub
|
|
30
|
-
**Releases** page — it bundles its own Python runtime, so nothing else is needed.
|
|
31
|
-
|
|
32
|
-
```bat
|
|
33
|
-
flexitracker.exe login
|
|
34
|
-
flexitracker.exe test
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
### Windows SmartScreen (unsigned executable)
|
|
38
|
-
|
|
39
|
-
The standalone executable is **not code-signed**, so Windows SmartScreen may warn
|
|
40
|
-
on first run. To allow it: click **More info → Run anyway**, or right-click the
|
|
41
|
-
file → **Properties** → tick **Unblock** → **OK**. If your organization blocks
|
|
42
|
-
unsigned executables entirely, use the **uv** path above instead — it does not run
|
|
43
|
-
an executable.
|
|
@@ -1,35 +0,0 @@
|
|
|
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"
|
|
@@ -1,35 +0,0 @@
|
|
|
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\")"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/01-activation-debounced-backdated.json
RENAMED
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/02-idle-backdated-to-last-input.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/07-recover-after-sudden-shutdown.json
RENAMED
|
File without changes
|
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/09-recover-anchors-later-evidence.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/14-small-forward-clock-absorbed.json
RENAMED
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/15-recover-from-active-matches-loop.json
RENAMED
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/16-explicit-close-no-double-close.json
RENAMED
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/17-freshness-independent-of-poll.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/22-min-inactivity-exact-boundary.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|