summaryrefslogtreecommitdiff
path: root/src/sailfish_devel_mcp
diff options
context:
space:
mode:
Diffstat (limited to 'src/sailfish_devel_mcp')
-rw-r--r--src/sailfish_devel_mcp/config.py97
-rw-r--r--src/sailfish_devel_mcp/server.py79
-rw-r--r--src/sailfish_devel_mcp/tools.py559
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",