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,36 @@
|
|
|
1
|
+
# coord-web-dist-build — fires coord-web-dist-build.service (Type=oneshot) on
|
|
2
|
+
# a cadence. See that unit's header, and deploy/coord-web-dist-build.sh's
|
|
3
|
+
# header, for the full rationale.
|
|
4
|
+
#
|
|
5
|
+
# 1 minute — the #1543 acceptance target ("live... within a minute"). This
|
|
6
|
+
# is safe at a tight cadence for two reasons a 15-minute cadence (like
|
|
7
|
+
# coord-drive-queue.timer) doesn't need to justify:
|
|
8
|
+
#
|
|
9
|
+
# 1. Most fires are a no-op: the script compares origin/main's resolved SHA
|
|
10
|
+
# against the currently-published release and exits immediately if
|
|
11
|
+
# nothing merged since the last run — no npm invocation, no I/O beyond
|
|
12
|
+
# one `git fetch` + `rev-parse`.
|
|
13
|
+
# 2. The script's own flock means a slow build (network hiccup during
|
|
14
|
+
# `npm ci`, etc.) can't stack with the next fire — a busy run is
|
|
15
|
+
# detected and the new invocation exits 0 immediately, exactly like
|
|
16
|
+
# coord-drive-queue's tick.
|
|
17
|
+
#
|
|
18
|
+
# A build that actually runs (something under coord/dashboard/webapp/**
|
|
19
|
+
# merged) takes on the order of tens of seconds; that run and the next may
|
|
20
|
+
# overlap the flock, in which case the next fire simply retries a minute
|
|
21
|
+
# later — still within the acceptance target on average.
|
|
22
|
+
#
|
|
23
|
+
# Install: see deploy/coord-web-dist-build.sh's header, or
|
|
24
|
+
# docs/PHONE_WEBAPP.md / docs/AGENT_OPERATIONS.md.
|
|
25
|
+
|
|
26
|
+
[Unit]
|
|
27
|
+
Description=Rebuild coord webapp dist from merged main every minute (#1543)
|
|
28
|
+
|
|
29
|
+
[Timer]
|
|
30
|
+
OnBootSec=1min
|
|
31
|
+
OnUnitActiveSec=1min
|
|
32
|
+
AccuracySec=10s
|
|
33
|
+
Persistent=false
|
|
34
|
+
|
|
35
|
+
[Install]
|
|
36
|
+
WantedBy=timers.target
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# coord web — phone/web control-center dashboard (#700/#703).
|
|
2
|
+
#
|
|
3
|
+
# A systemd *user* unit (mirrors coord-serve). Serves the board dashboard +
|
|
4
|
+
# the React/Vite phone PWA on port 7434. It is the THIRD always-on user
|
|
5
|
+
# service, after coord-agent (every worker box) and coord-serve (the DB host).
|
|
6
|
+
#
|
|
7
|
+
# Install on the SAME always-on box that owns ~/.coord/coord.db (dellserver):
|
|
8
|
+
# `coord web` reads the LOCAL board (state.load_board/build_board) — it does
|
|
9
|
+
# NOT yet go through the coord serve daemon — so it MUST run where the DB
|
|
10
|
+
# lives. Do not run it on a thin-client machine (it would render an empty
|
|
11
|
+
# board). Routing it through the daemon so it can run anywhere — like the
|
|
12
|
+
# CLI/TUI thin clients — is tracked in #749 (the BoardService facade).
|
|
13
|
+
#
|
|
14
|
+
# Install:
|
|
15
|
+
# mkdir -p ~/.config/systemd/user
|
|
16
|
+
# cp deploy/coord-web.service ~/.config/systemd/user/
|
|
17
|
+
# loginctl enable-linger "$USER" # survive logout / reboot
|
|
18
|
+
# # First build the dist dir BEFORE starting the service — the assets
|
|
19
|
+
# # Mount/SPA catch-all routes are registered once at process start, so
|
|
20
|
+
# # they need something to point at (see coord-web-dist-build.sh):
|
|
21
|
+
# ~/.local/bin/coord-web-dist-build.sh
|
|
22
|
+
# systemctl --user daemon-reload
|
|
23
|
+
# systemctl --user enable --now coord-web
|
|
24
|
+
# # ...and the timer that keeps the dist dir tracking merged main (#1543):
|
|
25
|
+
# cp deploy/coord-web-dist-build.service deploy/coord-web-dist-build.timer \
|
|
26
|
+
# ~/.config/systemd/user/
|
|
27
|
+
# systemctl --user enable --now coord-web-dist-build.timer
|
|
28
|
+
#
|
|
29
|
+
# Prereqs on the dashboard host (== the DB host):
|
|
30
|
+
# - ~/.coord-venv has code-coordinator installed via PyPI (`coord`
|
|
31
|
+
# itself — the daemon/CLI binary; unrelated to the webapp bundle below).
|
|
32
|
+
# - ~/.coord/coordinator.yml exists (canonical location; same resolution as
|
|
33
|
+
# the daemon: $COORD_CONFIG → ~/.coord/coordinator.yml → ./coordinator.yml).
|
|
34
|
+
# - ~/.coord/coord.db exists (this box owns it).
|
|
35
|
+
# - ~/coord-web-dist exists — a symlink that coord-web-dist-build.sh /
|
|
36
|
+
# .timer keep pointed at a fresh build of coord/dashboard/webapp from
|
|
37
|
+
# merged main (#1543; see deploy/coord-web-dist-build.sh's header for the
|
|
38
|
+
# full mechanism). This is intentionally decoupled from ~/.coord-venv: a
|
|
39
|
+
# merged webapp PR goes live within ~1 minute via that timer, with no
|
|
40
|
+
# `pip install`, no PyPI release, and no restart of coord-web,
|
|
41
|
+
# coord-agent, or coord-serve.
|
|
42
|
+
#
|
|
43
|
+
# #1543 CORRECTION (2026-08-04): earlier revisions of this file said
|
|
44
|
+
# ~/.coord-venv's wheel bundles the built webapp (#758, landed
|
|
45
|
+
# 2026-08-03) and that upgrading the venv was how a webapp change went
|
|
46
|
+
# live. That is still TRUE of the wheel, but is no longer how this unit
|
|
47
|
+
# serves the webapp — the --dist override below reads from
|
|
48
|
+
# ~/coord-web-dist instead, specifically so shipping a webapp change
|
|
49
|
+
# does not require upgrading the venv that coord-agent/coord-serve also
|
|
50
|
+
# run from. If ~/coord-web-dist is ever missing (fresh install before
|
|
51
|
+
# the first build, or the timer disabled), --dist falls back to the
|
|
52
|
+
# bundled coord/dashboard/webapp/dist inside the venv, same legacy
|
|
53
|
+
# fallback as before.
|
|
54
|
+
#
|
|
55
|
+
# Reach it from a phone on the tailnet: http://<dellserver-host>:7434
|
|
56
|
+
# (same-origin API, so the phone needs no config; Add to Home Screen installs
|
|
57
|
+
# the PWA).
|
|
58
|
+
#
|
|
59
|
+
# Restart over SSH needs the runtime-dir prefix (same #404 caveat as the agent):
|
|
60
|
+
# XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user restart coord-web
|
|
61
|
+
#
|
|
62
|
+
# Restarting coord-web (or its dist rebuilding) never touches ~/.coord-venv,
|
|
63
|
+
# so it cannot disturb coord-serve (7435) or coord-agent (7433) — see
|
|
64
|
+
# docs/PHONE_WEBAPP.md's "Going live automatically (#1543)" section for the
|
|
65
|
+
# before/after version proof this was verified against.
|
|
66
|
+
#
|
|
67
|
+
# See docs/AGENT_OPERATIONS.md → "Web dashboard (coord web)".
|
|
68
|
+
#
|
|
69
|
+
# TimeoutStopSec / KillMode (#2095): coord-web serves `text/event-stream`
|
|
70
|
+
# endpoints (coord/dashboard/server.py), and uvicorn's graceful shutdown
|
|
71
|
+
# waits for every open connection to drain. An SSE stream does not close on
|
|
72
|
+
# its own — a browser tab or the phone PWA left open holds the stop open
|
|
73
|
+
# INDEFINITELY, past any timeout a restart caller is willing to wait. During
|
|
74
|
+
# the 2026-08-10 0.5.15 -> 0.5.26 roll that left the unit `deactivating` for
|
|
75
|
+
# the run's entire wait, which the caller (coord/agent_app.py's
|
|
76
|
+
# `_restart_sibling_unit`, at the time a blocking `systemctl restart` under
|
|
77
|
+
# a hard 15s subprocess timeout) turned into an abandoned mid-stop unit:
|
|
78
|
+
# not restarted — STOPPED — while still being reported a success one layer
|
|
79
|
+
# up. Bounding the stop HERE, at the unit, means systemd escalates to
|
|
80
|
+
# SIGKILL on its own after TimeoutStopSec regardless of what any caller's
|
|
81
|
+
# own timeout is doing, instead of leaving an open-ended wait for a client
|
|
82
|
+
# that may never disconnect. KillMode=process (rather than the default
|
|
83
|
+
# control-group) targets only the main uvicorn process — sidestepping the
|
|
84
|
+
# same recovery failure the incident hit by hand afterwards
|
|
85
|
+
# (`systemctl --user kill -s SIGKILL coord-web` refused with "Failed to send
|
|
86
|
+
# signal SIGKILL to auxiliary processes: Invalid argument") — and follows
|
|
87
|
+
# the KillMode precedent already established on coord-drive-queue.service
|
|
88
|
+
# (#1830), even though the underlying reason differs (that one protects a
|
|
89
|
+
# spawned tmux server from this unit's own cgroup teardown; this one avoids
|
|
90
|
+
# ever having to signal a nonexistent auxiliary process group at all).
|
|
91
|
+
|
|
92
|
+
[Unit]
|
|
93
|
+
Description=coord web — phone/web control-center dashboard (#700)
|
|
94
|
+
After=network-online.target
|
|
95
|
+
Wants=network-online.target
|
|
96
|
+
|
|
97
|
+
[Service]
|
|
98
|
+
Type=simple
|
|
99
|
+
ExecStart=%h/.coord-venv/bin/coord web --config %h/.coord/coordinator.yml --host 0.0.0.0 --port 7434 --dist %h/coord-web-dist
|
|
100
|
+
# #2095: the `--port` above is the ONLY declaration of this dashboard's port.
|
|
101
|
+
# `coord/agent_app.py`'s `_probe_liveness` — the post-restart liveness check
|
|
102
|
+
# `_restart_sibling_unit` runs (from inside the coord-agent process) before
|
|
103
|
+
# trusting coord-web's restart — reads it back off this very line via
|
|
104
|
+
# `systemctl --user show coord-web --property=ExecStart`, so changing the port
|
|
105
|
+
# here needs no matching edit anywhere else and cannot desync the probe from
|
|
106
|
+
# the listener. An earlier revision declared `Environment=COORD_WEB_PORT=7434`
|
|
107
|
+
# here instead; that could never work, because systemd does not share
|
|
108
|
+
# `Environment=` across units and the process that reads it runs under
|
|
109
|
+
# coord-agent.service. Do not re-add it (here or there): a second declaration
|
|
110
|
+
# that has to agree with this one by hand is the failure mode, not the fix.
|
|
111
|
+
# Belt-and-suspenders for #1229: systemd user units have no controlling TTY so
|
|
112
|
+
# TERM is absent from the process environment. The terminal bridge code now
|
|
113
|
+
# calls env.setdefault("TERM", "xterm-256color") before spawning tmux, but
|
|
114
|
+
# setting it here too ensures any other subprocess spawned by coord web also
|
|
115
|
+
# has a sane terminal type without requiring a code fix for each callsite.
|
|
116
|
+
Environment=TERM=xterm-256color
|
|
117
|
+
Restart=on-failure
|
|
118
|
+
RestartSec=3
|
|
119
|
+
# #2095 — see the header comment above. Bounds the SSE-drain hang that took
|
|
120
|
+
# the dashboard down during the 2026-08-10 fleet roll.
|
|
121
|
+
TimeoutStopSec=10
|
|
122
|
+
KillMode=process
|
|
123
|
+
|
|
124
|
+
[Install]
|
|
125
|
+
WantedBy=default.target
|
coord/deploy_manifest.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Per-role systemd unit manifest — which units a host runs (#2098).
|
|
2
|
+
|
|
3
|
+
`deploy/` ships every packaged unit and `coord release verify` /
|
|
4
|
+
`deploy_units.install_units` both deliberately decline to guess which of
|
|
5
|
+
them a given host should run — *"a release does not decide which services
|
|
6
|
+
a host runs"* (`coord/deploy_units.py`'s module docstring). That refusal is
|
|
7
|
+
correct, but it left the actual decision nowhere: not in a file, not in
|
|
8
|
+
code, only as `~/.config/systemd/user/timers.target.wants/` symlinks on
|
|
9
|
+
dellserver. Losing dellserver would have meant rebuilding that list from
|
|
10
|
+
memory.
|
|
11
|
+
|
|
12
|
+
This module is that list, in code instead of folklore. It mirrors
|
|
13
|
+
`docs/AGENT_OPERATIONS.md`'s "Daemon-host unit inventory" table — keep both
|
|
14
|
+
in sync; `tests/test_deploy_manifest.py` cross-checks the doc table against
|
|
15
|
+
:data:`ROLE_UNITS` so the two cannot quietly drift apart the way the
|
|
16
|
+
propagate-timer enable step did.
|
|
17
|
+
|
|
18
|
+
Each name is the unit that should actually be `systemctl --user enable
|
|
19
|
+
--now`'d. For a timer-backed lane that is always the `.timer`, never the
|
|
20
|
+
oneshot `.service` it fires — enabling the oneshot does nothing; only the
|
|
21
|
+
timer's own `[Install]` section wires anything into
|
|
22
|
+
`timers.target.wants/`. `coord-agent`, `coord-serve` and `coord-web` have
|
|
23
|
+
no timer and are listed as `.service` because they ARE the long-running
|
|
24
|
+
unit.
|
|
25
|
+
|
|
26
|
+
Consumed by :mod:`coord.health.checks.unit_enablement` (#2098), which
|
|
27
|
+
reads :func:`all_manifest_units` to decide which *installed* units it is
|
|
28
|
+
entitled to expect `enabled`. It does not use this module to guess which
|
|
29
|
+
role a given host plays — same "don't infer topology" boundary as
|
|
30
|
+
`unit_drift` and `deploy_units`: an installed-but-unlisted unit is not this
|
|
31
|
+
module's business, and an uninstalled manifest unit is not a fault (most
|
|
32
|
+
hosts are workers, and are not supposed to run the daemon lanes).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
ROLE_WORKER = "worker"
|
|
38
|
+
ROLE_DAEMON = "daemon"
|
|
39
|
+
|
|
40
|
+
#: role -> unit names that role's host should have running, in the order
|
|
41
|
+
#: they appear in docs/AGENT_OPERATIONS.md's "Daemon-host unit inventory"
|
|
42
|
+
#: table. `coord-agent.service` is listed under both roles because every
|
|
43
|
+
#: machine — daemon host included — runs it.
|
|
44
|
+
ROLE_UNITS: dict[str, tuple[str, ...]] = {
|
|
45
|
+
ROLE_WORKER: (
|
|
46
|
+
"coord-agent.service",
|
|
47
|
+
),
|
|
48
|
+
ROLE_DAEMON: (
|
|
49
|
+
"coord-agent.service",
|
|
50
|
+
"coord-serve.service",
|
|
51
|
+
"coord-web.service",
|
|
52
|
+
"coord-web-dist-build.timer",
|
|
53
|
+
"coord-notify.timer",
|
|
54
|
+
"coord-drive-queue.timer",
|
|
55
|
+
"coord-release-propagate.timer",
|
|
56
|
+
"coord-db-backup.timer",
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def units_for_role(role: str) -> tuple[str, ...]:
|
|
62
|
+
"""The units *role* should run, or `()` for an unknown role."""
|
|
63
|
+
return ROLE_UNITS.get(role, ())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def all_manifest_units() -> tuple[str, ...]:
|
|
67
|
+
"""Every unit named by any role, deduped and sorted.
|
|
68
|
+
|
|
69
|
+
What :mod:`coord.health.checks.unit_enablement` iterates: it does not
|
|
70
|
+
need to know *which* role this host plays, only whether an installed
|
|
71
|
+
unit is one this manifest ever expects to be enabled somewhere.
|
|
72
|
+
"""
|
|
73
|
+
seen: set[str] = set()
|
|
74
|
+
out: list[str] = []
|
|
75
|
+
for units in ROLE_UNITS.values():
|
|
76
|
+
for name in units:
|
|
77
|
+
if name not in seen:
|
|
78
|
+
seen.add(name)
|
|
79
|
+
out.append(name)
|
|
80
|
+
return tuple(sorted(out))
|
coord/deploy_units.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""The `deploy/**` lane's missing *deploy step* (#1831, wired up by #1835).
|
|
2
|
+
|
|
3
|
+
#1831 gave `deploy/**` a **detector** — ``coord.health.checks.unit_drift``
|
|
4
|
+
diffs each host's installed unit under ``~/.config/systemd/user/`` against
|
|
5
|
+
the units packaged in the wheel (``coord/deploy/``, #1927) — and a printed
|
|
6
|
+
remedy that a human then runs by hand. That was the right first half. It is
|
|
7
|
+
not a deploy lane: cutting v0.4.106 still meant installing five files into
|
|
8
|
+
``~/.config/systemd/user/`` and ``~/.local/bin/`` on dellserver, plus a
|
|
9
|
+
``daemon-reload``, plus retiring a machine-local drop-in.
|
|
10
|
+
|
|
11
|
+
#1835 cannot claim "the fleet reaches that version" while a whole lane needs
|
|
12
|
+
a human with ``cp`` and ``systemctl``, and this is not a corner case: #1543's
|
|
13
|
+
actual mechanism was three unit files and a shell script, while its Python
|
|
14
|
+
change was a single ``--dist`` flag. A release that propagated only the
|
|
15
|
+
Python lane would have shipped the flag and none of the behaviour.
|
|
16
|
+
|
|
17
|
+
So this module applies what ``unit_drift`` reports.
|
|
18
|
+
|
|
19
|
+
THREE SAFETY PROPERTIES, ALL DELIBERATE
|
|
20
|
+
---------------------------------------
|
|
21
|
+
1. **Refresh only units this host already runs.** Which services a host runs
|
|
22
|
+
is a *topology* decision (``coordinator.yml``), not a release decision. A
|
|
23
|
+
packaged unit with no installed counterpart is reported as ``new`` and
|
|
24
|
+
left alone — installing ``coord-web.service`` onto a machine that never
|
|
25
|
+
wanted a web server, because a release happened to contain the file, is a
|
|
26
|
+
far worse failure than a human running one ``cp``. The report names them
|
|
27
|
+
so the human action is visible rather than implicit.
|
|
28
|
+
|
|
29
|
+
2. **Templates are rendered, never copied verbatim (#1928).** Several units
|
|
30
|
+
carry ``<MACHINE_NAME>`` / ``<PORT>`` placeholders. Copying one verbatim
|
|
31
|
+
installs the placeholder as literal text and the unit then refuses to
|
|
32
|
+
start — the exact hazard #1928 documented. Placeholders with no known
|
|
33
|
+
substitution abort *that unit* (reported, not written); they never get
|
|
34
|
+
guessed.
|
|
35
|
+
|
|
36
|
+
3. **The previous content is kept.** Every overwrite writes
|
|
37
|
+
``<name>.pre-<version>.bak`` next to the unit first, so the rollback for
|
|
38
|
+
this lane is a file copy the operator can see and `diff`, not a re-run of
|
|
39
|
+
an install script whose inputs have moved on.
|
|
40
|
+
|
|
41
|
+
The write itself is atomic per file (temp file + ``os.replace``), so a unit
|
|
42
|
+
is never observed half-written by a ``daemon-reload`` racing this.
|
|
43
|
+
|
|
44
|
+
Pure-ish by construction: every path is a parameter, so the whole thing is
|
|
45
|
+
testable against ``tmp_path`` with no systemd, no fleet, and no root.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
import shutil
|
|
53
|
+
from dataclasses import asdict, dataclass, field
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
# Reuse #1831's own definitions rather than re-spelling them. Two
|
|
57
|
+
# definitions of "which files are units" or "what a placeholder looks like"
|
|
58
|
+
# would let the detector and the deployer disagree — and a deployer that
|
|
59
|
+
# disagrees with its detector reports clean while shipping nothing.
|
|
60
|
+
from coord.health.checks.unit_drift import (
|
|
61
|
+
_KNOWN_PLACEHOLDER_VALUES,
|
|
62
|
+
_PLACEHOLDER_RE,
|
|
63
|
+
_SYSTEMD_USER_DIR,
|
|
64
|
+
_UNIT_GLOBS,
|
|
65
|
+
packaged_unit_dir,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
#: Placeholder -> how to fill it, given the host facts we actually know.
|
|
69
|
+
#: ``unit_drift`` renders these as *shell* text for a copy-pasteable remedy
|
|
70
|
+
#: (``$(hostname -s)``); here they must be real values, so the mapping is
|
|
71
|
+
#: from placeholder name to the keyword of :func:`install_units`.
|
|
72
|
+
_PLACEHOLDER_SOURCES = {
|
|
73
|
+
"MACHINE_NAME": "machine_name",
|
|
74
|
+
"PORT": "port",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#: Outcome of one unit's deploy step.
|
|
78
|
+
ACTION_UNCHANGED = "unchanged"
|
|
79
|
+
ACTION_UPDATED = "updated"
|
|
80
|
+
ACTION_NEW = "new"
|
|
81
|
+
ACTION_SKIPPED = "skipped"
|
|
82
|
+
ACTION_FAILED = "failed"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class UnitOutcome:
|
|
87
|
+
name: str
|
|
88
|
+
action: str
|
|
89
|
+
detail: str = ""
|
|
90
|
+
backup: str | None = None
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict:
|
|
93
|
+
return asdict(self)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class InstallReport:
|
|
98
|
+
"""What the deploy step did, per unit."""
|
|
99
|
+
|
|
100
|
+
units: list[UnitOutcome] = field(default_factory=list)
|
|
101
|
+
reference: str | None = None
|
|
102
|
+
error: str | None = None
|
|
103
|
+
#: True once at least one unit's bytes changed — the only case that
|
|
104
|
+
#: needs a ``systemctl --user daemon-reload``.
|
|
105
|
+
@property
|
|
106
|
+
def changed(self) -> bool:
|
|
107
|
+
return any(u.action == ACTION_UPDATED for u in self.units)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def ok(self) -> bool:
|
|
111
|
+
return self.error is None and not any(
|
|
112
|
+
u.action == ACTION_FAILED for u in self.units
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict:
|
|
116
|
+
return {
|
|
117
|
+
"reference": self.reference,
|
|
118
|
+
"error": self.error,
|
|
119
|
+
"changed": self.changed,
|
|
120
|
+
"ok": self.ok,
|
|
121
|
+
"units": [u.to_dict() for u in self.units],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def summary(self) -> str:
|
|
125
|
+
counts: dict[str, int] = {}
|
|
126
|
+
for unit in self.units:
|
|
127
|
+
counts[unit.action] = counts.get(unit.action, 0) + 1
|
|
128
|
+
if self.error:
|
|
129
|
+
return f"units: {self.error}"
|
|
130
|
+
if not counts:
|
|
131
|
+
return "units: nothing packaged to install"
|
|
132
|
+
return "units: " + ", ".join(
|
|
133
|
+
f"{count} {action}" for action, count in sorted(counts.items())
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def systemd_user_dir(home: Path | None = None) -> Path:
|
|
138
|
+
base = home or Path.home()
|
|
139
|
+
return Path(str(_SYSTEMD_USER_DIR).replace("~", str(base), 1))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _packaged_units(reference_dir: Path) -> list[Path]:
|
|
143
|
+
seen: set[str] = set()
|
|
144
|
+
out: list[Path] = []
|
|
145
|
+
for pattern in _UNIT_GLOBS:
|
|
146
|
+
for path in sorted(reference_dir.glob(pattern)):
|
|
147
|
+
if path.name in seen:
|
|
148
|
+
continue
|
|
149
|
+
seen.add(path.name)
|
|
150
|
+
out.append(path)
|
|
151
|
+
return sorted(out, key=lambda p: p.name)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def render_unit(text: str, *, machine_name: str | None, port: int | str | None) -> tuple[str | None, str]:
|
|
155
|
+
"""Fill ``<PLACEHOLDER>`` tokens. Returns ``(rendered_or_None, note)``.
|
|
156
|
+
|
|
157
|
+
``None`` means "this unit is a template with a placeholder we cannot
|
|
158
|
+
fill" — the caller must skip it and say so. Guessing a value here is how
|
|
159
|
+
a unit lands with ``<MACHINE_NAME>`` as literal text and then refuses to
|
|
160
|
+
start (#1928).
|
|
161
|
+
"""
|
|
162
|
+
names = sorted(set(_PLACEHOLDER_RE.findall(text)))
|
|
163
|
+
if not names:
|
|
164
|
+
return text, ""
|
|
165
|
+
values = {"machine_name": machine_name, "port": port}
|
|
166
|
+
filled: dict[str, str] = {}
|
|
167
|
+
for name in names:
|
|
168
|
+
key = _PLACEHOLDER_SOURCES.get(name)
|
|
169
|
+
value = values.get(key) if key else None
|
|
170
|
+
if value in (None, ""):
|
|
171
|
+
fallback = _KNOWN_PLACEHOLDER_VALUES.get(name)
|
|
172
|
+
return None, (
|
|
173
|
+
f"template placeholder <{name}> has no value for this host"
|
|
174
|
+
+ (f" (unit_drift's documented default is {fallback})" if fallback else "")
|
|
175
|
+
+ " — refusing to install it verbatim (#1928); install this "
|
|
176
|
+
"unit by hand"
|
|
177
|
+
)
|
|
178
|
+
filled[name] = str(value)
|
|
179
|
+
|
|
180
|
+
def _sub(match: re.Match[str]) -> str:
|
|
181
|
+
return filled[match.group(1)]
|
|
182
|
+
|
|
183
|
+
return _PLACEHOLDER_RE.sub(_sub, text), f"rendered {', '.join(names)}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
187
|
+
tmp = path.with_name(path.name + ".coord-tmp")
|
|
188
|
+
tmp.write_text(text, encoding="utf-8")
|
|
189
|
+
os.replace(tmp, path)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def install_units(
|
|
193
|
+
*,
|
|
194
|
+
target_dir: Path | None = None,
|
|
195
|
+
reference_dir: Path | None = None,
|
|
196
|
+
machine_name: str | None = None,
|
|
197
|
+
port: int | str | None = None,
|
|
198
|
+
version: str | None = None,
|
|
199
|
+
dry_run: bool = False,
|
|
200
|
+
home: Path | None = None,
|
|
201
|
+
) -> InstallReport:
|
|
202
|
+
"""Refresh this host's installed systemd user units from the wheel.
|
|
203
|
+
|
|
204
|
+
*reference_dir* defaults to :func:`~coord.health.checks.unit_drift.
|
|
205
|
+
packaged_unit_dir` — ``coord/deploy/`` inside the *installed*
|
|
206
|
+
distribution, i.e. the released artifact, which cannot drift with the
|
|
207
|
+
host's checkout (#1927). Only units already present in *target_dir* are
|
|
208
|
+
rewritten; see the module docstring for why.
|
|
209
|
+
"""
|
|
210
|
+
report = InstallReport()
|
|
211
|
+
ref = reference_dir or packaged_unit_dir()
|
|
212
|
+
if ref is None:
|
|
213
|
+
report.error = (
|
|
214
|
+
"this install ships no coord/deploy/ — it predates #1927, so "
|
|
215
|
+
"there is no released unit set to deploy from. Upgrade the "
|
|
216
|
+
"Python lane first."
|
|
217
|
+
)
|
|
218
|
+
return report
|
|
219
|
+
report.reference = str(ref)
|
|
220
|
+
|
|
221
|
+
dest_dir = target_dir or systemd_user_dir(home)
|
|
222
|
+
suffix = f".pre-{version}" if version else ".pre-update"
|
|
223
|
+
|
|
224
|
+
for source in _packaged_units(ref):
|
|
225
|
+
installed = dest_dir / source.name
|
|
226
|
+
if not installed.exists():
|
|
227
|
+
report.units.append(
|
|
228
|
+
UnitOutcome(
|
|
229
|
+
source.name,
|
|
230
|
+
ACTION_NEW,
|
|
231
|
+
"packaged but not installed on this host — a release does "
|
|
232
|
+
"not decide which services a host runs; install and enable "
|
|
233
|
+
"it by hand if this host should have it",
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
source_text = source.read_text(encoding="utf-8")
|
|
240
|
+
except OSError as exc:
|
|
241
|
+
report.units.append(
|
|
242
|
+
UnitOutcome(source.name, ACTION_FAILED, f"unreadable reference: {exc}")
|
|
243
|
+
)
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
rendered, note = render_unit(
|
|
247
|
+
source_text, machine_name=machine_name, port=port
|
|
248
|
+
)
|
|
249
|
+
if rendered is None:
|
|
250
|
+
report.units.append(UnitOutcome(source.name, ACTION_SKIPPED, note))
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
current = installed.read_text(encoding="utf-8")
|
|
255
|
+
except OSError as exc:
|
|
256
|
+
report.units.append(
|
|
257
|
+
UnitOutcome(source.name, ACTION_FAILED, f"unreadable installed unit: {exc}")
|
|
258
|
+
)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
if current == rendered:
|
|
262
|
+
report.units.append(UnitOutcome(source.name, ACTION_UNCHANGED, note))
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
if dry_run:
|
|
266
|
+
report.units.append(
|
|
267
|
+
UnitOutcome(source.name, ACTION_UPDATED, f"would rewrite ({note})".strip())
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
backup = installed.with_name(installed.name + suffix + ".bak")
|
|
272
|
+
try:
|
|
273
|
+
shutil.copy2(installed, backup)
|
|
274
|
+
_atomic_write(installed, rendered)
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
report.units.append(
|
|
277
|
+
UnitOutcome(source.name, ACTION_FAILED, f"write failed: {exc}")
|
|
278
|
+
)
|
|
279
|
+
continue
|
|
280
|
+
report.units.append(
|
|
281
|
+
UnitOutcome(
|
|
282
|
+
source.name,
|
|
283
|
+
ACTION_UPDATED,
|
|
284
|
+
note or "content refreshed from the packaged release",
|
|
285
|
+
backup=str(backup),
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return report
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def daemon_reload(*, runner=None, timeout: float = 30.0) -> tuple[bool, str]:
|
|
293
|
+
"""``systemctl --user daemon-reload``. Returns ``(ok, output)``.
|
|
294
|
+
|
|
295
|
+
Split out and injectable so :func:`install_units` stays a pure filesystem
|
|
296
|
+
operation testable without systemd — and so a host with no systemd (a
|
|
297
|
+
macOS worker) degrades to a reported skip rather than a traceback.
|
|
298
|
+
"""
|
|
299
|
+
import subprocess # noqa: PLC0415
|
|
300
|
+
|
|
301
|
+
run = runner or subprocess.run
|
|
302
|
+
try:
|
|
303
|
+
proc = run(
|
|
304
|
+
["systemctl", "--user", "daemon-reload"],
|
|
305
|
+
capture_output=True,
|
|
306
|
+
text=True,
|
|
307
|
+
timeout=timeout,
|
|
308
|
+
)
|
|
309
|
+
except FileNotFoundError:
|
|
310
|
+
return False, "systemctl not found (no systemd on this host)"
|
|
311
|
+
except Exception as exc: # noqa: BLE001 — a reload must never crash a roll
|
|
312
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
313
|
+
ok = getattr(proc, "returncode", 1) == 0
|
|
314
|
+
out = (getattr(proc, "stderr", "") or getattr(proc, "stdout", "") or "").strip()
|
|
315
|
+
return ok, out or ("daemon-reload ok" if ok else "daemon-reload failed")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
#: Which of :func:`install_units`'s outcomes mean "this unit is actually
|
|
319
|
+
#: present on this host right now" — the only units eligible to be enabled
|
|
320
|
+
#: below. ``ACTION_NEW`` (never installed here — safety property 1: a
|
|
321
|
+
#: release does not decide which services a host runs) and
|
|
322
|
+
#: ``ACTION_SKIPPED``/``ACTION_FAILED`` (this write did not land) are
|
|
323
|
+
#: deliberately excluded.
|
|
324
|
+
_PRESENT_ACTIONS = frozenset({ACTION_UNCHANGED, ACTION_UPDATED})
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def enable_timers(
|
|
328
|
+
report: InstallReport, *, runner=None, timeout: float = 30.0,
|
|
329
|
+
) -> dict[str, tuple[bool, str]]:
|
|
330
|
+
"""``systemctl --user enable --now <unit>`` for every installed timer in
|
|
331
|
+
*report* (#2082).
|
|
332
|
+
|
|
333
|
+
#2082: ``coord-release-propagate.timer`` reached three hosts'
|
|
334
|
+
``~/.config/systemd/user/`` and sat there — :func:`install_units`
|
|
335
|
+
refreshed its *content* on every release, but nothing ever ran
|
|
336
|
+
``enable --now`` on it, and nothing noticed because a disabled timer's
|
|
337
|
+
file looks byte-for-byte identical to an active one's (see
|
|
338
|
+
:mod:`coord.health.checks.timer_active`, the detector for exactly this).
|
|
339
|
+
|
|
340
|
+
A ``.timer`` unit is different from a ``.service`` here: which
|
|
341
|
+
*services* a host runs is a one-time topology choice
|
|
342
|
+
(``install-agent.sh``, or a human at machine setup — see this module's
|
|
343
|
+
docstring), never something a routine refresh should override. A timer
|
|
344
|
+
that exists at all has no reason to exist disabled — its whole job is to
|
|
345
|
+
fire on a schedule with nobody watching, so "installed but not enabled"
|
|
346
|
+
is invisible from the outside and would otherwise self-heal never.
|
|
347
|
+
Applying this on every deploy makes enablement an assertion this lane
|
|
348
|
+
re-checks every time it runs, the same way :func:`install_units`
|
|
349
|
+
re-asserts content every time it runs.
|
|
350
|
+
|
|
351
|
+
Both ``enable`` and ``--now`` are idempotent — an already-enabled,
|
|
352
|
+
already-active timer is untouched — so calling this on every deploy is a
|
|
353
|
+
correctness check, not a state change, in the common case. Scoped to
|
|
354
|
+
units *this* report found actually installed (:data:`_PRESENT_ACTIONS`),
|
|
355
|
+
matching the deploy step's own "only touch what this host already runs"
|
|
356
|
+
rule.
|
|
357
|
+
"""
|
|
358
|
+
import subprocess # noqa: PLC0415
|
|
359
|
+
|
|
360
|
+
run = runner or subprocess.run
|
|
361
|
+
out: dict[str, tuple[bool, str]] = {}
|
|
362
|
+
timers = sorted(
|
|
363
|
+
u.name
|
|
364
|
+
for u in report.units
|
|
365
|
+
if u.name.endswith(".timer") and u.action in _PRESENT_ACTIONS
|
|
366
|
+
)
|
|
367
|
+
for name in timers:
|
|
368
|
+
try:
|
|
369
|
+
proc = run(
|
|
370
|
+
["systemctl", "--user", "enable", "--now", name],
|
|
371
|
+
capture_output=True,
|
|
372
|
+
text=True,
|
|
373
|
+
timeout=timeout,
|
|
374
|
+
)
|
|
375
|
+
except FileNotFoundError:
|
|
376
|
+
out[name] = (False, "systemctl not found (no systemd on this host)")
|
|
377
|
+
continue
|
|
378
|
+
except Exception as exc: # noqa: BLE001 — must never crash a deploy
|
|
379
|
+
out[name] = (False, f"{type(exc).__name__}: {exc}")
|
|
380
|
+
continue
|
|
381
|
+
ok = getattr(proc, "returncode", 1) == 0
|
|
382
|
+
detail = (getattr(proc, "stderr", "") or getattr(proc, "stdout", "") or "").strip()
|
|
383
|
+
out[name] = (ok, detail or ("enabled" if ok else "enable failed"))
|
|
384
|
+
return out
|