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.
- flexitracker/__init__.py +9 -0
- flexitracker/__main__.py +6 -0
- flexitracker/_backend.py +16 -0
- flexitracker/cli.py +295 -0
- flexitracker/config.py +108 -0
- flexitracker/core.py +29 -0
- flexitracker/idle.py +97 -0
- flexitracker/outbox.py +109 -0
- flexitracker/sender.py +97 -0
- flexitracker/state_machine.py +177 -0
- flexitracker/version.py +26 -0
- flexitracker-0.2.1.dist-info/METADATA +48 -0
- flexitracker-0.2.1.dist-info/RECORD +15 -0
- flexitracker-0.2.1.dist-info/WHEEL +4 -0
- flexitracker-0.2.1.dist-info/entry_points.txt +2 -0
flexitracker/__init__.py
ADDED
|
@@ -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
|
+
"""
|
flexitracker/__main__.py
ADDED
flexitracker/_backend.py
ADDED
|
@@ -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
|
flexitracker/version.py
ADDED
|
@@ -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,,
|