feat(ai-workspace): consume prebuilt console runtime for final deployment

The macOS console API previously ran via `go run .`, which fails under
launchd's minimal PATH (no `go`) and recompiles on every launch. Switch to
the same prebuilt-runtime consumption model the bridge/qmd/litellm runtimes
already use.

The ai-workspace role now does final deployment only (never builds):
- download xworkspace-console-runtime-<os>-<arch>.tar.gz (incl. darwin-arm64)
  from the latest-runtime release, or use an offline-staged archive via
  XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE;
- unpack to a per-user system dir (~/.local/share/xworkspace-console),
  idempotent via a sha256 marker;
- read manifest.json to resolve the prebuilt API binary and assert it is a
  present, executable native binary;
- on macOS, deploy a LaunchAgent that sources portal.env and execs the
  prebuilt binary directly — no go, no Homebrew, no PATH games.

The Go API is pure-Go (no cgo), so CI cross-compiles darwin-arm64 cleanly;
this role only consumes that artifact. Validated end-to-end on darwin-arm64:
packaged binary serves :8788 (200 with token, 401 without) under launchd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Haitao Pan 2026-06-22 17:04:55 +08:00
parent a5850cfcee
commit 01f1499a60
4 changed files with 233 additions and 0 deletions

View File

@ -9,3 +9,40 @@ migrate_litellm_db_host: "127.0.0.1"
# Migration paths
openclaw_data_dir: "~/.openclaw"
xworkspace_state_dir: "~/.local/state/xworkspace"
# =============================================================================
# XWorkspace Console runtime — final deployment (consumption only)
#
# The console runtime binary (Go API) and dashboard dist are cross-compiled and
# published by CI:
# ai-workspace-lab/xworkspace-console
# .github/workflows/offline-package-xworkspace-console-runtime.yaml
# as xworkspace-console-runtime-<os>-<arch>.tar.gz (incl. darwin-arm64). This
# role NEVER builds from source; it only downloads/stages the prebuilt tarball,
# unpacks it to a per-user system dir, and deploys the launchd service that
# execs the prebuilt API binary recorded in the package manifest.
# =============================================================================
ai_workspace_console_deploy_enabled: true
ai_workspace_console_runtime_os: "{{ ansible_system | lower }}"
ai_workspace_console_runtime_arch: "{{ 'amd64' if ansible_architecture in ['x86_64', 'amd64'] else 'arm64' }}"
# Stable moving release maintained by the CI publish job (matches the
# latest-runtime convention used by the bridge/qmd/litellm runtimes).
ai_workspace_console_runtime_release_tag: "latest-runtime"
ai_workspace_console_runtime_release_base: "https://github.com/ai-workspace-lab/xworkspace-console/releases/download/{{ ai_workspace_console_runtime_release_tag }}"
ai_workspace_console_runtime_asset: "xworkspace-console-runtime-{{ ai_workspace_console_runtime_os }}-{{ ai_workspace_console_runtime_arch }}.tar.gz"
ai_workspace_console_runtime_url: "{{ ai_workspace_console_runtime_release_base }}/{{ ai_workspace_console_runtime_asset }}"
# Offline/air-gapped override: a locally-staged runtime tarball. When set it is
# used verbatim and no download happens (offline package path).
ai_workspace_console_runtime_archive: "{{ lookup('ansible.builtin.env', 'XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE') | default('', true) }}"
# The tarball carries a top-level xworkspace-console/ dir, so it is extracted
# into the parent and lands at <parent>/xworkspace-console.
ai_workspace_console_install_parent: "{{ ansible_env.HOME }}/.local/share"
ai_workspace_console_install_dir: "{{ ai_workspace_console_install_parent }}/xworkspace-console"
ai_workspace_console_runtime_marker: "{{ ai_workspace_console_install_dir }}/.runtime-archive-sha256"
ai_workspace_console_manifest_path: "{{ ai_workspace_console_install_dir }}/manifest.json"
# Token env file produced by the console play; the API sources it for auth.
ai_workspace_console_config_dir: "{{ ansible_env.HOME }}/.config/ai-workspace"
ai_workspace_console_portal_env: "{{ ai_workspace_console_config_dir }}/portal.env"
ai_workspace_console_log_dir: "{{ ansible_env.HOME }}/.local/state/xworkspace"
ai_workspace_console_api_label: "plus.svc.xworkspace.api"
ai_workspace_console_api_port: 8788

View File

@ -0,0 +1,42 @@
---
# macOS final deployment of the console API: run the prebuilt arm64 binary
# from the unpacked runtime via a user LaunchAgent. The binary is self-contained
# (pure Go, no cgo), so it needs neither `go` nor any Homebrew tooling at runtime
# — this avoids the launchd minimal-PATH problem that broke the `go run .` path.
- name: Ensure XWorkspace Console runtime directories exist (macOS)
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ ai_workspace_console_config_dir }}"
- "{{ ai_workspace_console_log_dir }}"
- "{{ ansible_env.HOME }}/Library/LaunchAgents"
- name: Deploy XWorkspace Console API LaunchAgent (macOS)
ansible.builtin.template:
src: xworkspace-api.plist.j2
dest: "{{ ansible_env.HOME }}/Library/LaunchAgents/{{ ai_workspace_console_api_label }}.plist"
mode: "0644"
register: ai_workspace_console_api_plist
- name: Restart XWorkspace Console API LaunchAgent on change (macOS)
ansible.builtin.command: "launchctl stop {{ ai_workspace_console_api_label }}"
when: ai_workspace_console_api_plist.changed
changed_when: false
failed_when: false
- name: Load XWorkspace Console API LaunchAgent (macOS)
ansible.builtin.command: "launchctl load -w {{ ansible_env.HOME }}/Library/LaunchAgents/{{ ai_workspace_console_api_label }}.plist"
register: ai_workspace_console_api_load
changed_when: false
failed_when: >-
ai_workspace_console_api_load.rc != 0
and 'already loaded' not in ai_workspace_console_api_load.stderr
- name: Start XWorkspace Console API LaunchAgent on change (macOS)
ansible.builtin.command: "launchctl start {{ ai_workspace_console_api_label }}"
when: ai_workspace_console_api_plist.changed
changed_when: false
failed_when: false

View File

@ -0,0 +1,125 @@
---
# =============================================================================
# Final deployment of the prebuilt XWorkspace Console runtime.
#
# The runtime binary is built in CI and published as
# xworkspace-console-runtime-<os>-<arch>.tar.gz (incl. darwin-arm64). This role
# is consumption-only: download/stage -> unpack to a per-user system dir ->
# read the package manifest -> exec the prebuilt API binary via launchd. It
# never compiles from source and never runs `go`.
# =============================================================================
- name: Resolve XWorkspace Console runtime source
ansible.builtin.set_fact:
ai_workspace_console_runtime_archive_resolved: >-
{{ ai_workspace_console_runtime_archive
if (ai_workspace_console_runtime_archive | length > 0)
else '/tmp/xworkspace-console-runtime.tar.gz' }}
when: ai_workspace_console_deploy_enabled | bool
- name: Ensure XWorkspace Console install parent exists
ansible.builtin.file:
path: "{{ ai_workspace_console_install_parent }}"
state: directory
mode: "0755"
when: ai_workspace_console_deploy_enabled | bool
- name: Download XWorkspace Console runtime release
ansible.builtin.get_url:
url: "{{ ai_workspace_console_runtime_url }}"
dest: "{{ ai_workspace_console_runtime_archive_resolved }}"
mode: "0644"
force: true
# Only fetch from the network when an offline archive was not supplied.
when:
- ai_workspace_console_deploy_enabled | bool
- ai_workspace_console_runtime_archive | length == 0
- name: Stat XWorkspace Console runtime archive
ansible.builtin.stat:
path: "{{ ai_workspace_console_runtime_archive_resolved }}"
checksum_algorithm: sha256
register: ai_workspace_console_runtime_archive_stat
when: ai_workspace_console_deploy_enabled | bool
- name: Require a valid XWorkspace Console runtime archive
ansible.builtin.assert:
that:
- ai_workspace_console_runtime_archive_stat.stat.exists | default(false)
fail_msg: >-
No XWorkspace Console runtime archive at
{{ ai_workspace_console_runtime_archive_resolved }}.
Set XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE (offline) or ensure
{{ ai_workspace_console_runtime_url }} is reachable.
when: ai_workspace_console_deploy_enabled | bool
- name: Read installed XWorkspace Console runtime marker
ansible.builtin.slurp:
path: "{{ ai_workspace_console_runtime_marker }}"
register: ai_workspace_console_runtime_marker_content
failed_when: false
when: ai_workspace_console_deploy_enabled | bool
- name: Install (unpack) XWorkspace Console runtime
ansible.builtin.unarchive:
src: "{{ ai_workspace_console_runtime_archive_resolved }}"
dest: "{{ ai_workspace_console_install_parent }}"
remote_src: true
mode: "0755"
# Re-extract only when the package checksum changed or the binary is missing,
# so repeat runs are idempotent and do not thrash the service.
when:
- ai_workspace_console_deploy_enabled | bool
- >-
(ai_workspace_console_runtime_marker_content.content | default('') | b64decode | trim)
!= (ai_workspace_console_runtime_archive_stat.stat.checksum | default(''))
or not (ai_workspace_console_manifest_path is file)
- name: Read XWorkspace Console runtime manifest
ansible.builtin.slurp:
path: "{{ ai_workspace_console_manifest_path }}"
register: ai_workspace_console_manifest_raw
when: ai_workspace_console_deploy_enabled | bool
- name: Resolve XWorkspace Console API binary from manifest
ansible.builtin.set_fact:
ai_workspace_console_manifest: "{{ ai_workspace_console_manifest_raw.content | b64decode | from_json }}"
when: ai_workspace_console_deploy_enabled | bool
- name: Set XWorkspace Console API binary path
ansible.builtin.set_fact:
ai_workspace_console_api_binary: "{{ ai_workspace_console_install_dir }}/{{ ai_workspace_console_manifest.apiBinary }}"
when: ai_workspace_console_deploy_enabled | bool
- name: Stat XWorkspace Console API binary
ansible.builtin.stat:
path: "{{ ai_workspace_console_api_binary }}"
register: ai_workspace_console_api_binary_stat
when: ai_workspace_console_deploy_enabled | bool
- name: Require an executable XWorkspace Console API binary
ansible.builtin.assert:
that:
- ai_workspace_console_api_binary_stat.stat.exists | default(false)
- ai_workspace_console_api_binary_stat.stat.executable | default(false)
fail_msg: >-
Prebuilt API binary missing or not executable:
{{ ai_workspace_console_api_binary }} (manifest os/arch:
{{ ai_workspace_console_manifest.os | default('?') }}/{{ ai_workspace_console_manifest.arch | default('?') }}).
when: ai_workspace_console_deploy_enabled | bool
- name: Record installed XWorkspace Console runtime marker
ansible.builtin.copy:
dest: "{{ ai_workspace_console_runtime_marker }}"
content: "{{ ai_workspace_console_runtime_archive_stat.stat.checksum }}\n"
mode: "0644"
when:
- ai_workspace_console_deploy_enabled | bool
- ai_workspace_console_runtime_archive_stat.stat.exists | default(false)
# --- macOS service: exec the prebuilt binary directly (no go, no PATH games) ---
- name: Deploy XWorkspace Console API on macOS
ansible.builtin.import_tasks: macos.yml
when:
- ai_workspace_console_deploy_enabled | bool
- ansible_os_family == 'Darwin'

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{ ai_workspace_console_api_label }}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>
set -a
[ -f "{{ ai_workspace_console_portal_env }}" ] &amp;&amp; . "{{ ai_workspace_console_portal_env }}"
set +a
exec "{{ ai_workspace_console_api_binary }}"
</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>{{ ai_workspace_console_install_dir }}</string>
<key>StandardOutPath</key>
<string>{{ ai_workspace_console_log_dir }}/api.log</string>
<key>StandardErrorPath</key>
<string>{{ ai_workspace_console_log_dir }}/api.err.log</string>
</dict>
</plist>