Add bounded Panama recovery actions

This commit is contained in:
Gabriel Brown
2026-08-18 10:35:18 -04:00
parent d855a26bd9
commit 2cc109f5e1
6 changed files with 596 additions and 24 deletions
@@ -11,6 +11,11 @@ Item {
property int actionActivationCount: 0 property int actionActivationCount: 0
signal actionRequested(var check) 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}` objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
implicitHeight: 62 implicitHeight: 62
@@ -38,6 +43,14 @@ Item {
return Theme.fgMuted; return Theme.fgMuted;
} }
function displayedStatus(): string {
if (root.repairWorking)
return "Working…";
if (root.repairFailed)
return "Repair failed";
return root.statusLabel(root.check.status);
}
Rectangle { Rectangle {
id: issueMark id: issueMark
@@ -91,7 +104,7 @@ Item {
Text { Text {
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}` objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: root.statusLabel(root.check.status) text: root.displayedStatus()
color: root.statusColor(root.check.status) color: root.statusColor(root.check.status)
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -102,8 +115,8 @@ Item {
objectName: `health-row-action:${root.issue ? "issue" : "quiet"}:${root.check.id}` objectName: `health-row-action:${root.issue ? "issue" : "quiet"}:${root.check.id}`
visible: root.check.action !== undefined visible: root.check.action !== undefined
text: Health.repairingId === root.check.id ? "Working…" : (root.check.action?.label ?? "") text: root.check.action?.label ?? ""
enabled: visible && Health.repairingId !== root.check.id && !Health.busy enabled: visible && !root.repairWorking && !Health.busy
activeFocusOnTab: enabled activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1 border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
+174 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
@@ -15,6 +15,7 @@ from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from types import MappingProxyType
from typing import Callable, Literal from typing import Callable, Literal
Status = Literal["ok", "warning", "error", "unconfigured"] Status = Literal["ok", "warning", "error", "unconfigured"]
@@ -73,6 +74,23 @@ class CommandResult:
stdout: str = "" 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 = ( CHECK_ORDER = (
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals", "desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
"desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire", "desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire",
@@ -90,6 +108,19 @@ SYSTEMCTL_COMMANDS = {
"nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"), "nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"),
"rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.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") PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b") 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) 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) 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: def executable_exists(name: str, config: DoctorConfig) -> bool:
return shutil.which(name, path=config.path) is not None 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: def check_runtime_links(config: DoctorConfig) -> Check:
names = ("hypr", "quickshell", "uwsm", "vicinae") def valid_link(name: str, relative_source: Path) -> bool:
if any(not (config.config_home / name).is_symlink() or not (config.config_home / name).exists() for name in names): 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", "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.") 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]} 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: 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 = parser.add_mutually_exclusive_group()
output.add_argument("--json", action="store_true") output.add_argument("--json", action="store_true")
output.add_argument("--summary", action="store_true") output.add_argument("--summary", action="store_true")
parser.add_argument("--repair", metavar="CHECK_ID")
args = parser.parse_args(argv) 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()) result = snapshot(config_from_environment())
if args.summary: if args.summary:
summary = result["summary"] summary = result["summary"]
+95 -9
View File
@@ -24,9 +24,10 @@ Singleton {
property var lastRepair: ({}) property var lastRepair: ({})
property string lastCopyResult: "" property string lastCopyResult: ""
property bool startupScanEnabled: true property bool startupScanEnabled: true
property bool postRepairScanPending: false
readonly property bool actionable: root.status === "warning" || root.status === "error" 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") readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
|| Quickshell.shellDir + "/scripts/panama-doctor" || Quickshell.shellDir + "/scripts/panama-doctor"
readonly property var statuses: ["ok", "warning", "error", "unconfigured"] readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
@@ -68,8 +69,25 @@ Singleton {
property string checkId: "" property string checkId: ""
property bool external: false 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 { Process {
@@ -226,26 +244,92 @@ Singleton {
root.lastError = ""; root.lastError = "";
repairProcess.checkId = id; repairProcess.checkId = id;
repairProcess.external = external; repairProcess.external = external;
repairProcess.outputText = "";
repairProcess.exitCode = -1;
repairProcess.exited = false;
repairProcess.streamFinished = false;
repairProcess.settled = false;
repairProcess.exec([root.helperPath, "--repair", id, "--json"]); repairProcess.exec([root.helperPath, "--repair", id, "--json"]);
return true; return true;
} }
function finishRepair(exitCode: int, id: string, external: bool): void { function settleRepair(): void {
const succeeded = exitCode === 0; 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.repairingId = "";
root.lastRepair = ({ id: id, succeeded: succeeded }); root.lastRepair = result;
if (!succeeded) { root.lastError = failed ? result.message : "";
root.lastError = "Panama could not repair this item. Try refreshing or use the recommended setup steps."; if (failed && external && !failureNotification.running) {
if (external) {
failureNotification.exec([ failureNotification.exec([
"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic", "notify-send", "-a", "Panama", "-i", "dialog-error-symbolic",
"Panama action failed", "The requested health repair could not be completed." "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(); 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 { function copyReport(): bool {
if (copyProcess.running) if (copyProcess.running)
return false; return false;
@@ -265,6 +349,8 @@ Singleton {
generation: root.generation, generation: root.generation,
acceptedGeneration: root.acceptedGeneration, acceptedGeneration: root.acceptedGeneration,
repairingId: root.repairingId, repairingId: root.repairingId,
lastRepair: root.lastRepair,
lastError: root.lastError,
checks: root.checks.map(check => check.id), checks: root.checks.map(check => check.id),
checkStates: root.checks.map(check => ({ id: check.id, status: check.status })) checkStates: root.checks.map(check => ({ id: check.id, status: check.status }))
}; };
+83 -7
View File
@@ -75,15 +75,26 @@ fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)"
helper="$fixture_dir/panama-doctor" helper="$fixture_dir/panama-doctor"
copy_bin="$fixture_dir/bin" copy_bin="$fixture_dir/bin"
copy_file="$fixture_dir/copied-report.json" 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' \ printf '%s\n' \
'#!/usr/bin/env bash' \ '#!/usr/bin/env bash' \
'printf "%s\n" "$*" >>"$PANAMA_HEALTH_REPAIR_LOG"' \
'if [[ "$1" == "--json" ]]; then' \ 'if [[ "$1" == "--json" ]]; then' \
' sleep 0.2' \ ' sleep 0.2' \
" printf '%s\\n' '$warning_snapshot'" \ " printf '%s\\n' '$warning_snapshot'" \
' exit 0' \ ' exit 0' \
'fi' \ 'fi' \
'if [[ "$1" == "--repair" && "$2" == "panama.caffeine" && "$3" == "--json" ]]; then' \ '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' \ 'fi' \
'exit 2' >"$helper" 'exit 2' >"$helper"
chmod +x "$helper" chmod +x "$helper"
@@ -91,9 +102,16 @@ mkdir -p "$copy_bin"
printf '%s\n' \ printf '%s\n' \
'#!/usr/bin/env bash' \ '#!/usr/bin/env bash' \
'/usr/bin/cat > "$PANAMA_HEALTH_COPY_FILE"' >"$copy_bin/wl-copy" '/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="" harness_pid=""
cleanup() { cleanup() {
@@ -103,6 +121,8 @@ cleanup() {
trap cleanup EXIT trap cleanup EXIT
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ 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 qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
run ipc show 2>/dev/null | rg -q '^target health-test$' && break 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" \ 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" >/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" ]] \ [[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \
|| fail 'repairable check was refused' || 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 for _ in $(seq 1 120); do
state="$(run ipc call health-test status)" 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 >/dev/null <<<"$state" && break
sleep 0.1 sleep 0.1
done done
jq -e '.busy == false and .generation == ($before + 3)' --argjson before "$before_generation" \ jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false
>/dev/null <<<"$state" || fail "accepted repair did not trigger one rescan: $state" 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" ]] \ [[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \
|| fail 'unknown check started a repair' || fail 'unknown check started a repair'
[[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \ [[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \
|| fail 'non-repairable check started a repair' || fail 'non-repairable check started a repair'
state="$(run ipc call health-test status)" 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" >/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 trap - EXIT
cleanup cleanup
printf 'health service contract: PASS\n' printf 'health service contract: PASS\n'
+35 -1
View File
@@ -114,6 +114,11 @@ cp -a "$repo_dir/config/dot/quickshell" "$config_path"
cat >"$helper" <<'EOF' cat >"$helper" <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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 sleep 1.5
printf '%s\n' "$PANAMA_HEALTH_FIXTURE" printf '%s\n' "$PANAMA_HEALTH_FIXTURE"
EOF EOF
@@ -296,13 +301,42 @@ jq -e '
and (.focusChain | any(startswith("health-row-action:"))) 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" ' >/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" ]] \ [[ "$(run ipc call health-ui-test request desktop.quickshell)" == "true" ]] \
|| fail 'restart confirmation fixture could not be requested' || fail 'restart confirmation fixture could not be requested'
confirmation_state="$(run ipc call health-ui-test state)" confirmation_state="$(run ipc call health-ui-test state)"
jq -e ' jq -e '
.confirmationVisible == true .confirmationVisible == true
and .confirmationId == "desktop.quickshell" 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' >/dev/null <<<"$confirmation_state" || fail 'Quickshell restart did not open confirmation sheet'
+193
View File
@@ -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\)$ ]] \ [[ "$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" || 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' printf 'panama doctor contract: PASS\n'