Add bounded Panama recovery actions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 }))
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user