summaryrefslogtreecommitdiff
path: root/src/sailfish_devel_mcp/tools.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/sailfish_devel_mcp/tools.py')
-rw-r--r--src/sailfish_devel_mcp/tools.py805
1 files changed, 705 insertions, 100 deletions
diff --git a/src/sailfish_devel_mcp/tools.py b/src/sailfish_devel_mcp/tools.py
index 979e699..0820ce6 100644
--- a/src/sailfish_devel_mcp/tools.py
+++ b/src/sailfish_devel_mcp/tools.py
@@ -14,6 +14,7 @@ import sys
import time
from typing import Any, Callable
from urllib.parse import quote
+import uuid
from .config import AndroidBuildHostConfig, Config, DeviceConfig
from .runner import (
@@ -30,6 +31,18 @@ from .vendor import build_sailfishos
ToolHandler = Callable[[dict[str, Any]], dict[str, Any]]
+_BACKGROUND_SUPERVISORS: dict[int, subprocess.Popen[Any]] = {}
+OBS_SERVER_API_ALIASES = {
+ "internal": "jolla",
+ "partner": "partner",
+ "community": "community",
+}
+
+
+def _reap_background_supervisors() -> None:
+ for pid, process in list(_BACKGROUND_SUPERVISORS.items()):
+ if process.poll() is not None:
+ _BACKGROUND_SUPERVISORS.pop(pid, None)
@dataclass(frozen=True)
@@ -74,7 +87,9 @@ def build_registry(config: Config) -> dict[str, Tool]:
lambda args: handle_device_browser_launch(config, args),
),
Tool(_spec_build_rpm(), lambda args: handle_build_rpm(config, args)),
+ Tool(_spec_build_preflight(), lambda args: handle_build_preflight(config, args)),
Tool(_spec_build_status(), lambda args: handle_build_status(config, args)),
+ Tool(_spec_build_cancel(), lambda args: handle_build_cancel(config, args)),
Tool(
_spec_android_build_hosts(),
lambda args: handle_android_build_hosts(config, args),
@@ -88,6 +103,10 @@ def build_registry(config: Config) -> dict[str, Tool]:
lambda args: handle_android_build_status(config, args),
),
Tool(
+ _spec_android_build_cancel(),
+ lambda args: handle_android_build_cancel(config, args),
+ ),
+ Tool(
_spec_sdk_refresh_metadata(),
lambda args: handle_sdk_refresh_metadata(config, args),
),
@@ -484,19 +503,34 @@ def handle_device_browser_launch(config: Config, args: dict[str, Any]) -> dict[s
)
-def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+def _sailfish_build_command(
+ config: Config,
+ args: dict[str, Any],
+ *,
+ dry_run: bool = False,
+) -> tuple[list[str], Path]:
project_path = _safe_input_path(config, _str_arg(args, "project_path"), allow_tmp=False)
device = config.device(_optional_str(args, "device")) if args.get("device") else None
script = config.paths.build_sailfishos
if not script.exists():
- return tool_error(f"build helper not found: {script}")
+ raise ValueError(f"build helper not found: {script}")
command = ["python3", str(script), "--project-dir", str(project_path)]
+ backend = _enum_arg(args, "backend", ["auto", "docker", "local"], default="auto")
+ command += ["--backend", backend]
release = _optional_str(args, "release") or (device.release if device else None)
arches = args.get("arch")
artifacts_dir = _optional_str(args, "artifacts_dir")
- if config.paths.local_sdk:
- command += ["--local-sdk", str(config.paths.local_sdk)]
+ local_sdk_arg = _optional_str(args, "local_sdk")
+ if local_sdk_arg and backend == "docker":
+ raise ValueError("local_sdk cannot be combined with the Docker backend")
+ local_sdk = (
+ _safe_local_sdk_path(config, local_sdk_arg)
+ if local_sdk_arg
+ else config.paths.local_sdk
+ )
+ if local_sdk and backend != "docker":
+ command += ["--local-sdk", str(local_sdk)]
if not release:
release = "live"
if release:
@@ -506,12 +540,22 @@ def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
elif isinstance(arches, list):
for arch in arches:
if not isinstance(arch, str):
- return tool_error("arch must be a string or list of strings")
+ raise ValueError("arch must be a string or list of strings")
command += ["--arch", arch]
elif arches is not None:
- return tool_error("arch must be a string or list of strings")
+ raise ValueError("arch must be a string or list of strings")
elif device and device.architecture:
command += ["--arch", device.architecture]
+ targets = args.get("target")
+ if isinstance(targets, str):
+ command += ["--target", targets]
+ elif isinstance(targets, list):
+ for target in targets:
+ if not isinstance(target, str):
+ raise ValueError("target must be a string or list of strings")
+ command += ["--target", target]
+ elif targets is not None:
+ raise ValueError("target must be a string or list of strings")
if artifacts_dir:
output = _safe_output_path(config, artifacts_dir)
command += ["--artifacts-dir", str(output)]
@@ -521,14 +565,61 @@ def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
command.append("--clean")
if _bool_arg(args, "debug", default=False):
command.append("--debug")
+ permission_fallback = _enum_arg(
+ args,
+ "permission_fallback",
+ ["error", "chmod"],
+ default="error",
+ )
+ command += ["--permission-fallback", permission_fallback]
+ pull_policy = _enum_arg(args, "pull_policy", ["always", "missing", "never"], default="always")
if _bool_arg(args, "no_pull", default=False):
- command.append("--no-pull")
+ pull_policy = "never"
+ command += ["--pull-policy", pull_policy]
+ if _bool_arg(args, "no_vcs_apply", default=False):
+ command.append("--no-vcs-apply")
+ if _bool_arg(args, "allow_untrusted_rpms", default=False):
+ command.append("--allow-untrusted-rpms")
for local_dir in _string_list_arg(args, "local_rpms_dir"):
command += ["--local-rpms-dir", str(_safe_input_path(config, local_dir, allow_tmp=True))]
+ if dry_run:
+ command += ["--dry-run", "--json"]
+ return command, project_path
+
+
+def handle_build_preflight(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ command, project_path = _sailfish_build_command(config, args, dry_run=True)
+ timeout = _int_arg(args, "timeout", default=120, minimum=1, maximum=600)
+ result = run(command, timeout=timeout)
+ structured: dict[str, Any] = {
+ "project_path": str(project_path),
+ "command": command,
+ **result.public_dict(),
+ }
+ if result.ok:
+ try:
+ plan = json.loads(result.stdout)
+ except json.JSONDecodeError:
+ return tool_error("build preflight returned invalid JSON", structured)
+ if not isinstance(plan, dict):
+ return tool_error("build preflight did not return a JSON object", structured)
+ structured["plan"] = plan
+ return ok_text(result.stdout.strip(), structured)
+ return command_result("Sailfish build preflight", result, structured)
+
+
+def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ command, project_path = _sailfish_build_command(config, args)
timeout = _int_arg(args, "timeout", default=3600, minimum=1, maximum=21600)
if _bool_arg(args, "wait", default=False):
return command_result("build Sailfish RPM", run(command, timeout=timeout))
- job = _start_background_command("build Sailfish RPM", command, timeout=timeout)
+ metadata_path = project_path / ".mb2" / build_sailfishos.BUILD_METADATA_NAME
+ job = _start_background_command(
+ "build Sailfish RPM",
+ command,
+ timeout=timeout,
+ metadata_path=metadata_path,
+ )
text = "\n".join(
[
f"started build job {job['job_id']}",
@@ -543,13 +634,17 @@ def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]:
job_id = _optional_str(args, "job_id")
lines = _int_arg(args, "lines", default=80, minimum=0, maximum=1000)
+ wait_seconds = _int_arg(args, "wait_seconds", default=0, minimum=0, maximum=60)
jobs_dir = _build_jobs_dir()
if not job_id:
jobs = []
if jobs_dir.exists():
for status_path in sorted(jobs_dir.glob("*/status.json"), key=lambda p: p.stat().st_mtime):
+ if not _valid_local_job_id(status_path.parent.name):
+ continue
status = _read_job_status(status_path)
if status:
+ status = _with_supervisor_health(status, status_path.parent)
jobs.append(status)
jobs = jobs[-20:]
text = "\n".join(
@@ -558,14 +653,25 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]:
)
return ok_text(text or "no build jobs found", {"jobs_dir": str(jobs_dir), "jobs": jobs})
- job_dir = jobs_dir / job_id
+ _validate_local_job_id(job_id)
+ job_dir = _local_job_dir(jobs_dir, job_id)
status_path = job_dir / "status.json"
if not status_path.exists():
return tool_error(f"unknown build job: {job_id}", {"jobs_dir": str(jobs_dir)})
+ deadline = time.monotonic() + wait_seconds
status = _read_job_status(status_path)
+ if status:
+ status = _with_supervisor_health(status, job_dir)
+ while status and wait_seconds and not _job_is_terminal(status):
+ if time.monotonic() >= deadline:
+ break
+ time.sleep(0.25)
+ status = _read_job_status(status_path)
+ if status:
+ status = _with_supervisor_health(status, job_dir)
if not status:
return tool_error(f"could not read build job status: {job_id}")
- log_path = Path(str(status.get("log_path") or job_dir / "build.log"))
+ log_path = job_dir / "build.log"
log_tail = _tail_file(log_path, lines)
state = str(status.get("state") or "unknown")
returncode = status.get("returncode")
@@ -575,6 +681,12 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]:
f"returncode: {returncode}",
f"log: {log_path}",
]
+ if status.get("failure_class"):
+ text_lines.append(f"failure: {status.get('failure_class')}: {status.get('failure_message', '')}")
+ artifacts = status.get("artifacts")
+ if isinstance(artifacts, list) and artifacts:
+ text_lines.append(f"artifacts: {len(artifacts)}")
+ text_lines.extend(f" {artifact}" for artifact in artifacts)
if log_tail:
text_lines += ["", log_tail]
structured = dict(status)
@@ -582,10 +694,29 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]:
return {
"content": [{"type": "text", "text": "\n".join(text_lines)}],
"structuredContent": structured,
- "isError": state == "finished" and returncode not in (0, None),
+ "isError": state in {"failed", "timed_out"} or (
+ state == "finished" and returncode not in (0, None)
+ ),
}
+def handle_build_cancel(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ job_id = _str_arg(args, "job_id")
+ _validate_local_job_id(job_id)
+ job_dir = _local_job_dir(_build_jobs_dir(), job_id)
+ status_path = job_dir / "status.json"
+ status = _read_job_status(status_path)
+ if status is None:
+ return tool_error(f"unknown build job: {job_id}")
+ if _job_is_terminal(status):
+ return ok_text(f"build job {job_id} is already {status.get('state')}", status)
+ marker = job_dir / "cancel.requested"
+ marker.write_text(datetime.now(timezone.utc).isoformat() + "\n", encoding="utf-8")
+ structured = dict(status)
+ structured["cancel_requested"] = True
+ return ok_text(f"cancellation requested for build job {job_id}", structured)
+
+
def handle_android_build_hosts(config: Config, args: dict[str, Any]) -> dict[str, Any]:
text = "\n".join(
(
@@ -623,6 +754,7 @@ def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any]
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)
+ build_timeout = _int_arg(args, "build_timeout", default=0, minimum=0, maximum=86400)
job_id = _optional_str(args, "job_id") or _new_android_build_job_id()
_validate_remote_job_id(job_id)
@@ -635,6 +767,7 @@ def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any]
job_id=job_id,
shell=shell,
shell_command=shell_command,
+ build_timeout=build_timeout,
)
result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout)
structured = {
@@ -645,6 +778,7 @@ def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any]
"job_dir": job_dir,
"log_path": log_path,
"shell": shell,
+ "build_timeout": build_timeout,
}
if not result.ok:
return command_result("start Android build", result, structured)
@@ -698,11 +832,31 @@ def handle_android_build_status(config: Config, args: dict[str, Any]) -> dict[st
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):
+ if result.ok and (
+ state == "timed_out" or (state == "finished" and returncode not in (0, None))
+ ):
response["isError"] = True
return response
+def handle_android_build_cancel(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 = _str_arg(args, "job_id")
+ _validate_remote_job_id(job_id)
+ timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=600)
+ remote = _android_build_cancel_command(state_dir, job_id)
+ result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout)
+ return command_result(
+ "cancel Android build",
+ result,
+ {"host": host.public_dict(), "state_dir": state_dir, "job_id": job_id},
+ )
+
+
def _mcp_state_dir() -> Path:
value = os.environ.get("SAILFISH_DEVEL_MCP_STATE_DIR")
if value:
@@ -725,17 +879,109 @@ def _build_jobs_dir() -> Path:
def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
- tmp = path.with_suffix(path.suffix + ".tmp")
- tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
- tmp.replace(path)
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
+ try:
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ tmp.replace(path)
+ finally:
+ try:
+ tmp.unlink()
+ except FileNotFoundError:
+ pass
+
+def _valid_local_job_id(job_id: str) -> bool:
+ return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", job_id))
+
+
+def _validate_local_job_id(job_id: str) -> None:
+ if not _valid_local_job_id(job_id):
+ raise ValueError("job_id contains unsupported characters")
-def _start_background_command(label: str, command: list[str], *, timeout: int) -> dict[str, Any]:
+
+def _local_job_dir(jobs_dir: Path, job_id: str) -> Path:
+ _validate_local_job_id(job_id)
+ root = jobs_dir.resolve(strict=False)
+ job_dir = (root / job_id).resolve(strict=False)
+ try:
+ job_dir.relative_to(root)
+ except ValueError as exc: # Defensive in case validation changes later.
+ raise ValueError("job_id escapes the build jobs directory") from exc
+ return job_dir
+
+
+def _new_local_build_job(jobs_dir: Path) -> tuple[str, Path]:
+ jobs_dir.mkdir(parents=True, exist_ok=True)
+ for _ in range(3):
+ now = datetime.now(timezone.utc)
+ job_id = f"build-{now.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:16]}"
+ job_dir = _local_job_dir(jobs_dir, job_id)
+ try:
+ job_dir.mkdir(mode=0o700)
+ except FileExistsError:
+ continue
+ return job_id, job_dir
+ raise RuntimeError("could not allocate a unique build job directory")
+
+
+def _process_start_time(pid: int) -> str | None:
+ try:
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
+ except OSError:
+ return None
+ marker = stat.rfind(") ")
+ if marker < 0:
+ return None
+ fields = stat[marker + 2 :].split()
+ return fields[19] if len(fields) > 19 else None
+
+
+def _job_is_terminal(status: dict[str, Any]) -> bool:
+ return status.get("state") in {"finished", "failed", "timed_out", "cancelled"}
+
+
+def _with_supervisor_health(status: dict[str, Any], job_dir: Path | None = None) -> dict[str, Any]:
+ if _job_is_terminal(status):
+ return status
+ pid = status.get("supervisor_pid")
+ expected_start = status.get("supervisor_start_time")
+ if not isinstance(pid, int) and job_dir is not None:
+ sidecar = _read_job_status(job_dir / "supervisor.json")
+ if sidecar:
+ pid = sidecar.get("pid")
+ expected_start = sidecar.get("start_time")
+ if not isinstance(pid, int):
+ return status
+ actual_start = _process_start_time(pid)
+ if actual_start is None or (expected_start and actual_start != expected_start):
+ result = dict(status)
+ result.update(
+ state="failed",
+ failure_class="supervisor-lost",
+ failure_message="build supervisor is no longer running",
+ )
+ return result
+ return status
+
+
+def _start_background_command(
+ label: str,
+ command: list[str],
+ *,
+ timeout: int,
+ metadata_path: Path | None = None,
+) -> dict[str, Any]:
+ _reap_background_supervisors()
now = datetime.now(timezone.utc)
- job_id = f"build-{now.strftime('%Y%m%dT%H%M%S')}-{os.getpid()}-{int(time.time() * 1000) % 100000}"
- job_dir = _build_jobs_dir() / job_id
+ job_id, job_dir = _new_local_build_job(_build_jobs_dir())
log_path = job_dir / "build.log"
status_path = job_dir / "status.json"
+ supervisor_status_path = job_dir / "supervisor.json"
+ cancel_path = job_dir / "cancel.requested"
+ try:
+ metadata_mtime_ns = metadata_path.stat().st_mtime_ns if metadata_path else None
+ except OSError:
+ metadata_mtime_ns = None
status = {
"job_id": job_id,
"label": label,
@@ -745,6 +991,8 @@ def _start_background_command(label: str, command: list[str], *, timeout: int) -
"created_at": now.isoformat(),
"status_path": str(status_path),
"log_path": str(log_path),
+ "metadata_path": str(metadata_path) if metadata_path else None,
+ "metadata_mtime_ns": metadata_mtime_ns,
}
_write_json_atomic(status_path, status)
@@ -766,83 +1014,183 @@ def now() -> str:
def write_status(path: Path, data: dict[str, object]) -> None:
- tmp = path.with_suffix(path.suffix + ".tmp")
- tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
- tmp.replace(path)
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp")
+ try:
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ tmp.replace(path)
+ finally:
+ try:
+ tmp.unlink()
+ except FileNotFoundError:
+ pass
+
+
+def process_start_time(pid: int) -> str | None:
+ try:
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
+ except OSError:
+ return None
+ marker = stat.rfind(") ")
+ fields = stat[marker + 2:].split() if marker >= 0 else []
+ return fields[19] if len(fields) > 19 else None
+
+
+def terminate(process: subprocess.Popen[str], log, reason: str) -> int:
+ log.write(f"[{now()}] {reason}; terminating process group {process.pid}\n")
+ log.flush()
+ try:
+ os.killpg(process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ try:
+ return process.wait(timeout=30)
+ except subprocess.TimeoutExpired:
+ log.write(f"[{now()}] process group did not exit; killing {process.pid}\n")
+ log.flush()
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ return process.wait()
status_path = Path(sys.argv[1])
log_path = Path(sys.argv[2])
-timeout = int(sys.argv[3])
-command = sys.argv[4:]
+cancel_path = Path(sys.argv[3])
+timeout = int(sys.argv[4])
+metadata_arg = sys.argv[5]
+metadata_path = Path(metadata_arg) if metadata_arg else None
+command = sys.argv[6:]
status = json.loads(status_path.read_text(encoding="utf-8"))
status["supervisor_pid"] = os.getpid()
+status["supervisor_start_time"] = process_start_time(os.getpid())
status["started_at"] = now()
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf-8", errors="replace") as log:
log.write(f"[{now()}] starting {' '.join(command)}\n")
log.flush()
- process = subprocess.Popen(
- command,
- stdin=subprocess.DEVNULL,
- stdout=log,
- stderr=subprocess.STDOUT,
- text=True,
- start_new_session=True,
- close_fds=True,
- )
- status["pid"] = process.pid
- status["state"] = "running"
- write_status(status_path, status)
-
- deadline = time.monotonic() + timeout
- timed_out = False
- returncode = None
- while True:
- returncode = process.poll()
- if returncode is not None:
- break
- if time.monotonic() >= deadline:
- timed_out = True
- log.write(f"[{now()}] timeout after {timeout}s; terminating process group {process.pid}\n")
- log.flush()
+ process = None
+ try:
+ process = subprocess.Popen(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ text=True,
+ start_new_session=True,
+ close_fds=True,
+ )
+ status["pid"] = process.pid
+ status["state"] = "running"
+ write_status(status_path, status)
+
+ deadline = time.monotonic() + timeout
+ timed_out = False
+ cancelled = False
+ returncode = None
+ while True:
+ returncode = process.poll()
+ if returncode is not None:
+ break
+ if cancel_path.exists():
+ cancelled = True
+ returncode = terminate(process, log, "cancellation requested")
+ break
+ if time.monotonic() >= deadline:
+ timed_out = True
+ returncode = terminate(process, log, f"timeout after {timeout}s")
+ break
+ time.sleep(0.25)
+
+ status["state"] = (
+ "cancelled" if cancelled else "timed_out" if timed_out else "finished" if returncode == 0 else "failed"
+ )
+ status["returncode"] = returncode
+ status["timed_out"] = timed_out
+ status["cancelled"] = cancelled
+ status["finished_at"] = now()
+ baseline_mtime = status.get("metadata_mtime_ns")
+ try:
+ metadata_stat = metadata_path.stat() if metadata_path else None
+ except OSError:
+ metadata_stat = None
+ if (
+ metadata_path
+ and metadata_stat
+ and metadata_stat.st_size <= 1024 * 1024
+ and (baseline_mtime is None or metadata_stat.st_mtime_ns > baseline_mtime)
+ ):
try:
- os.killpg(process.pid, signal.SIGTERM)
- except ProcessLookupError:
- pass
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ metadata = None
+ if isinstance(metadata, dict):
+ status["build_metadata"] = metadata
+ status["artifacts"] = metadata.get("rpms", [])
+ if metadata.get("failure_class"):
+ status["failure_class"] = metadata["failure_class"]
+ if metadata.get("failure_message"):
+ status["failure_message"] = metadata["failure_message"]
+ write_status(status_path, status)
+ log.write(
+ f"[{now()}] finished state={status['state']} returncode={returncode} "
+ f"timed_out={timed_out} cancelled={cancelled}\n"
+ )
+ except BaseException as error:
+ if process is not None and process.poll() is None:
try:
- returncode = process.wait(timeout=30)
- except subprocess.TimeoutExpired:
- log.write(f"[{now()}] process group did not exit; killing {process.pid}\n")
- log.flush()
- try:
- os.killpg(process.pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
- returncode = process.wait()
- break
- time.sleep(1)
-
- status["state"] = "finished"
- status["returncode"] = returncode
- status["timed_out"] = timed_out
- status["finished_at"] = now()
- write_status(status_path, status)
- log.write(f"[{now()}] finished returncode={returncode} timed_out={timed_out}\n")
+ terminate(process, log, "supervisor failure")
+ except BaseException as terminate_error:
+ log.write(
+ f"[{now()}] failed to terminate process group: "
+ f"{type(terminate_error).__name__}: {terminate_error}\n"
+ )
+ status["state"] = "failed"
+ status["failure_class"] = "supervisor"
+ status["failure_message"] = f"{type(error).__name__}: {error}"
+ status["finished_at"] = now()
+ write_status(status_path, status)
+ log.write(f"[{now()}] supervisor failed: {type(error).__name__}: {error}\n")
+ raise
"""
- process = subprocess.Popen(
- [sys.executable, "-c", supervisor, str(status_path), str(log_path), str(timeout), *command],
- stdin=subprocess.DEVNULL,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- start_new_session=True,
- close_fds=True,
+ try:
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-c",
+ supervisor,
+ str(status_path),
+ str(log_path),
+ str(cancel_path),
+ str(timeout),
+ str(metadata_path) if metadata_path else "",
+ *command,
+ ],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ close_fds=True,
+ )
+ except BaseException as error:
+ status.update(
+ state="failed",
+ failure_class="supervisor-start",
+ failure_message=f"{type(error).__name__}: {error}",
+ finished_at=datetime.now(timezone.utc).isoformat(),
+ )
+ _write_json_atomic(status_path, status)
+ raise
+ _write_json_atomic(
+ supervisor_status_path,
+ {"pid": process.pid, "start_time": _process_start_time(process.pid)},
)
- status["supervisor_pid"] = process.pid
- _write_json_atomic(status_path, status)
- return status
+ _BACKGROUND_SUPERVISORS[process.pid] = process
+ response = dict(status)
+ response["supervisor_pid"] = process.pid
+ return response
def _read_job_status(path: Path) -> dict[str, Any] | None:
@@ -850,7 +1198,19 @@ def _read_job_status(path: Path) -> dict[str, Any] | None:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
- return data if isinstance(data, dict) else None
+ if not isinstance(data, dict):
+ return None
+ if _job_is_terminal(data):
+ pid = data.get("supervisor_pid")
+ supervisor = _BACKGROUND_SUPERVISORS.get(pid) if isinstance(pid, int) else None
+ if supervisor is not None:
+ try:
+ supervisor.wait(timeout=1)
+ except subprocess.TimeoutExpired:
+ pass
+ else:
+ _BACKGROUND_SUPERVISORS.pop(pid, None)
+ return data
def _tail_file(path: Path, lines: int) -> str:
@@ -890,7 +1250,7 @@ def handle_sdk_refresh_metadata(config: Config, args: dict[str, Any]) -> dict[st
def handle_obs_results(config: Config, args: dict[str, Any]) -> dict[str, Any]:
project = _str_arg(args, "project")
package = _optional_str(args, "package")
- api_alias = _optional_str(args, "api_alias") or config.paths.osc_api_alias
+ server, api_alias = _obs_server_selection(config, args)
command = ["osc"]
if api_alias:
command += ["-A", api_alias]
@@ -898,7 +1258,11 @@ def handle_obs_results(config: Config, args: dict[str, Any]) -> dict[str, Any]:
if package:
command.append(package)
timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=600)
- return command_result("OBS results", run(command, timeout=timeout))
+ return command_result(
+ "OBS results",
+ run(command, timeout=timeout),
+ {"server": server, "api_alias": api_alias},
+ )
def handle_obs_buildlog(config: Config, args: dict[str, Any]) -> dict[str, Any]:
@@ -906,7 +1270,7 @@ def handle_obs_buildlog(config: Config, args: dict[str, Any]) -> dict[str, Any]:
package = _str_arg(args, "package")
repository = _str_arg(args, "repository")
arch = _str_arg(args, "arch")
- api_alias = _optional_str(args, "api_alias") or config.paths.osc_api_alias
+ server, api_alias = _obs_server_selection(config, args)
timeout = _int_arg(args, "timeout", default=90, minimum=1, maximum=1800)
nostream = _bool_arg(args, "nostream", default=True)
command = ["osc"]
@@ -922,7 +1286,11 @@ def handle_obs_buildlog(config: Config, args: dict[str, Any]) -> dict[str, Any]:
command += ["api", path]
else:
command += ["remotebuildlog", project, package, repository, arch]
- return command_result("OBS build log", run(command, timeout=timeout))
+ return command_result(
+ "OBS build log",
+ run(command, timeout=timeout),
+ {"server": server, "api_alias": api_alias},
+ )
def handle_repo_status(config: Config, args: dict[str, Any]) -> dict[str, Any]:
@@ -1057,7 +1425,7 @@ def _android_build_ssh_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}"
+ return f"android-{now}-{uuid.uuid4().hex[:16]}"
def _validate_remote_job_id(job_id: str) -> None:
@@ -1075,7 +1443,7 @@ def _remote_absolute_path(value: str, name: str) -> str:
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}"
+ return f"/tmp/sailfish-devel-mcp-rpms-{now}-{uuid.uuid4().hex[:16]}"
def _android_build_start_command(
@@ -1086,16 +1454,21 @@ def _android_build_start_command(
job_id: str,
shell: str,
shell_command: str,
+ build_timeout: int,
) -> 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")
+ process_start_path = str(PurePosixPath(job_dir) / "process_start")
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")
+ timed_out_path = str(PurePosixPath(job_dir) / "timed_out")
+ watchdog_pid_path = str(PurePosixPath(job_dir) / "watchdog_pid")
+ watchdog_start_path = str(PurePosixPath(job_dir) / "watchdog_start")
run_script = f"""#!/bin/sh
set +e
project_dir={shlex.quote(project_dir)}
@@ -1106,6 +1479,10 @@ returncode_path={shlex.quote(returncode_path)}
host_name={shlex.quote(host.name)}
shell_bin={shlex.quote(shell)}
shell_command={shlex.quote(shell_command)}
+build_timeout={build_timeout}
+timed_out_path={shlex.quote(timed_out_path)}
+watchdog_pid_path={shlex.quote(watchdog_pid_path)}
+watchdog_start_path={shlex.quote(watchdog_start_path)}
date -Is > "$started_at_path"
printf '[%s] starting Android build on %s\\n' "$(date -Is)" "$host_name" >> "$log_path"
@@ -1121,8 +1498,41 @@ if [ "$cd_rc" -ne 0 ]; then
exit "$cd_rc"
fi
+if [ "$build_timeout" -gt 0 ]; then
+ setsid sh -c '
+ sleep "$1"
+ if kill -0 "$2" 2>/dev/null; then
+ date -Is > "$3"
+ kill -TERM -"$2" 2>/dev/null || true
+ attempts=0
+ while [ "$attempts" -lt 30 ]; do
+ sleep 1
+ if ! kill -0 -"$2" 2>/dev/null; then
+ exit 0
+ fi
+ attempts=$((attempts + 1))
+ done
+ kill -KILL -"$2" 2>/dev/null || true
+ fi
+ ' android-build-watchdog "$build_timeout" "$$" "$timed_out_path" \
+ >/dev/null 2>&1 </dev/null &
+ watchdog_pid=$!
+ printf '%s\n' "$watchdog_pid" > "$watchdog_pid_path"
+ sed 's/.*) //' "/proc/$watchdog_pid/stat" 2>/dev/null | cut -d' ' -f20 > "$watchdog_start_path" || true
+else
+ watchdog_pid=
+fi
+
"$shell_bin" -lc "$shell_command" >> "$log_path" 2>&1
rc=$?
+if [ -n "$watchdog_pid" ]; then
+ if [ -f "$timed_out_path" ]; then
+ rc=124
+ else
+ kill "$watchdog_pid" 2>/dev/null || true
+ wait "$watchdog_pid" 2>/dev/null || true
+ fi
+fi
printf '%s\\n' "$rc" > "$returncode_path"
date -Is > "$finished_at_path"
printf '[%s] finished returncode=%s\\n' "$(date -Is)" "$rc" >> "$log_path"
@@ -1138,20 +1548,22 @@ run_path={shlex.quote(run_path)}
log_path={shlex.quote(log_path)}
command_path={shlex.quote(command_path)}
pid_path={shlex.quote(pid_path)}
+process_start_path={shlex.quote(process_start_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
+umask 077
+mkdir -p "$state_dir"
+if ! mkdir "$job_dir"; 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 &
+nohup setsid "$run_path" >/dev/null 2>&1 </dev/null &
pid=$!
+sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | cut -d' ' -f20 > "$process_start_path" || true
printf '%s\\n' "$pid" > "$pid_path"
printf 'job_id: %s\\n' "$job_id"
printf 'pid: %s\\n' "$pid"
@@ -1176,14 +1588,22 @@ output=$(
[ -d "$job_dir" ] || continue
job_id=${{job_dir##*/}}
pid=$(cat "$job_dir/pid" 2>/dev/null || true)
+ expected_start=$(cat "$job_dir/process_start" 2>/dev/null || true)
+ actual_start=$(sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | cut -d' ' -f20 || true)
returncode=$(cat "$job_dir/returncode" 2>/dev/null || true)
+ cancel_requested=$(cat "$job_dir/cancel_requested" 2>/dev/null || true)
+ timed_out=$(cat "$job_dir/timed_out" 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
+ if [ -n "$timed_out" ]; then
+ state=timed_out
+ elif [ -n "$returncode" ]; then
state=finished
- elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
- state=running
+ elif [ -n "$cancel_requested" ] && [ "$actual_start" != "$expected_start" ]; then
+ state=cancelled
+ elif [ -n "$pid" ] && [ -n "$expected_start" ] && [ "$actual_start" = "$expected_start" ] && kill -0 "$pid" 2>/dev/null; then
+ if [ -n "$cancel_requested" ]; then state=cancelling; else state=running; fi
elif [ -n "$pid" ]; then
state=unknown
else
@@ -1215,15 +1635,26 @@ if [ ! -d "$job_dir" ]; then
exit 2
fi
pid=$(cat "$job_dir/pid" 2>/dev/null || true)
+expected_start=$(cat "$job_dir/process_start" 2>/dev/null || true)
+actual_start=$(sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | cut -d' ' -f20 || true)
+watchdog_pid=$(cat "$job_dir/watchdog_pid" 2>/dev/null || true)
+watchdog_expected_start=$(cat "$job_dir/watchdog_start" 2>/dev/null || true)
+watchdog_actual_start=$(sed 's/.*) //' "/proc/$watchdog_pid/stat" 2>/dev/null | cut -d' ' -f20 || true)
returncode=$(cat "$job_dir/returncode" 2>/dev/null || true)
+cancel_requested=$(cat "$job_dir/cancel_requested" 2>/dev/null || true)
+timed_out=$(cat "$job_dir/timed_out" 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
+if [ -n "$timed_out" ]; then
+ state=timed_out
+elif [ -n "$returncode" ]; then
state=finished
-elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
- state=running
+elif [ -n "$cancel_requested" ] && [ "$actual_start" != "$expected_start" ]; then
+ state=cancelled
+elif [ -n "$pid" ] && [ -n "$expected_start" ] && [ "$actual_start" = "$expected_start" ] && kill -0 "$pid" 2>/dev/null; then
+ if [ -n "$cancel_requested" ]; then state=cancelling; else state=running; fi
elif [ -n "$pid" ]; then
state=unknown
else
@@ -1276,6 +1707,37 @@ def _parse_android_build_status(stdout: str) -> dict[str, Any]:
return fields
+def _android_build_cancel_command(state_dir: str, job_id: str) -> str:
+ job_dir = str(PurePosixPath(state_dir) / job_id)
+ script = f"""
+set -eu
+job_id={shlex.quote(job_id)}
+job_dir={shlex.quote(job_dir)}
+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)
+expected_start=$(cat "$job_dir/process_start" 2>/dev/null || true)
+actual_start=$(sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | cut -d' ' -f20 || true)
+if [ -f "$job_dir/returncode" ] || [ -f "$job_dir/timed_out" ]; then
+ echo "job already finished: $job_id"
+ exit 0
+fi
+date -Is > "$job_dir/cancel_requested"
+if [ -n "$watchdog_pid" ] && [ -n "$watchdog_expected_start" ] && [ "$watchdog_actual_start" = "$watchdog_expected_start" ]; then
+ kill "$watchdog_pid" 2>/dev/null || true
+fi
+if [ -n "$pid" ] && [ -n "$expected_start" ] && [ "$actual_start" = "$expected_start" ] && kill -0 "$pid" 2>/dev/null; then
+ kill -TERM -"$pid" 2>/dev/null || true
+ printf 'cancellation requested for %s (process group %s)\n' "$job_id" "$pid"
+else
+ printf 'job %s is no longer running; marked cancelled\n' "$job_id"
+fi
+"""
+ return remote_command(["sh", "-lc", script])
+
+
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"
@@ -1756,6 +2218,32 @@ def _device(config: Config, args: dict[str, Any]) -> DeviceConfig:
return config.device(_optional_str(args, "device"))
+def _obs_server_selection(config: Config, args: dict[str, Any]) -> tuple[str | None, str]:
+ server = _optional_str(args, "server")
+ api_alias = _optional_str(args, "api_alias")
+ if server and api_alias:
+ raise ValueError("server and api_alias cannot be combined")
+ if server:
+ try:
+ return server, OBS_SERVER_API_ALIASES[server]
+ except KeyError as exc:
+ choices = ", ".join(OBS_SERVER_API_ALIASES)
+ raise ValueError(f"server must be one of: {choices}") from exc
+
+ selected_alias = api_alias or config.paths.osc_api_alias
+ if selected_alias in OBS_SERVER_API_ALIASES:
+ return selected_alias, OBS_SERVER_API_ALIASES[selected_alias]
+ selected_server = next(
+ (
+ name
+ for name, alias in OBS_SERVER_API_ALIASES.items()
+ if alias == selected_alias
+ ),
+ None,
+ )
+ return selected_server, selected_alias
+
+
def _str_arg(args: dict[str, Any], name: str) -> str:
value = args.get(name)
if not isinstance(value, str) or not value:
@@ -1859,6 +2347,22 @@ def _safe_output_path(config: Config, value: str) -> Path:
return path
+def _safe_local_sdk_path(config: Config, value: str) -> Path:
+ path = Path(value).expanduser()
+ if not path.is_absolute():
+ raise ValueError("local_sdk must be an absolute path")
+ path = path.resolve(strict=False)
+ srv_mer = Path("/srv/mer").resolve(strict=False)
+ configured = (
+ config.paths.local_sdk.expanduser().resolve(strict=False)
+ if config.paths.local_sdk
+ else None
+ )
+ if not _is_relative_to_any(path, [srv_mer]) and path != configured:
+ raise ValueError("local_sdk must be under /srv/mer or match the configured SDK path")
+ return path
+
+
def _host_path_roots(config: Config) -> list[Path]:
roots: list[Path] = []
for root in (config.paths.git_root, config.paths.obs_root):
@@ -2244,7 +2748,7 @@ def _spec_build_rpm() -> dict[str, Any]:
return {
"name": "sailfish_build_rpm",
"title": "Build Sailfish RPM",
- "description": "Start an asynchronous RPM build; paths.local_sdk defaults to the live installed SDK, with public SDK fallback only for explicit named releases.",
+ "description": "Start an asynchronous RPM build; paths.local_sdk defaults to the live installed SDK, with third-party coderus Docker-image fallback only for explicit named releases.",
"inputSchema": _object_schema(
{
"project_path": {"type": "string"},
@@ -2253,17 +2757,44 @@ def _spec_build_rpm() -> dict[str, Any]:
"description": "Optional configured device to supply default release and architecture.",
},
"release": {"type": "string"},
+ "backend": {
+ "type": "string",
+ "enum": ["auto", "docker", "local"],
+ "default": "auto",
+ },
+ "local_sdk": {
+ "type": "string",
+ "description": "Installed sdk-chroot path under /srv/mer; defaults to paths.local_sdk.",
+ },
"arch": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}},
]
},
+ "target": {
+ "oneOf": [
+ {"type": "string"},
+ {"type": "array", "items": {"type": "string"}},
+ ]
+ },
"artifacts_dir": {"type": "string"},
"all_arches": {"type": "boolean", "default": False},
"clean": {"type": "boolean", "default": False},
"debug": {"type": "boolean", "default": False},
+ "permission_fallback": {
+ "type": "string",
+ "enum": ["error", "chmod"],
+ "default": "error",
+ },
"no_pull": {"type": "boolean", "default": False},
+ "pull_policy": {
+ "type": "string",
+ "enum": ["always", "missing", "never"],
+ "default": "always",
+ },
+ "no_vcs_apply": {"type": "boolean", "default": False},
+ "allow_untrusted_rpms": {"type": "boolean", "default": False},
"local_rpms_dir": {"type": "array", "items": {"type": "string"}},
"wait": {
"type": "boolean",
@@ -2278,6 +2809,19 @@ def _spec_build_rpm() -> dict[str, Any]:
}
+def _spec_build_preflight() -> dict[str, Any]:
+ properties = dict(_spec_build_rpm()["inputSchema"]["properties"])
+ properties.pop("wait", None)
+ properties["timeout"] = _timeout_prop(120)
+ return {
+ "name": "sailfish_build_preflight",
+ "title": "Preflight Sailfish Build",
+ "description": "Validate and return a structured Sailfish RPM build plan without mutating the project.",
+ "inputSchema": _object_schema(properties, ["project_path"]),
+ "annotations": _read_only_annotations("Preflight Sailfish Build"),
+ }
+
+
def _spec_build_status() -> dict[str, Any]:
return {
"name": "sailfish_build_status",
@@ -2296,12 +2840,29 @@ def _spec_build_status() -> dict[str, Any]:
"default": 80,
"description": "Number of trailing log lines to include.",
},
+ "wait_seconds": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 60,
+ "default": 0,
+ "description": "Wait up to this many seconds for job completion.",
+ },
}
),
"annotations": _read_only_annotations("Build Job Status"),
}
+def _spec_build_cancel() -> dict[str, Any]:
+ return {
+ "name": "sailfish_build_cancel",
+ "title": "Cancel Sailfish Build",
+ "description": "Request cancellation of a local asynchronous Sailfish RPM build job.",
+ "inputSchema": _object_schema({"job_id": {"type": "string"}}, ["job_id"]),
+ "annotations": _mutating_annotations("Cancel Sailfish Build"),
+ }
+
+
def _spec_android_build_hosts() -> dict[str, Any]:
return {
"name": "sailfish_android_build_hosts",
@@ -2350,6 +2911,13 @@ def _spec_android_build() -> dict[str, Any]:
"enum": ["bash", "sh"],
"default": "bash",
},
+ "build_timeout": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 86400,
+ "default": 0,
+ "description": "Remote build lifetime in seconds; zero disables the build timeout.",
+ },
"timeout": _timeout_prop(60),
},
["shell_command"],
@@ -2394,6 +2962,27 @@ def _spec_android_build_status() -> dict[str, Any]:
}
+def _spec_android_build_cancel() -> dict[str, Any]:
+ return {
+ "name": "sailfish_android_build_cancel",
+ "title": "Cancel Android Build",
+ "description": "Cancel a remote Android/AppSupport build after verifying its process identity.",
+ "inputSchema": _object_schema(
+ {
+ "host": {
+ "type": "string",
+ "description": "Configured build host alias or ssh target.",
+ },
+ "job_id": {"type": "string"},
+ "state_dir": {"type": "string"},
+ "timeout": _timeout_prop(30),
+ },
+ ["job_id"],
+ ),
+ "annotations": _mutating_annotations("Cancel Android Build"),
+ }
+
+
def _spec_sdk_refresh_metadata() -> dict[str, Any]:
return {
"name": "sailfish_sdk_refresh_metadata",
@@ -2431,12 +3020,20 @@ def _spec_obs_results() -> dict[str, Any]:
return {
"name": "sailfish_obs_results",
"title": "OBS Results",
- "description": "Run osc results using the configured OBS API alias when set.",
+ "description": "Run osc results against internal, partner, community, or an explicit OBS API alias.",
"inputSchema": _object_schema(
{
"project": {"type": "string"},
"package": {"type": "string"},
- "api_alias": {"type": "string"},
+ "server": {
+ "type": "string",
+ "enum": list(OBS_SERVER_API_ALIASES),
+ "description": "Named OBS server; internal maps to the .oscrc alias jolla.",
+ },
+ "api_alias": {
+ "type": "string",
+ "description": "Advanced raw osc -A alias or API URL; overrides paths.osc_api_alias and cannot be combined with server.",
+ },
"timeout": _timeout_prop(60),
},
["project"],
@@ -2449,14 +3046,22 @@ def _spec_obs_buildlog() -> dict[str, Any]:
return {
"name": "sailfish_obs_buildlog",
"title": "OBS Build Log",
- "description": "Fetch an OBS build log with osc; defaults to the API nostream form to avoid live streams.",
+ "description": "Fetch a build log from internal, partner, community, or an explicit OBS API alias; defaults to the API nostream form.",
"inputSchema": _object_schema(
{
"project": {"type": "string"},
"package": {"type": "string"},
"repository": {"type": "string"},
"arch": {"type": "string"},
- "api_alias": {"type": "string"},
+ "server": {
+ "type": "string",
+ "enum": list(OBS_SERVER_API_ALIASES),
+ "description": "Named OBS server; internal maps to the .oscrc alias jolla.",
+ },
+ "api_alias": {
+ "type": "string",
+ "description": "Advanced raw osc -A alias or API URL; overrides paths.osc_api_alias and cannot be combined with server.",
+ },
"nostream": {"type": "boolean", "default": True},
"timeout": _timeout_prop(90),
},