Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

429 lines
14 KiB
Python

"""Persistent install / deployment CLI commands."""
from __future__ import annotations
import shutil
import subprocess
from copy import deepcopy
import click
from headroom.install.health import probe_json, probe_ready
from headroom.install.models import (
ConfigScope,
DeploymentManifest,
InstallPreset,
ProviderSelectionMode,
RuntimeKind,
SupervisorKind,
)
from headroom.install.planner import build_manifest
from headroom.install.providers import apply_mutations, revert_mutations
from headroom.install.runtime import (
acquire_runtime_start_lock,
run_foreground,
runtime_status,
start_detached_agent,
start_persistent_docker,
stop_runtime,
wait_ready,
)
from headroom.install.state import (
ManifestError,
delete_manifest,
load_manifest,
save_manifest,
)
from headroom.install.supervisors import (
install_supervisor,
remove_supervisor,
start_supervisor,
stop_supervisor,
)
from .main import main
@main.group()
def install() -> None:
"""Install and manage persistent Headroom deployments."""
def _require_manifest(profile: str) -> DeploymentManifest:
try:
manifest = load_manifest(profile)
except ManifestError as e:
raise click.ClickException(str(e)) from None
if manifest is None:
raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
return manifest
def _start_deployment(manifest: DeploymentManifest, *, assume_start_lock: bool = False) -> None:
if not assume_start_lock:
with acquire_runtime_start_lock(manifest.profile) as acquired:
if not acquired:
click.echo(f"Deployment '{manifest.profile}' start is already in progress.")
return
_start_deployment(manifest, assume_start_lock=True)
return
if probe_ready(manifest.health_url):
return
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value and shutil.which("docker") is None:
raise click.ClickException(
"Docker is required for this deployment but 'docker' was not found on PATH."
)
if runtime_status(manifest) == "running":
if wait_ready(manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS):
return
stop_runtime(manifest)
try:
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
start_persistent_docker(manifest)
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
start_supervisor(manifest)
else:
start_detached_agent(manifest.profile)
except FileNotFoundError as e:
# A required external binary (docker, launchctl, systemctl) is missing.
raise click.ClickException(f"Cannot start deployment '{manifest.profile}': {e}") from None
except subprocess.CalledProcessError as e:
raise click.ClickException(
f"Cannot start deployment '{manifest.profile}': command failed "
f"({' '.join(map(str, e.cmd)) if isinstance(e.cmd, list | tuple) else e.cmd})"
) from None
if not wait_ready(manifest, timeout_seconds=45):
raise click.ClickException(
f"Deployment '{manifest.profile}' did not become ready after start."
)
def _stop_deployment(manifest: DeploymentManifest) -> None:
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
stop_supervisor(manifest)
stop_runtime(manifest)
def _remove_deployment(manifest: DeploymentManifest) -> None:
try:
_stop_deployment(manifest)
except Exception:
pass
try:
remove_supervisor(manifest)
except Exception:
pass
try:
revert_mutations(manifest)
except Exception:
pass
delete_manifest(manifest.profile)
def _restore_deployment(manifest: DeploymentManifest) -> None:
restored = deepcopy(manifest)
restored.mutations = apply_mutations(restored)
restored.artifacts = install_supervisor(restored)
save_manifest(restored)
_start_deployment(restored)
def _reject_task_lifecycle(manifest: DeploymentManifest, action: str) -> None:
if manifest.supervisor_kind == SupervisorKind.TASK.value:
raise click.ClickException(
f"Deployment '{manifest.profile}' uses persistent-task scheduling; "
f"`headroom install {action}` is not supported for task deployments."
)
@install.command("apply")
@click.option(
"--preset",
type=click.Choice([preset.value for preset in InstallPreset]),
default=InstallPreset.PERSISTENT_SERVICE.value,
show_default=True,
help="Persistent runtime preset to install.",
)
@click.option(
"--runtime",
type=click.Choice([runtime.value for runtime in RuntimeKind]),
default=RuntimeKind.PYTHON.value,
show_default=True,
help="Runtime used to execute Headroom for service/task modes.",
)
@click.option(
"--scope",
type=click.Choice([scope.value for scope in ConfigScope]),
default=ConfigScope.USER.value,
show_default=True,
help="Where to apply persistent configuration.",
)
@click.option(
"--providers",
"provider_mode",
type=click.Choice([mode.value for mode in ProviderSelectionMode]),
default=ProviderSelectionMode.AUTO.value,
show_default=True,
help="Target selection mode for direct tool configuration.",
)
@click.option(
"--target",
"targets",
multiple=True,
type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw", "opencode"]),
help="Tool target to configure when --providers manual is used.",
)
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
@click.option(
"--port",
"-p",
default=8787,
type=click.IntRange(1, 65535),
show_default=True,
help="Persistent proxy port.",
)
@click.option(
"--backend",
default="anthropic",
show_default=True,
help="Proxy backend for the persistent runtime.",
)
@click.option(
"--anyllm-provider",
default=None,
help="Provider for any-llm backends when --backend anyllm is used.",
)
@click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
@click.option(
"--mode", "proxy_mode", default="token", show_default=True, help="Proxy optimization mode."
)
@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
@click.option(
"--telemetry",
is_flag=True,
help="Opt in to anonymous telemetry in the runtime (off by default).",
)
@click.option(
"--no-telemetry",
is_flag=True,
help="Force anonymous telemetry off in the runtime (already the default).",
)
@click.option(
"--image",
default="ghcr.io/chopratejas/headroom:latest",
show_default=True,
help="Docker image to use when runtime=docker or preset=persistent-docker.",
)
@click.option(
"--no-http2",
is_flag=True,
help="Disable HTTP/2 in the persistent runtime (enabled by default).",
)
def install_apply(
preset: str,
runtime: str,
scope: str,
provider_mode: str,
targets: tuple[str, ...],
profile: str,
port: int,
backend: str,
anyllm_provider: str | None,
region: str | None,
proxy_mode: str,
memory: bool,
telemetry: bool,
no_telemetry: bool,
image: str,
no_http2: bool,
) -> None:
"""Install a persistent Headroom deployment."""
if anyllm_provider and backend != "anyllm":
click.echo(
f"Warning: --anyllm-provider is ignored unless --backend anyllm "
f"(got --backend {backend})."
)
if preset == InstallPreset.PERSISTENT_DOCKER.value:
runtime = RuntimeKind.DOCKER.value
manifest = build_manifest(
profile=profile,
preset=preset,
runtime_kind=runtime,
scope=scope,
provider_mode=provider_mode,
targets=list(targets),
port=port,
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
proxy_mode=proxy_mode,
memory_enabled=memory,
telemetry_enabled=telemetry and not no_telemetry,
image=image,
no_http2=no_http2,
)
try:
existing = load_manifest(profile)
except ManifestError as e:
# A corrupt existing manifest shouldn't block a fresh apply; overwrite it.
click.echo(f"Warning: {e}; overwriting.")
existing = None
if existing is not None:
click.echo(f"Updating existing deployment profile '{profile}'...")
_remove_deployment(existing)
try:
manifest.mutations = apply_mutations(manifest)
manifest.artifacts = install_supervisor(manifest)
save_manifest(manifest)
_start_deployment(manifest)
except Exception as exc:
_remove_deployment(manifest)
if existing is not None:
click.echo(f"Restoring previous deployment '{profile}'...")
_restore_deployment(existing)
# Surface non-Click errors (OSError, CalledProcessError, …) as a clean
# message rather than a raw traceback; Click errors pass through as-is.
if isinstance(exc, click.ClickException | click.Abort):
raise
raise click.ClickException(f"Failed to install deployment '{profile}': {exc}") from exc
click.echo(
f"Installed persistent deployment '{profile}' "
f"({manifest.preset}, runtime={manifest.runtime_kind}, scope={manifest.scope})."
)
click.echo(f"Health: {manifest.health_url}")
if manifest.targets:
click.echo(f"Targets: {', '.join(manifest.targets)}")
@install.command("status")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_status(profile: str) -> None:
"""Show persistent deployment status."""
manifest = _require_manifest(profile)
payload = probe_json(manifest.health_url.replace("/readyz", "/health"))
click.echo(f"Profile: {manifest.profile}")
click.echo(f"Preset: {manifest.preset}")
click.echo(f"Runtime: {manifest.runtime_kind}")
click.echo(f"Supervisor: {manifest.supervisor_kind}")
click.echo(f"Scope: {manifest.scope}")
click.echo(f"Port: {manifest.port}")
click.echo(f"Status: {runtime_status(manifest)}")
click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}")
if payload and isinstance(payload, dict):
click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}")
click.echo(f"Backend: {payload.get('config', {}).get('backend', manifest.backend)}")
@install.command("start")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_start(profile: str) -> None:
"""Start a persistent deployment."""
manifest = _require_manifest(profile)
_reject_task_lifecycle(manifest, "start")
_start_deployment(manifest)
click.echo(f"Started deployment '{profile}'.")
@install.command("stop")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_stop(profile: str) -> None:
"""Stop a persistent deployment."""
manifest = _require_manifest(profile)
_reject_task_lifecycle(manifest, "stop")
_stop_deployment(manifest)
click.echo(f"Stopped deployment '{profile}'.")
@install.command("restart")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_restart(profile: str) -> None:
"""Restart a persistent deployment."""
manifest = _require_manifest(profile)
_reject_task_lifecycle(manifest, "restart")
_stop_deployment(manifest)
_start_deployment(manifest)
click.echo(f"Restarted deployment '{profile}'.")
@install.command("remove")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_remove(profile: str) -> None:
"""Remove a persistent deployment and undo managed config."""
manifest = _require_manifest(profile)
try:
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
stop_supervisor(manifest)
except Exception:
pass
try:
stop_runtime(manifest)
except Exception:
pass
try:
remove_supervisor(manifest)
except Exception:
pass
revert_mutations(manifest)
delete_manifest(profile)
click.echo(f"Removed deployment '{profile}'.")
@install.group("agent", hidden=True)
def install_agent() -> None:
"""Hidden runtime helpers used by persistent supervisors."""
@install_agent.command("run")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_agent_run(profile: str) -> None:
"""Run the persistent runtime in the foreground."""
manifest = _require_manifest(profile)
raise SystemExit(run_foreground(manifest))
_STARTUP_READY_TIMEOUT_SECONDS = 15
@install_agent.command("ensure")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_agent_ensure(profile: str) -> None:
"""Ensure a persistent deployment is healthy, starting it when needed."""
manifest = _require_manifest(profile)
if probe_ready(manifest.health_url):
click.echo(f"Deployment '{profile}' is already healthy.")
return
with acquire_runtime_start_lock(manifest.profile) as acquired:
if not acquired:
click.echo(f"Deployment '{profile}' start is already in progress.")
return
# Double-check after acquiring the lock — another ensure may have
# started the runtime while we waited for the lock.
if probe_ready(manifest.health_url):
click.echo(f"Deployment '{profile}' is already healthy.")
return
if runtime_status(manifest) == "running":
# Runtime exists but isn't ready yet — give it a grace period
# before deciding it's wedged and restarting.
if wait_ready(manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS):
click.echo(f"Deployment '{profile}' is healthy.")
return
stop_runtime(manifest)
_start_deployment(manifest, assume_start_lock=True)
click.echo(f"Deployment '{profile}' is healthy.")