Skip to content

Watcher: Per-environment .env files, file creation time, API key sanitization, and Windows service auto-restart - #42

Merged
wasimxyz merged 8 commits into
stagingfrom
wa/watcher-fixes-20260427
Apr 28, 2026
Merged

Watcher: Per-environment .env files, file creation time, API key sanitization, and Windows service auto-restart#42
wasimxyz merged 8 commits into
stagingfrom
wa/watcher-fixes-20260427

Conversation

@wasimxyz

@wasimxyz wasimxyz commented Apr 28, 2026

Copy link
Copy Markdown
Member

Summary

A grab-bag of small, independent watcher fixes and quality-of-life improvements observed across the staging/production fleet, plus the matching web-app changes to surface and persist the new file metadata. Each commit on the branch is self-contained; there is no single overarching feature.

Changes

  • Per-environment .env files (60d02d3) — API keys are now saved to ~/.data-hub/.env.<environment> (e.g. .env.staging, .env.production, .env.preview) so operators can switch environments by re-running init without re-entering credentials. The legacy ~/.data-hub/.env is still loaded first as a fallback.

    • watcher/src/data_hub_watcher/constants.py: new env_file_path(), SUPPORTED_ENVIRONMENTS, and load_env(environment) overlay semantics; save_api_key now takes an environment.
    • watcher/src/data_hub_watcher/cli.py: init overlays the env-specific file, prompts to reuse a saved key when one exists, and service install --env-path defaults to the per-environment file.
    • Docs updated in docs/watcher.md and docs/guides/installing-a-watcher.md.
  • Capture and display on-disk file creation time (91b9e69) — the watcher now reports st_birthtime (falling back to st_mtime) for every detected file and the run files table prefers it over the row's created_at.

    • watcher/src/data_hub_watcher/run_detector.py: new file_created_at() helper, FileInfo.file_created_at, payload field file_created_at.
    • watcher/src/data_hub_watcher/api_client.py, uploader.py: send file_created_at on request_upload_url.
    • watcher/src/data_hub_watcher/state.py: nullable file_created_at column on detected_files with a migration for legacy DBs.
    • web-app/lib/db/schema.ts + web-app/drizzle/0009_clammy_tyger_tiger.sql: new nullable files.file_created_at column.
    • web-app/app/api/v1/...: accept file_created_at on report-run, PATCH run, and request-upload-url; emit it on every file response; backfill the column when a queue-mode upload follows an older detected_files report.
    • web-app/components/runs/run-files-table.tsx: prefer fileCreatedAt, fall back to createdAt.
  • Sanitize and validate API keys during init (4007831) — strips zero-width / non-breaking-space characters that Outlook/Teams/Word frequently inject into copied keys, validates the dhub_ prefix, and adds a --show-key flag for Windows terminals where hidden paste is unreliable. Operators get a clear error instead of a confusing 401.

  • Unify the watcher status badge (af8f0cb) — deletes web-app/components/watchers/status-badge.ts and folds per-watcher statuses (watching, stale, stopped, registered) into the existing WatcherStatusBadge, with shared color treatments, distinct labels (Unresponsive vs Offline), and a tooltip that shows last-online time only for unexpected silences. WatcherHeader and WatchersTable now use the unified component.

  • Auto-restart the Windows service after a lab-PC reboot (1712c70) — addresses the recurring failure mode where a freshly-rebooted PC starts the watcher before DHCP/DNS is up, the API health check fails, and the service stays stopped.

    • Registers the service with delayedstart=True and a dependency on Tcpip + Dnscache.
    • Sets SERVICE_CONFIG_FAILURE_ACTIONS_FLAG (fFailureActionsOnNonCrashFailures) so the existing recovery actions also fire on non-zero SystemExit — previously the SCM only restarted on hard crashes.
    • Refactors SvcDoRun into a top-level, platform-agnostic _run_service_loop(stop_event, sm) so the full startup sequence (registry read, env loading, instrument check, checksum sync, runtime build/start/stop) is unit-testable on any platform via a mocked servicemanager.
  • Unit tests for the Windows service module (b997b65) — new watcher/tests/test_service.py covering _run_service_loop startup paths and recovery exit semantics.

  • Pytest upgrade (f057746) — bumps pytest from >=8.3.5 to >=9.0.3 (and uv.lock to 9.0.3) to address a dependabot security alert.

  • CI.github/workflows/python-test.yml now triggers on changes under watcher/** so the new test suite runs on every PR.

Breaking changes

None for end users. A few internal contracts changed but are handled with migrations / fallbacks:

  • StateDB.record_detected_files now expects 5-tuples (adds file_created_at); the detected_files table is migrated in-place via ALTER TABLE … ADD COLUMN for existing watchers.
  • save_api_key(api_key, environment) and load_env(environment) gained an environment argument; the legacy ~/.data-hub/.env is still loaded as a base layer for backwards compatibility.
  • web-app/components/watchers/status-badge.ts was deleted. Anything importing statusBadge from it must switch to WatcherStatusBadge.

Driveby changes

  • watcher/src/data_hub_watcher/cli.py: the service install warning now correctly points to data-hub-watcher init instead of the (non-existent) login subcommand.
  • watcher/src/data_hub_watcher/service.py: TYPE_CHECKING import block removed since it was empty after the refactor; threading import hoisted to module scope.
  • An assert cfg.api_base_url is not None on the preview branch makes the existing WatcherConfig validator invariant visible to pyright.

Testing

  • make check-all passes (format, lint, type-check).
  • uv run pytest watcher/ passes, including the new watcher/tests/test_service.py.
  • Web app: pnpm drizzle-kit migrate applies 0009_clammy_tyger_tiger.sql cleanly on a staging DB.
  • Manually run data-hub-watcher init against staging, confirm the key is saved to ~/.data-hub/.env.staging and that re-running offers to reuse it.
  • Paste an API key with a trailing zero-width space and confirm the CLI reports a clear validation error rather than a 401.
  • On a Windows lab PC: data-hub-watcher service install, reboot, confirm the service comes up after the network stack is ready (check Event Viewer for LogInfoMsg lines).
  • Trigger a controlled startup failure (e.g. revoke the API key) and confirm the SCM restarts the service per the configured recovery actions.
  • Generate a new run on a watcher, confirm the run files table shows the on-disk creation time (not the row's created_at) and that files.file_created_at is populated in the DB.
  • Verify the unified WatcherStatusBadge renders correctly in: instruments table, instrument header, watchers table, and watcher detail header — including the tooltip for offline / stale.

Persist the file's on-disk creation time alongside each row in the
`files` table and surface it in the run files table so users see when
the instrument actually wrote the file, not when the watcher first
reported it to the API.

- Add nullable `files.file_created_at` column (drizzle migration 0009).
- Watcher: capture `st_birthtime` (with `st_mtime` fallback) at
  stability time, persist it in the local SQLite manifest, and send it
  as ISO 8601 UTC on POST /runs, PATCH /runs/:runId, and the
  request-upload-url path. The latter also backfills the column when an
  earlier detected_files report predated this change.
- API: accept `file_created_at` on all three watcher entry points and
  include it in every JSON file response.
- UI: render `fileCreatedAt` in the "Created" column of the run files
  table, falling back to the row's `createdAt` for legacy rows and
  Lambda-created files.
- Tests: round-trip coverage for the SQLite manifest, the wire payload,
  and a unit test for the platform-portable `file_created_at` helper.
Pasting an API key into the hidden `click.prompt` on Windows can
silently introduce trailing CR/LF, non-breaking spaces, or zero-width
characters from rich-text clipboards (Outlook, Teams, Word). The bad
bytes flow through to the Authorization header, the server hashes the
wrong value, and every request 401s — including the heartbeats that run
after init persists the corrupted key to `.env.<environment>`.

Add a `_clean_api_key` helper that strips invisible characters and
whitespace, then validates the `dhub_` prefix and rejects any internal
whitespace. Apply it to every code path in `init` that produces a key,
including the value read from the environment, so a previously saved
bad key is caught and re-prompted instead of silently reused. Surface a
clear ClickException at init time rather than a confusing 401 from
`list_instruments`.

Also add an `--show-key` flag so operators on Windows terminals where
hidden-input paste is unreliable can fall back to visible entry.
Merge the per-watcher status badge (status-badge.ts) into
WatcherStatusBadge so there's one component handling both the
instrument-level aggregate (online/offline/no_watcher) and the per-watcher
states (watching/stale/stopped/registered).

Aligns the per-watcher labels with the aggregate vocabulary:
  watching   -> "Online"       (green, matches aggregate online)
  stale      -> "Unresponsive" (destructive, matches aggregate offline)
  stopped    -> "Stopped"      (muted filled — intentional, not an alarm)
  registered -> "Registered"   (muted outlined — transient pre-heartbeat)

Tooltip with "Last online {time}" now also fires for `stale` watchers,
and watcher-header.tsx wires lastHeartbeatAt through so hovering the
header badge surfaces it too.
The service was registered with SERVICE_AUTO_START, which triggers
before the network stack is up on a freshly-booted lab PC. The startup
sequence in SvcDoRun would then fail its API health check and `return`
cleanly, which the SCM treats as a graceful stop -- so the configured
recovery actions never fired and the watcher stayed down until a human
intervened.

Three changes to watcher/src/data_hub_watcher/service.py:

1. install_service now passes delayedstart=True and serviceDeps=
   ["Tcpip", "Dnscache"] so the SCM waits for the network stack and
   defers the start until ~2 minutes after boot.

2. Each early-exit path in SvcDoRun (registry-read failure, transient
   ApiError at startup, missing watcher_id, instrument still pending)
   now `raise SystemExit(1)` instead of returning, so the process
   exits non-zero.

3. _configure_recovery additionally sets
   SERVICE_CONFIG_FAILURE_ACTIONS_FLAG with
   fFailureActionsOnNonCrashFailures=True, so the existing
   60s/120s restart actions also fire on non-zero exits, not only on
   actual crashes.

Operators must reinstall the service (`data-hub-watcher service
uninstall && data-hub-watcher service install`) to pick up the new
registration flags.
Extract the SvcDoRun body into a top-level `_run_service_loop` so the
service startup sequence (registry read, env loading, API health
check, checksum sync, runtime build/start/stop) can be exercised on
any platform by injecting a mock servicemanager. Add a test suite
that mocks the win32 layer via sys.modules and locks in the contract
that has historically regressed on lab PCs: install kwargs
(delayed-start + Tcpip/Dnscache deps), the two-restart recovery
policy plus the non-crash-failure flag, and the four SystemExit
branches in service startup.

Also include watcher/** in the python-test.yml paths filter so
watcher-only PRs actually trigger the Python test job.
@wasimxyz wasimxyz self-assigned this Apr 28, 2026
@vercel

vercel Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
data-hub Ready Ready Preview, Comment Apr 28, 2026 8:40pm

Request Review

@wasimxyz
wasimxyz merged commit 07eded1 into staging Apr 28, 2026
7 checks passed
@wasimxyz
wasimxyz deleted the wa/watcher-fixes-20260427 branch April 28, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant