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 }))
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user