code-coordinator 0.5.46__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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Systemd unit-file drift against `deploy/` (#1831).
|
|
2
|
+
|
|
3
|
+
`deploy/*.service`/`*.timer` is version-controlled, reviewed, and merged
|
|
4
|
+
like code. **Nothing ever installs it.** The release path is bump -> PR ->
|
|
5
|
+
merge -> tag push -> `publish.yml` -> PyPI, then `coord agent update` for the
|
|
6
|
+
venvs — no step copies `deploy/*.service` into `~/.config/systemd/user/`.
|
|
7
|
+
Unit files are hand-installed once at machine setup and drift forever after.
|
|
8
|
+
|
|
9
|
+
The 2026-08-04 incident this closes: dellserver's `coord-serve.service` was
|
|
10
|
+
three weeks stale, its `Environment=PATH=` still starting with an **editable**
|
|
11
|
+
checkout of this repo (`~/src/claude-coordinator/.venv/bin`). `coord_argv()`
|
|
12
|
+
(`coord/drive.py`) resolves subprocesses via `shutil.which("coord")` — i.e.
|
|
13
|
+
from that PATH — so the daemon itself ran the pinned release while
|
|
14
|
+
everything it spawned ran whatever stale branch that checkout happened to be
|
|
15
|
+
on. Two failure modes, one probe:
|
|
16
|
+
|
|
17
|
+
`unit_drift`
|
|
18
|
+
Per deploy-lane unit: does the installed copy under
|
|
19
|
+
`~/.config/systemd/user/` match `deploy/<name>`? Absence is the common
|
|
20
|
+
case (most machines don't run every lane) and is reported OK, not a
|
|
21
|
+
fault — same convention as `cli_venv`/`tui_binary`
|
|
22
|
+
(:mod:`coord.health.checks.deploy_lane_facts`).
|
|
23
|
+
|
|
24
|
+
`_path_shadow_risk`
|
|
25
|
+
Independent of content drift: does the installed unit's
|
|
26
|
+
`Environment=PATH=` put an editable checkout's `.venv/bin` ahead of the
|
|
27
|
+
release entry points (`~/.local/bin`, `~/.coord-venv/bin`)? This is what
|
|
28
|
+
made the drift above *harmful* rather than merely untidy, and it can
|
|
29
|
+
exist even on a unit whose content otherwise matches `deploy/` bit for
|
|
30
|
+
bit if `deploy/` itself regresses — which is exactly what happened to
|
|
31
|
+
`coord-serve.service`'s v0.4.105 cut (it dropped the #1117 PATH entry
|
|
32
|
+
that had also fixed a real bug). CRIT regardless of the content-diff
|
|
33
|
+
verdict — a shadowed release is the split-brain, not a cosmetic
|
|
34
|
+
difference.
|
|
35
|
+
|
|
36
|
+
#1927 — where the reference comes from
|
|
37
|
+
--------------------------------------
|
|
38
|
+
The original cut of this check diffed the installed unit against
|
|
39
|
+
``<checkout>/deploy/<name>``: a file in the host's own git working copy that
|
|
40
|
+
nothing verifies is at the released tag, or current at all. Installed units
|
|
41
|
+
and checkouts go stale for the *same* reason (nobody pulled), so they go
|
|
42
|
+
stale *together* — and when they do the comparison reports clean. The check
|
|
43
|
+
was least reliable in exactly the case it exists to catch, and the remedy it
|
|
44
|
+
printed (``cp <checkout>/deploy/... ~/.config/systemd/user/...``) sourced
|
|
45
|
+
from the same unverified working copy, cementing the stale unit.
|
|
46
|
+
|
|
47
|
+
So the reference is now the *packaged* unit set — ``coord/deploy/`` inside
|
|
48
|
+
the installed distribution (see :func:`packaged_unit_dir`). That is the
|
|
49
|
+
released artifact for the version this process is running, and it cannot
|
|
50
|
+
drift with the host. When the reference is NOT a released artifact (an
|
|
51
|
+
editable/source checkout, a configured directory, or an old wheel that ships
|
|
52
|
+
no units) the verdict is annotated and a *match* grades UNKNOWN rather than
|
|
53
|
+
OK: an un-annotated green from an unverified reference is worse than no
|
|
54
|
+
check at all.
|
|
55
|
+
|
|
56
|
+
#1928 — ``coord-agent.service`` is a template, not a plain file
|
|
57
|
+
-----------------------------------------------------------------
|
|
58
|
+
Unlike its siblings (byte-for-byte ``cp``-installed from ``deploy/``),
|
|
59
|
+
``deploy/coord-agent.service`` carries ``<MACHINE_NAME>``/``<PORT>``
|
|
60
|
+
placeholders that every real install fills in — via the documented manual
|
|
61
|
+
``sed`` (leaving systemd's ``%h`` specifier alone) or via
|
|
62
|
+
``install-agent.sh``'s inline heredoc (which expands ``%h`` to a literal
|
|
63
|
+
``$HOME`` and drops the ~76-line doc-comment header entirely). A byte-diff
|
|
64
|
+
against the raw template can never match either of those, so it warned on
|
|
65
|
+
every host, permanently — and printed a bare ``cp`` remedy that, followed
|
|
66
|
+
verbatim, installs the placeholders as literal text and takes the unit
|
|
67
|
+
down.
|
|
68
|
+
|
|
69
|
+
``_content_matches`` fixes this without weakening the check: it normalizes
|
|
70
|
+
away the two kinds of noise that are never a real difference — comment/blank
|
|
71
|
+
lines, and the ``%h`` vs. literal-``$HOME`` spelling — and then, only if the
|
|
72
|
+
(normalized) reference still contains placeholder tokens, accepts an
|
|
73
|
+
installed copy that fills them in *consistently* (the same placeholder
|
|
74
|
+
resolves to the same value everywhere it appears). A unit that is missing a
|
|
75
|
+
real property — e.g. elitebook's installed copy drops ``--machine``/
|
|
76
|
+
``--port`` from ``ExecStart`` entirely rather than filling them in — still
|
|
77
|
+
fails the match and still warns; that is a real defect, not template noise.
|
|
78
|
+
The remedy for a still-drifting *templated* unit is never the bare ``cp``:
|
|
79
|
+
see :func:`_templated_remedy`.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
from __future__ import annotations
|
|
83
|
+
|
|
84
|
+
import difflib
|
|
85
|
+
import re
|
|
86
|
+
from dataclasses import dataclass
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
|
|
89
|
+
from coord.health.models import CheckResult, HealthContext, Severity
|
|
90
|
+
from coord.health.registry import check
|
|
91
|
+
from coord.health.units import expand, human_hours
|
|
92
|
+
|
|
93
|
+
_UNIT_GLOBS = ("*.service", "*.timer")
|
|
94
|
+
_SYSTEMD_USER_DIR = "~/.config/systemd/user"
|
|
95
|
+
|
|
96
|
+
# Entry points that resolve to the pinned release. A `.venv/bin` entry ahead
|
|
97
|
+
# of ALL of these on a unit's PATH can shadow it (#1831's dellserver case).
|
|
98
|
+
_RELEASE_MARKERS = ("/.local/bin", "/.coord-venv/bin")
|
|
99
|
+
|
|
100
|
+
_PATH_LINE_RE = re.compile(r"^Environment\s*=\s*PATH=(.*)$", re.MULTILINE)
|
|
101
|
+
|
|
102
|
+
# #1928: a template placeholder, e.g. `<MACHINE_NAME>` or `<PORT>` — see
|
|
103
|
+
# `deploy/coord-agent.service`. Uppercase-with-underscores by convention so
|
|
104
|
+
# it can never collide with a real systemd directive or value.
|
|
105
|
+
_PLACEHOLDER_RE = re.compile(r"<([A-Z][A-Z0-9_]*)>")
|
|
106
|
+
|
|
107
|
+
# Placeholders this module knows a real, safe substitution for — used to
|
|
108
|
+
# build a runnable remedy in `_templated_remedy` instead of a bare `cp`
|
|
109
|
+
# (#1928). Anything not listed here still gets a safe remedy, just not one
|
|
110
|
+
# that reaches for a value this module has no business guessing.
|
|
111
|
+
_KNOWN_PLACEHOLDER_VALUES = {
|
|
112
|
+
"MACHINE_NAME": "$(hostname -s)",
|
|
113
|
+
"PORT": "7433",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Sentinel used to fold systemd's `%h` specifier and this host's literal
|
|
117
|
+
# $HOME into one token before comparing unit text (#1928) — install-agent.sh
|
|
118
|
+
# expands $HOME to a literal path in the unit it writes; deploy/*.service
|
|
119
|
+
# and a manual sed-install both keep `%h` literal. Both are correct, so
|
|
120
|
+
# neither spelling may count as drift. Chosen to be inert under re.escape()
|
|
121
|
+
# (see `_placeholder_pattern`) and never appear in a real unit file.
|
|
122
|
+
_HOME_TOKEN = "\x00HOME\x00"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def packaged_unit_dir():
|
|
126
|
+
"""`coord/deploy/` inside *this* installed distribution, or None.
|
|
127
|
+
|
|
128
|
+
Shipped as package data (see `pyproject.toml`), so on a pip-installed
|
|
129
|
+
host it is the unit set as of the installed version — the released
|
|
130
|
+
artifact, which cannot drift with the host's git checkout (#1927).
|
|
131
|
+
Returns None on a wheel old enough to predate #1927, which is why the
|
|
132
|
+
working-copy fallbacks below still exist.
|
|
133
|
+
"""
|
|
134
|
+
candidate = Path(__file__).resolve().parent.parent.parent / "deploy"
|
|
135
|
+
if candidate.is_dir() and _unit_files(candidate):
|
|
136
|
+
return candidate
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def in_git_worktree(path: Path) -> bool:
|
|
141
|
+
"""Is `path` inside a git working copy?
|
|
142
|
+
|
|
143
|
+
The discriminator between "released artifact" and "working copy" for
|
|
144
|
+
:func:`packaged_unit_dir`: an editable/source install puts the package
|
|
145
|
+
under a checkout, where `coord/deploy/` is as unverified as any other
|
|
146
|
+
tracked file. A pip-installed wheel lands in `site-packages`, which has
|
|
147
|
+
no `.git` above it.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
resolved = path.resolve()
|
|
151
|
+
except OSError: # pragma: no cover - resolve() is effectively total here
|
|
152
|
+
return False
|
|
153
|
+
for parent in (resolved, *resolved.parents):
|
|
154
|
+
try:
|
|
155
|
+
if (parent / ".git").exists():
|
|
156
|
+
return True
|
|
157
|
+
except OSError: # pragma: no cover - unreadable ancestor
|
|
158
|
+
continue
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def installed_version() -> str | None:
|
|
163
|
+
"""The version of the installed coordinator distribution, or None.
|
|
164
|
+
|
|
165
|
+
#2103: resolves via :func:`coord.dist_name.resolve_installed` — tries
|
|
166
|
+
`code-coordinator` then falls back to `claude-coordinator` — instead of
|
|
167
|
+
a single hardcoded name, so this (feeding the unit-drift health check)
|
|
168
|
+
doesn't start reporting a false "not installed" the moment a machine
|
|
169
|
+
lands on the new name.
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
from coord.dist_name import resolve_installed
|
|
173
|
+
|
|
174
|
+
return resolve_installed().version
|
|
175
|
+
except Exception: # pragma: no cover - metadata missing in odd installs
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class UnitReference:
|
|
181
|
+
"""The thing installed units are diffed against, and how much it's worth.
|
|
182
|
+
|
|
183
|
+
`verified` is the whole point of #1927: only a reference that is the
|
|
184
|
+
released artifact for the installed version can turn a match into a
|
|
185
|
+
green. Everything else is a working copy whose own currency is unknown,
|
|
186
|
+
so its match is reported as UNKNOWN with `label` naming what was
|
|
187
|
+
actually compared.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
path: Path
|
|
191
|
+
source: str # "package" | "configured" | "checkout"
|
|
192
|
+
verified: bool
|
|
193
|
+
version: str | None = None
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def label(self) -> str:
|
|
197
|
+
if self.source == "package":
|
|
198
|
+
ver = f" {self.version}" if self.version else ""
|
|
199
|
+
if self.verified:
|
|
200
|
+
return f"the packaged units of installed coord{ver}"
|
|
201
|
+
return f"coord{ver}'s packaged units (SOURCE CHECKOUT, unverified)"
|
|
202
|
+
if self.source == "configured":
|
|
203
|
+
return f"configured reference {self.path} (unverified working copy)"
|
|
204
|
+
return f"{self.path} (unverified working copy)"
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def short_label(self) -> str:
|
|
208
|
+
"""The `headroom` half of :attr:`label` — one line, no path."""
|
|
209
|
+
if self.source == "package" and self.verified:
|
|
210
|
+
return f"packaged coord{' ' + self.version if self.version else ''}"
|
|
211
|
+
return f"{self.path}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def resolve_reference(ctx: HealthContext) -> UnitReference | None:
|
|
215
|
+
"""Where to read the reference units from, and whether it's trustworthy.
|
|
216
|
+
|
|
217
|
+
Order (#1927): the packaged units of the running distribution first —
|
|
218
|
+
they are the released artifact and are the only reference that cannot go
|
|
219
|
+
stale with the host. `health.deploy_dir` and then the first local
|
|
220
|
+
checkout's `deploy/` remain as fallbacks for wheels that predate #1927
|
|
221
|
+
(and for operators who deliberately point the check elsewhere), but both
|
|
222
|
+
are working copies and are flagged as such.
|
|
223
|
+
"""
|
|
224
|
+
packaged = packaged_unit_dir()
|
|
225
|
+
if packaged is not None:
|
|
226
|
+
return UnitReference(
|
|
227
|
+
path=packaged,
|
|
228
|
+
source="package",
|
|
229
|
+
verified=not in_git_worktree(packaged),
|
|
230
|
+
version=installed_version(),
|
|
231
|
+
)
|
|
232
|
+
fallback = resolve_deploy_dir(ctx)
|
|
233
|
+
if fallback is None:
|
|
234
|
+
return None
|
|
235
|
+
configured = getattr(ctx.thresholds, "deploy_dir", None)
|
|
236
|
+
return UnitReference(
|
|
237
|
+
path=fallback,
|
|
238
|
+
source="configured" if configured else "checkout",
|
|
239
|
+
verified=False,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def resolve_deploy_dir(ctx: HealthContext):
|
|
244
|
+
"""The checked-in `deploy/` this machine can diff installed units against.
|
|
245
|
+
|
|
246
|
+
The #1927 *fallback* reference, used only when the installed
|
|
247
|
+
distribution ships no `coord/deploy/` of its own — see
|
|
248
|
+
:func:`resolve_reference`, which is what the probe calls.
|
|
249
|
+
|
|
250
|
+
Configured `health.deploy_dir` wins outright; otherwise the first local
|
|
251
|
+
checkout (see `coord.health.context.local_checkouts`) that has one —
|
|
252
|
+
normally the `claude-coordinator` entry in `repo_paths`.
|
|
253
|
+
"""
|
|
254
|
+
configured = getattr(ctx.thresholds, "deploy_dir", None)
|
|
255
|
+
if configured:
|
|
256
|
+
return expand(configured, ctx.home)
|
|
257
|
+
for checkout in ctx.checkouts:
|
|
258
|
+
candidate = checkout.path / "deploy"
|
|
259
|
+
if candidate.is_dir():
|
|
260
|
+
return candidate
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def resolve_systemd_user_dir(ctx: HealthContext):
|
|
265
|
+
"""Where installed systemd *user* units actually live on this machine."""
|
|
266
|
+
configured = getattr(ctx.thresholds, "systemd_user_dir", None)
|
|
267
|
+
if configured:
|
|
268
|
+
return expand(configured, ctx.home)
|
|
269
|
+
return expand(_SYSTEMD_USER_DIR, ctx.home)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _unit_files(deploy_dir):
|
|
273
|
+
seen = set()
|
|
274
|
+
out = []
|
|
275
|
+
for pattern in _UNIT_GLOBS:
|
|
276
|
+
for path in sorted(deploy_dir.glob(pattern)):
|
|
277
|
+
if path.name in seen:
|
|
278
|
+
continue
|
|
279
|
+
seen.add(path.name)
|
|
280
|
+
out.append(path)
|
|
281
|
+
return sorted(out, key=lambda p: p.name)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _strip_noise_lines(text: str) -> str:
|
|
285
|
+
"""Drop comment and blank lines.
|
|
286
|
+
|
|
287
|
+
Doc prose that never affects what systemd actually runs — and the
|
|
288
|
+
biggest source of #1928's false drift: `deploy/coord-agent.service`
|
|
289
|
+
carries ~76 lines of install documentation that `install-agent.sh`'s
|
|
290
|
+
generated unit never includes, so a raw byte-diff always "differed" on
|
|
291
|
+
that alone, template placeholders aside.
|
|
292
|
+
"""
|
|
293
|
+
return "\n".join(
|
|
294
|
+
line for line in text.splitlines() if line.strip() and not line.strip().startswith("#")
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _normalize_home(text: str, home: Path) -> str:
|
|
299
|
+
"""Fold systemd's `%h` specifier and this host's literal $HOME into one
|
|
300
|
+
token (#1928), so a unit that spells its home directory either way
|
|
301
|
+
compares equal. `install-agent.sh`'s heredoc expands `$HOME` to a
|
|
302
|
+
literal path when it writes the unit; `deploy/coord-agent.service` and a
|
|
303
|
+
manual sed-install both leave `%h` for systemd to resolve at run time.
|
|
304
|
+
Both are correct — neither spelling is drift.
|
|
305
|
+
"""
|
|
306
|
+
text = text.replace("%h", _HOME_TOKEN)
|
|
307
|
+
home_str = str(home)
|
|
308
|
+
if home_str and home_str != "/":
|
|
309
|
+
text = text.replace(home_str, _HOME_TOKEN)
|
|
310
|
+
return text
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _normalize_unit_text(text: str, home: Path) -> str:
|
|
314
|
+
"""Strip the noise `_content_matches` never treats as drift (#1928)."""
|
|
315
|
+
return _strip_noise_lines(_normalize_home(text, home))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _placeholder_pattern(normalized_deploy_text: str) -> re.Pattern[str]:
|
|
319
|
+
"""Compile *normalized_deploy_text* into a regex matching any rendering
|
|
320
|
+
of its `<PLACEHOLDER>` tokens — each becomes a capture group on first
|
|
321
|
+
use and a backreference on repeat, so the same placeholder must resolve
|
|
322
|
+
to the same value everywhere it appears (#1928: `<PORT>` shows up in
|
|
323
|
+
both `Description=` and `ExecStart=` in `deploy/coord-agent.service`).
|
|
324
|
+
|
|
325
|
+
`re.escape()` leaves `<`, `>`, letters, digits and `_` untouched (see
|
|
326
|
+
module-level note on `_HOME_TOKEN`), so the placeholder tokens and the
|
|
327
|
+
home sentinel both survive escaping intact and are still findable.
|
|
328
|
+
"""
|
|
329
|
+
seen: set[str] = set()
|
|
330
|
+
|
|
331
|
+
def repl(m: re.Match[str]) -> str:
|
|
332
|
+
name = m.group(1)
|
|
333
|
+
if name in seen:
|
|
334
|
+
return f"(?P={name})"
|
|
335
|
+
seen.add(name)
|
|
336
|
+
return f"(?P<{name}>.+?)"
|
|
337
|
+
|
|
338
|
+
escaped = re.escape(normalized_deploy_text)
|
|
339
|
+
return re.compile(_PLACEHOLDER_RE.sub(repl, escaped), re.DOTALL)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _content_matches(deploy_text: str, installed_text: str, home: Path) -> bool:
|
|
343
|
+
"""Does *installed_text* satisfy *deploy_text* (#1928)?
|
|
344
|
+
|
|
345
|
+
Exact, modulo the noise `_normalize_unit_text` strips, when
|
|
346
|
+
*deploy_text* is a plain file (every lane but `coord-agent.service`
|
|
347
|
+
today). When *deploy_text* is a template — it still contains
|
|
348
|
+
`<PLACEHOLDER>` tokens after normalizing — an installed copy that fills
|
|
349
|
+
them in consistently also counts as a match: the placeholder is the
|
|
350
|
+
only thing allowed to differ, so a unit that's missing a real property
|
|
351
|
+
(e.g. `ExecStart` dropping `--machine`/`--port` entirely rather than
|
|
352
|
+
filling them in) still fails to match and still reports drift.
|
|
353
|
+
"""
|
|
354
|
+
deploy_norm = _normalize_unit_text(deploy_text, home)
|
|
355
|
+
installed_norm = _normalize_unit_text(installed_text, home)
|
|
356
|
+
if deploy_norm == installed_norm:
|
|
357
|
+
return True
|
|
358
|
+
if not _PLACEHOLDER_RE.search(deploy_norm):
|
|
359
|
+
return False
|
|
360
|
+
return _placeholder_pattern(deploy_norm).fullmatch(installed_norm) is not None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _is_templated(deploy_text: str) -> bool:
|
|
364
|
+
"""Does the *raw* reference text contain unfilled placeholders (#1928)?
|
|
365
|
+
|
|
366
|
+
Checked against the raw text, not the normalized one, purely so this
|
|
367
|
+
reads naturally at call sites that haven't normalized anything yet —
|
|
368
|
+
normalization never introduces or removes a `<PLACEHOLDER>` token.
|
|
369
|
+
"""
|
|
370
|
+
return _PLACEHOLDER_RE.search(deploy_text) is not None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _templated_remedy(deploy_text: str, deploy_path: Path, installed_path: Path, service: str) -> str:
|
|
374
|
+
"""The remedy for a still-drifting *templated* unit (#1928).
|
|
375
|
+
|
|
376
|
+
Never the bare `cp` the non-template branch prints: run verbatim, that
|
|
377
|
+
installs `<MACHINE_NAME>`/`<PORT>` as literal text and the unit refuses
|
|
378
|
+
to start — which is this issue's entire complaint. When every
|
|
379
|
+
placeholder in the template has a known, safe substitution (today:
|
|
380
|
+
`MACHINE_NAME`, `PORT`) the remedy is a real, runnable `sed` — copy-paste
|
|
381
|
+
safe, per #1928's acceptance criteria. If some future placeholder has no
|
|
382
|
+
known substitution, fall back to a pointer at the template's own
|
|
383
|
+
documented install procedure rather than guess a value.
|
|
384
|
+
"""
|
|
385
|
+
names = sorted(set(_PLACEHOLDER_RE.findall(deploy_text)))
|
|
386
|
+
if names and all(n in _KNOWN_PLACEHOLDER_VALUES for n in names):
|
|
387
|
+
sed_args = " ".join(f'-e "s/<{n}>/{_KNOWN_PLACEHOLDER_VALUES[n]}/"' for n in names)
|
|
388
|
+
return (
|
|
389
|
+
f"{deploy_path} is a TEMPLATE — do not cp it verbatim (#1928). Render "
|
|
390
|
+
f"it for this host first: sed {sed_args} {deploy_path} > {installed_path} "
|
|
391
|
+
f"&& systemctl --user daemon-reload && systemctl --user restart {service}"
|
|
392
|
+
)
|
|
393
|
+
return (
|
|
394
|
+
f"{deploy_path} is a TEMPLATE ({', '.join(names)} placeholder(s)) — copying "
|
|
395
|
+
"it verbatim installs those as literal text and the unit will not start. "
|
|
396
|
+
f"See the install instructions at the top of {deploy_path} (sed substitution "
|
|
397
|
+
"or install-agent.sh) to render it for this host before installing."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _diff_summary(installed_text: str, deploy_text: str) -> tuple[int, int | None]:
|
|
402
|
+
"""(changed line count, first differing line number in the installed
|
|
403
|
+
file) between two unit files — a cheap stand-in for a full diff in a
|
|
404
|
+
one-line `headroom` string."""
|
|
405
|
+
diff = list(
|
|
406
|
+
difflib.unified_diff(
|
|
407
|
+
installed_text.splitlines(), deploy_text.splitlines(), lineterm=""
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
changed = sum(
|
|
411
|
+
1 for line in diff if line.startswith(("+", "-")) and not line.startswith(("+++", "---"))
|
|
412
|
+
)
|
|
413
|
+
first_line = None
|
|
414
|
+
for line in diff:
|
|
415
|
+
m = re.match(r"@@ -(\d+)", line)
|
|
416
|
+
if m:
|
|
417
|
+
first_line = int(m.group(1))
|
|
418
|
+
break
|
|
419
|
+
return changed, first_line
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def find_path_shadow(installed_text: str) -> str | None:
|
|
423
|
+
"""The PATH entry that shadows the release, or None if the installed
|
|
424
|
+
unit's PATH is safe (no editable checkout ahead of a release marker).
|
|
425
|
+
|
|
426
|
+
A "release marker" is `~/.local/bin` or `~/.coord-venv/bin` — either
|
|
427
|
+
resolves `coord` to the pinned install (`~/.local/bin/coord` is a
|
|
428
|
+
symlink onto `~/.coord-venv/bin/coord`). An entry whose LAST path
|
|
429
|
+
component is `.venv/bin` (a project-local dev venv, as opposed to the
|
|
430
|
+
dot-prefixed-but-distinct `.coord-venv`/`.coord-cli-venv`) ahead of that
|
|
431
|
+
marker is exactly the #1831 split-brain: `shutil.which("coord")`
|
|
432
|
+
(`coord_argv()`, `coord/drive.py`) resolves it first.
|
|
433
|
+
|
|
434
|
+
Only the LAST `Environment=PATH=` directive is read — systemd unit files
|
|
435
|
+
may repeat `Environment=`, and later directives for the same key are
|
|
436
|
+
what actually take effect.
|
|
437
|
+
"""
|
|
438
|
+
matches = _PATH_LINE_RE.findall(installed_text)
|
|
439
|
+
if not matches:
|
|
440
|
+
return None
|
|
441
|
+
entries = [e for e in matches[-1].split(":") if e]
|
|
442
|
+
|
|
443
|
+
release_idx = None
|
|
444
|
+
for idx, entry in enumerate(entries):
|
|
445
|
+
stripped = entry.rstrip("/")
|
|
446
|
+
if any(stripped.endswith(marker) for marker in _RELEASE_MARKERS):
|
|
447
|
+
release_idx = idx
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
for idx, entry in enumerate(entries):
|
|
451
|
+
if release_idx is not None and idx >= release_idx:
|
|
452
|
+
break
|
|
453
|
+
if entry.rstrip("/").endswith("/.venv/bin"):
|
|
454
|
+
return entry
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@check(
|
|
459
|
+
id="unit_drift",
|
|
460
|
+
scope="machine",
|
|
461
|
+
title="unit drift",
|
|
462
|
+
order=44,
|
|
463
|
+
description=(
|
|
464
|
+
"Installed systemd user units (~/.config/systemd/user/) match the "
|
|
465
|
+
"units packaged with the installed release, and no unit's PATH lets "
|
|
466
|
+
"an editable checkout shadow that release (#1831, #1927)."
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
def probe_unit_drift(ctx: HealthContext) -> list[CheckResult]:
|
|
470
|
+
reference = resolve_reference(ctx)
|
|
471
|
+
if reference is None:
|
|
472
|
+
return [
|
|
473
|
+
CheckResult(
|
|
474
|
+
check_id="unit_drift",
|
|
475
|
+
scope="machine",
|
|
476
|
+
severity=Severity.OK,
|
|
477
|
+
headroom="no deploy/ checkout found on this machine",
|
|
478
|
+
values={"deploy_dir": None, "reference_source": None},
|
|
479
|
+
)
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
deploy_dir = reference.path
|
|
483
|
+
installed_dir = resolve_systemd_user_dir(ctx)
|
|
484
|
+
results: list[CheckResult] = []
|
|
485
|
+
for deploy_path in _unit_files(deploy_dir):
|
|
486
|
+
name = deploy_path.name
|
|
487
|
+
installed_path = installed_dir / name
|
|
488
|
+
values: dict = {
|
|
489
|
+
"deploy_path": str(deploy_path),
|
|
490
|
+
"installed_path": str(installed_path),
|
|
491
|
+
# #1927: a green is only interpretable alongside what produced
|
|
492
|
+
# it, so every result carries its reference — and whether that
|
|
493
|
+
# reference is the released artifact or a working copy.
|
|
494
|
+
"reference_dir": str(reference.path),
|
|
495
|
+
"reference_source": reference.source,
|
|
496
|
+
"reference_verified": reference.verified,
|
|
497
|
+
"reference_version": reference.version,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if not installed_path.exists():
|
|
501
|
+
results.append(
|
|
502
|
+
CheckResult(
|
|
503
|
+
check_id="unit_drift",
|
|
504
|
+
scope="machine",
|
|
505
|
+
subject=name,
|
|
506
|
+
severity=Severity.OK,
|
|
507
|
+
headroom="not installed on this machine",
|
|
508
|
+
values={**values, "installed": False},
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
deploy_text = deploy_path.read_text()
|
|
515
|
+
installed_text = installed_path.read_text()
|
|
516
|
+
installed_mtime = installed_path.stat().st_mtime
|
|
517
|
+
except OSError as exc:
|
|
518
|
+
results.append(
|
|
519
|
+
CheckResult(
|
|
520
|
+
check_id="unit_drift",
|
|
521
|
+
scope="machine",
|
|
522
|
+
subject=name,
|
|
523
|
+
severity=Severity.UNKNOWN,
|
|
524
|
+
headroom=f"could not read unit: {exc}",
|
|
525
|
+
error=str(exc),
|
|
526
|
+
values={**values, "installed": True},
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
values["installed"] = True
|
|
532
|
+
values["installed_mtime"] = installed_mtime
|
|
533
|
+
# #1928: not a bare `==` — `deploy_text` may be a template (today,
|
|
534
|
+
# only coord-agent.service), and even for a plain file, comment/blank
|
|
535
|
+
# lines and %h-vs-literal-$HOME spelling are never real drift.
|
|
536
|
+
templated = _is_templated(deploy_text)
|
|
537
|
+
matches = _content_matches(deploy_text, installed_text, ctx.home)
|
|
538
|
+
values["matches"] = matches
|
|
539
|
+
values["templated"] = templated
|
|
540
|
+
shadow_entry = find_path_shadow(installed_text)
|
|
541
|
+
values["shadow_entry"] = shadow_entry
|
|
542
|
+
|
|
543
|
+
if shadow_entry:
|
|
544
|
+
age = ctx.now - installed_mtime
|
|
545
|
+
detail = (
|
|
546
|
+
f"editable checkout '{shadow_entry}' precedes the release "
|
|
547
|
+
"entry point on this unit's PATH — shutil.which(\"coord\") in "
|
|
548
|
+
"subprocesses this unit spawns resolves the checkout instead "
|
|
549
|
+
"of the pinned release (#1831). Reorder PATH= so ~/.local/bin "
|
|
550
|
+
"or ~/.coord-venv/bin comes first."
|
|
551
|
+
)
|
|
552
|
+
results.append(
|
|
553
|
+
CheckResult(
|
|
554
|
+
check_id="unit_drift",
|
|
555
|
+
scope="machine",
|
|
556
|
+
subject=name,
|
|
557
|
+
severity=Severity.CRIT,
|
|
558
|
+
headroom=f"PATH shadow risk ({human_hours(age)} since install)",
|
|
559
|
+
detail=detail,
|
|
560
|
+
threshold="crit when a .venv/bin entry precedes ~/.local/bin or ~/.coord-venv/bin",
|
|
561
|
+
values=values,
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
if not matches:
|
|
567
|
+
changed, first_line = _diff_summary(installed_text, deploy_text)
|
|
568
|
+
age = ctx.now - installed_mtime
|
|
569
|
+
values["diff_lines"] = changed
|
|
570
|
+
values["first_diff_line"] = first_line
|
|
571
|
+
where = f", first differing at line {first_line}" if first_line else ""
|
|
572
|
+
# #1928: a templated reference (coord-agent.service) never gets
|
|
573
|
+
# the bare `cp` remedy below — followed verbatim it installs
|
|
574
|
+
# `<MACHINE_NAME>`/`<PORT>` as literal text and takes the unit
|
|
575
|
+
# down. See `_templated_remedy`.
|
|
576
|
+
if templated:
|
|
577
|
+
detail = (
|
|
578
|
+
f"{_templated_remedy(deploy_text, deploy_path, installed_path, name.rsplit('.', 1)[0])}"
|
|
579
|
+
f" # reference: {reference.label}"
|
|
580
|
+
)
|
|
581
|
+
else:
|
|
582
|
+
# The remedy sources from the SAME file the diff read
|
|
583
|
+
# (#1927) — a `cp` out of an unverified checkout is how a
|
|
584
|
+
# stale unit got cemented in the first place.
|
|
585
|
+
detail = (
|
|
586
|
+
f"cp {deploy_path} {installed_path} && systemctl --user "
|
|
587
|
+
f"daemon-reload && systemctl --user restart "
|
|
588
|
+
f"{name.rsplit('.', 1)[0]} # reference: {reference.label}"
|
|
589
|
+
)
|
|
590
|
+
results.append(
|
|
591
|
+
CheckResult(
|
|
592
|
+
check_id="unit_drift",
|
|
593
|
+
scope="machine",
|
|
594
|
+
subject=name,
|
|
595
|
+
severity=Severity.WARN,
|
|
596
|
+
headroom=(
|
|
597
|
+
f"stale — installed {human_hours(age)} ago, {changed} "
|
|
598
|
+
f"line(s) differ from {reference.short_label}"
|
|
599
|
+
f"{where}"
|
|
600
|
+
),
|
|
601
|
+
detail=detail,
|
|
602
|
+
threshold=f"warn when installed content != {reference.short_label}",
|
|
603
|
+
values=values,
|
|
604
|
+
)
|
|
605
|
+
)
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
if not reference.verified:
|
|
609
|
+
# Content matches, but the reference is a working copy nothing
|
|
610
|
+
# verified is current (#1927). Reporting OK here is the exact
|
|
611
|
+
# false green this check was rebuilt to stop emitting: a stale
|
|
612
|
+
# checkout and a stale installed unit agree with each other.
|
|
613
|
+
results.append(
|
|
614
|
+
CheckResult(
|
|
615
|
+
check_id="unit_drift",
|
|
616
|
+
scope="machine",
|
|
617
|
+
subject=name,
|
|
618
|
+
severity=Severity.UNKNOWN,
|
|
619
|
+
headroom=(
|
|
620
|
+
f"matches {reference.short_label}, but that reference "
|
|
621
|
+
"is an unverified working copy — cannot confirm this "
|
|
622
|
+
"is the released unit"
|
|
623
|
+
),
|
|
624
|
+
detail=(
|
|
625
|
+
f"diffed against {reference.label}. Nothing checks "
|
|
626
|
+
"that copy is at the released tag, and an installed "
|
|
627
|
+
"unit drifts for the same reason a checkout does, so "
|
|
628
|
+
"the two go stale together and agree (#1927). Install "
|
|
629
|
+
"a release wheel on this host (it ships coord/deploy/) "
|
|
630
|
+
"to make this comparison meaningful."
|
|
631
|
+
),
|
|
632
|
+
values=values,
|
|
633
|
+
)
|
|
634
|
+
)
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
results.append(
|
|
638
|
+
CheckResult(
|
|
639
|
+
check_id="unit_drift",
|
|
640
|
+
scope="machine",
|
|
641
|
+
subject=name,
|
|
642
|
+
severity=Severity.OK,
|
|
643
|
+
headroom=f"matches {reference.short_label}",
|
|
644
|
+
values=values,
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
return results
|