diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/sailfish_devel_mcp/config.py | 97 | ||||
| -rw-r--r-- | src/sailfish_devel_mcp/server.py | 79 | ||||
| -rw-r--r-- | src/sailfish_devel_mcp/tools.py | 559 |
3 files changed, 715 insertions, 20 deletions
diff --git a/src/sailfish_devel_mcp/config.py b/src/sailfish_devel_mcp/config.py index 73740d1..198b09d 100644 --- a/src/sailfish_devel_mcp/config.py +++ b/src/sailfish_devel_mcp/config.py @@ -12,6 +12,10 @@ DEFAULT_BUS_ADDRESS = "unix:path=/run/user/100000/dbus/user_bus_socket" BUNDLED_BUILD_HELPER = ( Path(__file__).resolve().parent / "vendor" / "build_sailfishos.py" ) +DEFAULT_ANDROID_BUILD_HOST = "android-builder" +DEFAULT_ANDROID_BUILD_SSH_TARGET = "builder@example.invalid" +DEFAULT_ANDROID_BUILD_PROJECT_DIR = "" +DEFAULT_ANDROID_BUILD_STATE_DIR = "/tmp/sailfish-devel-mcp/android-builds" @dataclass(frozen=True) @@ -37,6 +41,33 @@ class DeviceConfig: @dataclass(frozen=True) +class AndroidBuildHostConfig: + name: str + ssh_target: str + project_dir: str + state_dir: str = DEFAULT_ANDROID_BUILD_STATE_DIR + + def public_dict(self) -> dict[str, object]: + return { + "name": self.name, + "ssh_target": self.ssh_target, + "project_dir": self.project_dir, + "state_dir": self.state_dir, + } + + +def default_android_build_hosts() -> dict[str, AndroidBuildHostConfig]: + return { + DEFAULT_ANDROID_BUILD_HOST: AndroidBuildHostConfig( + name=DEFAULT_ANDROID_BUILD_HOST, + ssh_target=DEFAULT_ANDROID_BUILD_SSH_TARGET, + project_dir=DEFAULT_ANDROID_BUILD_PROJECT_DIR, + state_dir=DEFAULT_ANDROID_BUILD_STATE_DIR, + ) + } + + +@dataclass(frozen=True) class PathConfig: git_root: Path = Path.home() / "git" obs_root: Path = Path.home() / "OBS" @@ -62,6 +93,10 @@ class Config: default_device: str devices: Mapping[str, DeviceConfig] = field(default_factory=dict) paths: PathConfig = field(default_factory=PathConfig) + default_android_build_host: str = DEFAULT_ANDROID_BUILD_HOST + android_build_hosts: Mapping[str, AndroidBuildHostConfig] = field( + default_factory=default_android_build_hosts + ) def device(self, name: str | None = None) -> DeviceConfig: key = name or self.default_device @@ -71,6 +106,18 @@ class Config: return DeviceConfig(name=name, ssh_target=name) raise KeyError(f"unknown Sailfish device: {key}") + def android_build_host(self, name: str | None = None) -> AndroidBuildHostConfig: + key = name or self.default_android_build_host + if key in self.android_build_hosts: + return self.android_build_hosts[key] + if name and "@" in name: + return AndroidBuildHostConfig( + name=name, + ssh_target=name, + project_dir=DEFAULT_ANDROID_BUILD_PROJECT_DIR, + ) + raise KeyError(f"unknown Android build host: {key}") + def public_dict(self) -> dict[str, Any]: return { "path": str(self.path) if self.path else None, @@ -79,6 +126,11 @@ class Config: name: device.public_dict() for name, device in self.devices.items() }, "paths": self.paths.public_dict(), + "default_android_build_host": self.default_android_build_host, + "android_build_hosts": { + name: host.public_dict() + for name, host in self.android_build_hosts.items() + }, } @@ -111,11 +163,17 @@ def load_config(path: str | os.PathLike[str] | None = None) -> Config: default_device = str(raw.get("default_device") or next(iter(devices))) paths = _load_paths(raw.get("paths", {})) + android_build_hosts = _load_android_build_hosts(raw.get("android_build_hosts", {})) + default_android_build_host = str( + raw.get("default_android_build_host") or next(iter(android_build_hosts)) + ) return Config( path=config_path if config_path.exists() else None, default_device=default_device, devices=devices, paths=paths, + default_android_build_host=default_android_build_host, + android_build_hosts=android_build_hosts, ) @@ -144,6 +202,45 @@ def _load_devices(raw_devices: Any) -> dict[str, DeviceConfig]: return devices +def _load_android_build_hosts(raw_hosts: Any) -> dict[str, AndroidBuildHostConfig]: + if not isinstance(raw_hosts, dict): + raise ValueError("android_build_hosts must be a JSON object") + + hosts: dict[str, AndroidBuildHostConfig] = {} + for name, value in raw_hosts.items(): + if isinstance(value, str): + value = {"ssh_target": value} + if not isinstance(value, dict): + raise ValueError(f"android build host {name!r} must be a string or object") + hosts[str(name)] = AndroidBuildHostConfig( + name=str(name), + ssh_target=str(value.get("ssh_target") or name), + project_dir=str( + value.get("project_dir") or DEFAULT_ANDROID_BUILD_PROJECT_DIR + ), + state_dir=str(value.get("state_dir") or DEFAULT_ANDROID_BUILD_STATE_DIR), + ) + + if not hosts: + name = os.environ.get("SAILFISH_MCP_ANDROID_BUILD_HOST", DEFAULT_ANDROID_BUILD_HOST) + hosts[name] = AndroidBuildHostConfig( + name=name, + ssh_target=os.environ.get( + "SAILFISH_MCP_ANDROID_BUILD_SSH_TARGET", + DEFAULT_ANDROID_BUILD_SSH_TARGET, + ), + project_dir=os.environ.get( + "SAILFISH_MCP_ANDROID_BUILD_PROJECT_DIR", + DEFAULT_ANDROID_BUILD_PROJECT_DIR, + ), + state_dir=os.environ.get( + "SAILFISH_MCP_ANDROID_BUILD_STATE_DIR", + DEFAULT_ANDROID_BUILD_STATE_DIR, + ), + ) + return hosts + + def _load_paths(raw_paths: Any) -> PathConfig: if not isinstance(raw_paths, dict): raise ValueError("paths must be a JSON object") diff --git a/src/sailfish_devel_mcp/server.py b/src/sailfish_devel_mcp/server.py index 7982b21..cabbb77 100644 --- a/src/sailfish_devel_mcp/server.py +++ b/src/sailfish_devel_mcp/server.py @@ -1,8 +1,10 @@ from __future__ import annotations import argparse +from datetime import datetime import json import sys +import time from typing import Any, TextIO from . import __version__ @@ -16,6 +18,23 @@ PROTOCOL_VERSIONS = [ "2025-03-26", "2024-11-05", ] +SERVER_NAME = "sailfish-devel-mcp" + + +def _log_value(value: Any, max_length: int = 200) -> str: + text = str(value).replace("\r", "\\r").replace("\n", "\\n") + if len(text) > max_length: + return text[: max_length - 3] + "..." + return text + + +def _log_line(message: str) -> None: + timestamp = datetime.now().astimezone().isoformat(timespec="seconds") + print(f"[{timestamp}] {SERVER_NAME} {message}", file=sys.stderr, flush=True) + + +def _duration_ms(start: float) -> int: + return int((time.monotonic() - start) * 1000) class JsonRpcError(Exception): @@ -33,26 +52,47 @@ class McpServer: def handle(self, message: dict[str, Any]) -> dict[str, Any] | None: if not isinstance(message, dict): + _log_line("mcp request rejected reason=non_object") raise JsonRpcError(-32600, "JSON-RPC message must be an object") request_id = message.get("id") method = message.get("method") if not method: + _log_line(f"mcp request rejected id={_log_value(request_id)} reason=missing_method") raise JsonRpcError(-32600, "JSON-RPC message is missing method") if request_id is None: + _log_line(f"mcp notification method={_log_value(method)}") self._handle_notification(method) return None + started = time.monotonic() + _log_line(f"mcp request start id={_log_value(request_id)} method={_log_value(method)}") try: result = self._dispatch(method, message.get("params") or {}) + _log_line( + "mcp request finish " + f"id={_log_value(request_id)} method={_log_value(method)} " + f"status=ok duration_ms={_duration_ms(started)}" + ) return {"jsonrpc": "2.0", "id": request_id, "result": result} except JsonRpcError as exc: + _log_line( + "mcp request finish " + f"id={_log_value(request_id)} method={_log_value(method)} " + f"status=jsonrpc_error code={exc.code} duration_ms={_duration_ms(started)}" + ) error: dict[str, Any] = {"code": exc.code, "message": exc.message} if exc.data is not None: error["data"] = exc.data return {"jsonrpc": "2.0", "id": request_id, "error": error} except Exception as exc: # pragma: no cover - defensive protocol boundary + _log_line( + "mcp request finish " + f"id={_log_value(request_id)} method={_log_value(method)} " + f"status=exception exception={type(exc).__name__} " + f"duration_ms={_duration_ms(started)}" + ) return { "jsonrpc": "2.0", "id": request_id, @@ -84,9 +124,22 @@ class McpServer: def _initialize(self, params: Any) -> dict[str, Any]: requested = "" + client_name = "" + client_version = "" if isinstance(params, dict): requested = str(params.get("protocolVersion") or "") + client_info = params.get("clientInfo") + if isinstance(client_info, dict): + client_name = str(client_info.get("name") or "") + client_version = str(client_info.get("version") or "") protocol = requested if requested in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0] + _log_line( + "mcp initialize " + f"client={_log_value(client_name or 'unknown')} " + f"client_version={_log_value(client_version or 'unknown')} " + f"requested_protocol={_log_value(requested or 'unspecified')} " + f"selected_protocol={protocol}" + ) return { "protocolVersion": protocol, "capabilities": { @@ -95,7 +148,7 @@ class McpServer: "prompts": {"listChanged": False}, }, "serverInfo": { - "name": "sailfish-devel-mcp", + "name": SERVER_NAME, "version": __version__, }, "instructions": ( @@ -114,12 +167,33 @@ class McpServer: raise JsonRpcError(-32602, f"unknown tool: {name}") args = params.get("arguments") or {} if not isinstance(args, dict): + _log_line(f"mcp tool call rejected tool={_log_value(name)} reason=arguments_not_object") return tool_error("tool arguments must be an object") + arg_keys = ",".join(sorted(str(key) for key in args.keys())) or "-" + started = time.monotonic() + _log_line(f"mcp tool call start tool={_log_value(name)} arg_keys={_log_value(arg_keys)}") try: - return self.registry[name].handler(args) + result = self.registry[name].handler(args) + is_error = bool(result.get("isError")) if isinstance(result, dict) else False + _log_line( + "mcp tool call finish " + f"tool={_log_value(name)} is_error={str(is_error).lower()} " + f"duration_ms={_duration_ms(started)}" + ) + return result except ValueError as exc: + _log_line( + "mcp tool call finish " + f"tool={_log_value(name)} is_error=true exception=ValueError " + f"duration_ms={_duration_ms(started)}" + ) return tool_error(str(exc)) except Exception as exc: + _log_line( + "mcp tool call finish " + f"tool={_log_value(name)} is_error=true exception={type(exc).__name__} " + f"duration_ms={_duration_ms(started)}" + ) return tool_error( f"{name} failed: {exc}", { @@ -216,6 +290,7 @@ def main(argv: list[str] | None = None) -> None: print(json.dumps(config.public_dict(), indent=2, sort_keys=True)) return + _log_line(f"mcp server ready name={SERVER_NAME} version={__version__}") run_stdio(McpServer(config)) diff --git a/src/sailfish_devel_mcp/tools.py b/src/sailfish_devel_mcp/tools.py index 854cd00..979e699 100644 --- a/src/sailfish_devel_mcp/tools.py +++ b/src/sailfish_devel_mcp/tools.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from dataclasses import dataclass from datetime import datetime, timezone import json @@ -14,7 +15,7 @@ import time from typing import Any, Callable from urllib.parse import quote -from .config import Config, DeviceConfig +from .config import AndroidBuildHostConfig, Config, DeviceConfig from .runner import ( CommandResult, remote_command, @@ -75,6 +76,18 @@ def build_registry(config: Config) -> dict[str, Tool]: Tool(_spec_build_rpm(), lambda args: handle_build_rpm(config, args)), Tool(_spec_build_status(), lambda args: handle_build_status(config, args)), Tool( + _spec_android_build_hosts(), + lambda args: handle_android_build_hosts(config, args), + ), + Tool( + _spec_android_build(), + lambda args: handle_android_build(config, args), + ), + Tool( + _spec_android_build_status(), + lambda args: handle_android_build_status(config, args), + ), + Tool( _spec_sdk_refresh_metadata(), lambda args: handle_sdk_refresh_metadata(config, args), ), @@ -364,29 +377,79 @@ def handle_device_user_session_command(config: Config, args: dict[str, Any]) -> def handle_device_install_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: device = _device(config, args) - rpm_path = _safe_input_path(config, _str_arg(args, "rpm_path"), allow_tmp=True) - remote_path = _optional_str(args, "remote_path") or f"/tmp/{rpm_path.name}" + rpm_path_arg = _optional_str(args, "rpm_path") + rpm_paths_arg = args.get("rpm_paths") + if rpm_path_arg and rpm_paths_arg is not None: + return tool_error("use either rpm_path or rpm_paths, not both") + if rpm_paths_arg is not None: + rpm_path_values = _string_list_arg(args, "rpm_paths") + if not rpm_path_values: + return tool_error("rpm_paths must contain at least one RPM") + elif rpm_path_arg: + rpm_path_values = [rpm_path_arg] + else: + return tool_error("rpm_path or rpm_paths is required") + + rpm_paths = [ + _safe_input_path(config, value, allow_tmp=True) + for value in rpm_path_values + ] + remote_path = _optional_str(args, "remote_path") + remote_dir = _optional_str(args, "remote_dir") + if len(rpm_paths) > 1 and remote_path: + return tool_error("remote_path only supports a single RPM; use remote_dir with rpm_paths") + if len(rpm_paths) > 1 and len({path.name for path in rpm_paths}) != len(rpm_paths): + return tool_error("rpm_paths must not contain duplicate file names") + installer = _enum_arg(args, "installer", ["pkcon", "rpm"], default="pkcon") timeout = _int_arg(args, "timeout", default=180, minimum=1, maximum=1200) - copy_result = run( - scp_to_argv(device, config.paths.ssh_config, rpm_path, remote_path), - timeout=timeout, - ) - if not copy_result.ok: - return command_result("copy RPM to device", copy_result) + mkdir_result: CommandResult | None = None + if len(rpm_paths) == 1 and not remote_dir: + remote_paths = [remote_path or f"/tmp/{rpm_paths[0].name}"] + else: + remote_dir = _remote_absolute_path(remote_dir or _new_remote_rpm_dir(), "remote_dir") + mkdir_result = _run_ssh(config, device, ["mkdir", "-p", remote_dir], timeout=timeout) + if not mkdir_result.ok: + return command_result("prepare RPM directory", mkdir_result, {"remote_dir": remote_dir}) + remote_paths = [ + str(PurePosixPath(remote_dir) / rpm_path.name) + for rpm_path in rpm_paths + ] + + copy_results = [] + for rpm_path, target_path in zip(rpm_paths, remote_paths): + copy_result = run( + scp_to_argv(device, config.paths.ssh_config, rpm_path, target_path), + timeout=timeout, + ) + copy_results.append(copy_result) + if not copy_result.ok: + return command_result( + "copy RPM to device", + copy_result, + { + "copies": [result.public_dict() for result in copy_results], + "remote_paths": remote_paths, + }, + ) if installer == "pkcon": - remote = ["pkcon", "install-local", "-y", remote_path] + remote = ["pkcon", "install-local", "-y", *remote_paths] else: - remote = ["rpm", "-Uvh", "--replacepkgs", remote_path] + remote = ["rpm", "-Uvh", "--replacepkgs", *remote_paths] install_result = _run_ssh(config, device, remote, timeout=timeout) structured = { - "copy": copy_result.public_dict(), + "copy": copy_results[0].public_dict(), + "copies": [result.public_dict() for result in copy_results], "install": install_result.public_dict(), - "remote_path": remote_path, + "remote_path": remote_paths[0], + "remote_paths": remote_paths, + "remote_dir": remote_dir, "installer": installer, } + if mkdir_result is not None: + structured["mkdir"] = mkdir_result.public_dict() return command_result("install RPM", install_result, structured) @@ -523,6 +586,123 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: } +def handle_android_build_hosts(config: Config, args: dict[str, Any]) -> dict[str, Any]: + text = "\n".join( + ( + f"{name}{' (default)' if name == config.default_android_build_host else ''}: " + f"{host.ssh_target} project={host.project_dir} state={host.state_dir}" + ) + for name, host in sorted(config.android_build_hosts.items(), key=lambda item: item[0]) + ) + structured = { + "default_android_build_host": config.default_android_build_host, + "android_build_hosts": { + name: host.public_dict() + for name, host in config.android_build_hosts.items() + }, + } + return ok_text(text or "No configured Android build hosts", structured) + + +def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any]: + host = _android_build_host(config, args) + project_dir_arg = _optional_str(args, "project_dir") or host.project_dir + if not project_dir_arg: + return tool_error( + "project_dir is required; configure android_build_hosts.<host>.project_dir " + "or pass project_dir" + ) + project_dir = _remote_absolute_path( + project_dir_arg, + "project_dir", + ) + state_dir = _remote_absolute_path( + _optional_str(args, "state_dir") or host.state_dir, + "state_dir", + ) + shell_command = _str_arg(args, "shell_command") + shell = _enum_arg(args, "shell", ["bash", "sh"], default="bash") + timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=600) + job_id = _optional_str(args, "job_id") or _new_android_build_job_id() + _validate_remote_job_id(job_id) + + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + remote = _android_build_start_command( + host=host, + project_dir=project_dir, + state_dir=state_dir, + job_id=job_id, + shell=shell, + shell_command=shell_command, + ) + result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout) + structured = { + "host": host.public_dict(), + "project_dir": project_dir, + "state_dir": state_dir, + "job_id": job_id, + "job_dir": job_dir, + "log_path": log_path, + "shell": shell, + } + if not result.ok: + return command_result("start Android build", result, structured) + + text = "\n".join( + [ + f"started Android build job {job_id} on {host.name}", + f"host: {host.ssh_target}", + f"project: {project_dir}", + f"state: {job_dir}", + f"log: {log_path}", + "poll with sailfish_android_build_status", + ] + ) + if result.stdout.strip(): + text += "\n\n" + result.stdout.strip() + data = result.public_dict() + data.update(structured) + return { + "content": [{"type": "text", "text": text}], + "structuredContent": data, + "isError": False, + } + + +def handle_android_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: + host = _android_build_host(config, args) + state_dir = _remote_absolute_path( + _optional_str(args, "state_dir") or host.state_dir, + "state_dir", + ) + job_id = _optional_str(args, "job_id") + lines = _int_arg(args, "lines", default=80, minimum=0, maximum=1000) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=600) + + if job_id: + _validate_remote_job_id(job_id) + remote = _android_build_status_command(state_dir, job_id, lines) + structured: dict[str, Any] = { + "host": host.public_dict(), + "state_dir": state_dir, + "job_id": job_id, + } + else: + remote = _android_build_list_command(state_dir) + structured = {"host": host.public_dict(), "state_dir": state_dir} + + result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout) + if job_id: + structured.update(_parse_android_build_status(result.stdout)) + response = command_result("Android build status", result, structured) + returncode = response["structuredContent"].get("returncode") + state = response["structuredContent"].get("state") + if result.ok and state == "finished" and returncode not in (0, None): + response["isError"] = True + return response + + def _mcp_state_dir() -> Path: value = os.environ.get("SAILFISH_DEVEL_MCP_STATE_DIR") if value: @@ -859,6 +1039,243 @@ def _run_ssh( ) +def _android_build_host(config: Config, args: dict[str, Any]) -> AndroidBuildHostConfig: + return config.android_build_host(_optional_str(args, "host")) + + +def _android_build_ssh_argv( + config: Config, + host: AndroidBuildHostConfig, + remote: str, +) -> list[str]: + argv = ["ssh"] + if config.paths.ssh_config: + argv += ["-F", str(config.paths.ssh_config)] + argv += [host.ssh_target, remote] + return argv + + +def _new_android_build_job_id() -> str: + now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + return f"android-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}" + + +def _validate_remote_job_id(job_id: str) -> None: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", job_id): + raise ValueError("job_id contains unsupported characters") + + +def _remote_absolute_path(value: str, name: str) -> str: + if "\0" in value or "\n" in value: + raise ValueError(f"{name} contains unsupported characters") + if not value.startswith("/"): + raise ValueError(f"{name} must be an absolute remote path") + return value + + +def _new_remote_rpm_dir() -> str: + now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + return f"/tmp/sailfish-devel-mcp-rpms-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}" + + +def _android_build_start_command( + *, + host: AndroidBuildHostConfig, + project_dir: str, + state_dir: str, + job_id: str, + shell: str, + shell_command: str, +) -> str: + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + run_path = str(PurePosixPath(job_dir) / "run.sh") + command_path = str(PurePosixPath(job_dir) / "command") + pid_path = str(PurePosixPath(job_dir) / "pid") + created_at_path = str(PurePosixPath(job_dir) / "created_at") + started_at_path = str(PurePosixPath(job_dir) / "started_at") + finished_at_path = str(PurePosixPath(job_dir) / "finished_at") + returncode_path = str(PurePosixPath(job_dir) / "returncode") + run_script = f"""#!/bin/sh +set +e +project_dir={shlex.quote(project_dir)} +log_path={shlex.quote(log_path)} +started_at_path={shlex.quote(started_at_path)} +finished_at_path={shlex.quote(finished_at_path)} +returncode_path={shlex.quote(returncode_path)} +host_name={shlex.quote(host.name)} +shell_bin={shlex.quote(shell)} +shell_command={shlex.quote(shell_command)} + +date -Is > "$started_at_path" +printf '[%s] starting Android build on %s\\n' "$(date -Is)" "$host_name" >> "$log_path" +printf '[%s] project_dir=%s\\n' "$(date -Is)" "$project_dir" >> "$log_path" +printf '[%s] command=%s\\n' "$(date -Is)" "$shell_command" >> "$log_path" + +cd "$project_dir" +cd_rc=$? +if [ "$cd_rc" -ne 0 ]; then + printf '[%s] failed to cd to %s\\n' "$(date -Is)" "$project_dir" >> "$log_path" + printf '%s\\n' "$cd_rc" > "$returncode_path" + date -Is > "$finished_at_path" + exit "$cd_rc" +fi + +"$shell_bin" -lc "$shell_command" >> "$log_path" 2>&1 +rc=$? +printf '%s\\n' "$rc" > "$returncode_path" +date -Is > "$finished_at_path" +printf '[%s] finished returncode=%s\\n' "$(date -Is)" "$rc" >> "$log_path" +exit "$rc" +""" + encoded_run_script = base64.b64encode(run_script.encode("utf-8")).decode("ascii") + script = f""" +set -eu +state_dir={shlex.quote(state_dir)} +job_id={shlex.quote(job_id)} +job_dir={shlex.quote(job_dir)} +run_path={shlex.quote(run_path)} +log_path={shlex.quote(log_path)} +command_path={shlex.quote(command_path)} +pid_path={shlex.quote(pid_path)} +created_at_path={shlex.quote(created_at_path)} +mkdir -p "$state_dir" "$job_dir" +if [ -e "$pid_path" ] || [ -e {shlex.quote(returncode_path)} ]; then + echo "job already exists: $job_id" >&2 + exit 2 +fi +umask 077 +printf '%s' {shlex.quote(encoded_run_script)} | base64 -d > "$run_path" +chmod +x "$run_path" +: > "$log_path" +printf '%s\\n' {shlex.quote(shell_command)} > "$command_path" +date -Is > "$created_at_path" +nohup "$run_path" >/dev/null 2>&1 </dev/null & +pid=$! +printf '%s\\n' "$pid" > "$pid_path" +printf 'job_id: %s\\n' "$job_id" +printf 'pid: %s\\n' "$pid" +printf 'host: %s\\n' {shlex.quote(host.name)} +printf 'project_dir: %s\\n' {shlex.quote(project_dir)} +printf 'job_dir: %s\\n' "$job_dir" +printf 'log_path: %s\\n' "$log_path" +""" + return remote_command(["sh", "-lc", script]) + + +def _android_build_list_command(state_dir: str) -> str: + script = f""" +set -eu +state_dir={shlex.quote(state_dir)} +if [ ! -d "$state_dir" ]; then + echo "no android build jobs found" + exit 0 +fi +output=$( + for job_dir in "$state_dir"/*; do + [ -d "$job_dir" ] || continue + job_id=${{job_dir##*/}} + pid=$(cat "$job_dir/pid" 2>/dev/null || true) + returncode=$(cat "$job_dir/returncode" 2>/dev/null || true) + created_at=$(cat "$job_dir/created_at" 2>/dev/null || true) + started_at=$(cat "$job_dir/started_at" 2>/dev/null || true) + timestamp=${{started_at:-$created_at}} + if [ -n "$returncode" ]; then + state=finished + elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + state=running + elif [ -n "$pid" ]; then + state=unknown + else + state=starting + fi + printf '%s\\t%s: %s returncode=%s pid=%s\\n' "$timestamp" "$job_id" "$state" "$returncode" "$pid" + done | sort | tail -n 20 | cut -f2- +) +if [ -n "$output" ]; then + printf '%s\\n' "$output" +else + echo "no android build jobs found" +fi +""" + return remote_command(["sh", "-lc", script]) + + +def _android_build_status_command(state_dir: str, job_id: str, lines: int) -> str: + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + script = f""" +set -eu +job_id={shlex.quote(job_id)} +job_dir={shlex.quote(job_dir)} +log_path={shlex.quote(log_path)} +lines={lines} +if [ ! -d "$job_dir" ]; then + echo "unknown android build job: $job_id" >&2 + exit 2 +fi +pid=$(cat "$job_dir/pid" 2>/dev/null || true) +returncode=$(cat "$job_dir/returncode" 2>/dev/null || true) +created_at=$(cat "$job_dir/created_at" 2>/dev/null || true) +started_at=$(cat "$job_dir/started_at" 2>/dev/null || true) +finished_at=$(cat "$job_dir/finished_at" 2>/dev/null || true) +command=$(cat "$job_dir/command" 2>/dev/null || true) +if [ -n "$returncode" ]; then + state=finished +elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + state=running +elif [ -n "$pid" ]; then + state=unknown +else + state=starting +fi +printf 'job_id: %s\\n' "$job_id" +printf 'state: %s\\n' "$state" +printf 'returncode: %s\\n' "$returncode" +printf 'pid: %s\\n' "$pid" +printf 'created_at: %s\\n' "$created_at" +printf 'started_at: %s\\n' "$started_at" +printf 'finished_at: %s\\n' "$finished_at" +printf 'job_dir: %s\\n' "$job_dir" +printf 'log_path: %s\\n' "$log_path" +printf 'command: %s\\n' "$command" +if [ "$lines" -gt 0 ] && [ -f "$log_path" ]; then + printf '\\n' + tail -n "$lines" "$log_path" +fi +""" + return remote_command(["sh", "-lc", script]) + + +def _parse_android_build_status(stdout: str) -> dict[str, Any]: + fields: dict[str, Any] = {} + for line in stdout.splitlines(): + if ": " not in line: + continue + key, value = line.split(": ", 1) + if key not in { + "job_id", + "state", + "returncode", + "pid", + "created_at", + "started_at", + "finished_at", + "job_dir", + "log_path", + "command", + }: + continue + if key in {"returncode", "pid"} and value: + try: + fields[key] = int(value) + except ValueError: + fields[key] = value + else: + fields[key] = value or None + return fields + + def _default_screenshot_path(device: DeviceConfig, label: str) -> str: timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f") return f"{_device_home_path(device)}/Pictures/Screenshots/{label}-{timestamp}.png" @@ -1752,16 +2169,30 @@ def _spec_device_install_rpm() -> dict[str, Any]: return { "name": "sailfish_device_install_rpm", "title": "Install Device RPM", - "description": "Copy a local RPM to a Sailfish OS device and install it.", + "description": "Copy one or more local RPMs to a Sailfish OS device and install them.", "inputSchema": _object_schema( { "device": _device_prop(), - "rpm_path": {"type": "string"}, - "remote_path": {"type": "string"}, + "rpm_path": { + "type": "string", + "description": "Single local RPM path. Use rpm_paths for dependency sets.", + }, + "rpm_paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Local RPM paths to copy and install in one transaction.", + }, + "remote_path": { + "type": "string", + "description": "Remote file path override for a single rpm_path.", + }, + "remote_dir": { + "type": "string", + "description": "Remote directory for copied RPMs. Created when missing.", + }, "installer": {"type": "string", "enum": ["pkcon", "rpm"], "default": "pkcon"}, "timeout": _timeout_prop(180), - }, - ["rpm_path"], + } ), "annotations": _mutating_annotations("Install Device RPM"), } @@ -1871,6 +2302,98 @@ def _spec_build_status() -> dict[str, Any]: } +def _spec_android_build_hosts() -> dict[str, Any]: + return { + "name": "sailfish_android_build_hosts", + "title": "Android Build Hosts", + "description": "List configured remote Android/AppSupport build hosts.", + "inputSchema": _object_schema({}), + "annotations": _read_only_annotations("Android Build Hosts"), + } + + +def _spec_android_build() -> dict[str, Any]: + return { + "name": "sailfish_android_build", + "title": "Start Android Build", + "description": ( + "Start a remote Android/AppSupport build under nohup on the configured " + "build host; project_dir must be configured for the host or supplied." + ), + "inputSchema": _object_schema( + { + "host": { + "type": "string", + "description": "Configured build host alias or ssh target.", + }, + "project_dir": { + "type": "string", + "description": "Remote Android tree. Defaults to the host config.", + }, + "state_dir": { + "type": "string", + "description": "Remote directory where job state and logs are stored.", + }, + "shell_command": { + "type": "string", + "description": ( + "Build command run from project_dir, for example " + "'source build/envsetup.sh && lunch ... && m ...'." + ), + }, + "job_id": { + "type": "string", + "description": "Optional stable job id; autogenerated when omitted.", + }, + "shell": { + "type": "string", + "enum": ["bash", "sh"], + "default": "bash", + }, + "timeout": _timeout_prop(60), + }, + ["shell_command"], + ), + "annotations": _mutating_annotations("Start Android Build"), + } + + +def _spec_android_build_status() -> dict[str, Any]: + return { + "name": "sailfish_android_build_status", + "title": "Android Build Status", + "description": ( + "List remote Android/AppSupport build jobs or read one job's status and " + "recent log output." + ), + "inputSchema": _object_schema( + { + "host": { + "type": "string", + "description": "Configured build host alias or ssh target.", + }, + "job_id": { + "type": "string", + "description": "Job id returned by sailfish_android_build. Omit to list jobs.", + }, + "state_dir": { + "type": "string", + "description": "Remote directory where job state and logs are stored.", + }, + "lines": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "default": 80, + "description": "Number of trailing log lines to include.", + }, + "timeout": _timeout_prop(30), + } + ), + "annotations": _read_only_annotations("Android Build Status"), + } + + def _spec_sdk_refresh_metadata() -> dict[str, Any]: return { "name": "sailfish_sdk_refresh_metadata", |
