--- - name: Setup AI Agentic Workspace runtime hosts: "{{ xworkspace_console_hosts | default('all') }}" become: true gather_facts: true module_defaults: ansible.builtin.apt: lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}" vars: # 跟随连接用户,与 xworkspace_console_home(ansible_env.HOME) 保持一致: # 以 root 连接时 user=root/home=/root,避免 become_user=ubuntu 去 link /root # 下的 unit 文件而报 "src does not exist"(root 家目录 700,ubuntu 无法进入)。 xworkspace_console_user: "{{ ansible_env.USER | default('ubuntu') }}" xworkspace_console_public_access: false xworkspace_console_domain: workspace.svc.plus xworkspace_console_home: "{{ ansible_env.HOME | default('/home/ubuntu') }}" xworkspace_console_root: "{{ xworkspace_console_home }}/.local/state/ai-workspace" xworkspace_console_repo_dir: "{{ xworkspace_console_home }}/xworkspace-console" xworkspace_console_runtime_archive: "{{ lookup('ansible.builtin.env', 'XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE') | default('', true) }}" ai_workspace_prebuilt_components_required: "{{ lookup('ansible.builtin.env', 'AI_WORKSPACE_PREBUILT_COMPONENTS_REQUIRED') | default('false', true) | bool }}" xworkspace_console_dashboard_dir: "{{ xworkspace_console_repo_dir }}/dashboard" # 预编译 runtime tar 的 manifest.json 记 apiBinary: bin/xworkspace-api, # 二进制落在 bin/(非源码布局的 api/)。对齐之,否则服务 203/EXEC 崩溃重启。 xworkspace_console_api_dir: "{{ xworkspace_console_repo_dir }}/bin" xworkspace_console_api_binary: "{{ xworkspace_console_api_dir }}/xworkspace-api" xworkspace_console_runtime_marker: "{{ xworkspace_console_repo_dir }}/.runtime-archive-sha256" xworkspace_console_api_working_dir: "{{ xworkspace_console_repo_dir }}" xworkspace_console_api_exec: "{{ xworkspace_console_api_binary }}" xworkspace_console_scripts_dir: "{{ xworkspace_console_root }}/scripts" xworkspace_console_config_dir: "{{ xworkspace_console_home }}/.config/xworkspace" xworkspace_console_url: http://127.0.0.1:17000 xworkspace_console_port: 17000 xworkspace_console_api_port: 8788 xworkspace_console_ttyd_port: 7681 xworkspace_console_enable_ttyd: true xworkspace_console_install_chrome: true xworkspace_console_browser_package: >- {{ 'google-chrome-stable' if ansible_architecture in ['x86_64', 'amd64'] else '' }} xworkspace_console_browser_binary: >- {{ '/usr/bin/google-chrome' if ansible_architecture in ['x86_64', 'amd64'] else '/usr/local/bin/chromium' }} ai_workspace_offline_active: "{{ lookup('ansible.builtin.env', 'AI_WORKSPACE_OFFLINE_ACTIVE') | default('false', true) | bool }}" xworkspace_console_autostart_enabled: true xworkspace_console_ttyd_binary_path: /usr/local/bin/ttyd ai_workspace_auth_token: "{{ lookup('ansible.builtin.env', 'AI_WORKSPACE_AUTH_TOKEN') | default('', true) }}" xworkspace_console_auth_token: >- {{ lookup('ansible.builtin.env', 'XWORKSPACE_CONSOLE_AUTH_TOKEN') | default(lookup('ansible.builtin.env', 'AI_WORKSPACE_AUTH_TOKEN'), true) | default(lookup('ansible.builtin.env', 'BRIDGE_AUTH_TOKEN'), true) | default(lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_AUTH_TOKEN'), true) | default(lookup('ansible.builtin.env', 'INTERNAL_SERVICE_TOKEN'), true) }} xworkspace_console_review_auth_token: "{{ lookup('ansible.builtin.env', 'BRIDGE_REVIEW_AUTH_TOKEN') | default('', true) }}" xworkspace_console_portal_services: - key: litellm name: LiteLLM Admin UI url: http://localhost:4000/ui openMode: iframe healthUrl: http://127.0.0.1:4000/ui description: Model routing and provider administration. icon: chart match: - litellm - lite port: 4000 role: model-router - key: openclaw name: OpenClaw url: http://127.0.0.1:18789/channels openMode: external healthUrl: http://127.0.0.1:18789/channels description: Gateway dashboard. icon: claw match: - openclaw - gateway port: 18789 role: gateway - key: vault name: Vault Server url: http://127.0.0.1:8200/ui openMode: external healthUrl: http://127.0.0.1:8200/ui description: Vault UI. icon: shield match: - vault port: 8200 - key: terminal name: Terminal url: http://127.0.0.1:7681 openMode: iframe healthUrl: http://127.0.0.1:7681 description: Local ttyd terminal. icon: terminal match: - ttyd - terminal port: 7681 tasks: - name: Install Google Chrome apt repository prerequisites ansible.builtin.apt: name: - ca-certificates - curl - gnupg - xdg-utils state: present install_recommends: false update_cache: true when: ansible_os_family != 'Darwin' - name: Ensure Google Chrome apt keyring directory exists ansible.builtin.file: path: /etc/apt/keyrings state: directory owner: root group: root mode: "0755" when: ansible_os_family != 'Darwin' - name: Install Google Linux signing key ansible.builtin.shell: | set -euo pipefail tmp="$(mktemp)" curl -fsSL "https://dl.google.com/linux/linux_signing_key.pub" -o "$tmp" gpg --dearmor -o /etc/apt/keyrings/google-linux-signing-key.gpg "$tmp" rm -f "$tmp" chmod 0644 /etc/apt/keyrings/google-linux-signing-key.gpg args: executable: /bin/bash creates: /etc/apt/keyrings/google-linux-signing-key.gpg when: - ansible_architecture in ['x86_64', 'amd64'] - not ai_workspace_offline_active - ansible_os_family != 'Darwin' - name: Configure Google Chrome apt repository ansible.builtin.copy: dest: /etc/apt/sources.list.d/google-chrome.list owner: root group: root mode: "0644" content: "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-linux-signing-key.gpg] https://dl.google.com/linux/chrome/deb/ stable main\n" when: - ansible_architecture in ['x86_64', 'amd64'] - not ai_workspace_offline_active - ansible_os_family != 'Darwin' - name: Refresh apt cache after Google Chrome repository changes ansible.builtin.apt: update_cache: true when: - ansible_architecture in ['x86_64', 'amd64'] - not ai_workspace_offline_active - ansible_os_family != 'Darwin' - name: Install AI Agentic Workspace runtime packages ansible.builtin.apt: update_cache: true name: >- {{ ['xfce4', 'python3', 'golang-go'] + (['caddy'] if caddy_enabled | default(true) | bool else []) + ([xworkspace_console_browser_package] if xworkspace_console_browser_package | length > 0 else []) }} state: present # xfce4 元包会拉入整套桌面,安装期间偶发重置网络/拖长,导致前台 SSH 会话 # 掉线 → ansible 误判 UNREACHABLE(实际包已在主机装完)。改异步执行 + 轮询, # 让安装在主机后台跑、ansible 重连轮询,掉线也不影响。 async: "{{ ai_workspace_runtime_apt_async | default(1800) | int }}" poll: 15 when: ansible_os_family != 'Darwin' - name: Ensure ttyd binary target directory exists ansible.builtin.file: path: "{{ xworkspace_console_ttyd_binary_path | dirname }}" state: directory owner: root group: root mode: "0755" when: ansible_os_family != 'Darwin' - name: Set ttyd binary download metadata ansible.builtin.set_fact: xworkspace_console_ttyd_arch: >- {{ 'x86_64' if ansible_architecture in ['x86_64', 'amd64'] else 'aarch64' if ansible_architecture in ['aarch64', 'arm64'] else '' }} - name: Fail when ttyd binary architecture is unsupported ansible.builtin.fail: msg: "Unsupported architecture for ttyd binary: {{ ansible_architecture }}" when: xworkspace_console_ttyd_arch == '' - name: Download ttyd release binary ansible.builtin.get_url: url: "https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{{ xworkspace_console_ttyd_arch }}" dest: "{{ xworkspace_console_ttyd_binary_path }}" mode: "0755" owner: root group: root force: false when: - not ai_workspace_offline_active - ansible_os_family != 'Darwin' - name: Verify ttyd binary ansible.builtin.command: "{{ xworkspace_console_ttyd_binary_path }} --version" changed_when: false register: ttyd_version_check failed_when: ttyd_version_check.rc != 0 when: ansible_os_family != 'Darwin' - name: Find ttyd path on macOS ansible.builtin.command: which ttyd register: ttyd_macos_path changed_when: false when: ansible_os_family == 'Darwin' - name: Set ttyd binary path for macOS ansible.builtin.set_fact: xworkspace_console_ttyd_binary_path: "{{ ttyd_macos_path.stdout }}" when: ansible_os_family == 'Darwin' - name: Show ttyd binary version ansible.builtin.debug: msg: "{{ ttyd_version_check.stdout | default('ttyd path: ' + ttyd_macos_path.stdout | default('Unknown')) }}" - name: Ensure AI Agentic Workspace user exists ansible.builtin.user: name: "{{ xworkspace_console_user }}" state: present create_home: true shell: /bin/bash when: - xworkspace_console_user != 'root' - ansible_os_family != 'Darwin' - name: Ensure AI Agentic Workspace directories exist ansible.builtin.file: path: "{{ item }}" state: directory owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0755" loop: "{{ _directories | reject('search', 'systemd') | list if ansible_os_family == 'Darwin' else _directories }}" vars: _directories: - "{{ xworkspace_console_root }}" - "{{ xworkspace_console_scripts_dir }}" - "{{ xworkspace_console_repo_dir }}" - "{{ xworkspace_console_home }}/.config" - "{{ xworkspace_console_config_dir }}" - "{{ xworkspace_console_home }}/.config/autostart" - "{{ xworkspace_console_home }}/.config/systemd" - "{{ xworkspace_console_home }}/.config/systemd/user" - "{{ xworkspace_console_home }}/.config/systemd/user/default.target.wants" - "{{ xworkspace_console_home }}/.config/systemd/user/timers.target.wants" - name: Deploy AI Agentic Workspace status generator ansible.builtin.copy: dest: "{{ xworkspace_console_scripts_dir }}/generate-status.py" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0755" content: | #!/usr/bin/env python3 from __future__ import annotations import datetime as dt import json import os import re import subprocess import sys from pathlib import Path from urllib import request, error HOME = Path("{{ xworkspace_console_home }}") ROOT = Path("{{ xworkspace_console_root }}") CONSOLE_DIR = Path("{{ xworkspace_console_dashboard_dir }}") LITELLM_CONFIG = HOME / ".local/share/xworkspace/litellm-config.yaml" OUTPUT = CONSOLE_DIR / "public/status.json" def run(*cmd: str) -> str: try: return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip() except Exception: return "" def systemd_state(unit: str, scope: str = "--user") -> str: cmd = ["systemctl"] if scope == "--user": cmd.append("--user") cmd += ["is-active", unit] return run(*cmd) or "unknown" def health(url: str) -> tuple[str, str]: try: with request.urlopen(url, timeout=2) as response: payload = response.read().decode("utf-8", "replace").strip() return ("active" if response.status < 400 else "degraded", payload[:160] or "ok") except error.HTTPError as exc: return ("degraded", f"HTTP {exc.code}") except Exception as exc: return ("inactive", str(exc).splitlines()[0][:160]) def probe_unit(name: str, label: str, group: str, endpoint: str | None = None, candidates: list[str] | None = None, scope: str = "--user") -> dict: candidates = candidates or [name] state = "unknown" found = None for candidate in candidates: state = systemd_state(candidate, scope=scope) if state != "unknown": found = candidate break details = f"{label} {state}" if endpoint: endpoint_state, endpoint_detail = health(endpoint) if endpoint_state == "active" and state in {"active", "unknown"}: state = "active" elif endpoint_state == "degraded" and state == "active": state = "degraded" details = endpoint_detail return { "id": found or name, "name": found or name, "label": label, "state": state, "details": details, "endpoint": endpoint, "group": group, } def parse_models(path: Path) -> list[dict]: if not path.exists(): return [] text = path.read_text(encoding="utf-8", errors="replace") models = [] current = None for line in text.splitlines(): match = re.search(r"^\s*model_name:\s*(.+?)\s*$", line) if match: current = match.group(1).strip().strip('"\'') models.append({ "name": current, "state": "active", "details": "Detected from litellm-config.yaml", }) return models services = [ probe_unit("xworkspace-console.service", "console", "core", "{{ xworkspace_console_url }}", ["xworkspace-console.service"], "--user"), probe_unit("xworkspace-litellm.service", "litellm", "core", "http://127.0.0.1:4000/health/liveliness", ["xworkspace-litellm.service", "litellm.service"], "--user"), probe_unit("xworkmate-bridge.service", "xworkmate-bridge", "workspace", None, ["xworkmate-bridge.service"], "--user"), probe_unit("openclaw-gateway.service", "openclaw", "workspace", None, ["openclaw-gateway.service", "openclaw.service"], "--user"), probe_unit("hermes-gateway.service", "hermes", "workspace", None, ["hermes-gateway.service", "hermes.service"], "--user"), probe_unit("qmd.service", "QMD", "workspace", None, ["qmd.service", "QMD.service", "xworkspace-qmd.service"], "--user"), probe_unit("xworkspace-ttyd.service", "ttyd", "workspace", "http://127.0.0.1:7681", ["xworkspace-ttyd.service"], "--user"), probe_unit("ttyd.service", "ttyd(system)", "workspace", "http://127.0.0.1:7681", ["ttyd.service"], "system"), ] litellm = next((item for item in services if item["label"] == "litellm"), None) terminal = next((item for item in services if item["label"].startswith("ttyd")), None) console = next((item for item in services if item["label"] == "console"), None) output = { "generatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), "summary": "Workspace healthy" if all(item["state"] == "active" for item in services if item["label"] != "QMD") else "Workspace degraded", "services": services, "models": parse_models(LITELLM_CONFIG), "terminalTranscript": "ubuntu@workspace:~$ openclaw status\nstatus snapshot generated from live probes", "latencySummary": "live probe", "latencySource": litellm["details"] if litellm else "no litellm probe", "rpmSummary": "live probe", "rpmSource": "xworkspace-litellm.service", "costSummary": "live probe", "costSource": "litellm-config.yaml", "consoleState": console["state"] if console else "unknown", "consoleDetails": console["details"] if console else "No console probe", "terminalState": terminal["state"] if terminal else "unknown", "terminalDetails": terminal["details"] if terminal else "No ttyd probe", } OUTPUT.parent.mkdir(parents=True, exist_ok=True) tmp = OUTPUT.with_suffix(".json.tmp") tmp.write_text(json.dumps(output, indent=2, sort_keys=True), encoding="utf-8") os.replace(tmp, OUTPUT) print(f"Wrote {OUTPUT}") - name: Deploy AI Agentic Workspace status service ansible.builtin.copy: dest: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-status.service" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0644" content: | [Unit] Description=AI Agentic Workspace status snapshot generator After=xworkspace-console.service xworkspace-litellm.service Wants=xworkspace-console.service xworkspace-litellm.service [Service] Type=oneshot ExecStart={{ xworkspace_console_scripts_dir }}/generate-status.py when: ansible_os_family != 'Darwin' - name: Deploy AI Agentic Workspace status timer ansible.builtin.copy: dest: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-status.timer" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0644" content: | [Unit] Description=Refresh AI Agentic Workspace status snapshot periodically [Timer] OnBootSec=5 OnUnitActiveSec=8 Unit=xworkspace-status.service Persistent=true [Install] WantedBy=timers.target when: ansible_os_family != 'Darwin' - name: Deploy Xinit entrypoint for AI Agentic Workspace ansible.builtin.copy: dest: "{{ xworkspace_console_home }}/.xinitrc" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0755" content: | #!/usr/bin/env bash set -euo pipefail export XDG_CURRENT_DESKTOP=XFCE export DESKTOP_SESSION=xfce export BROWSER={{ xworkspace_console_scripts_dir }}/chrome-app.sh mkdir -p "$HOME/.config/xworkspace" if [ -z "${XAUTHORITY:-}" ]; then export XAUTHORITY="$HOME/.Xauthority" fi cat > "$HOME/.config/xworkspace/session.env" </dev/null 2>&1 || true dbus-update-activation-environment --systemd DISPLAY XAUTHORITY XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS >/dev/null 2>&1 || true systemctl --user daemon-reload >/dev/null 2>&1 || true systemctl --user start xworkspace-console.service >/dev/null 2>&1 || true exec dbus-launch --exit-with-session startxfce4 when: ansible_os_family != 'Darwin' - name: Point Xsession to Xinit entrypoint ansible.builtin.copy: dest: "{{ xworkspace_console_home }}/.xsession" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0755" content: | #!/usr/bin/env bash exec "$HOME/.xinitrc" when: ansible_os_family != 'Darwin' - name: Deploy AI Agentic Workspace console launcher script ansible.builtin.copy: dest: "{{ xworkspace_console_scripts_dir }}/start.sh" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0755" content: | #!/usr/bin/env bash set -euo pipefail exit 0 - name: Deploy AI Agentic Workspace Chrome launcher wrapper ansible.builtin.copy: dest: "{{ xworkspace_console_scripts_dir }}/chrome-app.sh" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0755" content: | #!/usr/bin/env bash set -euo pipefail mkdir -p "$HOME/.config/xworkspace-chrome" exec {{ xworkspace_console_browser_binary }} \ --app=http://localhost:17000 \ --user-data-dir="$HOME/.config/xworkspace-chrome" \ --profile-directory=Default \ --no-first-run \ --disable-session-crashed-bubble \ --disable-sync \ --new-window - name: Download XWorkspace Console runtime release ansible.builtin.get_url: url: "https://github.com/ai-workspace-lab/xworkspace-console/releases/download/latest-runtime/xworkspace-console-runtime-{{ ansible_system | lower }}-{{ 'amd64' if ansible_architecture in ['x86_64', 'amd64'] else 'arm64' }}.tar.gz" dest: "/tmp/xworkspace-console-runtime.tar.gz" mode: "0644" force: true register: xworkspace_console_runtime_download until: xworkspace_console_runtime_download is succeeded retries: 3 delay: 5 when: xworkspace_console_runtime_archive | length == 0 - name: Set runtime archive path ansible.builtin.set_fact: xworkspace_console_runtime_archive_resolved: "{{ xworkspace_console_runtime_archive if (xworkspace_console_runtime_archive | length > 0) else '/tmp/xworkspace-console-runtime.tar.gz' }}" - name: Validate packaged XWorkspace Console runtime ansible.builtin.stat: path: "{{ xworkspace_console_runtime_archive_resolved }}" register: xworkspace_console_runtime_archive_stat - name: Require packaged XWorkspace Console runtime ansible.builtin.assert: that: - xworkspace_console_runtime_archive_stat.stat.exists | default(false) fail_msg: "A valid XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE is required or download failed." - name: Inspect installed XWorkspace Console runtime marker ansible.builtin.slurp: path: "{{ xworkspace_console_runtime_marker }}" register: xworkspace_console_runtime_marker_content failed_when: false - name: Stop xworkspace-console services before extracting ansible.builtin.shell: | uid="$(id -u {{ xworkspace_console_user }})" systemctl start "user@${uid}.service" || true for svc in xworkspace-status.timer xworkspace-status.service xworkspace-console.service; do runuser -u {{ xworkspace_console_user }} -- env XDG_RUNTIME_DIR="/run/user/${uid}" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" systemctl --user stop "$svc" || true done become: true changed_when: false when: ansible_os_family != 'Darwin' - name: Install packaged XWorkspace Console runtime ansible.builtin.unarchive: src: "{{ xworkspace_console_runtime_archive_resolved }}" dest: "{{ xworkspace_console_repo_dir | dirname }}" remote_src: true owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" when: - xworkspace_console_runtime_archive_stat.stat.exists | default(false) - >- (xworkspace_console_runtime_marker_content.content | default('') | b64decode | trim) != (xworkspace_console_runtime_archive_stat.stat.checksum | default('')) or not (xworkspace_console_api_binary is file) or not ((xworkspace_console_dashboard_dir ~ '/dist/index.html') is file) - name: Record installed XWorkspace Console runtime checksum ansible.builtin.copy: dest: "{{ xworkspace_console_runtime_marker }}" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0644" content: "{{ xworkspace_console_runtime_archive_stat.stat.checksum }}\n" when: - xworkspace_console_runtime_archive_stat.stat.exists | default(false) - name: Deploy AI Workspace portal service configuration ansible.builtin.copy: dest: "{{ xworkspace_console_config_dir }}/portal-services.json" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0644" content: "{{ {'services': xworkspace_console_portal_services} | to_nice_json }}\n" - name: Deploy AI Workspace portal token file ansible.builtin.copy: dest: "{{ xworkspace_console_config_dir }}/auth-token" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0600" content: "{{ xworkspace_console_auth_token }}\n" no_log: true - name: Deploy AI Workspace shared auth token file ansible.builtin.copy: dest: "{{ xworkspace_console_home }}/.ai_workspace_auth_token" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0600" content: "{{ xworkspace_console_auth_token }}\n" no_log: true - name: Deploy XWorkspace API environment ansible.builtin.copy: dest: "{{ xworkspace_console_config_dir }}/portal.env" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0600" content: | AI_WORKSPACE_AUTH_TOKEN={{ xworkspace_console_auth_token }} XWORKSPACE_CONSOLE_AUTH_TOKEN={{ xworkspace_console_auth_token }} BRIDGE_AUTH_TOKEN={{ xworkspace_console_auth_token }} BRIDGE_REVIEW_AUTH_TOKEN={{ xworkspace_console_review_auth_token }} XWORKMATE_BRIDGE_AUTH_TOKEN={{ xworkspace_console_auth_token }} INTERNAL_SERVICE_TOKEN={{ xworkspace_console_auth_token }} XWORKSPACE_PORTAL_SERVICES_FILE={{ xworkspace_console_config_dir }}/portal-services.json no_log: true - name: Deploy XWorkspace Console service ansible.builtin.copy: dest: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-console.service" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0644" content: | [Unit] Description=XWorkspace Console dashboard After=network-online.target Wants=network-online.target [Service] Type=simple WorkingDirectory={{ xworkspace_console_dashboard_dir }}/dist # console 只是 17000 上的静态后端(dashboard 为无路由单页 dist),由系统 # caddy 经 /etc/caddy/conf.d/ 反代对外。用 python3 静态伺服即可,跨 Linux/ # macOS 统一、不再起第二个 caddy(避免与系统 caddy 抢 :80)。 ExecStart=/usr/bin/env python3 -m http.server {{ xworkspace_console_port }} --bind 127.0.0.1 --directory {{ xworkspace_console_dashboard_dir }}/dist Restart=always RestartSec=2 [Install] WantedBy=default.target when: ansible_os_family != 'Darwin' - name: Deploy XWorkspace API service ansible.builtin.copy: dest: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-api.service" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0644" content: | [Unit] Description=XWorkspace status API After=network-online.target Wants=network-online.target [Service] Type=simple WorkingDirectory={{ xworkspace_console_api_working_dir }} EnvironmentFile={{ xworkspace_console_config_dir }}/portal.env ExecStart={{ xworkspace_console_api_exec }} Restart=always RestartSec=2 [Install] WantedBy=default.target when: ansible_os_family != 'Darwin' - name: Deploy AI Agentic Workspace ttyd service ansible.builtin.copy: dest: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-ttyd.service" owner: "{{ xworkspace_console_user }}" group: "{{ 'staff' if ansible_os_family == 'Darwin' else xworkspace_console_user }}" mode: "0644" content: | [Unit] Description=AI Agentic Workspace ttyd After=graphical-session.target Wants=graphical-session.target [Service] Type=simple ExecStart={{ xworkspace_console_ttyd_binary_path }} -i lo -p {{ xworkspace_console_ttyd_port }} -O login bash Restart=always RestartSec=2 [Install] WantedBy=default.target when: ansible_os_family != 'Darwin' - name: Enable XWorkspace Console service ansible.builtin.file: src: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-console.service" dest: "{{ xworkspace_console_home }}/.config/systemd/user/default.target.wants/xworkspace-console.service" state: link become_user: "{{ xworkspace_console_user }}" when: ansible_os_family != 'Darwin' - name: Enable XWorkspace API service ansible.builtin.file: src: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-api.service" dest: "{{ xworkspace_console_home }}/.config/systemd/user/default.target.wants/xworkspace-api.service" state: link become_user: "{{ xworkspace_console_user }}" when: ansible_os_family != 'Darwin' - name: Enable AI Agentic Workspace ttyd service ansible.builtin.file: src: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-ttyd.service" dest: "{{ xworkspace_console_home }}/.config/systemd/user/default.target.wants/xworkspace-ttyd.service" state: link become_user: "{{ xworkspace_console_user }}" when: ansible_os_family != 'Darwin' - name: Enable AI Agentic Workspace LiteLLM service ansible.builtin.file: src: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-litellm.service" dest: "{{ xworkspace_console_home }}/.config/systemd/user/default.target.wants/xworkspace-litellm.service" state: link force: true become_user: "{{ xworkspace_console_user }}" when: ansible_os_family != 'Darwin' - name: Enable AI Agentic Workspace status timer ansible.builtin.file: src: "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-status.timer" dest: "{{ xworkspace_console_home }}/.config/systemd/user/timers.target.wants/xworkspace-status.timer" state: link become_user: "{{ xworkspace_console_user }}" when: ansible_os_family != 'Darwin' - name: Kill legacy python http.server on port 7000 ansible.builtin.shell: | pid=$(lsof -ti:7000 2>/dev/null || true) if [ -n "$pid" ]; then kill "$pid" 2>/dev/null || true sleep 1 fi become_user: "{{ xworkspace_console_user }}" - name: Remove legacy portal chrome and target units ansible.builtin.file: path: "{{ item }}" state: absent loop: - "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-portal.service" - "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-chrome.service" - "{{ xworkspace_console_home }}/.config/systemd/user/xworkspace-console.target" - "{{ xworkspace_console_home }}/.config/systemd/user/default.target.wants/xworkspace-portal.service" - "{{ xworkspace_console_home }}/.config/systemd/user/default.target.wants/xworkspace-chrome.service" - "{{ xworkspace_console_home }}/.config/systemd/user/default.target.wants/xworkspace-console.target" when: ansible_os_family != 'Darwin' - name: Remove legacy portal directory ansible.builtin.file: path: "{{ xworkspace_console_root }}/portal" state: absent - name: Reload systemd user daemon ansible.builtin.shell: | uid="$(id -u {{ xworkspace_console_user }})" loginctl enable-linger {{ xworkspace_console_user }} || true systemctl start "user@${uid}.service" || true runuser -u {{ xworkspace_console_user }} -- env XDG_RUNTIME_DIR="/run/user/${uid}" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" systemctl --user daemon-reload become: true when: ansible_os_family != 'Darwin' - name: Restart xworkspace-console service ansible.builtin.shell: | uid="$(id -u {{ xworkspace_console_user }})" loginctl enable-linger {{ xworkspace_console_user }} || true systemctl start "user@${uid}.service" || true runuser -u {{ xworkspace_console_user }} -- env XDG_RUNTIME_DIR="/run/user/${uid}" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" systemctl --user restart xworkspace-console.service become: true when: ansible_os_family != 'Darwin' - name: Restart xworkspace-api service ansible.builtin.shell: | uid="$(id -u {{ xworkspace_console_user }})" loginctl enable-linger {{ xworkspace_console_user }} || true systemctl start "user@${uid}.service" || true runuser -u {{ xworkspace_console_user }} -- env XDG_RUNTIME_DIR="/run/user/${uid}" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" systemctl --user restart xworkspace-api.service become: true when: ansible_os_family != 'Darwin' - name: Restart xworkspace-ttyd service ansible.builtin.shell: | uid="$(id -u {{ xworkspace_console_user }})" loginctl enable-linger {{ xworkspace_console_user }} || true systemctl start "user@${uid}.service" || true runuser -u {{ xworkspace_console_user }} -- env XDG_RUNTIME_DIR="/run/user/${uid}" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" systemctl --user restart xworkspace-ttyd.service become: true when: ansible_os_family != 'Darwin' - name: Hide XFCE desktop icons ansible.builtin.command: xfconf-query -c xfce4-desktop -p /desktop-icons/style -t int -s 0 --create changed_when: true failed_when: false - name: Ensure Caddy fragment directory exists ansible.builtin.file: path: /etc/caddy/conf.d state: directory owner: root group: root mode: "0755" when: ansible_os_family != 'Darwin' - name: Deploy xworkspace-console public Caddy site ansible.builtin.copy: dest: "/etc/caddy/conf.d/{{ xworkspace_console_domain }}.caddy" owner: root group: root mode: "0644" content: | {{ xworkspace_console_domain }} { reverse_proxy 127.0.0.1:{{ xworkspace_console_port }} } when: - xworkspace_console_public_access | bool - ansible_os_family != 'Darwin' register: xworkspace_caddy_deploy - name: Remove xworkspace-console public Caddy site when disabled ansible.builtin.file: path: "/etc/caddy/conf.d/{{ xworkspace_console_domain }}.caddy" state: absent when: - not (xworkspace_console_public_access | bool) - ansible_os_family != 'Darwin' register: xworkspace_caddy_remove - name: Reload Caddy if xworkspace-console proxy changed ansible.builtin.service: name: caddy state: reloaded when: - (xworkspace_caddy_deploy.changed or xworkspace_caddy_remove.changed) and not ansible_check_mode - ansible_os_family != 'Darwin' failed_when: false - name: Import macOS specific XWorkspace console tasks ansible.builtin.include_tasks: xworkspace_console_macos.yml when: ansible_os_family == 'Darwin'