flexitracker 0.2.1__py3-none-any.whl

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.
@@ -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
flexitracker/cli.py ADDED
@@ -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)
flexitracker/config.py ADDED
@@ -0,0 +1,108 @@
1
+ """Daemon config file — mirror of `config.rs`.
2
+
3
+ `{backend_url, access_key, account_id?, machine_id?, thresholds}` stored as TOML
4
+ with 0600 permissions. Thresholds are refreshed from the backend on startup,
5
+ falling back to these cached/default values when offline.
6
+
7
+ Read uses stdlib `tomllib`; write uses a tiny local emitter (the schema is fixed
8
+ and small, so no third-party TOML writer is pulled in).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tomllib
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from .state_machine import Thresholds
20
+
21
+
22
+ @dataclass
23
+ class ThresholdCfg:
24
+ poll_sec: int = 15
25
+ min_inactivity_sec: int = 600
26
+ min_activity_sec: int = 30
27
+ heartbeat_sec: int = 300
28
+
29
+ def to_thresholds(self) -> Thresholds:
30
+ return Thresholds(
31
+ poll_ms=self.poll_sec * 1000,
32
+ min_inactivity_ms=self.min_inactivity_sec * 1000,
33
+ min_activity_ms=self.min_activity_sec * 1000,
34
+ heartbeat_ms=self.heartbeat_sec * 1000,
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class Config:
40
+ backend_url: str = ""
41
+ access_key: str = ""
42
+ account_id: Optional[str] = None
43
+ machine_id: Optional[str] = None
44
+ thresholds: ThresholdCfg = field(default_factory=ThresholdCfg)
45
+
46
+ @staticmethod
47
+ def default_path() -> Path:
48
+ override = os.environ.get("FLEXITRACKER_CONFIG")
49
+ if override:
50
+ return Path(override)
51
+ base = os.environ.get("HOME") or os.environ.get("APPDATA") or "."
52
+ return Path(base) / ".config" / "flexitracker" / "config.toml"
53
+
54
+ @classmethod
55
+ def load(cls, path: Path) -> "Config":
56
+ with open(path, "rb") as fh:
57
+ data = tomllib.load(fh)
58
+ th = data.get("thresholds", {})
59
+ return cls(
60
+ backend_url=data.get("backend_url", ""),
61
+ access_key=data.get("access_key", ""),
62
+ account_id=data.get("account_id"),
63
+ machine_id=data.get("machine_id"),
64
+ thresholds=ThresholdCfg(
65
+ poll_sec=th.get("poll_sec", 15),
66
+ min_inactivity_sec=th.get("min_inactivity_sec", 600),
67
+ min_activity_sec=th.get("min_activity_sec", 30),
68
+ heartbeat_sec=th.get("heartbeat_sec", 300),
69
+ ),
70
+ )
71
+
72
+ def save(self, path: Path) -> None:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ path.write_text(self._to_toml(), encoding="utf-8")
75
+ _restrict_permissions(path)
76
+
77
+ def _to_toml(self) -> str:
78
+ lines = [
79
+ f'backend_url = "{_esc(self.backend_url)}"',
80
+ f'access_key = "{_esc(self.access_key)}"',
81
+ ]
82
+ if self.account_id is not None:
83
+ lines.append(f'account_id = "{_esc(self.account_id)}"')
84
+ if self.machine_id is not None:
85
+ lines.append(f'machine_id = "{_esc(self.machine_id)}"')
86
+ lines += [
87
+ "",
88
+ "[thresholds]",
89
+ f"poll_sec = {self.thresholds.poll_sec}",
90
+ f"min_inactivity_sec = {self.thresholds.min_inactivity_sec}",
91
+ f"min_activity_sec = {self.thresholds.min_activity_sec}",
92
+ f"heartbeat_sec = {self.thresholds.heartbeat_sec}",
93
+ "",
94
+ ]
95
+ return "\n".join(lines)
96
+
97
+
98
+ def _esc(s: str) -> str:
99
+ return s.replace("\\", "\\\\").replace('"', '\\"')
100
+
101
+
102
+ def _restrict_permissions(path: Path) -> None:
103
+ # No-op on Windows (POSIX modes do not apply); mirror the Rust cfg(unix) gate.
104
+ if os.name == "posix":
105
+ try:
106
+ os.chmod(path, 0o600)
107
+ except OSError:
108
+ pass
flexitracker/core.py ADDED
@@ -0,0 +1,29 @@
1
+ """Shared wire types — mirrors `flexitracker-core/src/lib.rs` and
2
+ `docs/wire-schema.md`. Keep all in sync; the conformance harness proves it.
3
+
4
+ Events are represented as plain dicts ``{"ts": int, "kind": str}`` so their JSON
5
+ form is exactly the wire form. `kind` is one of the lowercase EventKind values.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class EventKind:
12
+ """The `kind` field values (serialize lowercase, per the wire schema)."""
13
+
14
+ ACTIVE = "active"
15
+ IDLE = "idle"
16
+ LOCK = "lock"
17
+ UNLOCK = "unlock"
18
+ LOGIN = "login"
19
+ LOGOUT = "logout"
20
+ HEARTBEAT = "heartbeat"
21
+
22
+
23
+ def event(ts: int, kind: str) -> dict:
24
+ """A single back-dated activity event. `ts` is unix epoch milliseconds."""
25
+ return {"ts": ts, "kind": kind}
26
+
27
+
28
+ def machine_descriptor(hostname: str, os_name: str) -> dict:
29
+ return {"hostname": hostname, "os": os_name}
flexitracker/idle.py ADDED
@@ -0,0 +1,97 @@
1
+ """Idle-time and session-lock detection — mirror of `idle.rs`, via ctypes.
2
+
3
+ No input content is ever captured — only "how long since any input" and whether
4
+ the session is locked. Platform sources are selected at runtime; a simulated
5
+ source drives scripted runs and tests. Using ctypes (stdlib) means no compiled
6
+ extension and so no C toolchain at install time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ import sys
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass
17
+ class Sample:
18
+ idle_ms: int
19
+ locked: bool
20
+
21
+
22
+ class SimulatedIdle:
23
+ """Replays a scripted sequence of samples (scripted runs / tests)."""
24
+
25
+ def __init__(self, samples: list) -> None:
26
+ self._samples = samples
27
+ self._i = 0
28
+
29
+ def sample(self) -> Sample:
30
+ if self._i < len(self._samples):
31
+ s = self._samples[self._i]
32
+ else:
33
+ s = Sample(idle_ms=2**63 - 1, locked=False) # exhausted -> very idle
34
+ self._i += 1
35
+ return s
36
+
37
+
38
+ # XScreenSaverInfo: idle (unsigned long) is at byte offset 24 on LP64
39
+ # (window u64, state i32, kind i32, since u64, idle u64, ...).
40
+ _IDLE_OFFSET = 24
41
+
42
+
43
+ class LinuxIdle:
44
+ def __init__(self) -> None:
45
+ try:
46
+ x11 = ctypes.CDLL("libX11.so.6")
47
+ xss = ctypes.CDLL("libXss.so.1")
48
+ except OSError as e:
49
+ raise OSError(f"cannot load X libraries ({e}); set them up or use --simulate") from e
50
+
51
+ x11.XOpenDisplay.restype = ctypes.c_void_p
52
+ x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
53
+ x11.XDefaultRootWindow.restype = ctypes.c_ulong
54
+ x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
55
+ xss.XScreenSaverAllocInfo.restype = ctypes.c_void_p
56
+ xss.XScreenSaverQueryInfo.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_void_p]
57
+ xss.XScreenSaverQueryInfo.restype = ctypes.c_int
58
+
59
+ self._display = x11.XOpenDisplay(None)
60
+ if not self._display:
61
+ raise OSError("cannot open X display (set DISPLAY, or use --simulate)")
62
+ self._root = x11.XDefaultRootWindow(self._display)
63
+ self._info = xss.XScreenSaverAllocInfo()
64
+ self._query = xss.XScreenSaverQueryInfo
65
+
66
+ def sample(self) -> Sample:
67
+ self._query(self._display, self._root, self._info)
68
+ idle_ptr = ctypes.cast(self._info + _IDLE_OFFSET, ctypes.POINTER(ctypes.c_ulong))
69
+ return Sample(idle_ms=int(idle_ptr.contents.value), locked=False)
70
+
71
+
72
+ class _LASTINPUTINFO(ctypes.Structure):
73
+ _fields_ = [("cbSize", ctypes.c_uint), ("dwTime", ctypes.c_uint)]
74
+
75
+
76
+ class WindowsIdle:
77
+ def __init__(self) -> None:
78
+ self._user32 = ctypes.windll.user32 # type: ignore[attr-defined]
79
+ self._kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
80
+
81
+ def sample(self) -> Sample:
82
+ info = _LASTINPUTINFO()
83
+ info.cbSize = ctypes.sizeof(_LASTINPUTINFO)
84
+ if self._user32.GetLastInputInfo(ctypes.byref(info)) == 0:
85
+ raise OSError("GetLastInputInfo failed")
86
+ tick = self._kernel32.GetTickCount() & 0xFFFFFFFF
87
+ idle_ms = (tick - info.dwTime) & 0xFFFFFFFF # wrapping_sub, as u32
88
+ return Sample(idle_ms=idle_ms, locked=False)
89
+
90
+
91
+ def platform_source():
92
+ """Construct the real platform idle source, or fail fast with a clear message."""
93
+ if sys.platform.startswith("linux"):
94
+ return LinuxIdle()
95
+ if sys.platform == "win32":
96
+ return WindowsIdle()
97
+ raise OSError(f"no idle source for platform {sys.platform!r}; use --simulate")
flexitracker/outbox.py ADDED
@@ -0,0 +1,109 @@
1
+ """Durable local outbox — mirror of `outbox.rs`.
2
+
3
+ Events are buffered to disk so nothing is lost while offline; the whole queue is
4
+ flushed on reconnect, and a monotonic `batch_seq` lets the backend deduplicate
5
+ re-sent batches. The on-disk JSON format matches the Rust daemon's exactly so
6
+ either implementation can resume the other's outbox (proven by the harness).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ # Events older than the backend's edit window are rejected on arrival; holding
17
+ # them only grows the file.
18
+ MAX_EVENT_AGE_MS = 120 * 86_400_000
19
+
20
+ # Upper bound on one flush, so a long backlog drains across several acknowledged
21
+ # batches rather than one oversized request that can never succeed.
22
+ MAX_BATCH_EVENTS = 2_000
23
+
24
+
25
+ class Outbox:
26
+ def __init__(self, path: Path, next_seq: int, pending: list, machine: Optional[dict]) -> None:
27
+ self.path = path
28
+ self.next_seq = next_seq
29
+ self.pending = pending
30
+ self.machine = machine
31
+
32
+ @classmethod
33
+ def open(cls, path: Path) -> "Outbox":
34
+ try:
35
+ text = path.read_text(encoding="utf-8")
36
+ except FileNotFoundError:
37
+ return cls(path, 0, [], None)
38
+ try:
39
+ state = json.loads(text)
40
+ except json.JSONDecodeError as e:
41
+ # A torn file must not strand the daemon: refusing to start would
42
+ # hold buffered events hostage AND stop new capture. Move it aside so
43
+ # the contents stay recoverable, then start empty.
44
+ aside = path.with_suffix(".corrupt")
45
+ moved = False
46
+ try:
47
+ path.rename(aside)
48
+ moved = True
49
+ except OSError:
50
+ pass
51
+ extra = f" (previous contents kept at {aside})" if moved else ""
52
+ print(f"warning: outbox unreadable ({e}); starting with an empty queue{extra}")
53
+ return cls(path, 0, [], None)
54
+ return cls(
55
+ path,
56
+ state.get("next_seq", 0),
57
+ state.get("pending", []),
58
+ state.get("machine"),
59
+ )
60
+
61
+ def trim_expired(self, now: int) -> int:
62
+ """Drop events the backend would reject as too old. Returns how many went."""
63
+ before = len(self.pending)
64
+ self.pending = [e for e in self.pending if now - e["ts"] < MAX_EVENT_AGE_MS]
65
+ return before - len(self.pending)
66
+
67
+ def pending_len(self) -> int:
68
+ return len(self.pending)
69
+
70
+ def set_machine(self, machine: dict) -> None:
71
+ self.machine = machine
72
+ self._persist()
73
+
74
+ def append(self, events: list) -> None:
75
+ if not events:
76
+ return
77
+ self.pending.extend(events)
78
+ self._persist()
79
+
80
+ def next_batch(self) -> Optional[dict]:
81
+ """The next chunk to send (empty pending -> None). Includes the machine
82
+ descriptor on every batch until first successfully sent. Capped at
83
+ MAX_BATCH_EVENTS."""
84
+ if not self.pending:
85
+ return None
86
+ n = min(len(self.pending), MAX_BATCH_EVENTS)
87
+ return {
88
+ "batch_seq": self.next_seq,
89
+ "events": list(self.pending[:n]),
90
+ "machine": self.machine,
91
+ }
92
+
93
+ def ack(self) -> None:
94
+ """Drop exactly the events the just-sent batch carried and advance the
95
+ sequence, so the next chunk gets its own batch_seq."""
96
+ n = min(len(self.pending), MAX_BATCH_EVENTS)
97
+ del self.pending[:n]
98
+ self.next_seq += 1
99
+ self.machine = None
100
+ self._persist()
101
+
102
+ def _persist(self) -> None:
103
+ """Write via a temp file and rename (atomic within a directory), so a
104
+ reader never sees a torn file."""
105
+ self.path.parent.mkdir(parents=True, exist_ok=True)
106
+ state = {"next_seq": self.next_seq, "pending": self.pending, "machine": self.machine}
107
+ tmp = self.path.with_suffix(".tmp")
108
+ tmp.write_text(json.dumps(state), encoding="utf-8")
109
+ os.replace(tmp, self.path)
flexitracker/sender.py ADDED
@@ -0,0 +1,97 @@
1
+ """Thin HTTP client to the backend — mirror of `sender.rs`.
2
+
3
+ Post a batch, fetch thresholds, and the read-only whoami self-test. Uses stdlib
4
+ `urllib` so no HTTP dependency is pulled in. The wire form is exactly the schema
5
+ in `docs/wire-schema.md`; the black-box harness proves it matches the reference.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ from .config import ThresholdCfg
17
+
18
+
19
+ class SenderError(Exception):
20
+ pass
21
+
22
+
23
+ def _request(url: str, key: str, method: str, body: Optional[dict]) -> dict:
24
+ data = None
25
+ headers = {"authorization": f"Bearer {key}"}
26
+ if body is not None:
27
+ data = json.dumps(body).encode("utf-8")
28
+ headers["content-type"] = "application/json"
29
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
30
+ with urllib.request.urlopen(req) as resp: # noqa: S310 (fixed backend URL)
31
+ raw = resp.read()
32
+ return json.loads(raw) if raw else {}
33
+
34
+
35
+ def _ingest_url(base: str) -> str:
36
+ return f"{base.rstrip('/')}/ingest"
37
+
38
+
39
+ def post_batch(base: str, key: str, batch: dict) -> None:
40
+ """Post a batch. 2xx (including a duplicate ack) is success; anything else
41
+ raises so the caller keeps the batch queued for retry.
42
+
43
+ `machine` is omitted from the body when absent (never sent as null), per the
44
+ wire schema.
45
+ """
46
+ body = {"batch_seq": batch["batch_seq"], "events": batch["events"]}
47
+ if batch.get("machine") is not None:
48
+ body["machine"] = batch["machine"]
49
+ try:
50
+ _request(_ingest_url(base), key, "POST", body)
51
+ except urllib.error.HTTPError as e:
52
+ raise SenderError(f"server returned {e.code}") from e
53
+ except urllib.error.URLError as e:
54
+ raise SenderError(str(e.reason)) from e
55
+
56
+
57
+ @dataclass
58
+ class WhoAmI:
59
+ email: str
60
+ machine_label: Optional[str]
61
+ status: str
62
+ active: bool
63
+
64
+
65
+ def whoami(base: str, key: str) -> WhoAmI:
66
+ """Read-only account echo for the self-test. Sends/stores no activity data."""
67
+ url = f"{base.rstrip('/')}/whoami"
68
+ try:
69
+ data = _request(url, key, "GET", None)
70
+ except urllib.error.HTTPError as e:
71
+ if e.code == 401:
72
+ raise SenderError("key rejected (401)") from e
73
+ raise SenderError(f"server returned {e.code}") from e
74
+ except urllib.error.URLError as e:
75
+ raise SenderError(str(e.reason)) from e
76
+ return WhoAmI(
77
+ email=data["email"],
78
+ machine_label=data.get("machineLabel"),
79
+ status=data["status"],
80
+ active=data["active"],
81
+ )
82
+
83
+
84
+ def fetch_thresholds(base: str, key: str, poll_sec: int) -> ThresholdCfg:
85
+ """Fetch current thresholds from the backend (keeps the caller's poll)."""
86
+ url = f"{base.rstrip('/')}/config"
87
+ try:
88
+ data = _request(url, key, "GET", None)
89
+ except (urllib.error.HTTPError, urllib.error.URLError) as e:
90
+ reason = getattr(e, "code", None) or getattr(e, "reason", e)
91
+ raise SenderError(str(reason)) from e
92
+ return ThresholdCfg(
93
+ poll_sec=poll_sec,
94
+ min_inactivity_sec=data["minInactivitySec"],
95
+ min_activity_sec=data["minActivitySec"],
96
+ heartbeat_sec=data["heartbeatSec"],
97
+ )
@@ -0,0 +1,177 @@
1
+ """Debounced hysteresis state machine.
2
+
3
+ Any change here MUST land as a change to the behavioural vectors in
4
+ `tests/vectors/`, which are the daemon's oracle for this logic. The behaviour:
5
+ - idle is confirmed only after `min_inactivity` and emitted **back-dated** to the
6
+ last real input,
7
+ - return-to-active is confirmed only after `min_activity` of sustained input,
8
+ - periodic heartbeats bound crash/sleep damage,
9
+ - downtime (reboot/suspend/clock-step) is reconciled via monotonic-vs-wall
10
+ divergence and startup recover().
11
+
12
+ Pure: `step` takes an observation and returns events, with no I/O. All arithmetic
13
+ is integer milliseconds.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Optional
20
+
21
+ from .core import EventKind, event
22
+
23
+ # Input newer than this counts as "fresh" on a tick. Deliberately NOT poll_ms:
24
+ # freshness is "how recent must input be to mean the user is here", independent
25
+ # of how often we poll. See the Rust comment for the bug this prevents.
26
+ FRESH_INPUT_MS = 20_000
27
+
28
+ ACTIVE = "Active"
29
+ IDLE = "Idle"
30
+
31
+
32
+ @dataclass
33
+ class Thresholds:
34
+ poll_ms: int = 15_000
35
+ min_inactivity_ms: int = 10 * 60_000
36
+ min_activity_ms: int = 30_000
37
+ heartbeat_ms: int = 5 * 60_000
38
+
39
+
40
+ @dataclass
41
+ class Persisted:
42
+ """The persisted part — survives restarts so reboots reconcile (see recover).
43
+
44
+ Written on every poll; accurate to the poll interval and independent of the
45
+ network.
46
+ """
47
+
48
+ reported_state: str = IDLE
49
+ last_active_time: Optional[int] = None
50
+ last_heartbeat: Optional[int] = None
51
+ pending_active_since: Optional[int] = None
52
+ # Timestamp of the most recently emitted event; a backwards clock must never
53
+ # emit below it (spans are paired in ts order).
54
+ last_emitted_ts: Optional[int] = None
55
+ # Wall clock at the previous tick, differenced against monotonic elapsed time
56
+ # to detect a suspend. Only meaningful within a single process run.
57
+ last_seen_wall: Optional[int] = None
58
+
59
+
60
+ @dataclass
61
+ class Tick:
62
+ """One observation from the OS."""
63
+
64
+ now: int
65
+ idle_ms: int
66
+ locked: bool = False
67
+ # Monotonic ms since the previous tick; None on the first tick.
68
+ mono_elapsed_ms: Optional[int] = None
69
+
70
+
71
+ class StateMachine:
72
+ def __init__(self, thresholds: Thresholds, persisted: Persisted) -> None:
73
+ self.t = thresholds
74
+ self.p = persisted
75
+
76
+ def emit(self, ts: int, kind: str) -> dict:
77
+ """Emit an event, holding the monotonic-timestamp watermark. Clamping
78
+ here means no path can emit out of order."""
79
+ prev = self.p.last_emitted_ts
80
+ if prev is not None and ts < prev:
81
+ ts = prev
82
+ self.p.last_emitted_ts = ts
83
+ return event(ts, kind)
84
+
85
+ def input_fresh(self, tick: Tick) -> bool:
86
+ return (not tick.locked) and tick.idle_ms < FRESH_INPUT_MS
87
+
88
+ def last_evidence(self) -> Optional[int]:
89
+ """The most recent local evidence the user was here: the later of
90
+ heartbeat and last-active-time (last_active is written every poll)."""
91
+ h = self.p.last_heartbeat
92
+ a = self.p.last_active_time
93
+ if h is not None and a is not None:
94
+ return max(h, a)
95
+ return h if h is not None else a
96
+
97
+ def reconcile_gap(self, gap_ms: int) -> list:
98
+ """Reconcile an unobserved interval (reboot/crash/suspend). Gaps below
99
+ min_inactivity are absorbed; longer ones close the span back-dated to the
100
+ last local evidence. Shared by recover() and in-loop detection so they
101
+ cannot drift apart."""
102
+ if self.p.reported_state != ACTIVE or gap_ms < self.t.min_inactivity_ms:
103
+ return []
104
+ anchor = self.last_evidence()
105
+ if anchor is None:
106
+ return []
107
+ self.p.reported_state = IDLE
108
+ self.p.pending_active_since = None
109
+ return [self.emit(anchor, EventKind.IDLE)]
110
+
111
+ def recover(self, now: int) -> list:
112
+ """On startup, reconcile downtime the daemon could not observe. The gap
113
+ is measured from the last local evidence to now."""
114
+ a = self.last_evidence()
115
+ if a is None:
116
+ return []
117
+ return self.reconcile_gap(now - a)
118
+
119
+ def due_heartbeat(self, now: int) -> bool:
120
+ hb = self.p.last_heartbeat
121
+ if hb is None:
122
+ return True
123
+ return now - hb >= self.t.heartbeat_ms
124
+
125
+ def step(self, tick: Tick) -> list:
126
+ out: list = []
127
+
128
+ # Downtime the daemon slept through: wall advances across a suspend while
129
+ # the monotonic clock does not, so the difference isolates the frozen
130
+ # interval. Runs BEFORE state handling (fresh input on wake must not
131
+ # absorb the suspend). Backwards divergence is a clock correction, ignored.
132
+ if tick.mono_elapsed_ms is not None:
133
+ seen = self.p.last_seen_wall if self.p.last_seen_wall is not None else tick.now
134
+ divergence = (tick.now - seen) - tick.mono_elapsed_ms
135
+ if divergence > 0:
136
+ out.extend(self.reconcile_gap(divergence))
137
+ self.p.last_seen_wall = tick.now
138
+
139
+ fresh = self.input_fresh(tick)
140
+ if fresh:
141
+ self.p.last_active_time = tick.now
142
+
143
+ if self.p.reported_state == ACTIVE:
144
+ idle_long = tick.locked or tick.idle_ms >= self.t.min_inactivity_ms
145
+ if idle_long:
146
+ # Back-date idle to when input actually stopped. On the LOCK path
147
+ # keep the last-input anchor (reported idle at lock reflects the
148
+ # lock, not the user's last activity); otherwise now - idle_ms is
149
+ # the exact stop moment.
150
+ if tick.locked:
151
+ ts = self.p.last_active_time if self.p.last_active_time is not None else tick.now
152
+ else:
153
+ ts = tick.now - tick.idle_ms
154
+ out.append(self.emit(ts, EventKind.IDLE))
155
+ self.p.reported_state = IDLE
156
+ self.p.pending_active_since = None
157
+ elif self.due_heartbeat(tick.now):
158
+ out.append(self.emit(tick.now, EventKind.HEARTBEAT))
159
+ self.p.last_heartbeat = tick.now
160
+ else: # IDLE
161
+ # "Activity persisted for min_activity" must tolerate short pauses.
162
+ # The claim dies only when idle reaches min_activity itself; below
163
+ # that the accumulator keeps running.
164
+ if tick.locked or tick.idle_ms >= self.t.min_activity_ms:
165
+ self.p.pending_active_since = None
166
+ else:
167
+ # Start only on genuinely fresh input; once started, short pauses
168
+ # no longer reset it. Anchor to the real resume moment.
169
+ if fresh and self.p.pending_active_since is None:
170
+ self.p.pending_active_since = tick.now - tick.idle_ms
171
+ if self.p.pending_active_since is not None:
172
+ if tick.now - self.p.pending_active_since >= self.t.min_activity_ms:
173
+ out.append(self.emit(self.p.pending_active_since, EventKind.ACTIVE))
174
+ self.p.reported_state = ACTIVE
175
+ self.p.pending_active_since = None
176
+ self.p.last_heartbeat = tick.now
177
+ return out
@@ -0,0 +1,26 @@
1
+ """Runtime version resolution.
2
+
3
+ The package version is derived from the git tag by hatch-vcs at build time and
4
+ read back here from the installed distribution metadata. A **development** build
5
+ (an untagged checkout, or a build between tags) reports ``0.0.0`` so the version
6
+ string unambiguously distinguishes a real release from a dev run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import PackageNotFoundError, version as _dist_version
12
+
13
+
14
+ def get_version() -> str:
15
+ """Return the released version, or ``0.0.0`` for a development build.
16
+
17
+ A version carrying a development marker (`dev`) or a local segment (`+…`),
18
+ or a distribution with no metadata at all, is a development build.
19
+ """
20
+ try:
21
+ v = _dist_version("flexitracker")
22
+ except PackageNotFoundError:
23
+ return "0.0.0"
24
+ if "dev" in v or "+" in v:
25
+ return "0.0.0"
26
+ return v
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: flexitracker
3
+ Version: 0.2.1
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,15 @@
1
+ flexitracker/__init__.py,sha256=Ijk0VDI5_Kss_9DC-AaoXL-kz-EQQ7Pik1leMoKUmZg,420
2
+ flexitracker/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
3
+ flexitracker/_backend.py,sha256=nD5gFWdkExbN3Y2x4hMXH7X8dZ5kdq7_H7tqK_HrJHM,619
4
+ flexitracker/cli.py,sha256=gJFr9ymEQqGubiync1K1aco35OMYysz1i00keZgjFeM,9685
5
+ flexitracker/config.py,sha256=c8AFB0-LjP7bsiq04Gzay2sjujGidUiHccCFUuG9rkQ,3564
6
+ flexitracker/core.py,sha256=tffEFMkr_JeoOVIT48wC2vCaaXuMQkUedPWzs9qFC7I,862
7
+ flexitracker/idle.py,sha256=tSvPHIKLkvfx2Ec3qAg6-SHyT2NDNMeizjnwJPEKAsw,3477
8
+ flexitracker/outbox.py,sha256=uIxcavH7adASIXBZwiHE7Ajev37uzSPuO_grS3pyViI,4005
9
+ flexitracker/sender.py,sha256=BFS7n0u-FVVu9qWMsy4UaolgU0pQwoaI0VHWRE-tcVM,3230
10
+ flexitracker/state_machine.py,sha256=wqeqKGz8Sl-Hyw1Fxciu2SuEjP5eN5LgEwFa7dnWR8k,7215
11
+ flexitracker/version.py,sha256=dNIVXaHwLgB0iQc5CS4xF3Jq44lmTBURasN5pnhwkkc,895
12
+ flexitracker-0.2.1.dist-info/METADATA,sha256=pqlE2ElAdx36Ty0JwnRh-0QaVgY1tsa8l_-ejT9vPUM,1592
13
+ flexitracker-0.2.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ flexitracker-0.2.1.dist-info/entry_points.txt,sha256=39O8Xs-n_gy9XRyryRyzWqfbigxhmJNLUXSl8hsVd3U,55
15
+ flexitracker-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flexitracker = flexitracker.cli:main