data-hub-watcher 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {data_hub_watcher-0.2.2/src/data_hub_watcher.egg-info → data_hub_watcher-0.2.4}/PKG-INFO +1 -1
  2. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/pyproject.toml +1 -1
  3. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/service.py +89 -18
  4. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/updater.py +30 -1
  5. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/upgrade_worker.py +133 -1
  6. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4/src/data_hub_watcher.egg-info}/PKG-INFO +1 -1
  7. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_service.py +131 -12
  8. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_updater.py +245 -2
  9. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_upgrade_worker.py +189 -1
  10. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/LICENSE +0 -0
  11. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/README.md +0 -0
  12. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/setup.cfg +0 -0
  13. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/__init__.py +0 -0
  14. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/api_client.py +0 -0
  15. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/cli.py +0 -0
  16. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/config_io.py +0 -0
  17. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/constants.py +0 -0
  18. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/events.py +0 -0
  19. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/heartbeat.py +0 -0
  20. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/models.py +0 -0
  21. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/monitor.py +0 -0
  22. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/run_detector.py +0 -0
  23. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/runtime.py +0 -0
  24. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/scheduled_task.py +0 -0
  25. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/self_update.py +0 -0
  26. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/state.py +0 -0
  27. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/uploader.py +0 -0
  28. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/util.py +0 -0
  29. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/SOURCES.txt +0 -0
  30. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/dependency_links.txt +0 -0
  31. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/entry_points.txt +0 -0
  32. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/requires.txt +0 -0
  33. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/top_level.txt +0 -0
  34. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_cli_self_update.py +0 -0
  35. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_cli_service_reinstall.py +0 -0
  36. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_events.py +0 -0
  37. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_heartbeat.py +0 -0
  38. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_monitor_events.py +0 -0
  39. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_monitor_initial_scan.py +0 -0
  40. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_preview_environment.py +0 -0
  41. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_run_detection_config.py +0 -0
  42. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_run_detector.py +0 -0
  43. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_run_detector_hydration.py +0 -0
  44. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_runtime.py +0 -0
  45. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_scheduled_task.py +0 -0
  46. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_self_update.py +0 -0
  47. {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_uploader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-hub-watcher
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: File-watcher agent for lab instrument PCs that ingests data into Data Hub.
5
5
  Author-email: Arcadia Science <swe@arcadiascience.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "data-hub-watcher"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "File-watcher agent for lab instrument PCs that ingests data into Data Hub."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -113,13 +113,57 @@ def _install_upgrade_worker(config_dir: Path) -> None:
113
113
  request/result paths in at render time, so a mismatch here results
114
114
  in the worker reading from one directory while the service writes
115
115
  to another (and the auto-update silently no-ops every tick).
116
+
117
+ The operator's uv tool directories are resolved here (rather than
118
+ inside the worker) because the SYSTEM account that later executes
119
+ the worker resolves them against the wrong profile —
120
+ ``C:\\Windows\\System32\\config\\systemprofile\\.local\\...`` rather
121
+ than ``C:\\Users\\<op>\\.local\\...``. We capture the operator's
122
+ paths once at install time and bake them into the rendered
123
+ template via ``UV_TOOL_DIR`` / ``UV_TOOL_BIN_DIR`` overrides; see
124
+ :func:`data_hub_watcher.upgrade_worker.resolve_uv_tool_dirs` for
125
+ the long form of the rationale.
116
126
  """
117
127
  from data_hub_watcher.scheduled_task import install_upgrade_task
118
- from data_hub_watcher.upgrade_worker import write_worker_script
128
+ from data_hub_watcher.self_update import (
129
+ UvExecutableNotFoundError,
130
+ _resolve_uv_executable,
131
+ )
132
+ from data_hub_watcher.upgrade_worker import (
133
+ UvToolDirResolutionError,
134
+ resolve_uv_tool_dirs,
135
+ write_worker_script,
136
+ )
119
137
 
120
- script_path = write_worker_script(config_dir, service_name=SERVICE_NAME)
138
+ uv_path, _tried = _resolve_uv_executable()
139
+ if uv_path is None:
140
+ # The same `UvExecutableNotFoundError` the in-process updater
141
+ # raises — re-uses the operator-facing message format so a
142
+ # ``service install`` failure here looks identical to a stuck
143
+ # ``self-update``, which lab-PC docs already cover.
144
+ raise UvExecutableNotFoundError(_tried)
145
+ try:
146
+ tool_dir, tool_bin_dir = resolve_uv_tool_dirs(uv_path)
147
+ except UvToolDirResolutionError:
148
+ # Re-raise so ``service install`` aborts loudly. Letting the
149
+ # install proceed with default tool dirs (i.e. omitting the
150
+ # env-var overrides) would just reproduce the SYSTEM-installs-
151
+ # to-wrong-place bug we're trying to fix.
152
+ raise
153
+
154
+ script_path = write_worker_script(
155
+ config_dir,
156
+ service_name=SERVICE_NAME,
157
+ tool_dir=tool_dir,
158
+ tool_bin_dir=tool_bin_dir,
159
+ )
121
160
  install_upgrade_task(script_path)
122
- logger.info("Upgrade worker script + scheduled task registered under %s", config_dir)
161
+ logger.info(
162
+ "Upgrade worker script + scheduled task registered under %s (uv tool dir=%s, bin dir=%s)",
163
+ config_dir,
164
+ tool_dir,
165
+ tool_bin_dir,
166
+ )
123
167
 
124
168
 
125
169
  def _store_paths_in_registry(config_path: Path, env_path: Path) -> None:
@@ -400,25 +444,37 @@ def query_service_status() -> dict[str, Any]:
400
444
 
401
445
 
402
446
  def _repair_upgrade_worker_if_missing(sm: Any, config_dir: Path) -> None:
403
- """Re-install the upgrade scheduled task on startup if it has gone missing.
447
+ """Re-register the upgrade scheduled task on startup if it has gone missing.
404
448
 
405
449
  Lab PCs that auto-update into the version that introduced the
406
450
  out-of-process worker won't have run ``service install`` with the
407
451
  new code, so the scheduled task simply isn't there — and the next
408
452
  auto-update tick would fail loudly. Repairing on each service
409
453
  startup means the host self-heals on the very next service restart
410
- (which the auto-updater triggers anyway), without any operator
411
- intervention.
454
+ (which the auto-updater triggers anyway) for the common case where
455
+ the rendered worker script is still on disk and only the task
456
+ record was lost (e.g. an operator manually deleted it in
457
+ ``taskschd.msc``).
458
+
459
+ Critical scope limit: this helper does NOT re-render the worker
460
+ template. Re-rendering requires the *operator's* uv tool
461
+ directories from ``uv tool dir`` / ``uv tool dir --bin``, and the
462
+ service runs as LocalSystem so any uv invocation from here would
463
+ resolve those paths against the SYSTEM profile rather than the
464
+ operator's. A SYSTEM-rendered template would bake in the wrong
465
+ ``UV_TOOL_DIR`` / ``UV_TOOL_BIN_DIR`` and silently install future
466
+ upgrades into ``C:\\Windows\\System32\\config\\systemprofile\\
467
+ .local\\...`` instead of the operator's profile — exactly the bug
468
+ we're trying to prevent. If the rendered script is also missing
469
+ from *config_dir*, the only safe recovery is for the operator to
470
+ run ``data-hub-watcher service reinstall`` from their own shell;
471
+ we log a clear pointer and punt rather than render-against-SYSTEM
472
+ a worker that would corrupt the next auto-update.
412
473
 
413
474
  *config_dir* must be the operator's resolved config directory (the
414
- parent of the registry-stored config path) — NOT
415
- ``DEFAULT_CONFIG_DIR``, because under the LocalSystem account the
416
- service runs as the latter resolves to
417
- ``C:\\Windows\\System32\\config\\systemprofile\\.data-hub`` rather
418
- than the operator's ``~\\.data-hub``. Rendering the worker template
419
- against the wrong directory bakes in a sentinel path the running
420
- service will never actually write to, which silently breaks every
421
- subsequent auto-update.
475
+ parent of the registry-stored config path) — see the comment on
476
+ :class:`~data_hub_watcher.runtime.WatcherRuntime.config_dir` for
477
+ why ``DEFAULT_CONFIG_DIR`` would resolve incorrectly here.
422
478
 
423
479
  Failures are logged via the Windows event log but do not raise:
424
480
  a host that can't register the task should still be able to run
@@ -432,7 +488,7 @@ def _repair_upgrade_worker_if_missing(sm: Any, config_dir: Path) -> None:
432
488
  install_upgrade_task,
433
489
  task_exists,
434
490
  )
435
- from data_hub_watcher.upgrade_worker import write_worker_script
491
+ from data_hub_watcher.upgrade_worker import upgrade_worker_script_path
436
492
  except Exception as exc:
437
493
  sm.LogWarningMsg(f"Cannot import upgrade worker modules during startup: {exc}")
438
494
  return
@@ -450,12 +506,27 @@ def _repair_upgrade_worker_if_missing(sm: Any, config_dir: Path) -> None:
450
506
  if present:
451
507
  return
452
508
 
509
+ script_path = upgrade_worker_script_path(config_dir)
510
+ if not script_path.exists():
511
+ # We deliberately do NOT re-render here — see the docstring
512
+ # for why a SYSTEM-rendered template would silently break the
513
+ # very thing the repair is meant to fix.
514
+ sm.LogWarningMsg(
515
+ f"Upgrade scheduled task is missing AND the rendered worker "
516
+ f"script is absent from {script_path}. Cannot self-repair "
517
+ "from a LocalSystem context. Run "
518
+ "'data-hub-watcher service reinstall' as Administrator to "
519
+ "re-render the worker against the operator's uv tool "
520
+ "directories and re-register the task."
521
+ )
522
+ return
523
+
453
524
  sm.LogInfoMsg(
454
- "Upgrade scheduled task missing; re-registering it now (one-time "
455
- "self-repair after auto-update into worker-aware code)."
525
+ f"Upgrade scheduled task missing but worker script is present at "
526
+ f"{script_path}; re-registering the task pointing at the existing "
527
+ "rendered script."
456
528
  )
457
529
  try:
458
- script_path = write_worker_script(config_dir, service_name=SERVICE_NAME)
459
530
  install_upgrade_task(script_path)
460
531
  except (OSError, ScheduledTaskError) as exc:
461
532
  sm.LogWarningMsg(
@@ -85,6 +85,20 @@ class UpdaterConfig:
85
85
  # reason about heartbeat timing.
86
86
  min_run_age_seconds: float | None = None
87
87
  run_quiet_multiplier: int = DEFAULT_RUN_QUIET_MULTIPLIER
88
+ # When True (the production default), the very first heartbeat tick
89
+ # after process startup fires an ``/update-check`` API call instead
90
+ # of waiting a full ``check_interval_ticks`` window — so a fresh
91
+ # service restart picks up a pending release within one heartbeat
92
+ # interval (~60 s) rather than up to an hour. The activity-window
93
+ # gates inside ``should_attempt_update`` still apply: if an upload
94
+ # or run was happening when the previous instance died, the
95
+ # ``last_run_age_seconds`` / ``idle_ticks_required`` checks defer
96
+ # the actual upgrade attempt as designed. The on-start fast path
97
+ # only short-circuits the *cadence* part of the gate, not the
98
+ # *safety* part. Tests opt out via ``check_on_start=False`` so
99
+ # their existing cadence assertions (``for _ in range(N): on_tick();
100
+ # then trigger on the (N+1)th``) keep working unchanged.
101
+ check_on_start: bool = True
88
102
 
89
103
 
90
104
  @dataclass
@@ -336,7 +350,22 @@ class Updater:
336
350
  self._upgrade_executor = upgrade_executor or _default_upgrade_executor
337
351
 
338
352
  self._idle_ticks = 0
339
- self._ticks_since_check = 0
353
+ # Seed the cadence counter so the very first tick fires an
354
+ # ``/update-check`` API call when ``check_on_start`` is set
355
+ # (the production default). With ``check_interval_ticks=60``
356
+ # and a 60-second heartbeat, the cold-start latency for
357
+ # picking up a pending release drops from "up to 60 minutes"
358
+ # to "~60 seconds" — which matters most during testing
359
+ # iteration (operator runs ``service reinstall``, then sees
360
+ # the next attempt within one heartbeat instead of an hour)
361
+ # but is also a small operability win in production: a host
362
+ # that just rebooted picks up the latest version immediately
363
+ # rather than running stale code for the first hour after
364
+ # boot. The activity-window gates in ``should_attempt_update``
365
+ # still defer the actual upgrade if there's recent run /
366
+ # upload activity, so this short-circuits cadence only — not
367
+ # safety.
368
+ self._ticks_since_check = c.check_interval_ticks if c.check_on_start else 0
340
369
  self._upgrade_in_progress = False
341
370
  # Per-target memo for the "ineligible install method" refusal
342
371
  # path: we want one ``UPDATE_FAILED`` event per *new* server
@@ -40,6 +40,7 @@ from __future__ import annotations
40
40
  import json
41
41
  import logging
42
42
  import os
43
+ import subprocess
43
44
  import sys
44
45
  import uuid
45
46
  from dataclasses import dataclass
@@ -355,6 +356,82 @@ def build_pkg_spec(
355
356
  return spec
356
357
 
357
358
 
359
+ # ---------------------------------------------------------------------------
360
+ # uv tool directory resolution
361
+ # ---------------------------------------------------------------------------
362
+
363
+
364
+ class UvToolDirResolutionError(RuntimeError):
365
+ """Raised when ``uv tool dir`` / ``uv tool dir --bin`` can't be resolved.
366
+
367
+ Carries the failing argv and uv's stderr so the caller can surface a
368
+ diagnosable error rather than silently rendering a worker template
369
+ against an empty path.
370
+ """
371
+
372
+ def __init__(self, argv: list[str], *, stderr: str = "", returncode: int = -1) -> None:
373
+ super().__init__(
374
+ f"`{' '.join(argv)}` failed (exit {returncode}): {stderr.strip() or '<no stderr>'}"
375
+ )
376
+ self.argv = argv
377
+ self.stderr = stderr
378
+ self.returncode = returncode
379
+
380
+
381
+ def resolve_uv_tool_dirs(uv_executable: str) -> tuple[Path, Path]:
382
+ """Return ``(tool_dir, tool_bin_dir)`` for the operator's uv install.
383
+
384
+ Captured at install time so the worker template can bake them in
385
+ explicitly. The worker runs as LocalSystem via the scheduled task,
386
+ and uv resolves its install directories from ``$env:USERPROFILE``
387
+ plus the platform-specific defaults — under SYSTEM that resolves
388
+ to ``C:\\Windows\\System32\\config\\systemprofile\\.local\\share\\
389
+ uv\\tools`` (and ``…\\.local\\bin``), which is **not** where the
390
+ running watcher service was originally installed from. Without
391
+ this override uv happily installs a *second* copy of the package
392
+ into SYSTEM's profile while the operator's profile (where the
393
+ Windows-service registered ``ImagePath`` actually points) keeps
394
+ serving the old version. The next service restart loads the old
395
+ code and the marker comparison fails with "expected X, running Y".
396
+
397
+ By shelling out to ``uv tool dir`` from the install context (where
398
+ ``$env:USERPROFILE`` correctly points at the operator's home),
399
+ we capture the *operator's* tool directories and bake them in so
400
+ the SYSTEM-running worker can force ``uv`` to install in place at
401
+ the right location via ``UV_TOOL_DIR`` / ``UV_TOOL_BIN_DIR``.
402
+
403
+ Raises :class:`UvToolDirResolutionError` on a non-zero exit. The
404
+ caller (``_install_upgrade_worker``) treats this as fatal so a
405
+ silent miss surfaces during ``service install`` — by far the
406
+ cheapest place to catch it.
407
+ """
408
+ tool_dir = _run_uv_tool_dir(uv_executable, [])
409
+ tool_bin_dir = _run_uv_tool_dir(uv_executable, ["--bin"])
410
+ return tool_dir, tool_bin_dir
411
+
412
+
413
+ def _run_uv_tool_dir(uv_executable: str, extra_args: list[str]) -> Path:
414
+ argv = [uv_executable, "tool", "dir", *extra_args]
415
+ try:
416
+ proc = subprocess.run(
417
+ argv,
418
+ capture_output=True,
419
+ text=True,
420
+ check=False,
421
+ timeout=30,
422
+ )
423
+ except (OSError, subprocess.TimeoutExpired) as exc:
424
+ raise UvToolDirResolutionError(argv, stderr=str(exc)) from exc
425
+
426
+ if proc.returncode != 0:
427
+ raise UvToolDirResolutionError(argv, stderr=proc.stderr, returncode=proc.returncode)
428
+
429
+ raw = proc.stdout.strip()
430
+ if not raw:
431
+ raise UvToolDirResolutionError(argv, stderr="empty stdout", returncode=proc.returncode)
432
+ return Path(raw)
433
+
434
+
358
435
  # ---------------------------------------------------------------------------
359
436
  # PowerShell worker template
360
437
  # ---------------------------------------------------------------------------
@@ -385,6 +462,18 @@ $ServiceName = "{service_name}"
385
462
  $RequestPath = "{request_path}"
386
463
  $ResultPath = "{result_path}"
387
464
  $LogPath = "{log_path}"
465
+ # Operator-context paths captured at install time. The scheduled task
466
+ # runs as LocalSystem, so without these overrides `uv tool install`
467
+ # would resolve UV_TOOL_DIR / UV_TOOL_BIN_DIR against
468
+ # C:\\Windows\\System32\\config\\systemprofile\\.local\\... and install
469
+ # a SECOND copy of the package into SYSTEM's profile while the
470
+ # operator's tool venv (which the Windows-service ImagePath actually
471
+ # points at) is left at the old version. The env-var assignments
472
+ # below force uv to reinstall *in place* at the operator's tool dir
473
+ # so the existing service binary path resolves to the new wheel on
474
+ # the next service restart.
475
+ $ToolDir = "{tool_dir}"
476
+ $ToolBinDir = "{tool_bin_dir}"
388
477
 
389
478
  function Write-Log($msg) {{
390
479
  $ts = (Get-Date).ToUniversalTime().ToString("o")
@@ -393,8 +482,19 @@ function Write-Log($msg) {{
393
482
  }}
394
483
 
395
484
  function Write-ResultJson($payload) {{
485
+ # PowerShell 5.1 (the default on Windows 10/11) emits UTF-8 *with*
486
+ # a BOM when invoked as `Set-Content -Encoding UTF8`. Python's
487
+ # `json.loads(path.read_text(encoding="utf-8"))` then fails on the
488
+ # leading U+FEFF and `read_upgrade_result` silently swallows the
489
+ # JSONDecodeError, returning None. The post-restart inspection in
490
+ # runtime.py then flags `worker_result_missing=True` even though
491
+ # the file was written successfully — the operator sees no actual
492
+ # error context in the failure event and has to remote in to find
493
+ # it. Use the .NET API with an explicit BOM-less UTF-8 encoder so
494
+ # the result sentinel is readable on every supported PowerShell.
396
495
  $tmp = "$ResultPath.tmp"
397
- $payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $tmp -Encoding UTF8
496
+ $json = $payload | ConvertTo-Json -Depth 4
497
+ [System.IO.File]::WriteAllText($tmp, $json, [System.Text.UTF8Encoding]::new($false))
398
498
  Move-Item -LiteralPath $tmp -Destination $ResultPath -Force
399
499
  }}
400
500
 
@@ -453,6 +553,16 @@ try {{
453
553
  # the in-process path produce equivalent uv invocations.
454
554
  $uvArgs = @("tool", "install", "--reinstall", "--index-url", $IndexUrl, $PkgSpec)
455
555
 
556
+ # Pin uv's view of the tool directories to the operator's profile,
557
+ # not LocalSystem's. See the comment on `$ToolDir` / `$ToolBinDir`
558
+ # above for why this matters. We set them on the process
559
+ # environment so the subprocess `uv` inherits them; setting via
560
+ # `Start-Process -Environment` would be cleaner but is not
561
+ # available on Windows PowerShell 5.1.
562
+ $env:UV_TOOL_DIR = $ToolDir
563
+ $env:UV_TOOL_BIN_DIR = $ToolBinDir
564
+ Write-Log "uv env: UV_TOOL_DIR=$ToolDir UV_TOOL_BIN_DIR=$ToolBinDir"
565
+
456
566
  Write-Log "running: $UvExe $($uvArgs -join ' ')"
457
567
 
458
568
  $stdoutFile = [System.IO.Path]::GetTempFileName()
@@ -531,6 +641,8 @@ def render_worker_script(
531
641
  request_path: Path,
532
642
  result_path: Path,
533
643
  log_path: Path,
644
+ tool_dir: Path,
645
+ tool_bin_dir: Path,
534
646
  generated_at: str | None = None,
535
647
  ) -> str:
536
648
  """Render :data:`WORKER_SCRIPT_TEMPLATE` with concrete paths.
@@ -540,6 +652,13 @@ def render_worker_script(
540
652
  the request sentinel so a post-install relocation of ``uv.exe``
541
653
  doesn't require regenerating the script.
542
654
 
655
+ *tool_dir* and *tool_bin_dir* MUST be the paths reported by
656
+ ``uv tool dir`` / ``uv tool dir --bin`` from the operator's
657
+ install context. See :func:`resolve_uv_tool_dirs` for the full
658
+ rationale; the short version is that without these baked into
659
+ the SYSTEM-running worker, ``uv`` installs into the wrong profile
660
+ and the auto-update silently no-ops.
661
+
543
662
  The ``.upgrade-in-progress`` marker is intentionally not handed to
544
663
  the worker: its full lifecycle (write before dispatch, evaluate
545
664
  and clear on the next process start) lives on the Python side in
@@ -551,6 +670,8 @@ def render_worker_script(
551
670
  request_path=str(request_path),
552
671
  result_path=str(result_path),
553
672
  log_path=str(log_path),
673
+ tool_dir=str(tool_dir),
674
+ tool_bin_dir=str(tool_bin_dir),
554
675
  )
555
676
 
556
677
 
@@ -558,12 +679,21 @@ def write_worker_script(
558
679
  config_dir: Path,
559
680
  *,
560
681
  service_name: str,
682
+ tool_dir: Path,
683
+ tool_bin_dir: Path,
561
684
  generated_at: str | None = None,
562
685
  ) -> Path:
563
686
  """Render the worker script and drop it under *config_dir*.
564
687
 
565
688
  Returns the resulting path so callers (the service installer) can
566
689
  hand it to ``schtasks`` as the action target.
690
+
691
+ *tool_dir* / *tool_bin_dir* are required because rendering against
692
+ a default that resolves under LocalSystem (the account the worker
693
+ later runs as) silently produces a worker that installs to the
694
+ wrong profile. Forcing the caller to supply them at install time
695
+ — when the operator-context :func:`resolve_uv_tool_dirs` lookup
696
+ is available — keeps the failure mode loud rather than silent.
567
697
  """
568
698
  script_path = upgrade_worker_script_path(config_dir)
569
699
  script = render_worker_script(
@@ -571,6 +701,8 @@ def write_worker_script(
571
701
  request_path=upgrade_request_path(config_dir),
572
702
  result_path=upgrade_result_path(config_dir),
573
703
  log_path=upgrade_worker_log_path(config_dir),
704
+ tool_dir=tool_dir,
705
+ tool_bin_dir=tool_bin_dir,
574
706
  generated_at=generated_at,
575
707
  )
576
708
  script_path.parent.mkdir(parents=True, exist_ok=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-hub-watcher
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: File-watcher agent for lab instrument PCs that ingests data into Data Hub.
5
5
  Author-email: Arcadia Science <swe@arcadiascience.com>
6
6
  License-Expression: MIT
@@ -303,6 +303,21 @@ class TestInstallUpgradeWorker:
303
303
  "data_hub_watcher.scheduled_task.install_upgrade_task",
304
304
  lambda script_path: install_calls.append(script_path),
305
305
  )
306
+ # `_install_upgrade_worker` resolves the operator's uv path
307
+ # and tool dirs at install time so the SYSTEM-running worker
308
+ # later forces uv to install in place at the operator's
309
+ # profile via UV_TOOL_DIR / UV_TOOL_BIN_DIR overrides. Stub
310
+ # both so the test doesn't depend on a real uv install.
311
+ operator_tool_dir = tmp_path / "tools"
312
+ operator_tool_bin_dir = tmp_path / "bin"
313
+ monkeypatch.setattr(
314
+ "data_hub_watcher.self_update._resolve_uv_executable",
315
+ lambda: (str(tmp_path / "uv.exe"), [str(tmp_path / "uv.exe")]),
316
+ )
317
+ monkeypatch.setattr(
318
+ "data_hub_watcher.upgrade_worker.resolve_uv_tool_dirs",
319
+ lambda _uv: (operator_tool_dir, operator_tool_bin_dir),
320
+ )
306
321
 
307
322
  service_module._install_upgrade_worker(tmp_path)
308
323
 
@@ -315,19 +330,76 @@ class TestInstallUpgradeWorker:
315
330
 
316
331
  script_path = upgrade_worker_script_path(tmp_path)
317
332
  assert script_path.exists()
318
- assert "Stop-Service" in script_path.read_text(encoding="utf-8")
333
+ rendered = script_path.read_text(encoding="utf-8")
334
+ assert "Stop-Service" in rendered
319
335
  # The rendered template MUST bake in sentinel paths under
320
336
  # the supplied config dir — anything else means the running
321
337
  # service (which writes sentinels to its own resolved
322
338
  # ``config_dir``) would write to a different directory than
323
339
  # the worker reads from, and every auto-update silently
324
340
  # no-ops with "no request sentinel".
325
- rendered = script_path.read_text(encoding="utf-8")
326
341
  assert str(tmp_path / ".upgrade-request.json") in rendered
327
342
  assert str(tmp_path / ".upgrade-result.json") in rendered
343
+ # The operator's tool dirs must be baked in too — without the
344
+ # UV_TOOL_DIR override the SYSTEM-running worker installs to
345
+ # the wrong profile and the upgrade silently no-ops at the
346
+ # next service restart even though uv exited 0.
347
+ assert str(operator_tool_dir) in rendered
348
+ assert str(operator_tool_bin_dir) in rendered
349
+ assert "$env:UV_TOOL_DIR" in rendered
350
+ assert "$env:UV_TOOL_BIN_DIR" in rendered
328
351
  # Task installer was handed the same path.
329
352
  assert install_calls == [script_path]
330
353
 
354
+ def test_install_raises_when_uv_cannot_be_located(
355
+ self,
356
+ service_module: ModuleType,
357
+ monkeypatch: pytest.MonkeyPatch,
358
+ tmp_path: Path,
359
+ ) -> None:
360
+ # A `service install` on a host without uv on PATH and without
361
+ # uv in any of the sys.prefix-derived candidates must fail
362
+ # loudly here — proceeding would render a template against
363
+ # default tool dirs, which under SYSTEM would silently install
364
+ # future upgrades into the wrong profile.
365
+ from data_hub_watcher.self_update import UvExecutableNotFoundError
366
+
367
+ monkeypatch.setattr(
368
+ "data_hub_watcher.self_update._resolve_uv_executable",
369
+ lambda: (None, ["/no/such/uv"]),
370
+ )
371
+
372
+ with pytest.raises(UvExecutableNotFoundError):
373
+ service_module._install_upgrade_worker(tmp_path)
374
+
375
+ def test_install_raises_when_uv_tool_dir_lookup_fails(
376
+ self,
377
+ service_module: ModuleType,
378
+ monkeypatch: pytest.MonkeyPatch,
379
+ tmp_path: Path,
380
+ ) -> None:
381
+ # Same loud-failure rule for the second half of the lookup:
382
+ # if `uv tool dir` itself can't tell us where the operator's
383
+ # tool venvs live, we have no business rendering a worker
384
+ # template that pretends to know.
385
+ from data_hub_watcher.upgrade_worker import UvToolDirResolutionError
386
+
387
+ monkeypatch.setattr(
388
+ "data_hub_watcher.self_update._resolve_uv_executable",
389
+ lambda: (str(tmp_path / "uv.exe"), [str(tmp_path / "uv.exe")]),
390
+ )
391
+
392
+ def boom(_uv: str) -> tuple[Path, Path]:
393
+ raise UvToolDirResolutionError(["uv", "tool", "dir"], stderr="nope")
394
+
395
+ monkeypatch.setattr(
396
+ "data_hub_watcher.upgrade_worker.resolve_uv_tool_dirs",
397
+ boom,
398
+ )
399
+
400
+ with pytest.raises(UvToolDirResolutionError):
401
+ service_module._install_upgrade_worker(tmp_path)
402
+
331
403
  def test_uninstall_removes_task_and_script(
332
404
  self,
333
405
  service_module: ModuleType,
@@ -401,12 +473,22 @@ class TestRepairUpgradeWorkerOnStartup:
401
473
 
402
474
  assert install_calls == []
403
475
 
404
- def test_repair_re_registers_when_task_missing(
476
+ def test_repair_re_registers_when_task_missing_and_script_present(
405
477
  self,
406
478
  service_module: ModuleType,
407
479
  monkeypatch: pytest.MonkeyPatch,
408
480
  tmp_path: Path,
409
481
  ) -> None:
482
+ # A pre-existing rendered script means a previous valid
483
+ # `service install` ran in operator context and captured the
484
+ # right uv tool dirs. Re-registering the task to point at
485
+ # that script is the safe self-repair — no need to re-render.
486
+ from data_hub_watcher.upgrade_worker import upgrade_worker_script_path
487
+
488
+ script_path = upgrade_worker_script_path(tmp_path)
489
+ script_path.parent.mkdir(parents=True, exist_ok=True)
490
+ script_path.write_text("# previously rendered worker", encoding="utf-8")
491
+
410
492
  sm = MagicMock(name="servicemanager")
411
493
  monkeypatch.setattr("data_hub_watcher.scheduled_task.task_exists", lambda: False)
412
494
  install_calls: list[Path] = []
@@ -417,16 +499,46 @@ class TestRepairUpgradeWorkerOnStartup:
417
499
 
418
500
  service_module._repair_upgrade_worker_if_missing(sm, tmp_path)
419
501
 
420
- from data_hub_watcher.upgrade_worker import upgrade_worker_script_path
502
+ # Task is re-registered against the existing script.
503
+ assert install_calls == [script_path]
504
+ # CRITICAL: existing script content is NOT overwritten — the
505
+ # operator's UV_TOOL_DIR / UV_TOOL_BIN_DIR baked at install
506
+ # time must be preserved. Re-rendering under SYSTEM (which
507
+ # is what runs the repair) would resolve the wrong tool dirs
508
+ # and silently break future auto-updates.
509
+ assert script_path.read_text(encoding="utf-8") == "# previously rendered worker"
510
+
511
+ def test_repair_punts_when_task_and_script_both_missing(
512
+ self,
513
+ service_module: ModuleType,
514
+ monkeypatch: pytest.MonkeyPatch,
515
+ tmp_path: Path,
516
+ ) -> None:
517
+ # Without a rendered script on disk we have no way to
518
+ # reconstruct the operator's UV_TOOL_DIR from a SYSTEM
519
+ # context. Re-rendering with whatever LocalSystem's
520
+ # `uv tool dir` would return bakes in the wrong profile and
521
+ # silently corrupts future auto-updates. The only safe move
522
+ # is to log a clear pointer at `service reinstall` and punt;
523
+ # the next auto-update tick will fail loudly with a usable
524
+ # error.
525
+ sm = MagicMock(name="servicemanager")
526
+ monkeypatch.setattr("data_hub_watcher.scheduled_task.task_exists", lambda: False)
527
+ install_calls: list[Path] = []
528
+ monkeypatch.setattr(
529
+ "data_hub_watcher.scheduled_task.install_upgrade_task",
530
+ lambda script_path: install_calls.append(script_path),
531
+ )
421
532
 
422
- # The repair must render against the supplied *config_dir*,
423
- # not ``DEFAULT_CONFIG_DIR`` — this is the regression guard
424
- # for the "service runs as LocalSystem so ~ resolves to the
425
- # SYSTEM profile" failure mode.
426
- assert install_calls == [upgrade_worker_script_path(tmp_path)]
427
- assert upgrade_worker_script_path(tmp_path).exists()
428
- rendered = upgrade_worker_script_path(tmp_path).read_text(encoding="utf-8")
429
- assert str(tmp_path / ".upgrade-request.json") in rendered
533
+ service_module._repair_upgrade_worker_if_missing(sm, tmp_path)
534
+
535
+ # No re-registration happened punted to operator action.
536
+ assert install_calls == []
537
+ # …and the operator-facing message points at the recovery
538
+ # command, not a bare error code.
539
+ sm.LogWarningMsg.assert_called_once()
540
+ msg = sm.LogWarningMsg.call_args.args[0]
541
+ assert "service reinstall" in msg
430
542
 
431
543
  def test_repair_swallows_query_errors(
432
544
  self,
@@ -457,6 +569,13 @@ class TestRepairUpgradeWorkerOnStartup:
457
569
  tmp_path: Path,
458
570
  ) -> None:
459
571
  from data_hub_watcher.scheduled_task import ScheduledTaskError
572
+ from data_hub_watcher.upgrade_worker import upgrade_worker_script_path
573
+
574
+ # Need an existing script for the repair to attempt task
575
+ # registration; the failure is on `install_upgrade_task`.
576
+ script_path = upgrade_worker_script_path(tmp_path)
577
+ script_path.parent.mkdir(parents=True, exist_ok=True)
578
+ script_path.write_text("# previously rendered worker", encoding="utf-8")
460
579
 
461
580
  sm = MagicMock(name="servicemanager")
462
581
  monkeypatch.setattr("data_hub_watcher.scheduled_task.task_exists", lambda: False)
@@ -147,6 +147,15 @@ def _make_updater(
147
147
  idle_ticks_required=2,
148
148
  check_interval_ticks=3,
149
149
  min_run_age_seconds=10.0,
150
+ # Opt out of the production-default "fire /update-check
151
+ # on the very first tick" fast path so the existing
152
+ # ``for _ in range(N): on_tick(); then trigger on the
153
+ # (N+1)th tick`` cadence assertions throughout this file
154
+ # keep working unchanged. The on-start fast path has its
155
+ # own dedicated test class below — pinning the default-on
156
+ # behaviour separately keeps each test focused on the
157
+ # specific behaviour it's exercising.
158
+ check_on_start=False,
150
159
  ),
151
160
  upgrade_runner=upgrade_runner,
152
161
  upgrade_executor=upgrade_executor,
@@ -328,9 +337,16 @@ class TestUpdaterOnTick:
328
337
  h.request_restart.assert_not_called()
329
338
  h.reporter.queue_event.assert_not_called()
330
339
 
331
- def test_first_ticks_are_no_op_until_check_interval(self, tmp_path: Path) -> None:
340
+ def test_first_ticks_are_no_op_until_check_interval_when_check_on_start_disabled(
341
+ self, tmp_path: Path
342
+ ) -> None:
343
+ # The default test helper opts out of the on-start fast path
344
+ # (see comment in ``_make_updater``) so this still pins the
345
+ # cadence-only behaviour: with ``check_interval_ticks=3``,
346
+ # ticks 1 and 2 are no-ops and tick 3 fires the API call.
347
+ # The on-start fast path's effect on this same shape is
348
+ # pinned in ``TestUpdaterChecksOnStart`` below.
332
349
  h = _make_updater(tmp_path)
333
- # Below check_interval_ticks=3 we should not call the API at all.
334
350
  h.counters.last_files_uploaded = 0
335
351
  assert h.updater.on_tick() is None
336
352
  assert h.updater.on_tick() is None
@@ -345,6 +361,10 @@ class TestUpdaterOnTick:
345
361
  idle_ticks_required=2,
346
362
  check_interval_ticks=2,
347
363
  min_run_age_seconds=1.0,
364
+ # See helper-level comment in ``_make_updater`` for
365
+ # why cadence-focused tests opt out of the on-start
366
+ # fast path.
367
+ check_on_start=False,
348
368
  ),
349
369
  )
350
370
  h.client.get_update_info.return_value = _info(latest="9.9.9")
@@ -386,6 +406,12 @@ class TestUpdaterOnTick:
386
406
  idle_ticks_required=0,
387
407
  check_interval_ticks=1, # fire on every tick
388
408
  min_run_age_seconds=0.0,
409
+ # ``check_interval_ticks=1`` already fires on every
410
+ # tick so the on-start fast path would be a no-op
411
+ # here anyway, but pin the opt-out so the test's
412
+ # "exactly 3 invocations → exactly 3 failures"
413
+ # arithmetic stays explicit.
414
+ check_on_start=False,
389
415
  ),
390
416
  )
391
417
  h.client.get_update_info.side_effect = ApiError("dead", status_code=502)
@@ -414,6 +440,10 @@ class TestUpdaterOnTick:
414
440
  idle_ticks_required=0,
415
441
  check_interval_ticks=1,
416
442
  min_run_age_seconds=0.0,
443
+ # See sibling test for why we pin the opt-out even
444
+ # though check_interval_ticks=1 makes the on-start
445
+ # fast path effectively a no-op here.
446
+ check_on_start=False,
417
447
  ),
418
448
  )
419
449
  # 2 failures, then a success, then 2 more failures.
@@ -769,6 +799,219 @@ class TestUpdaterOnTick:
769
799
  assert all(e.details["install_method"] == "unknown" for e in emitted)
770
800
 
771
801
 
802
+ class TestUpdaterChecksOnStart:
803
+ """Pin the production-default ``check_on_start=True`` behaviour.
804
+
805
+ Why this matters: lab PCs only run ``/update-check`` once an hour
806
+ by default, so a fresh ``service install`` / ``service reinstall``
807
+ / boot would otherwise wait up to 60 minutes before noticing a
808
+ pending release. That latency is fine in steady state but
809
+ disastrous during fix-forward iteration on the auto-update path
810
+ itself: the operator pushes a release, manually reinstalls the
811
+ watcher, then twiddles their thumbs for an hour to see if the
812
+ next auto-update tick works. Seeding ``_ticks_since_check`` to
813
+ ``check_interval_ticks`` at construction collapses that wait to
814
+ one heartbeat (~60 s) without disturbing the steady-state
815
+ cadence — and without bypassing the activity-window safety
816
+ gates, which still defer the actual upgrade dispatch as designed.
817
+ """
818
+
819
+ def _make_updater_with_on_start(
820
+ self,
821
+ tmp_path: Path,
822
+ *,
823
+ idle_ticks_required: int = 0,
824
+ check_interval_ticks: int = 60,
825
+ last_run_age_seconds: float | None = None,
826
+ ) -> _UpdaterHarness:
827
+ # ``check_on_start=True`` is the production default but the
828
+ # default helper opts out, so build directly here. Tests in
829
+ # this class want the production wiring exactly.
830
+ h = _make_updater(
831
+ tmp_path,
832
+ updater_cfg=UpdaterConfig(
833
+ idle_ticks_required=idle_ticks_required,
834
+ check_interval_ticks=check_interval_ticks,
835
+ min_run_age_seconds=10.0,
836
+ check_on_start=True,
837
+ ),
838
+ )
839
+ if last_run_age_seconds is not None:
840
+ from datetime import datetime, timedelta, timezone
841
+
842
+ # ``state_db.last_run_reported_at`` returns an ISO-8601
843
+ # string (it's a SQLite TEXT column), not a datetime —
844
+ # ``_last_run_age_seconds`` parses it via ``fromisoformat``.
845
+ past = datetime.now(timezone.utc) - timedelta(seconds=last_run_age_seconds)
846
+ h.state_db.last_run_reported_at.return_value = past.isoformat()
847
+ return h
848
+
849
+ def test_default_config_enables_check_on_start(self) -> None:
850
+ # Pin the production default explicitly so a future refactor
851
+ # that flips the default to False (and only updates the
852
+ # docstring) trips here rather than silently regressing the
853
+ # cold-start latency back to ~60 minutes.
854
+ cfg = UpdaterConfig()
855
+ assert cfg.check_on_start is True
856
+
857
+ def test_first_tick_fires_update_check_immediately(self, tmp_path: Path) -> None:
858
+ # The headline behaviour: with the production-default
859
+ # ``check_on_start=True`` and the production-default
860
+ # ``check_interval_ticks=60``, the very first tick after
861
+ # construction calls ``/update-check`` rather than waiting 59
862
+ # more ticks. Without this, an operator who just ran
863
+ # ``service reinstall`` to apply a fix-forward release would
864
+ # have to wait up to an hour to confirm whether the fix
865
+ # actually unblocked the auto-update path.
866
+ h = self._make_updater_with_on_start(tmp_path)
867
+ h.client.get_update_info.return_value = _info(latest="0.0.0+unknown")
868
+ h.counters.last_files_uploaded = 0
869
+
870
+ result = h.updater.on_tick()
871
+
872
+ assert result is not None, (
873
+ "first tick must call /update-check when check_on_start=True; "
874
+ "got None which means we waited for the cadence window"
875
+ )
876
+ h.client.get_update_info.assert_called_once()
877
+
878
+ def test_subsequent_ticks_resume_normal_cadence(self, tmp_path: Path) -> None:
879
+ # The on-start fast path is one-shot: tick 1 fires, then
880
+ # ticks 2..(check_interval_ticks) are no-ops, then the
881
+ # check_interval_ticks+1-th tick fires again. Otherwise we'd
882
+ # be calling /update-check every single heartbeat which
883
+ # would 60x the load on the watcher-update endpoint.
884
+ h = self._make_updater_with_on_start(tmp_path, check_interval_ticks=3)
885
+ h.client.get_update_info.return_value = _info(latest="0.0.0+unknown")
886
+ h.counters.last_files_uploaded = 0
887
+
888
+ # Tick 1 — on-start fast path fires.
889
+ assert h.updater.on_tick() is not None
890
+ assert h.client.get_update_info.call_count == 1
891
+
892
+ # Ticks 2 and 3 — within the cadence window after the reset.
893
+ assert h.updater.on_tick() is None
894
+ assert h.updater.on_tick() is None
895
+ assert h.client.get_update_info.call_count == 1
896
+
897
+ # Tick 4 — cadence window elapsed, fires again.
898
+ assert h.updater.on_tick() is not None
899
+ assert h.client.get_update_info.call_count == 2
900
+
901
+ def test_opt_out_preserves_pre_existing_cadence(self, tmp_path: Path) -> None:
902
+ # The opt-out path must produce exactly the historical
903
+ # behaviour: ticks 1 and 2 are no-ops with check_interval=3,
904
+ # tick 3 fires the API call. This is what every other test
905
+ # class in this file relies on, so a regression here would
906
+ # cascade into many false failures.
907
+ h = _make_updater(
908
+ tmp_path,
909
+ updater_cfg=UpdaterConfig(
910
+ idle_ticks_required=0,
911
+ check_interval_ticks=3,
912
+ min_run_age_seconds=10.0,
913
+ check_on_start=False,
914
+ ),
915
+ )
916
+ h.client.get_update_info.return_value = _info(latest="0.0.0+unknown")
917
+ h.counters.last_files_uploaded = 0
918
+
919
+ assert h.updater.on_tick() is None
920
+ assert h.updater.on_tick() is None
921
+ assert h.client.get_update_info.call_count == 0
922
+ assert h.updater.on_tick() is not None
923
+ assert h.client.get_update_info.call_count == 1
924
+
925
+ def test_on_start_check_still_respects_idle_gate(self, tmp_path: Path) -> None:
926
+ # Critical safety pin: the on-start fast path short-circuits
927
+ # the *cadence* window, NOT the activity-window safety gate.
928
+ # If the operator restarts the service while uploads are
929
+ # actively flowing, the first tick should DO the API call
930
+ # (we want the operator to see "yes, an upgrade is pending"
931
+ # in the logs / dashboard immediately) but DEFER the actual
932
+ # upgrade dispatch until the upload activity quiets down —
933
+ # exactly as the steady-state cadence would. Without this
934
+ # pin a future refactor could conflate the two and start
935
+ # interrupting in-flight uploads on every restart.
936
+ h = self._make_updater_with_on_start(
937
+ tmp_path,
938
+ idle_ticks_required=5,
939
+ check_interval_ticks=60,
940
+ )
941
+ h.client.get_update_info.return_value = _info(latest="9.9.9")
942
+ # Simulate active upload activity: the heartbeat reports
943
+ # files uploaded on this tick.
944
+ h.counters.last_files_uploaded = 3
945
+
946
+ result = h.updater.on_tick()
947
+
948
+ # API was called (the fast path's job)…
949
+ h.client.get_update_info.assert_called_once()
950
+ # …but the dispatch was deferred (the safety gate's job).
951
+ assert result is not None
952
+ assert result.attempted is False
953
+ assert "idle ticks" in result.reason
954
+ h.request_restart.assert_not_called()
955
+
956
+ def test_on_start_check_still_respects_recent_run_gate(self, tmp_path: Path) -> None:
957
+ # Same shape as the idle-gate pin above, but for the
958
+ # ``last_run_age_seconds`` half of the activity-window
959
+ # check. A run reported 5 seconds before service restart
960
+ # (operator restarted mid-experiment to apply a fix) must
961
+ # NOT trigger an immediate upgrade just because the on-start
962
+ # fast path enabled the API call.
963
+ h = self._make_updater_with_on_start(
964
+ tmp_path,
965
+ idle_ticks_required=0, # Idle gate disabled.
966
+ last_run_age_seconds=5.0, # Recent run, gate active.
967
+ )
968
+ h.client.get_update_info.return_value = _info(latest="9.9.9")
969
+ h.counters.last_files_uploaded = 0
970
+
971
+ result = h.updater.on_tick()
972
+
973
+ h.client.get_update_info.assert_called_once()
974
+ assert result is not None
975
+ assert result.attempted is False
976
+ assert "recent run" in result.reason
977
+ h.request_restart.assert_not_called()
978
+
979
+ def test_on_start_check_dispatches_when_safety_gates_pass(
980
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
981
+ ) -> None:
982
+ # The flip side of the safety pins above: when nothing's
983
+ # happening (clean restart on an idle host with no recent
984
+ # runs and no pending uploads — the common case), the
985
+ # on-start fast path SHOULD result in an immediate dispatch.
986
+ # This is the behaviour the operator-facing testing-iteration
987
+ # win actually depends on.
988
+ runner = MagicMock(return_value=_success_completed_process())
989
+ h = _make_updater(
990
+ tmp_path,
991
+ updater_cfg=UpdaterConfig(
992
+ idle_ticks_required=0,
993
+ check_interval_ticks=60,
994
+ min_run_age_seconds=10.0,
995
+ check_on_start=True,
996
+ ),
997
+ upgrade_runner=runner,
998
+ )
999
+ h.client.get_update_info.return_value = _info(latest="9.9.9")
1000
+ h.state_db.last_run_reported_at.return_value = None
1001
+ h.counters.last_files_uploaded = 0
1002
+
1003
+ monkeypatch.setattr(
1004
+ "data_hub_watcher.updater.detect_install_method",
1005
+ lambda: InstallMethod.UV_TOOL,
1006
+ )
1007
+
1008
+ result = h.updater.on_tick()
1009
+
1010
+ h.request_restart.assert_called_once()
1011
+ assert result is not None
1012
+ assert result.attempted is True
1013
+
1014
+
772
1015
  class TestUpdaterAsyncDispatch:
773
1016
  """The upgrade subprocess runs off the heartbeat thread.
774
1017
 
@@ -10,6 +10,7 @@ that breaks the contract surfaces here rather than as a stuck
10
10
 
11
11
  from __future__ import annotations
12
12
  import json
13
+ import subprocess
13
14
  from pathlib import Path
14
15
 
15
16
  import pytest
@@ -259,6 +260,8 @@ class TestRenderWorkerScript:
259
260
  request_path=tmp_path / ".upgrade-request.json",
260
261
  result_path=tmp_path / ".upgrade-result.json",
261
262
  log_path=tmp_path / "upgrade-worker.log",
263
+ tool_dir=Path(r"C:\Users\op\AppData\Roaming\uv\tools"),
264
+ tool_bin_dir=Path(r"C:\Users\op\.local\bin"),
262
265
  generated_at="2026-05-04T22:30:00+00:00",
263
266
  )
264
267
 
@@ -313,7 +316,12 @@ class TestRenderWorkerScript:
313
316
  assert "Start-Service" in finally_block
314
317
 
315
318
  def test_write_worker_script_drops_file_at_expected_path(self, tmp_path: Path) -> None:
316
- path = write_worker_script(tmp_path, service_name="DataHubWatcher")
319
+ path = write_worker_script(
320
+ tmp_path,
321
+ service_name="DataHubWatcher",
322
+ tool_dir=Path(r"C:\Users\op\AppData\Roaming\uv\tools"),
323
+ tool_bin_dir=Path(r"C:\Users\op\.local\bin"),
324
+ )
317
325
  assert path == upgrade_worker_script_path(tmp_path)
318
326
  assert path.exists()
319
327
  assert path.name == UPGRADE_WORKER_SCRIPT_FILENAME
@@ -370,6 +378,90 @@ class TestRenderWorkerScript:
370
378
  # uv emits on the host.
371
379
  assert "`r?`n" in script or "\\r?\\n" in script
372
380
 
381
+ def test_uv_tool_dir_overrides_are_baked_in(self, tmp_path: Path) -> None:
382
+ # Regression guard for the silent "uv installed into SYSTEM
383
+ # profile" failure mode: the worker runs as LocalSystem via
384
+ # the scheduled task, and uv resolves UV_TOOL_DIR /
385
+ # UV_TOOL_BIN_DIR from $env:USERPROFILE which under SYSTEM
386
+ # is C:\Windows\System32\config\systemprofile\.local\... —
387
+ # NOT where the running watcher service was originally
388
+ # installed from. The rendered worker MUST set both env
389
+ # vars to the operator-context paths captured at install
390
+ # time so uv reinstalls in place at the operator's tool
391
+ # dir; otherwise the upgrade succeeds (uv exits 0) but
392
+ # produces a SECOND copy of the package that the running
393
+ # service never loads, and the marker comparison fails
394
+ # with "expected X, running Y" on the next restart.
395
+ script = render_worker_script(
396
+ service_name="DataHubWatcher",
397
+ request_path=tmp_path / ".upgrade-request.json",
398
+ result_path=tmp_path / ".upgrade-result.json",
399
+ log_path=tmp_path / "upgrade-worker.log",
400
+ tool_dir=Path(r"C:\Users\op\AppData\Roaming\uv\tools"),
401
+ tool_bin_dir=Path(r"C:\Users\op\.local\bin"),
402
+ generated_at="2026-05-04T22:30:00+00:00",
403
+ )
404
+
405
+ # The literal paths are baked in as PowerShell variables…
406
+ assert r'$ToolDir = "C:\Users\op\AppData\Roaming\uv\tools"' in script
407
+ assert r'$ToolBinDir = "C:\Users\op\.local\bin"' in script
408
+ # …and assigned to the env vars uv actually consults BEFORE
409
+ # the uv invocation. Order matters: setting them after
410
+ # `Start-Process -FilePath $UvExe` would have no effect on
411
+ # the subprocess.
412
+ env_idx = script.find("$env:UV_TOOL_DIR")
413
+ uv_invoke_idx = script.find("Start-Process -FilePath $UvExe")
414
+ assert env_idx != -1, "worker must set $env:UV_TOOL_DIR"
415
+ assert uv_invoke_idx != -1, "worker must invoke uv via Start-Process"
416
+ assert env_idx < uv_invoke_idx, (
417
+ "$env:UV_TOOL_DIR must be set BEFORE uv is invoked, "
418
+ "otherwise the override has no effect on the subprocess"
419
+ )
420
+ # Both env vars present.
421
+ assert "$env:UV_TOOL_DIR = $ToolDir" in script
422
+ assert "$env:UV_TOOL_BIN_DIR = $ToolBinDir" in script
423
+
424
+ def test_result_sentinel_is_written_without_utf8_bom(self, tmp_path: Path) -> None:
425
+ # Regression guard for the secondary symptom of the silent
426
+ # auto-update failure: PowerShell 5.1's `Set-Content -Encoding
427
+ # UTF8` writes a UTF-8 BOM, and Python's `json.loads(
428
+ # path.read_text(encoding="utf-8"))` raises JSONDecodeError on
429
+ # the leading U+FEFF. `read_upgrade_result` then swallows the
430
+ # error and returns None, causing the post-restart inspection
431
+ # to flag `worker_result_missing=True` even though the file
432
+ # was written successfully — the operator sees no actual
433
+ # error context in the failure event.
434
+ #
435
+ # The fix uses [System.IO.File]::WriteAllText with an explicit
436
+ # UTF8Encoding($false) so the BOM is omitted on every supported
437
+ # PowerShell version. Pin the encoder choice here.
438
+ script = render_worker_script(
439
+ service_name="DataHubWatcher",
440
+ request_path=tmp_path / ".upgrade-request.json",
441
+ result_path=tmp_path / ".upgrade-result.json",
442
+ log_path=tmp_path / "upgrade-worker.log",
443
+ tool_dir=tmp_path / "tools",
444
+ tool_bin_dir=tmp_path / "bin",
445
+ generated_at="2026-05-04T22:30:00+00:00",
446
+ )
447
+ # The .NET UTF8Encoding constructor takes a bool encoderShouldEmitUTF8Identifier
448
+ # — `$false` means "no BOM". We pin both the type and the
449
+ # `$false` argument so a future refactor can't accidentally
450
+ # flip it back to BOM-emitting.
451
+ assert "[System.IO.File]::WriteAllText" in script
452
+ assert "[System.Text.UTF8Encoding]::new($false)" in script
453
+ # The original BOM-emitting `Set-Content` invocation must
454
+ # not appear as an actual command anywhere in the rendered
455
+ # template. Search line-by-line, ignoring lines that start
456
+ # with `#` (PowerShell comments) so the regression-context
457
+ # commentary describing the *old* behaviour doesn't trip
458
+ # the assertion.
459
+ for line in script.splitlines():
460
+ stripped = line.lstrip()
461
+ if stripped.startswith("#"):
462
+ continue
463
+ assert "Set-Content -LiteralPath $tmp -Encoding UTF8" not in stripped
464
+
373
465
 
374
466
  # ---------------------------------------------------------------------------
375
467
  # Filename constants
@@ -384,3 +476,99 @@ def test_sentinel_filenames_are_dotfiles_under_config_dir(tmp_path: Path) -> Non
384
476
  assert upgrade_result_path(tmp_path).name == UPGRADE_RESULT_FILENAME
385
477
  assert UPGRADE_REQUEST_FILENAME.startswith(".")
386
478
  assert UPGRADE_RESULT_FILENAME.startswith(".")
479
+
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # resolve_uv_tool_dirs
483
+ # ---------------------------------------------------------------------------
484
+
485
+
486
+ class TestResolveUvToolDirs:
487
+ """Capture the operator's uv tool directories at install time.
488
+
489
+ Required because the worker later runs as LocalSystem and uv would
490
+ otherwise resolve UV_TOOL_DIR / UV_TOOL_BIN_DIR against the SYSTEM
491
+ profile rather than the operator's profile — silently installing
492
+ upgrades into the wrong location.
493
+ """
494
+
495
+ def test_returns_paths_from_uv_tool_dir_invocations(
496
+ self, monkeypatch: pytest.MonkeyPatch
497
+ ) -> None:
498
+ from data_hub_watcher import upgrade_worker as worker_mod
499
+
500
+ invocations: list[list[str]] = []
501
+
502
+ def fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
503
+ invocations.append(argv)
504
+ if "--bin" in argv:
505
+ stdout = r"C:\Users\op\.local\bin"
506
+ else:
507
+ stdout = r"C:\Users\op\AppData\Roaming\uv\tools"
508
+ return subprocess.CompletedProcess(argv, returncode=0, stdout=stdout, stderr="")
509
+
510
+ monkeypatch.setattr(worker_mod.subprocess, "run", fake_run)
511
+
512
+ tool_dir, tool_bin_dir = worker_mod.resolve_uv_tool_dirs(r"C:\Users\op\.local\bin\uv.exe")
513
+
514
+ assert tool_dir == Path(r"C:\Users\op\AppData\Roaming\uv\tools")
515
+ assert tool_bin_dir == Path(r"C:\Users\op\.local\bin")
516
+ # Both invocations must use the supplied uv path (not e.g. a
517
+ # PATH-resolved fallback) so the helper reflects the same uv
518
+ # the worker will eventually drive.
519
+ assert invocations[0] == [r"C:\Users\op\.local\bin\uv.exe", "tool", "dir"]
520
+ assert invocations[1] == [r"C:\Users\op\.local\bin\uv.exe", "tool", "dir", "--bin"]
521
+
522
+ def test_nonzero_exit_raises_with_stderr_attached(
523
+ self, monkeypatch: pytest.MonkeyPatch
524
+ ) -> None:
525
+ from data_hub_watcher import upgrade_worker as worker_mod
526
+
527
+ def fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
528
+ return subprocess.CompletedProcess(
529
+ argv, returncode=2, stdout="", stderr="uv: command does not exist"
530
+ )
531
+
532
+ monkeypatch.setattr(worker_mod.subprocess, "run", fake_run)
533
+
534
+ with pytest.raises(worker_mod.UvToolDirResolutionError) as excinfo:
535
+ worker_mod.resolve_uv_tool_dirs("uv")
536
+
537
+ # Operator-facing message must include the failing argv and
538
+ # uv's stderr — diagnostically those are the two pieces an
539
+ # operator needs to recover from a stuck install.
540
+ msg = str(excinfo.value)
541
+ assert "uv tool dir" in msg
542
+ assert "command does not exist" in msg
543
+
544
+ def test_empty_stdout_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
545
+ # An exit-0 with empty stdout would otherwise silently bake
546
+ # `Path("")` into the worker template, which uv interprets
547
+ # as "use the default" — i.e. the very SYSTEM-profile path
548
+ # we're trying to override. Treat it as a hard failure.
549
+ from data_hub_watcher import upgrade_worker as worker_mod
550
+
551
+ def fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
552
+ return subprocess.CompletedProcess(argv, returncode=0, stdout="\n", stderr="")
553
+
554
+ monkeypatch.setattr(worker_mod.subprocess, "run", fake_run)
555
+
556
+ with pytest.raises(worker_mod.UvToolDirResolutionError):
557
+ worker_mod.resolve_uv_tool_dirs("uv")
558
+
559
+ def test_oserror_raises_uv_tool_dir_resolution_error(
560
+ self, monkeypatch: pytest.MonkeyPatch
561
+ ) -> None:
562
+ # uv.exe missing from disk surfaces as FileNotFoundError from
563
+ # subprocess.run; the helper must convert that to its typed
564
+ # error so callers don't have to know about subprocess
565
+ # internals.
566
+ from data_hub_watcher import upgrade_worker as worker_mod
567
+
568
+ def boom(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
569
+ raise FileNotFoundError("uv.exe not found")
570
+
571
+ monkeypatch.setattr(worker_mod.subprocess, "run", boom)
572
+
573
+ with pytest.raises(worker_mod.UvToolDirResolutionError):
574
+ worker_mod.resolve_uv_tool_dirs("uv")