✅ fresh
last synced 2026-10-24T20:09:49.281395+00:00 · coverage 100% (
probe)Validation by Beadloom
doc_sync— same source assync-check.
Probe and dry run
Checks whether the service could run, without ever logging in: Probe asks the portal for its client bundles, the calendar for today, Chrome for its version and the absences file for what it holds, and names the day's drawn moments; DryRun plans the run a use case would make.
Source
src/horizon_tracker/application/probe.py
Specification
Purpose
The owner and the deploy need to know whether a run could go ahead before one does, and neither may log in to the portal to find out. horizon probe is the deploy's smoke test and the first command on a new VPS. horizon checkin --dry-run and horizon checkout --dry-run show what a run would do now.
What the probe measures
The probe asks each dependency the one question it can answer without credentials. Each check's detail is a Message (a catalogue key and its fields, domain/messages.py); the CLI shows it, the check's name and its status in the language HT_LANGUAGE names. The English texts:
| Check | Question | ok | Otherwise |
|---|---|---|---|
portal | which HTML Access client bundles does the portal serve? (HorizonClient.client_bundles()) | client bundles: <names> | failed, no answer: <reason> |
calendar | is today a working day? (WorkingDayCalendar.is_working_day) | isdayoff.ru: <date> is a working day or ... is a day off | degraded, isdayoff.ru did not answer for <date>; runs fall back to the Mon-Fri rule |
chrome | is Chrome at the configured path, and does it start? (file_exists, then HorizonClient.browser_version()) | Chrome <version> started from <path> | failed, not found at <path> or found at <path> but did not start: <reason> |
absences | can the absences file be read, and what does it hold? (AbsenceStore.load()) | none (<path>), <n> current or upcoming[, <m> finished], none ahead (<path>) or <n> current or upcoming[, <m> finished], next: vacation 03.11–14.11 (<path>) | failed, <path>: <reason> |
schedule | when do today's runs fall? (the moments the probe is given) | today HH:MM / HH:MM, the check-in and the check-out moment | never anything but ok |
The probe reads no portal version. It names the bundle files as the portal serves them in index.html and parses nothing out of them, and it names the version the started Chrome reports. The acceptance scenario keeps its approved PRD name, Probe reports the portal version without logging in, while its steps assert the client bundles and the Chrome version. Renaming it is an amendment of the approved PRD, which is the owner's decision.
The Chrome check looks for the file first and starts Chrome only when the file exists, so a missing binary is named without a launch. A binary that exists but does not start (for example, a Chrome updated past what the locked Playwright can drive, or missing system libraries) is named separately from a missing one.
An unreachable calendar degrades the probe rather than failing it, because a run falls back to the Monday-Friday rule. ProbeReport.ok is true unless a check FAILED.
The absences check loads the store once. <n> counts the absences whose last day is today or later (not_over, application/absence_service.py), the ones /list names. , <m> finished follows when the store also holds absences that ended before today: they stay in the store and are counted, never named (probe.absences.held or probe.absences.held_and_finished fills the line's {held} field). An empty store reads none (<path>), and a store of finished absences only reads 0 current or upcoming, <m> finished, none ahead (<path>). The next absence is next_after(absences, today) (domain/absence.py) over the absences not over: the earliest that starts after today, so an absence under way is counted and never named as next. Its kind reads as the run notification's reason phrase (reason.on_vacation, ...) and its days as absence_span (domain/report.py), the span the notice gives it: one day as 05.11, a range as 03.11–14.11. <path> on an ok line is the absences_file the probe was given. A store that raises AbsenceStoreError fails the check with the error's own path and detail, the store's short technical English (not valid JSON: ..., cannot be read: PermissionError), which stays English in the Russian text too: <path>: не прочитан: <reason>. A failed absences check fails the probe, because the owner's absences are then unknown. The probe never writes the file.
The schedule check is a fact to read, not a dependency's health, so it is always ok. It comes last and only when the probe is given the day's moments. The composition root draws them with the same draw_moments call a run makes today, so the owner can compare the probe's line with the day's runs. The CLI prints it without a status: schedule: today 07:38 / 16:39 (the probe.schedule key, rendered in Russian under HT_LANGUAGE=ru).
How the dry run works
DryRun(use_case, moments).plan(force=...) plans the very use case a run would execute:
- the decision is
use_case.runner.first_decision(force=...), the decision the run loop would take on its first attempt: now, or at the drawn moment when the run would wait for it first (this asks the calendar unless forced); - the steps are
use_case.steps()followed byuse_case.runner.steps(), each aMessagethe CLI prints in the configured language; waits_untilis the drawn moment a run started now would sleep until first, taken from the samefirst_decision, andmomentsis the day's drawn pair the dry run was given.
Neither the decision nor the steps are restated in this module, so a plan cannot drift from the run it describes. The dry run calls nothing on the Horizon client, so it cannot log in.
The settings are validated before either object is built: the composition root creates a probe or a dry run only for a configuration Settings.from_env accepted.
Invariants
- Neither the probe nor the dry run calls
loginor any other method that needs the account. - The probe runs every check once, whatever the others answered.
- The probe starts Chrome only after the file was found at the configured path.
- A degraded calendar never makes the probe fail.
- The probe reads the absences file once and never writes it.
- The probe and the dry run never draw the moments themselves: they name the pair the composition root drew, so they cannot disagree with a run of the same day.
- The dry run sends no report and reads its decision and steps from the use case and its run loop.
- Neither the probe nor the dry run picks a language: every text they hand out is a
Message, so the same plan reads in English or Russian without a second code path. - A check whose name or status has no text in the catalogue cannot be made.
API
Module src/horizon_tracker/application/probe.py:
CheckStatus—StrEnum:OK,DEGRADED,FAILED.Check(name: str, status: CheckStatus, detail: Message)— frozen.nameandstatusare identifiers;label(probe.check.<name>) andstatus_label(probe.status.<status>) are the same as messages, made with the check (not constructor arguments, not compared), so a name the catalogue lacks raisesKeyErrorwhere the check is made. The CLI showslabelandstatus_label.ProbeReport(checks: tuple[Check, ...])— frozen;ok -> boolproperty.Probe(client: HorizonClient, calendar: WorkingDayCalendar, clock: Clock, chrome_path: Path, file_exists: Callable[[Path], bool], absences: AbsenceStore, absences_file: Path, moments: DailyMoments | None = None)— frozen;run() -> ProbeReportwith the checksportal,calendar,chrome,absencesin that order, then, whenmomentsis given,schedule(ok, detailschedule_detail(moments)). The probe never draws the moments; the composition root does.schedule_detail(moments: DailyMoments) -> Message— today's drawn pair, keyprobe.schedule:today 07:43 / 16:52in English.RunPlan(kind, at, forced, decision, verdict_source, steps: tuple[Message, ...], waits_until: datetime | None = None, moments: DailyMoments | None = None)— frozen;would_run -> boolis true forRUNandRETRY.waits_untilcomes from the loop'sfirst_decision;momentsis the day's drawn pair as the dry run was given it, forced or not.PlannedRun—Protocol:kindandrunnerproperties,steps() -> tuple[Message, ...].MorningCheckinandEveningCheckoutsatisfy it.DryRun(use_case: PlannedRun, moments: DailyMoments | None = None)— frozen;plan(*, force: bool = False) -> RunPlan.
The CLI builds Probe with chrome_path=settings.chrome_path and file_exists=Path.is_file; the Horizon client takes its Chrome path from the same setting. It passes absences=adapters.absences, the JsonAbsenceStore(settings.absences_file) that open_adapters builds for every command, and absences_file=settings.absences_file, the file under HT_STATE_DIR.
Testing
Tests: tests/unit/application/test_probe.py, tests/unit/test_cli.py.
Scenarios (tests/acceptance/features/checks_without_login.feature):
Probe reports the portal version without logging inProbe names the absences entered and the next one aheadProbe with no absences entered names the file it readsA malformed absences file fails the probe and names the fileA dry run walks the plan and performs no login
Scenarios that read the schedule: line (tests/acceptance/features/randomised_moments.feature):
The check-out moment is nine hours plus up to ten minutes after the check-inThe same date gives the same drawn moments in two separate runsA dry run names the drawn moment of the day
Scenarios that read the probe and the dry run in a language (tests/acceptance/features/language.feature):
The probe reads in the configured languageA dry run reads in the configured language
Dependencies
Derived from the imports on reindex.
- Depends on:
absence,absence-service(not_over),messages,ports,run-report,schedule,windowed-run,working-day-calendar - Used by:
cli
Parent
application — application README