From 2cc109f5e109c6e7cdcf3c4ee13f9bb5a80c3ce4 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:35:18 -0400 Subject: [PATCH] Add bounded Panama recovery actions --- .../modules/settings/HealthCheckRow.qml | 19 +- config/dot/quickshell/scripts/panama-doctor | 178 +++++++++++++++- config/dot/quickshell/services/Health.qml | 104 +++++++++- tests/quickshell/health-service-contract.sh | 90 +++++++- tests/quickshell/health-ui-contract.sh | 36 +++- tests/quickshell/panama-doctor-contract.sh | 193 ++++++++++++++++++ 6 files changed, 596 insertions(+), 24 deletions(-) diff --git a/config/dot/quickshell/modules/settings/HealthCheckRow.qml b/config/dot/quickshell/modules/settings/HealthCheckRow.qml index eb0020e..e1e398e 100644 --- a/config/dot/quickshell/modules/settings/HealthCheckRow.qml +++ b/config/dot/quickshell/modules/settings/HealthCheckRow.qml @@ -11,6 +11,11 @@ Item { property int actionActivationCount: 0 signal actionRequested(var check) + readonly property bool repairWorking: Health.repairingId === root.check.id + readonly property bool repairFailed: root.check.status !== "ok" + && Health.lastRepair.checkId === root.check.id + && (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0) + objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}` implicitHeight: 62 @@ -38,6 +43,14 @@ Item { return Theme.fgMuted; } + function displayedStatus(): string { + if (root.repairWorking) + return "Working…"; + if (root.repairFailed) + return "Repair failed"; + return root.statusLabel(root.check.status); + } + Rectangle { id: issueMark @@ -91,7 +104,7 @@ Item { Text { objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}` anchors.verticalCenter: parent.verticalCenter - text: root.statusLabel(root.check.status) + text: root.displayedStatus() color: root.statusColor(root.check.status) font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall @@ -102,8 +115,8 @@ Item { objectName: `health-row-action:${root.issue ? "issue" : "quiet"}:${root.check.id}` visible: root.check.action !== undefined - text: Health.repairingId === root.check.id ? "Working…" : (root.check.action?.label ?? "") - enabled: visible && Health.repairingId !== root.check.id && !Health.busy + text: root.check.action?.label ?? "" + enabled: visible && !root.repairWorking && !Health.busy activeFocusOnTab: enabled border.width: activeFocus ? 2 : 1 border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index 4004a54..ccbf08f 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""Read-only, redacted diagnostics for Panama-owned desktop functionality.""" +"""Redacted diagnostics and bounded repairs for Panama-owned functionality.""" from __future__ import annotations @@ -15,6 +15,7 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path +from types import MappingProxyType from typing import Callable, Literal Status = Literal["ok", "warning", "error", "unconfigured"] @@ -73,6 +74,23 @@ class CommandResult: stdout: str = "" +@dataclass(frozen=True) +class RepairResult: + check_id: str + accepted: bool + exit_code: int + message: str + + def as_json(self) -> dict[str, object]: + return { + "schemaVersion": 1, + "checkId": self.check_id, + "accepted": self.accepted, + "exitCode": self.exit_code, + "message": self.message, + } + + CHECK_ORDER = ( "desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals", "desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire", @@ -90,6 +108,19 @@ SYSTEMCTL_COMMANDS = { "nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"), "rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.service"), } +REPAIR_COMMANDS = MappingProxyType({ + "desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"), + "desktop.hypridle": ("systemctl", "--user", "restart", "hypridle.service"), + "desktop.vicinae": ("systemctl", "--user", "restart", "vicinae.service"), + "desktop.quickshell": ("panama-action", "restart-shell"), +}) +RUNTIME_LINK_TARGETS = ( + ("hypr", Path("config/dot/hypr")), + ("quickshell", Path("config/dot/quickshell")), + ("uwsm", Path("config/dot/uwsm")), + ("vicinae", Path("config/dot/vicinae")), +) +REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine")) PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle") VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b") REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE) @@ -174,6 +205,28 @@ def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None return CommandResult("ok", completed.stdout) +def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> tuple[int, str]: + """Execute one authored repair argv and retain output only for strict parsing.""" + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=config.timeout, + check=False, + env=config.command_env, + cwd=cwd, + ) + except FileNotFoundError: + return 127, "" + except subprocess.TimeoutExpired: + return 124, "" + except OSError: + return 126, "" + exit_code = completed.returncode if 0 <= completed.returncode <= 255 else 1 + return exit_code, completed.stdout + + def executable_exists(name: str, config: DoctorConfig) -> bool: return shutil.which(name, path=config.path) is not None @@ -340,8 +393,16 @@ def check_calendar(config: DoctorConfig) -> Check: def check_runtime_links(config: DoctorConfig) -> Check: - names = ("hypr", "quickshell", "uwsm", "vicinae") - if any(not (config.config_home / name).is_symlink() or not (config.config_home / name).exists() for name in names): + def valid_link(name: str, relative_source: Path) -> bool: + destination = config.config_home / name + source = config.root / relative_source + try: + return source.is_dir() and destination.is_symlink() \ + and destination.resolve(strict=False) == source.resolve(strict=True) + except OSError: + return False + + if any(not valid_link(name, relative_source) for name, relative_source in RUNTIME_LINK_TARGETS): return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "warning", "One or more Panama runtime links are unavailable.", Action("repair", "Repair runtime links")) return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "ok", "Panama runtime links are available.") @@ -466,12 +527,121 @@ def snapshot(config: DoctorConfig) -> dict[str, object]: return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]} +def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult: + command = REPAIR_COMMANDS[check_id] + exit_code, _ = run_repair_command(command, config) + message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \ + else "The authored repair command could not be completed." + return RepairResult(check_id, True, exit_code, message) + + +def repair_runtime_links(config: DoctorConfig) -> RepairResult: + sources = [(name, config.root / relative_source) for name, relative_source in RUNTIME_LINK_TARGETS] + if any(not source.is_dir() for _, source in sources): + return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are unavailable.") + + try: + config.config_home.mkdir(parents=True, exist_ok=True) + except OSError: + return RepairResult("panama.runtime-links", True, 1, "Panama runtime links could not be accessed.") + + blocked = False + failed = False + for name, source in sources: + destination = config.config_home / name + try: + if destination.is_symlink(): + if destination.resolve(strict=False) == source.resolve(strict=True): + continue + destination.unlink() + destination.symlink_to(source, target_is_directory=True) + elif destination.exists(): + # A regular file or directory is user-owned unless proven + # otherwise. Report it, but never replace it. + blocked = True + else: + destination.symlink_to(source, target_is_directory=True) + except OSError: + failed = True + + if failed: + return RepairResult("panama.runtime-links", True, 1, "One or more Panama runtime links could not be recreated.") + if blocked: + return RepairResult("panama.runtime-links", True, 1, "A user-owned file or directory is blocking a Panama runtime link.") + return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.") + + +def repair_vicinae_commands(config: DoctorConfig) -> RepairResult: + helper = config.root / "setup/scripts/link-vicinae-scripts" + if not helper.is_file(): + return RepairResult("panama.vicinae-commands", True, 127, "The authored Vicinae link helper is unavailable.") + exit_code, _ = run_repair_command((str(helper),), config, config.root) + message = "Panama commands were relinked. A fresh health check will verify them." if exit_code == 0 \ + else "Panama commands could not be relinked." + return RepairResult("panama.vicinae-commands", True, exit_code, message) + + +def repair_caffeine(config: DoctorConfig) -> RepairResult: + list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend") + list_exit, output = run_repair_command(list_command, config) + if list_exit != 0: + return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.") + + uid = str(os.getuid()) + inhibitor_pids: list[str] = [] + for line in output.splitlines(): + parts = line.split() + if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid: + continue + if len(parts) != 8: + return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") + if parts[6] != "Caffeine" or parts[7] != "block": + continue + if not parts[3].isdecimal(): + return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") + inhibitor_pids.append(parts[3]) + + if len(inhibitor_pids) <= 1: + return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.") + + for pid in inhibitor_pids[1:]: + exit_code, _ = run_repair_command(("kill", "--", pid), config) + if exit_code != 0: + return RepairResult("panama.caffeine", True, exit_code, "A duplicate Panama Caffeine inhibitor could not be released.") + return RepairResult("panama.caffeine", True, 0, "Duplicate Panama Caffeine inhibitors were released. A fresh health check will verify recovery.") + + +def repair(check_id: str, config: DoctorConfig) -> RepairResult: + if check_id in REPAIR_COMMANDS: + return repair_authored_command(check_id, config) + if check_id == "panama.runtime-links": + return repair_runtime_links(config) + if check_id == "panama.vicinae-commands": + return repair_vicinae_commands(config) + if check_id == "panama.caffeine": + return repair_caffeine(config) + return RepairResult(check_id, False, 2, "This health check has no authored repair.") + + def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description="Read-only Panama system diagnostics") + parser = argparse.ArgumentParser(description="Panama system diagnostics and bounded repairs") output = parser.add_mutually_exclusive_group() output.add_argument("--json", action="store_true") output.add_argument("--summary", action="store_true") + parser.add_argument("--repair", metavar="CHECK_ID") args = parser.parse_args(argv) + + if args.repair is not None: + if args.repair not in REPAIR_IDS or args.summary: + result = RepairResult(args.repair, False, 2, "This health check has no authored repair.") + else: + try: + result = repair(args.repair, config_from_environment()) + except Exception: + result = RepairResult(args.repair, True, 1, "The authored repair could not be completed.") + print(json.dumps(result.as_json(), separators=(",", ":"), sort_keys=False)) + return result.exit_code + result = snapshot(config_from_environment()) if args.summary: summary = result["summary"] diff --git a/config/dot/quickshell/services/Health.qml b/config/dot/quickshell/services/Health.qml index ab5211b..9608dba 100644 --- a/config/dot/quickshell/services/Health.qml +++ b/config/dot/quickshell/services/Health.qml @@ -24,9 +24,10 @@ Singleton { property var lastRepair: ({}) property string lastCopyResult: "" property bool startupScanEnabled: true + property bool postRepairScanPending: false readonly property bool actionable: root.status === "warning" || root.status === "error" - readonly property bool busy: scanProcess.running || repairProcess.running + readonly property bool busy: scanProcess.running || repairProcess.running || root.postRepairScanPending readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER") || Quickshell.shellDir + "/scripts/panama-doctor" readonly property var statuses: ["ok", "warning", "error", "unconfigured"] @@ -68,8 +69,25 @@ Singleton { property string checkId: "" property bool external: false + property string outputText: "" + property int exitCode: -1 + property bool exited: false + property bool streamFinished: false + property bool settled: false - onExited: (exitCode, exitStatus) => root.finishRepair(exitCode, checkId, external) + stdout: StdioCollector { + onStreamFinished: { + repairProcess.outputText = this.text; + repairProcess.streamFinished = true; + root.settleRepair(); + } + } + + onExited: (exitCode, exitStatus) => { + repairProcess.exitCode = exitCode; + repairProcess.exited = true; + root.settleRepair(); + } } Process { @@ -226,26 +244,92 @@ Singleton { root.lastError = ""; repairProcess.checkId = id; repairProcess.external = external; + repairProcess.outputText = ""; + repairProcess.exitCode = -1; + repairProcess.exited = false; + repairProcess.streamFinished = false; + repairProcess.settled = false; repairProcess.exec([root.helperPath, "--repair", id, "--json"]); return true; } - function finishRepair(exitCode: int, id: string, external: bool): void { - const succeeded = exitCode === 0; + function settleRepair(): void { + if (repairProcess.settled || !repairProcess.exited || !repairProcess.streamFinished) + return; + repairProcess.settled = true; + root.finishRepair( + repairProcess.exitCode, + repairProcess.checkId, + repairProcess.external, + repairProcess.outputText + ); + } + + function finishRepair(exitCode: int, id: string, external: bool, text: string): void { + let candidate; + try { + candidate = JSON.parse(text.trim()); + } catch (error) { + candidate = null; + } + + const result = root.validRepairResult(candidate, id, exitCode) + ? { + schemaVersion: 1, + checkId: candidate.checkId, + accepted: candidate.accepted, + exitCode: candidate.exitCode, + message: candidate.message + } + : { + schemaVersion: 1, + checkId: id, + accepted: false, + exitCode: exitCode, + message: "Panama returned an invalid repair response." + }; + const failed = !result.accepted || result.exitCode !== 0; root.repairingId = ""; - root.lastRepair = ({ id: id, succeeded: succeeded }); - if (!succeeded) { - root.lastError = "Panama could not repair this item. Try refreshing or use the recommended setup steps."; - if (external) { + root.lastRepair = result; + root.lastError = failed ? result.message : ""; + if (failed && external && !failureNotification.running) { failureNotification.exec([ "notify-send", "-a", "Panama", "-i", "dialog-error-symbolic", "Panama action failed", "The requested health repair could not be completed." ]); - } } + + // Any refresh requested while the repair was running is satisfied by + // this one observed post-repair scan. Process exit alone never changes + // the accepted check rows. + root.queuedRefresh = false; + root.postRepairScanPending = true; + Qt.callLater(root.startPostRepairScan); + } + + function startPostRepairScan(): void { + root.postRepairScanPending = false; + root.queuedRefresh = false; root.refresh(); } + function validRepairResult(candidate: var, id: string, processExitCode: int): bool { + if (!root.plainObject(candidate)) + return false; + const keys = Object.keys(candidate).sort(); + const expectedKeys = ["accepted", "checkId", "exitCode", "message", "schemaVersion"]; + if (keys.length !== expectedKeys.length + || !keys.every((key, index) => key === expectedKeys[index])) + return false; + return candidate.schemaVersion === 1 + && candidate.checkId === id + && typeof candidate.accepted === "boolean" + && Number.isInteger(candidate.exitCode) + && candidate.exitCode === processExitCode + && typeof candidate.message === "string" + && candidate.message.length > 0; + } + function copyReport(): bool { if (copyProcess.running) return false; @@ -265,6 +349,8 @@ Singleton { generation: root.generation, acceptedGeneration: root.acceptedGeneration, repairingId: root.repairingId, + lastRepair: root.lastRepair, + lastError: root.lastError, checks: root.checks.map(check => check.id), checkStates: root.checks.map(check => ({ id: check.id, status: check.status })) }; diff --git a/tests/quickshell/health-service-contract.sh b/tests/quickshell/health-service-contract.sh index 2feeaef..61f81d2 100755 --- a/tests/quickshell/health-service-contract.sh +++ b/tests/quickshell/health-service-contract.sh @@ -75,15 +75,26 @@ fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)" helper="$fixture_dir/panama-doctor" copy_bin="$fixture_dir/bin" copy_file="$fixture_dir/copied-report.json" +repair_mode_file="$fixture_dir/repair-mode" +repair_log="$fixture_dir/repair.log" +notification_log="$fixture_dir/notifications.log" +printf 'success\n' >"$repair_mode_file" printf '%s\n' \ '#!/usr/bin/env bash' \ + 'printf "%s\n" "$*" >>"$PANAMA_HEALTH_REPAIR_LOG"' \ 'if [[ "$1" == "--json" ]]; then' \ ' sleep 0.2' \ " printf '%s\\n' '$warning_snapshot'" \ ' exit 0' \ 'fi' \ 'if [[ "$1" == "--repair" && "$2" == "panama.caffeine" && "$3" == "--json" ]]; then' \ - ' exit 0' \ + ' sleep 0.25' \ + ' case "$(cat "$PANAMA_HEALTH_REPAIR_MODE_FILE")" in' \ + ' success) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":0,\"message\":\"Duplicate inhibitors were released.\"}\\n"; exit 0 ;;' \ + ' failed) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":7,\"message\":\"Duplicate inhibitors could not be released.\"}\\n"; exit 7 ;;' \ + ' mismatch) printf "{\"schemaVersion\":1,\"checkId\":\"desktop.vicinae\",\"accepted\":true,\"exitCode\":0,\"message\":\"Wrong row.\"}\\n"; exit 0 ;;' \ + ' *) printf "not-json\\n"; exit 0 ;;' \ + ' esac' \ 'fi' \ 'exit 2' >"$helper" chmod +x "$helper" @@ -91,9 +102,16 @@ mkdir -p "$copy_bin" printf '%s\n' \ '#!/usr/bin/env bash' \ '/usr/bin/cat > "$PANAMA_HEALTH_COPY_FILE"' >"$copy_bin/wl-copy" -chmod +x "$copy_bin/wl-copy" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\n" "$*" >>"$PANAMA_HEALTH_NOTIFICATION_LOG"' >"$copy_bin/notify-send" +chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send" -run() { PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" qs -p "$harness" "$@"; } +run() { + PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ + PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \ + PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" qs -p "$harness" "$@" +} harness_pid="" cleanup() { @@ -103,6 +121,8 @@ cleanup() { trap cleanup EXIT PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ + PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \ + PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \ qs -p "$harness" --daemonize >/dev/null for _ in $(seq 1 40); do run ipc show 2>/dev/null | rg -q '^target health-test$' && break @@ -165,25 +185,81 @@ done jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == false' --argjson before "$before_generation" \ >/dev/null <<<"$state" || fail "queued refresh did not run exactly once: $state" +printf 'success\n' >"$repair_mode_file" +repair_generation="$(jq -r .generation <<<"$state")" [[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \ || fail 'repairable check was refused' +run ipc call health-test queue >/dev/null +working_state="$(run ipc call health-test status)" +jq -e '.repairingId == "panama.caffeine" and .queuedRefresh == true + and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "warning"' \ + >/dev/null <<<"$working_state" || fail "repair did not retain the degraded row while working: $working_state" for _ in $(seq 1 120); do state="$(run ipc call health-test status)" - jq -e '.busy == false and .generation == ($before + 3)' --argjson before "$before_generation" \ + jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' --argjson before "$repair_generation" \ >/dev/null <<<"$state" && break sleep 0.1 done -jq -e '.busy == false and .generation == ($before + 3)' --argjson before "$before_generation" \ - >/dev/null <<<"$state" || fail "accepted repair did not trigger one rescan: $state" +jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false + and .lastRepair == {schemaVersion:1, checkId:"panama.caffeine", accepted:true, exitCode:0, message:"Duplicate inhibitors were released."} + and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "warning"' \ + --argjson before "$repair_generation" >/dev/null <<<"$state" \ + || fail "accepted repair was trusted before exactly one observed rescan: $state" + +# A syntactically valid command failure remains inline for Settings and still +# receives exactly one observed rescan. +printf 'failed\n' >"$repair_mode_file" +failure_generation="$(jq -r .generation <<<"$state")" +[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \ + || fail 'second repairable check was refused' +for _ in $(seq 1 120); do + state="$(run ipc call health-test status)" + jq -e '.busy == false and .generation == ($before + 1)' --argjson before "$failure_generation" \ + >/dev/null <<<"$state" && break + sleep 0.1 +done +jq -e '.lastRepair.checkId == "panama.caffeine" + and .lastRepair.accepted == true and .lastRepair.exitCode == 7 + and .lastRepair.message == "Duplicate inhibitors could not be released." + and .generation == ($before + 1)' --argjson before "$failure_generation" \ + >/dev/null <<<"$state" || fail "known repair failure was not retained inline: $state" +[[ ! -e "$notification_log" || ! -s "$notification_log" ]] \ + || fail 'Settings-originated repair emitted an external notification' + +# A malformed or mismatched helper response is contained and cannot masquerade +# as recovery; it also schedules only one scan. +printf 'mismatch\n' >"$repair_mode_file" +mismatch_generation="$(jq -r .generation <<<"$state")" +[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \ + || fail 'mismatch repair fixture was refused' +for _ in $(seq 1 120); do + state="$(run ipc call health-test status)" + jq -e '.busy == false and .generation == ($before + 1)' --argjson before "$mismatch_generation" \ + >/dev/null <<<"$state" && break + sleep 0.1 +done +jq -e '.lastRepair.checkId == "panama.caffeine" and .lastRepair.accepted == false + and .lastRepair.exitCode == 0 and .generation == ($before + 1)' \ + --argjson before "$mismatch_generation" >/dev/null <<<"$state" \ + || fail "mismatched repair JSON escaped containment: $state" [[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \ || fail 'unknown check started a repair' [[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \ || fail 'non-repairable check started a repair' state="$(run ipc call health-test status)" -jq -e '.repairingId == "" and .generation == ($before + 3)' --argjson before "$before_generation" \ +jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$mismatch_generation" \ >/dev/null <<<"$state" || fail "rejected repair altered process state: $state" +python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded' +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +assert 'if (failed && external && !failureNotification.running)' in source +assert '"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic"' in source +assert '"Panama action failed", "The requested health repair could not be completed."' in source +PY + trap - EXIT cleanup printf 'health service contract: PASS\n' diff --git a/tests/quickshell/health-ui-contract.sh b/tests/quickshell/health-ui-contract.sh index 526453d..7e0cbad 100755 --- a/tests/quickshell/health-ui-contract.sh +++ b/tests/quickshell/health-ui-contract.sh @@ -114,6 +114,11 @@ cp -a "$repo_dir/config/dot/quickshell" "$config_path" cat >"$helper" <<'EOF' #!/usr/bin/env bash set -euo pipefail +if [[ "${1:-}" == "--repair" ]]; then + sleep 0.35 + printf '{"schemaVersion":1,"checkId":"%s","accepted":true,"exitCode":7,"message":"The authored repair failed."}\n' "$2" + exit 7 +fi sleep 1.5 printf '%s\n' "$PANAMA_HEALTH_FIXTURE" EOF @@ -296,13 +301,42 @@ jq -e ' and (.focusChain | any(startswith("health-row-action:"))) ' >/dev/null <<<"$settled_state" || fail "actual focus-chain traversal does not reach hero and row actions: $settled_state" +settled_heights="$(jq -c .rowHeights <<<"$settled_state")" +[[ "$(run ipc call health-ui-test request desktop.vicinae)" == "true" ]] \ + || fail 'inline repair fixture could not be requested' +working_repair_state="$(run ipc call health-ui-test state)" +jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Working…"' \ + >/dev/null <<<"$working_repair_state" || fail "repair row did not show Working state: $working_repair_state" +[[ "$(jq -c .rowHeights <<<"$working_repair_state")" == "$settled_heights" ]] \ + || fail 'repair Working state changed row geometry' +for _ in $(seq 1 40); do + failed_repair_state="$(run ipc call health-ui-test state)" + jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Repair failed"' \ + >/dev/null <<<"$failed_repair_state" && break + sleep 0.1 +done +jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Repair failed"' \ + >/dev/null <<<"$failed_repair_state" || fail "repair failure was not shown inline: $failed_repair_state" +[[ "$(jq -c .rowHeights <<<"$failed_repair_state")" == "$settled_heights" ]] \ + || fail 'repair failure changed row geometry' +[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 6 ]] \ + || fail 'repair state duplicated a health action row' +for _ in $(seq 1 40); do + failed_repair_state="$(run ipc call health-ui-test state)" + [[ "$(jq -r .checking <<<"$failed_repair_state")" == "false" ]] && break + sleep 0.1 +done +[[ "$(jq -r .checking <<<"$failed_repair_state")" == "false" ]] \ + || fail 'post-repair scan did not settle before the next action' + [[ "$(run ipc call health-ui-test request desktop.quickshell)" == "true" ]] \ || fail 'restart confirmation fixture could not be requested' confirmation_state="$(run ipc call health-ui-test state)" jq -e ' .confirmationVisible == true and .confirmationId == "desktop.quickshell" - and .activatedRows == ["health-check-row:issue:desktop.quickshell"] + and (.activatedRows | index("health-check-row:issue:desktop.quickshell")) != null + and (.activatedRows | map(select(endswith(":desktop.quickshell"))) | length) == 1 ' \ >/dev/null <<<"$confirmation_state" || fail 'Quickshell restart did not open confirmation sheet' diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh index d55db44..35fedfd 100755 --- a/tests/quickshell/panama-doctor-contract.sh +++ b/tests/quickshell/panama-doctor-contract.sh @@ -235,4 +235,197 @@ summary="$(run_doctor --summary)" [[ "$summary" =~ ^Panama\ system\ health:\ (healthy|warning|error)\ \([0-9]+\ ok,\ [0-9]+\ warnings,\ [0-9]+\ errors,\ [0-9]+\ unconfigured\)$ ]] \ || fail "summary is not concise: $summary" +# Repairs run against a second, disposable Panama root. Every process boundary +# records its argv, and every filesystem assertion is confined to this fixture. +repair_root="$fixture/repair-root" +repair_log="$runtime_dir/repair.log" +mkdir -p "$repair_root/config/dot" "$repair_root/setup/scripts" +for name in hypr quickshell uwsm vicinae; do + mkdir -p "$repair_root/config/dot/$name" +done + +mv "$bin_dir/systemctl" "$bin_dir/systemctl-probe" +cat >"$bin_dir/systemctl" <<'EOF' +#!/usr/bin/bash +set -euo pipefail +if [[ "${1:-}" == "--user" && "${2:-}" == "restart" ]]; then + printf 'systemctl' >>"$XDG_RUNTIME_DIR/repair.log" + printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" + printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" + [[ ! -e "$XDG_RUNTIME_DIR/fail-repair" ]] || exit 5 + exit 0 +fi +exec "${0%/*}/systemctl-probe" "$@" +EOF + +cat >"$bin_dir/panama-action" <<'EOF' +#!/usr/bin/bash +set -euo pipefail +printf 'panama-action' >>"$XDG_RUNTIME_DIR/repair.log" +printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" +printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" +EOF + +cat >"$bin_dir/kill" <<'EOF' +#!/usr/bin/bash +set -euo pipefail +printf 'kill' >>"$XDG_RUNTIME_DIR/repair.log" +printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" +printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" +EOF + +cat >"$bin_dir/systemd-inhibit" <<'EOF' +#!/usr/bin/bash +set -euo pipefail +printf 'systemd-inhibit' >>"$XDG_RUNTIME_DIR/repair.log" +printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" +printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" +uid="$(/usr/bin/id -u)" +printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid" +printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid" +printf 'Other %s fixture-user 4999 systemd-inhibit sleep:idle Caffeine block\n' "$uid" +printf 'Panama 99999 fixture-user 4998 systemd-inhibit sleep:idle Caffeine block\n' +printf 'Panama %s fixture-user 4997 systemd-inhibit sleep:idle Other block\n' "$uid" +printf 'Panama %s fixture-user 4996 systemd-inhibit sleep:idle Caffeine delay\n' "$uid" +EOF + +cat >"$repair_root/setup/scripts/link-vicinae-scripts" <<'EOF' +#!/usr/bin/bash +set -euo pipefail +printf 'link-vicinae-scripts|%s' "$0" >>"$XDG_RUNTIME_DIR/repair.log" +if (( $# > 0 )); then + printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" +fi +printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" +EOF +chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" "$bin_dir/kill" \ + "$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts" + +run_repair() { + HOME="$home" \ + PATH="$bin_dir" \ + XDG_CURRENT_DESKTOP=Hyprland \ + PANAMA_DOCTOR_ROOT="$repair_root" \ + PANAMA_DOCTOR_HOME="$home" \ + PANAMA_DOCTOR_CONFIG_HOME="$config_home" \ + PANAMA_DOCTOR_STATE_HOME="$state_home" \ + PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \ + PANAMA_DOCTOR_PATH="$bin_dir" \ + PANAMA_DOCTOR_TIMEOUT=0.2 \ + /usr/bin/python3 "$doctor" "$@" +} + +repair_output="" +repair_status=0 +invoke_repair() { + set +e + repair_output="$(run_repair --repair "$1" --json)" + repair_status=$? + set -e +} + +assert_repair_result() { + local id="$1" accepted="$2" exit_code="$3" + jq -e --arg id "$id" --argjson accepted "$accepted" --argjson exitCode "$exit_code" ' + (keys | sort) == ["accepted", "checkId", "exitCode", "message", "schemaVersion"] + and .schemaVersion == 1 + and .checkId == $id + and .accepted == $accepted + and .exitCode == $exitCode + and (.message | type == "string" and length > 0) + ' >/dev/null <<<"$repair_output" || fail "invalid repair result for $id: $repair_output" +} + +for repair_case in \ + 'desktop.hyprpaper|systemctl|--user|restart|hyprpaper.service' \ + 'desktop.hypridle|systemctl|--user|restart|hypridle.service' \ + 'desktop.vicinae|systemctl|--user|restart|vicinae.service' \ + 'desktop.quickshell|panama-action|restart-shell'; do + IFS='|' read -r repair_id executable arg1 arg2 arg3 <<<"$repair_case" + : >"$repair_log" + invoke_repair "$repair_id" + [[ "$repair_status" == 0 ]] || fail "$repair_id returned $repair_status" + assert_repair_result "$repair_id" true 0 + expected="$executable|$arg1" + [[ -z "$arg2" ]] || expected+="|$arg2" + [[ -z "$arg3" ]] || expected+="|$arg3" + [[ "$(<"$repair_log")" == "$expected" ]] \ + || fail "$repair_id argv was not exact: $(<"$repair_log")" +done + +# Known process failures still return complete JSON and preserve the command's +# exit status for the QML state machine. +touch "$runtime_dir/fail-repair" +: >"$repair_log" +invoke_repair desktop.vicinae +rm "$runtime_dir/fail-repair" +[[ "$repair_status" == 5 ]] || fail "failed repair returned $repair_status instead of 5" +assert_repair_result desktop.vicinae true 5 +[[ "$(<"$repair_log")" == 'systemctl|--user|restart|vicinae.service' ]] \ + || fail 'failed repair changed the authored argv' + +# The Vicinae repair executes only the authored setup helper with no arguments. +: >"$repair_log" +invoke_repair panama.vicinae-commands +[[ "$repair_status" == 0 ]] || fail "Vicinae command repair returned $repair_status" +assert_repair_result panama.vicinae-commands true 0 +[[ "$(<"$repair_log")" == "link-vicinae-scripts|$repair_root/setup/scripts/link-vicinae-scripts" ]] \ + || fail "Vicinae command repair argv was not exact: $(<"$repair_log")" + +# Runtime-link repair may replace only the four authored symlink names. Broken +# or absent links are recreated toward authored tracked destinations; regular +# files and directories remain untouched and make the result incomplete. +for name in hypr quickshell uwsm vicinae; do + path="$config_home/$name" + if [[ -e "$path" || -L "$path" ]]; then + mv "$path" "$fixture/pre-repair-$name" + fi +done +ln -s "$fixture/missing-hypr" "$config_home/hypr" +ln -s "$fixture/missing-quickshell" "$config_home/quickshell" +printf 'user-owned file\n' >"$config_home/uwsm" +mkdir "$config_home/vicinae" +ln -s "$fixture/untouched" "$config_home/not-panama" +: >"$repair_log" +invoke_repair panama.runtime-links +[[ "$repair_status" == 1 ]] || fail "blocked runtime-link repair returned $repair_status" +assert_repair_result panama.runtime-links true 1 +[[ -L "$config_home/hypr" && "$(readlink "$config_home/hypr")" == "$repair_root/config/dot/hypr" ]] \ + || fail 'hypr link was not recreated toward its authored destination' +[[ -L "$config_home/quickshell" && "$(readlink "$config_home/quickshell")" == "$repair_root/config/dot/quickshell" ]] \ + || fail 'quickshell link was not recreated toward its authored destination' +[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \ + || fail 'runtime-link repair replaced a regular file' +[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \ + || fail 'runtime-link repair replaced a user-owned directory' +[[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \ + || fail 'runtime-link repair touched an unauthored link name' +[[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process' + +# Caffeine repair parses exact authored metadata, keeps the first valid lock, +# and releases only later exact matches. +: >"$repair_log" +invoke_repair panama.caffeine +[[ "$repair_status" == 0 ]] || fail "Caffeine repair returned $repair_status" +assert_repair_result panama.caffeine true 0 +expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nkill|--|4102' +[[ "$(<"$repair_log")" == "$expected_caffeine" ]] \ + || fail "Caffeine repair did not preserve/filter exact inhibitors: $(<"$repair_log")" + +# Rejected IDs are complete JSON, exit 2, and cause neither a process launch +# nor a filesystem mutation. +fixture_state() { + find "$config_home" -mindepth 1 -printf '%P|%y|%l\n' | sort | sha256sum | awk '{print $1}' +} +for rejected_id in unknown.check integration.home-assistant input.brightness \ + desktop.notifications ../../escape 'desktop.vicinae;touch injected'; do + : >"$repair_log" + before_state="$(fixture_state)" + invoke_repair "$rejected_id" + [[ "$repair_status" == 2 ]] || fail "$rejected_id returned $repair_status instead of 2" + assert_repair_result "$rejected_id" false 2 + [[ ! -s "$repair_log" ]] || fail "$rejected_id launched a process" + [[ "$(fixture_state)" == "$before_state" ]] || fail "$rejected_id mutated the filesystem" +done + printf 'panama doctor contract: PASS\n'