feat: aggregate prebuilt workspace releases

This commit is contained in:
Haitao Pan 2026-06-15 21:59:35 +08:00
parent 52d2243478
commit 6f85f4d183
6 changed files with 771 additions and 140 deletions

View File

@ -1,14 +1,6 @@
name: Build Offline AI Workspace All-in-One Package
on:
push:
branches:
- main
paths:
- 'scripts/create-ai-workspace-offline-package.sh'
- 'scripts/ai-workspace-offline-install.sh'
- 'scripts/setup-ai-workspace-all-in-one.sh'
- '.github/workflows/offline-package-ai-workspace-installer.yaml'
workflow_dispatch:
inputs:
tag:
@ -30,6 +22,26 @@ on:
required: false
default: "main"
type: string
console_runtime_release_tag:
description: "Console runtime release tag, or latest-runtime"
required: false
default: "latest-runtime"
type: string
bridge_runtime_release_tag:
description: "Bridge runtime release tag, or latest-runtime"
required: false
default: "latest-runtime"
type: string
qmd_runtime_release_tag:
description: "QMD runtime release tag, or latest-runtime"
required: false
default: "latest-runtime"
type: string
litellm_runtime_release_tag:
description: "LiteLLM runtime release tag, or latest-runtime"
required: false
default: "latest-runtime"
type: string
permissions:
contents: write
@ -102,6 +114,11 @@ jobs:
PLAYBOOKS_REF: ${{ github.event.inputs.playbooks_ref || 'main' }}
CONSOLE_REF: ${{ github.event_name == 'push' && github.sha || github.event.inputs.console_ref || 'main' }}
CORE_SKILLS_REF: ${{ github.event.inputs.core_skills_ref || 'main' }}
CONSOLE_RUNTIME_RELEASE_TAG: ${{ github.event.inputs.console_runtime_release_tag || 'latest-runtime' }}
BRIDGE_RUNTIME_RELEASE_TAG: ${{ github.event.inputs.bridge_runtime_release_tag || 'latest-runtime' }}
QMD_RUNTIME_RELEASE_TAG: ${{ github.event.inputs.qmd_runtime_release_tag || 'latest-runtime' }}
LITELLM_RUNTIME_RELEASE_TAG: ${{ github.event.inputs.litellm_runtime_release_tag || 'latest-runtime' }}
GH_TOKEN: ${{ github.token }}
PACKAGE_VERSION: ${{ github.run_number }}
run: |
set -euo pipefail
@ -180,6 +197,10 @@ jobs:
grep -q 'metadata/target.env' contents.txt
grep -q 'repos/playbooks' contents.txt
grep -q 'repos/xworkspace-console' contents.txt
grep -q 'packages/components/xworkspace-console-runtime.tar.gz' contents.txt
grep -q 'packages/components/qmd-runtime.tar.gz' contents.txt
grep -q 'metadata/litellm-runtime.env' contents.txt
grep -q 'metadata/components/xworkmate-bridge.tag' contents.txt
grep -q 'packages/apt/Packages.gz' contents.txt
if [[ "${{ matrix.arch }}" == "arm64" ]]; then
grep -Eq 'packages/playwright-browsers/.*/chrome-linux(64)?/chrome$' contents.txt

128
.github/workflows/runtime-release.yaml vendored Normal file
View File

@ -0,0 +1,128 @@
name: Build XWorkspace Console Runtime Release
on:
push:
branches: [main]
paths:
- api/**
- dashboard/**
- scripts/**
- .github/workflows/runtime-release.yaml
workflow_dispatch:
permissions:
contents: write
actions: write
concurrency:
group: xworkspace-console-runtime-release-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build linux-${{ matrix.arch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: actions/setup-go@v6
with:
go-version-file: api/go.mod
cache: false
- name: Build target runtime
env:
TARGET_ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
root="dist/runtime/xworkspace-console"
mkdir -p "${root}/dashboard" "${root}/bin" "dist/assets"
cp -a scripts "${root}/"
cp dashboard/package.json dashboard/package-lock.json dashboard/index.html \
dashboard/tsconfig.json dashboard/vite.config.ts "${root}/dashboard/"
cp -a dashboard/src "${root}/dashboard/"
docker run --rm --platform "linux/${TARGET_ARCH}" \
-v "${PWD}/dashboard:/src:ro" \
-v "${PWD}/${root}/dashboard:/out" \
node:24-bookworm \
bash -lc '
set -euo pipefail
cp -a /src/. /tmp/dashboard/
cd /tmp/dashboard
npm ci --no-audit --no-fund
npm run build
cp -a dist node_modules /out/
'
(
cd api
CGO_ENABLED=0 GOOS=linux GOARCH="${TARGET_ARCH}" \
go build -buildvcs=false -trimpath -o "../${root}/bin/xworkspace-api" .
)
cat > "${root}/manifest.json" <<JSON
{
"component": "xworkspace-console",
"commit": "${GITHUB_SHA}",
"os": "linux",
"arch": "${TARGET_ARCH}",
"apiBinary": "bin/xworkspace-api",
"dashboard": "dashboard"
}
JSON
tar -czf "dist/assets/xworkspace-console-runtime-linux-${TARGET_ARCH}.tar.gz" \
-C "dist/runtime" xworkspace-console
(
cd dist/assets
sha256sum -- ./*.tar.gz | sed 's# \./# #' > "SHA256SUMS-${TARGET_ARCH}"
)
- uses: actions/upload-artifact@v4
with:
name: xworkspace-console-runtime-linux-${{ matrix.arch }}
path: |
dist/assets/*.tar.gz
dist/assets/SHA256SUMS-*
if-no-files-found: error
publish:
name: Publish runtime release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: xworkspace-console-runtime-linux-*
path: dist
merge-multiple: true
- name: Publish assets
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="runtime-${GITHUB_SHA::12}"
cat dist/SHA256SUMS-* | sort -u > dist/SHA256SUMS
rm -f dist/SHA256SUMS-*
if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
gh release upload "${tag}" dist/*.tar.gz dist/SHA256SUMS \
--repo "${GITHUB_REPOSITORY}" --clobber
else
gh release create "${tag}" dist/*.tar.gz dist/SHA256SUMS \
--repo "${GITHUB_REPOSITORY}" \
--target "${GITHUB_SHA}" \
--title "XWorkspace Console runtime ${GITHUB_SHA::12}" \
--notes "Prebuilt Linux runtime assets. No target-host build is required."
fi
gh workflow run offline-package-ai-workspace-installer.yaml \
--repo "${GITHUB_REPOSITORY}" \
-f "console_runtime_release_tag=${tag}"

View File

@ -454,13 +454,21 @@ ssh root@acp-bridge.onwalk.net \
- 收口铁律:任一 Phase 2 产物在**被 Phase 3 消费前**必须 `finished`
- dpkg/全局 npm/全局 pip **绝不** `async`§10.2)。
### 10.5 Shell 层 fork 并发≤5,预取层)
### 10.5 Shell 层动态 fork 并发(≤ CPU 核心数 × 2,预取层)
bootstrap 把可并行的外部 I/O 收敛到一个**有界 fork 池(上限 5**,在 ansible 前Phase 2 预取)与摘要阶段使用:
bootstrap 把可并行的外部 I/O 收敛到一个**负载自适应的有界 fork 池**,在 ansible 前Phase 2 预取)与摘要阶段使用。硬上限为目标主机在线 CPU 核心数的 2 倍;`AI_WORKSPACE_MAX_PARALLEL_JOBS` 可设更低人工上限,默认 `auto`。每次启动子任务前读取 1 分钟 load average`min(人工上限, 2 × CPU - ceil(load1))` 动态收缩,最低保留 1 路
```bash
MAX_FORKS=5; pids=(); rc=0
run_bounded(){ while [ "$(jobs -rp | wc -l)" -ge "$MAX_FORKS" ]; do wait -n; done; "$@" & pids+=($!); }
CPU_COUNT="$(getconf _NPROCESSORS_ONLN)"
HARD_LIMIT=$((CPU_COUNT * 2))
LOAD_CEILING="$(awk -v load="$(cut -d' ' -f1 /proc/loadavg)" 'BEGIN { n=int(load); print load > n ? n + 1 : n }')"
DYNAMIC_LIMIT=$((HARD_LIMIT - LOAD_CEILING))
[ "$DYNAMIC_LIMIT" -ge 1 ] || DYNAMIC_LIMIT=1
run_bounded() {
while [ "$(jobs -rp | wc -l)" -ge "$DYNAMIC_LIMIT" ]; do wait -n; done
"$@" &
}
# Phase 2 预取5 仓库 pull + 二进制下载 + 镜像 pull离线包存在则短路跳过
for r in playbooks console core-skills qmd litellm; do run_bounded fetch_repo "$r"; done
@ -470,7 +478,7 @@ for p in "${pids[@]}"; do wait "$p" || rc=1; done
[ "$rc" -eq 0 ] || { echo "[phase2] 存在失败子任务"; exit 1; }
```
- 健康探测 fan-out摘要前对 Portal/Bridge/OpenClaw/QMD/Hermes/PG/Vault/LiteLLM 的 `systemctl is-active`+`curl` 并发≤5统一汇总。
- 健康探测 fan-out摘要前对 Portal/Bridge/OpenClaw/QMD/Hermes/PG/Vault/LiteLLM 的 `systemctl is-active`+`curl` 使用同一动态上限,统一按固定顺序汇总。
- 每子进程带日志前缀(`[repo:qmd]`/`[bin:vault]`),失败非零退出、不静默。
- 串行保留:`ansible-playbook` 主执行Phase 1/Phase 3 由其内部保证)、一次性 token/摘要打印。

View File

@ -4,6 +4,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APT_DIR="${ROOT}/packages/apt"
BIN_DIR="${ROOT}/packages/bin"
COMPONENT_DIR="${ROOT}/packages/components"
IMAGE_DIR="${ROOT}/packages/images"
NPM_CACHE_DIR="${ROOT}/packages/npm-cache"
NPM_RUNTIME_CACHE_DIR="${AI_WORKSPACE_NPM_CACHE_DIR:-/var/cache/ai-workspace/npm}"
@ -134,18 +135,12 @@ EOF
}
configure_local_git_sources() {
local repo git_dir
for repo in \
"${ROOT}/repos/xworkspace-console" \
"${ROOT}/repos/qmd" \
"${ROOT}/repos/litellm"; do
git_dir="${repo}/.git"
[ -d "${git_dir}" ] || continue
if ! git config --system --get-all safe.directory 2>/dev/null | grep -Fxq "${git_dir}"; then
git config --system --add safe.directory "${git_dir}"
SAFE_GIT_DIRS+=("${git_dir}")
fi
done
local git_dir="${ROOT}/repos/xworkspace-console/.git"
[ -d "${git_dir}" ] || return
if ! git config --system --get-all safe.directory 2>/dev/null | grep -Fxq "${git_dir}"; then
git config --system --add safe.directory "${git_dir}"
SAFE_GIT_DIRS+=("${git_dir}")
fi
}
append_available_package() {
@ -369,11 +364,16 @@ configure_language_package_caches() {
info "Configuring pip to prefer bundled wheelhouse"
export PIP_FIND_LINKS="${PIP_WHEEL_DIR}"
export PIP_PREFER_BINARY=true
export PIP_NO_INDEX=true
if command -v python3 >/dev/null 2>&1 &&
python3 -m pip --version >/dev/null 2>&1; then
python3 -m pip config --global set global.find-links "${PIP_WHEEL_DIR}"
python3 -m pip config --global set global.prefer-binary true
python3 -m pip config --global set global.no-index true
fi
elif [ "${AI_WORKSPACE_PREBUILT_COMPONENTS_REQUIRED:-true}" = "true" ]; then
echo "Bundled LiteLLM wheelhouse is required but missing: ${PIP_WHEEL_DIR}" >&2
exit 1
fi
}
@ -385,18 +385,22 @@ run_bootstrap() {
export PLAYBOOK_DIR="${PLAYBOOK_DIR:-${ROOT}/repos/playbooks}"
export XWORKSPACE_CONSOLE_DIR="${XWORKSPACE_CONSOLE_DIR:-${ROOT}/repos/xworkspace-console}"
export XWORKSPACE_CONSOLE_SOURCE_REPO="${XWORKSPACE_CONSOLE_SOURCE_REPO:-file://${ROOT}/repos/xworkspace-console}"
export XWORKSPACE_CONSOLE_SOURCE_VERSION="${XWORKSPACE_CONSOLE_SOURCE_VERSION:-$(cat "${ROOT}/metadata/xworkspace-console.commit")}"
export XWORKSPACE_CORE_SKILLS_DIR="${XWORKSPACE_CORE_SKILLS_DIR:-${ROOT}/repos/xworkspace-core-skills}"
export XWORKMATE_BRIDGE_SOURCE_DIR="${XWORKMATE_BRIDGE_SOURCE_DIR:-${ROOT}/repos/xworkmate-bridge}"
export QMD_SOURCE_REPO="${QMD_SOURCE_REPO:-file://${ROOT}/repos/qmd}"
export LITELLM_SOURCE_REPO="${LITELLM_SOURCE_REPO:-file://${ROOT}/repos/litellm}"
export XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE="${XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE:-${COMPONENT_DIR}/xworkspace-console-runtime.tar.gz}"
export QMD_RUNTIME_ARCHIVE="${QMD_RUNTIME_ARCHIVE:-${COMPONENT_DIR}/qmd-runtime.tar.gz}"
if [ -f "${ROOT}/metadata/litellm-runtime.env" ]; then
# shellcheck disable=SC1091
source "${ROOT}/metadata/litellm-runtime.env"
export LITELLM_PACKAGE_SPEC
fi
export XWORKSPACE_CONSOLE_PUBLIC_ACCESS="${XWORKSPACE_CONSOLE_PUBLIC_ACCESS:-false}"
export XWORKMATE_BRIDGE_PUBLIC_ACCESS="${XWORKMATE_BRIDGE_PUBLIC_ACCESS:-true}"
export GATEWAY_OPENCLAW_PUBLIC_ACCESS="${GATEWAY_OPENCLAW_PUBLIC_ACCESS:-false}"
export VAULT_PUBLIC_ACCESS="${VAULT_PUBLIC_ACCESS:-false}"
export AI_WORKSPACE_OFFLINE_ACTIVE=true
export AI_WORKSPACE_PREBUILT_COMPONENTS_REQUIRED=true
export AI_WORKSPACE_USE_PREBUILT_BRIDGE=true
export AI_WORKSPACE_RUNTIME_PREBUILD_ENABLED=false
export AI_WORKSPACE_DEPLOYMENT_LOCK_HELD=true
export AI_WORKSPACE_APT_LOCK_TIMEOUT

View File

@ -12,13 +12,14 @@ CONSOLE_REPO="${CONSOLE_REPO:-https://github.com/ai-workspace-lab/xworkspace-con
CONSOLE_REF="${CONSOLE_REF:-main}"
CORE_SKILLS_REPO="${CORE_SKILLS_REPO:-https://github.com/ai-workspace-lab/xworkspace-core-skills.git}"
CORE_SKILLS_REF="${CORE_SKILLS_REF:-main}"
XWORKMATE_BRIDGE_REPO="${XWORKMATE_BRIDGE_REPO:-https://github.com/ai-workspace-lab/xworkmate-bridge.git}"
XWORKMATE_BRIDGE_REF="${XWORKMATE_BRIDGE_REF:-release/v1.1.4}"
QMD_REPO="${QMD_REPO:-https://github.com/ai-workspace-services/qmd.git}"
QMD_REF="${QMD_REF:-main}"
LITELLM_REPO="${LITELLM_REPO:-https://github.com/ai-workspace-services/litellm.git}"
LITELLM_REF="${LITELLM_REF:-litellm_internal_staging}"
LITELLM_DEBIAN_11_VERSION="${LITELLM_DEBIAN_11_VERSION:-1.74.9}"
CONSOLE_RUNTIME_RELEASE_REPO="${CONSOLE_RUNTIME_RELEASE_REPO:-ai-workspace-lab/xworkspace-console}"
CONSOLE_RUNTIME_RELEASE_TAG="${CONSOLE_RUNTIME_RELEASE_TAG:-latest-runtime}"
BRIDGE_RUNTIME_RELEASE_REPO="${BRIDGE_RUNTIME_RELEASE_REPO:-ai-workspace-lab/xworkmate-bridge}"
BRIDGE_RUNTIME_RELEASE_TAG="${BRIDGE_RUNTIME_RELEASE_TAG:-latest-runtime}"
QMD_RUNTIME_RELEASE_REPO="${QMD_RUNTIME_RELEASE_REPO:-ai-workspace-services/qmd}"
QMD_RUNTIME_RELEASE_TAG="${QMD_RUNTIME_RELEASE_TAG:-latest-runtime}"
LITELLM_RUNTIME_RELEASE_REPO="${LITELLM_RUNTIME_RELEASE_REPO:-ai-workspace-services/litellm}"
LITELLM_RUNTIME_RELEASE_TAG="${LITELLM_RUNTIME_RELEASE_TAG:-latest-runtime}"
NODEJS_MAJOR_VERSIONS="${NODEJS_MAJOR_VERSIONS:-22 24}"
NODEJS_22_VERSION="${NODEJS_22_VERSION:-22.22.3}"
@ -29,8 +30,6 @@ POSTGRES_IMAGE="${POSTGRES_IMAGE:-postgres:17.7}"
OPENCLAW_VERSION="${OPENCLAW_VERSION:-2026.6.6}"
PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION:-1.60.0}"
GOOGLE_CHROME_VERSION="${GOOGLE_CHROME_VERSION:-149.0.7827.114-1}"
UV_VERSION="${UV_VERSION:-0.11.21}"
PORTABLE_PYTHON_VERSION="${PORTABLE_PYTHON_VERSION:-3.13.14}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
@ -77,15 +76,100 @@ clone_repo() {
git -C "${dest}" rev-parse HEAD
}
github_api() {
local path=$1
local headers=(-H "Accept: application/vnd.github+json")
if [ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]; then
headers+=(-H "Authorization: Bearer ${GH_TOKEN:-${GITHUB_TOKEN}}")
fi
curl -fsSL --retry 5 --retry-all-errors "${headers[@]}" "https://api.github.com${path}"
}
resolve_runtime_tag() {
local repo=$1
local requested=$2
local attempt tag=""
if [ "${requested}" != "latest-runtime" ]; then
printf '%s\n' "${requested}"
return
fi
for attempt in $(seq 1 40); do
tag="$(
github_api "/repos/${repo}/releases?per_page=100" |
jq -r '[.[] | select(.draft == false and (.tag_name | startswith("runtime-")))] | first | .tag_name // empty'
)"
if [ -n "${tag}" ]; then
printf '%s\n' "${tag}"
return
fi
echo "Waiting for the first runtime release from ${repo} (${attempt}/40)..." >&2
sleep 30
done
}
download_release_asset() {
local repo=$1 requested_tag=$2 asset=$3 destination=$4 metadata_name=$5
local tag release_json asset_url checksums_url expected actual
tag="$(resolve_runtime_tag "${repo}" "${requested_tag}")"
[ -n "${tag}" ] || { echo "No runtime release found for ${repo}" >&2; exit 1; }
release_json="$(github_api "/repos/${repo}/releases/tags/${tag}")"
asset_url="$(jq -r --arg name "${asset}" '.assets[] | select(.name == $name) | .browser_download_url' <<<"${release_json}")"
checksums_url="$(jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url' <<<"${release_json}")"
[ -n "${asset_url}" ] && [ "${asset_url}" != "null" ] ||
{ echo "Release asset ${asset} is missing from ${repo}@${tag}" >&2; exit 1; }
[ -n "${checksums_url}" ] && [ "${checksums_url}" != "null" ] ||
{ echo "SHA256SUMS is missing from ${repo}@${tag}" >&2; exit 1; }
mkdir -p "$(dirname "${destination}")" "${WORKDIR}/metadata/components"
curl -fsSL --retry 5 --retry-all-errors "${asset_url}" -o "${destination}"
curl -fsSL --retry 5 --retry-all-errors "${checksums_url}" \
-o "${WORKDIR}/metadata/components/${metadata_name}.SHA256SUMS"
expected="$(awk -v file="${asset}" '$2 == file || $2 == "*" file { print $1; exit }' \
"${WORKDIR}/metadata/components/${metadata_name}.SHA256SUMS")"
actual="$(sha256sum "${destination}" | awk '{print $1}')"
[ -n "${expected}" ] && [ "${expected}" = "${actual}" ] ||
{ echo "Checksum mismatch or missing checksum for ${repo}@${tag}/${asset}" >&2; exit 1; }
printf '%s\n' "${tag}" > "${WORKDIR}/metadata/components/${metadata_name}.tag"
printf '%s\n' "${actual}" > "${WORKDIR}/metadata/components/${metadata_name}.sha256"
}
download_component_releases() {
local component_dir="${WORKDIR}/packages/components"
local bridge_tmp="${WORKDIR}/.bridge-runtime"
local litellm_tmp="${WORKDIR}/.litellm-runtime"
local console_asset="xworkspace-console-runtime-linux-${ARCH}.tar.gz"
local bridge_asset="xworkmate-bridge-linux-${ARCH}.tar.gz"
local qmd_asset="qmd-runtime-linux-${ARCH}.tar.gz"
local litellm_asset="litellm-runtime-${DISTRO_ID}-${DISTRO_VERSION}-${ARCH}.tar.gz"
mkdir -p "${component_dir}" "${bridge_tmp}" "${litellm_tmp}" "${WORKDIR}/packages/bin"
download_release_asset "${CONSOLE_RUNTIME_RELEASE_REPO}" "${CONSOLE_RUNTIME_RELEASE_TAG}" \
"${console_asset}" "${component_dir}/xworkspace-console-runtime.tar.gz" xworkspace-console
download_release_asset "${BRIDGE_RUNTIME_RELEASE_REPO}" "${BRIDGE_RUNTIME_RELEASE_TAG}" \
"${bridge_asset}" "${bridge_tmp}/${bridge_asset}" xworkmate-bridge
download_release_asset "${QMD_RUNTIME_RELEASE_REPO}" "${QMD_RUNTIME_RELEASE_TAG}" \
"${qmd_asset}" "${component_dir}/qmd-runtime.tar.gz" qmd
download_release_asset "${LITELLM_RUNTIME_RELEASE_REPO}" "${LITELLM_RUNTIME_RELEASE_TAG}" \
"${litellm_asset}" "${litellm_tmp}/${litellm_asset}" litellm
tar -xzf "${bridge_tmp}/${bridge_asset}" -C "${bridge_tmp}"
install -m 0755 "${bridge_tmp}/xworkmate-bridge/bin/xworkmate-go-core" \
"${WORKDIR}/packages/bin/xworkmate-go-core.${ARCH}"
tar -xzf "${litellm_tmp}/${litellm_asset}" -C "${litellm_tmp}"
cp -a "${litellm_tmp}/litellm-runtime/packages/pip" "${WORKDIR}/packages/"
if [ -d "${litellm_tmp}/litellm-runtime/packages/python" ]; then
cp -a "${litellm_tmp}/litellm-runtime/packages/python" "${WORKDIR}/packages/"
fi
cp "${litellm_tmp}/litellm-runtime/metadata/runtime.env" \
"${WORKDIR}/metadata/litellm-runtime.env"
}
download_apt_packages() {
local image=$1
local platform="linux/${ARCH}"
local apt_dir="${WORKDIR}/packages/apt"
local apt_lists="${WORKDIR}/metadata/apt"
local wheel_dir="${WORKDIR}/packages/pip"
local python_dir="${WORKDIR}/packages/python"
mkdir -p "${apt_dir}" "${apt_lists}" "${wheel_dir}" "${python_dir}"
mkdir -p "${apt_dir}" "${apt_lists}"
docker run --rm --platform "${platform}" \
-e DISTRO_ID="${DISTRO_ID}" \
@ -94,14 +178,8 @@ download_apt_packages() {
-e NODEJS_22_VERSION="${NODEJS_22_VERSION}" \
-e NODEJS_24_VERSION="${NODEJS_24_VERSION}" \
-e GOOGLE_CHROME_VERSION="${GOOGLE_CHROME_VERSION}" \
-e LITELLM_DEBIAN_11_VERSION="${LITELLM_DEBIAN_11_VERSION}" \
-e UV_VERSION="${UV_VERSION}" \
-e PORTABLE_PYTHON_VERSION="${PORTABLE_PYTHON_VERSION}" \
-v "${apt_dir}:/offline-apt" \
-v "${apt_lists}:/offline-meta" \
-v "${wheel_dir}:/offline-pip" \
-v "${python_dir}:/offline-python" \
-v "${WORKDIR}/repos/litellm:/litellm-src:ro" \
"${image}" \
bash -lc "$(cat <<'CONTAINER'
set -euo pipefail
@ -253,40 +331,6 @@ cd /offline-apt
dpkg-scanpackages --multiversion . /dev/null | gzip -9c > Packages.gz
find . -maxdepth 1 -name '*.deb' -printf '%f\n' | sort > /offline-meta/deb-files.txt
retry_command apt-get install -y --no-install-recommends \
build-essential git libpq-dev python3 python3-dev python3-pip python3-venv
python_bin=python3
if [ "${DISTRO_ID}:${DISTRO_VERSION}" = "ubuntu:26.04" ]; then
case "$(dpkg --print-architecture)" in
amd64) uv_arch=x86_64 ;;
arm64) uv_arch=aarch64 ;;
*) echo "Unsupported uv architecture: $(dpkg --print-architecture)" >&2; exit 1 ;;
esac
curl -fsSL \
"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${uv_arch}-unknown-linux-gnu.tar.gz" \
-o /tmp/uv.tar.gz
tar -xzf /tmp/uv.tar.gz -C /tmp
install -m 0755 "$(find /tmp -type f -path '*/uv-*/uv' -print -quit)" /usr/local/bin/uv
uv python install "${PORTABLE_PYTHON_VERSION}" --install-dir /offline-python --no-bin
python_bin="$(find -L /offline-python -type f -path '*/bin/python3.13' -perm /111 -print -quit)"
if [ -z "${python_bin}" ]; then
echo "Portable Python ${PORTABLE_PYTHON_VERSION} was not installed." >&2
exit 1
fi
find /offline-python -name EXTERNALLY-MANAGED -delete
"${python_bin}" -m ensurepip --upgrade
fi
"${python_bin}" -m venv /tmp/ai-workspace-wheel-builder
/tmp/ai-workspace-wheel-builder/bin/pip install --upgrade pip setuptools wheel
litellm_package_spec="/litellm-src[proxy]"
if [ "${DISTRO_ID}:${DISTRO_VERSION}" = "debian:11" ]; then
litellm_package_spec="litellm[proxy]==${LITELLM_DEBIAN_11_VERSION}"
fi
/tmp/ai-workspace-wheel-builder/bin/pip wheel \
--wheel-dir /offline-pip \
"${litellm_package_spec}" \
prisma \
psycopg2-binary
CONTAINER
)"
}
@ -298,8 +342,6 @@ warm_npm_dependency_cache() {
-e OPENCLAW_VERSION="${OPENCLAW_VERSION}" \
-e PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION}" \
-v "${npm_cache_dir}:/offline-cache" \
-v "${WORKDIR}/repos/xworkspace-console/dashboard:/sources/dashboard:ro" \
-v "${WORKDIR}/repos/qmd:/sources/qmd:ro" \
node:24-bookworm \
bash -lc "$(cat <<'CONTAINER'
set -euo pipefail
@ -307,19 +349,6 @@ export npm_config_cache=/offline-cache
export npm_config_audit=false
export npm_config_fund=false
mkdir -p /tmp/projects
cp -a /sources/dashboard /tmp/projects/dashboard
cp -a /sources/qmd /tmp/projects/qmd
(
cd /tmp/projects/dashboard
npm ci --ignore-scripts --no-audit --no-fund
)
(
cd /tmp/projects/qmd
npm install --ignore-scripts --no-audit --no-fund --package-lock=false
)
npm install --ignore-scripts --no-audit --no-fund --prefix /tmp/global \
opencode-ai \
@google/gemini-cli \
@ -379,19 +408,6 @@ download_binaries() {
chmod +x "${bin_dir}/ttyd.${ttyd_arch}"
}
build_xworkmate_bridge_binary() {
local bin_dir="${WORKDIR}/packages/bin"
mkdir -p "${bin_dir}"
docker run --rm --platform "linux/${ARCH}" \
-e GOOS=linux \
-e GOARCH="${ARCH}" \
-e CGO_ENABLED=0 \
-v "${WORKDIR}/repos/xworkmate-bridge:/src:ro" \
-v "${bin_dir}:/out" \
golang:1.25-bookworm \
bash -lc 'cd /src && /usr/local/go/bin/go build -buildvcs=false -trimpath -o "/out/xworkmate-go-core.${GOARCH}" .'
}
export_container_images() {
local images_dir="${WORKDIR}/packages/images"
mkdir -p "${images_dir}"
@ -419,19 +435,19 @@ EOF
"sources": {
"playbooks": {"repo": "${PLAYBOOKS_REPO}", "ref": "${PLAYBOOKS_REF}"},
"xworkspaceConsole": {"repo": "${CONSOLE_REPO}", "ref": "${CONSOLE_REF}"},
"xworkspaceCoreSkills": {"repo": "${CORE_SKILLS_REPO}", "ref": "${CORE_SKILLS_REF}"},
"xworkmateBridge": {"repo": "${XWORKMATE_BRIDGE_REPO}", "ref": "${XWORKMATE_BRIDGE_REF}"},
"qmd": {"repo": "${QMD_REPO}", "ref": "${QMD_REF}"},
"litellm": {"repo": "${LITELLM_REPO}", "ref": "${LITELLM_REF}"}
"xworkspaceCoreSkills": {"repo": "${CORE_SKILLS_REPO}", "ref": "${CORE_SKILLS_REF}"}
},
"componentReleases": {
"xworkspaceConsole": {"repo": "${CONSOLE_RUNTIME_RELEASE_REPO}", "tag": "$(cat "${WORKDIR}/metadata/components/xworkspace-console.tag")"},
"xworkmateBridge": {"repo": "${BRIDGE_RUNTIME_RELEASE_REPO}", "tag": "$(cat "${WORKDIR}/metadata/components/xworkmate-bridge.tag")"},
"qmd": {"repo": "${QMD_RUNTIME_RELEASE_REPO}", "tag": "$(cat "${WORKDIR}/metadata/components/qmd.tag")"},
"litellm": {"repo": "${LITELLM_RUNTIME_RELEASE_REPO}", "tag": "$(cat "${WORKDIR}/metadata/components/litellm.tag")"}
},
"versions": {
"vault": "${VAULT_VERSION}",
"openclaw": "${OPENCLAW_VERSION}",
"playwright": "${PLAYWRIGHT_VERSION}",
"googleChrome": "${GOOGLE_CHROME_VERSION}",
"litellmDebian11": "${LITELLM_DEBIAN_11_VERSION}",
"uv": "${UV_VERSION}",
"portablePython": "${PORTABLE_PYTHON_VERSION}",
"postgresImage": "${POSTGRES_IMAGE}"
}
}
@ -451,15 +467,11 @@ main() {
clone_repo "${PLAYBOOKS_REPO}" "${PLAYBOOKS_REF}" "${WORKDIR}/repos/playbooks" > "${WORKDIR}/metadata/playbooks.commit"
clone_repo "${CONSOLE_REPO}" "${CONSOLE_REF}" "${WORKDIR}/repos/xworkspace-console" > "${WORKDIR}/metadata/xworkspace-console.commit"
clone_repo "${CORE_SKILLS_REPO}" "${CORE_SKILLS_REF}" "${WORKDIR}/repos/xworkspace-core-skills" > "${WORKDIR}/metadata/xworkspace-core-skills.commit"
clone_repo "${XWORKMATE_BRIDGE_REPO}" "${XWORKMATE_BRIDGE_REF}" "${WORKDIR}/repos/xworkmate-bridge" > "${WORKDIR}/metadata/xworkmate-bridge.commit"
clone_repo "${QMD_REPO}" "${QMD_REF}" "${WORKDIR}/repos/qmd" > "${WORKDIR}/metadata/qmd.commit"
clone_repo "${LITELLM_REPO}" "${LITELLM_REF}" "${WORKDIR}/repos/litellm" > "${WORKDIR}/metadata/litellm.commit"
download_apt_packages "${image}"
download_component_releases
warm_npm_dependency_cache
download_playwright_browser
download_binaries
build_xworkmate_bridge_binary
export_container_images
cp "${SCRIPT_DIR}/ai-workspace-offline-install.sh" "${WORKDIR}/scripts/"

View File

@ -26,7 +26,8 @@ set -euo pipefail
# XWORKSPACE_CONSOLE_DIR (optional local xworkspace-console checkout for macOS)
# XWORKSPACE_CONSOLE_SOURCE_REPO / XWORKSPACE_CONSOLE_SOURCE_VERSION
# (optional Git source used by the Linux console playbook)
# QMD_SOURCE_REPO / LITELLM_SOURCE_REPO (optional local git sources for offline installs)
# XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE / QMD_RUNTIME_ARCHIVE
# LITELLM_PACKAGE_SPEC / AI_WORKSPACE_PREBUILT_COMPONENTS_REQUIRED
# AI_WORKSPACE_OFFLINE_MODE=auto (default) | force | off
# AI_WORKSPACE_OFFLINE_PACKAGE (local tarball/directory or URL)
# AI_WORKSPACE_OFFLINE_PACKAGE_URL (direct tarball URL)
@ -37,6 +38,11 @@ set -euo pipefail
# AI_WORKSPACE_OFFLINE_WORK_DIR=/tmp/ai-workspace-offline
# AI_WORKSPACE_DEPLOYMENT_LOCK_TIMEOUT=1800
# AI_WORKSPACE_APT_LOCK_TIMEOUT=900
# AI_WORKSPACE_PREFETCH_ENABLED=true
# AI_WORKSPACE_MAX_PARALLEL_JOBS=auto (never exceeds 2 x online CPU cores)
# AI_WORKSPACE_PREFETCH_DIR=/var/tmp/ai-workspace-prefetch
# AI_WORKSPACE_SPLIT_PHASES=true
# AI_WORKSPACE_RUNTIME_PREBUILD_ENABLED=false
# AI_WORKSPACE_DARWIN_MODE=local (default on macOS) | ansible
# ==============================================================================
@ -70,6 +76,15 @@ if [ -z "${AI_WORKSPACE_OFFLINE_WORK_DIR:-}" ]; then
fi
AI_WORKSPACE_DEPLOYMENT_LOCK_TIMEOUT=${AI_WORKSPACE_DEPLOYMENT_LOCK_TIMEOUT:-"1800"}
AI_WORKSPACE_APT_LOCK_TIMEOUT=${AI_WORKSPACE_APT_LOCK_TIMEOUT:-"900"}
AI_WORKSPACE_PREFETCH_ENABLED=${AI_WORKSPACE_PREFETCH_ENABLED:-"true"}
AI_WORKSPACE_MAX_PARALLEL_JOBS=${AI_WORKSPACE_MAX_PARALLEL_JOBS:-"auto"}
AI_WORKSPACE_PREFETCH_DIR=${AI_WORKSPACE_PREFETCH_DIR:-"/var/tmp/ai-workspace-prefetch"}
AI_WORKSPACE_SPLIT_PHASES=${AI_WORKSPACE_SPLIT_PHASES:-"true"}
AI_WORKSPACE_RUNTIME_PREBUILD_ENABLED=${AI_WORKSPACE_RUNTIME_PREBUILD_ENABLED:-"false"}
BOUNDED_JOB_PIDS=()
BOUNDED_JOB_LABELS=()
BOUNDED_JOB_FAILED=0
PARALLEL_LIMIT_WARNING_EMITTED=false
# Function: Output messages
info() {
@ -86,6 +101,120 @@ error() {
exit 1
}
reset_bounded_jobs() {
BOUNDED_JOB_PIDS=()
BOUNDED_JOB_LABELS=()
BOUNDED_JOB_FAILED=0
}
validate_parallel_job_limit() {
case "$AI_WORKSPACE_MAX_PARALLEL_JOBS" in
auto) ;;
''|*[!0-9]*|0) error "AI_WORKSPACE_MAX_PARALLEL_JOBS must be auto or a positive integer." ;;
esac
}
online_cpu_count() {
local count=""
if command -v getconf >/dev/null 2>&1; then
count="$(getconf _NPROCESSORS_ONLN 2>/dev/null || true)"
fi
if [ -z "$count" ] && command -v nproc >/dev/null 2>&1; then
count="$(nproc 2>/dev/null || true)"
fi
if [ -z "$count" ] && command -v sysctl >/dev/null 2>&1; then
count="$(sysctl -n hw.logicalcpu 2>/dev/null || true)"
fi
case "$count" in
''|*[!0-9]*|0) count=1 ;;
esac
printf '%s\n' "$count"
}
one_minute_load_average() {
if [ -r /proc/loadavg ]; then
awk '{print $1}' /proc/loadavg
return
fi
if command -v sysctl >/dev/null 2>&1; then
sysctl -n vm.loadavg 2>/dev/null | awk '{gsub(/[{}]/, ""); print $1}'
return
fi
printf '0\n'
}
dynamic_parallel_job_limit() {
local cpu_count hard_limit configured_limit load_average load_ceiling dynamic_limit
cpu_count="$(online_cpu_count)"
hard_limit=$((cpu_count * 2))
configured_limit="$hard_limit"
if [ "$AI_WORKSPACE_MAX_PARALLEL_JOBS" != "auto" ]; then
configured_limit="$AI_WORKSPACE_MAX_PARALLEL_JOBS"
if [ "$configured_limit" -gt "$hard_limit" ]; then
configured_limit="$hard_limit"
if [ "$PARALLEL_LIMIT_WARNING_EMITTED" = "false" ]; then
warn "Parallel job limit was capped at ${hard_limit} (2 x ${cpu_count} online CPU cores)."
PARALLEL_LIMIT_WARNING_EMITTED=true
fi
fi
fi
load_average="$(one_minute_load_average)"
load_ceiling="$(awk -v load="$load_average" 'BEGIN { value=int(load); if (load > value) value++; print value }')"
dynamic_limit=$((hard_limit - load_ceiling))
if [ "$dynamic_limit" -lt 1 ]; then
dynamic_limit=1
fi
if [ "$dynamic_limit" -gt "$configured_limit" ]; then
dynamic_limit="$configured_limit"
fi
printf '%s\n' "$dynamic_limit"
}
wait_for_bounded_job() {
local index=$1
local pid="${BOUNDED_JOB_PIDS[$index]}"
local label="${BOUNDED_JOB_LABELS[$index]}"
if wait "$pid"; then
info "Parallel job completed: $label"
else
warn "Parallel job failed: $label"
BOUNDED_JOB_FAILED=1
fi
unset 'BOUNDED_JOB_PIDS[index]'
unset 'BOUNDED_JOB_LABELS[index]'
BOUNDED_JOB_PIDS=("${BOUNDED_JOB_PIDS[@]}")
BOUNDED_JOB_LABELS=("${BOUNDED_JOB_LABELS[@]}")
}
run_bounded() {
local label=$1
local dynamic_limit
shift
validate_parallel_job_limit
dynamic_limit="$(dynamic_parallel_job_limit)"
while [ "${#BOUNDED_JOB_PIDS[@]}" -ge "$dynamic_limit" ]; do
wait_for_bounded_job 0
dynamic_limit="$(dynamic_parallel_job_limit)"
done
(
set -o pipefail
"$@" 2>&1 | sed "s/^/[${label}] /"
) &
BOUNDED_JOB_PIDS+=("$!")
BOUNDED_JOB_LABELS+=("$label")
}
wait_for_bounded_jobs() {
while [ "${#BOUNDED_JOB_PIDS[@]}" -gt 0 ]; do
wait_for_bounded_job 0
done
[ "$BOUNDED_JOB_FAILED" -eq 0 ]
}
mask_secret() {
local val="${1:-}"
if [ -z "$val" ]; then
@ -984,7 +1113,10 @@ PY
}
ensure_core_skills_source() {
if [ "${AI_WORKSPACE_OFFLINE_ACTIVE:-false}" = "true" ] &&
if [ "${AI_WORKSPACE_PREFETCH_COMPLETED:-false}" = "true" ] &&
[ -d "$XWORKSPACE_CORE_SKILLS_DIR/skills" ]; then
info "Using prefetched xworkspace-core-skills directory at $XWORKSPACE_CORE_SKILLS_DIR"
elif [ "${AI_WORKSPACE_OFFLINE_ACTIVE:-false}" = "true" ] &&
[ -d "$XWORKSPACE_CORE_SKILLS_DIR/skills" ]; then
info "Using packaged xworkspace-core-skills directory at $XWORKSPACE_CORE_SKILLS_DIR"
elif [ -d "$XWORKSPACE_CORE_SKILLS_DIR/.git" ]; then
@ -1003,7 +1135,10 @@ ensure_core_skills_source() {
}
ensure_xworkmate_bridge_source() {
if [ "${AI_WORKSPACE_OFFLINE_ACTIVE:-false}" = "true" ] &&
if [ "${AI_WORKSPACE_PREFETCH_COMPLETED:-false}" = "true" ] &&
[ -f "$XWORKMATE_BRIDGE_SOURCE_DIR/go.mod" ]; then
info "Using prefetched xworkmate-bridge source at $XWORKMATE_BRIDGE_SOURCE_DIR"
elif [ "${AI_WORKSPACE_OFFLINE_ACTIVE:-false}" = "true" ] &&
[ -f "$XWORKMATE_BRIDGE_SOURCE_DIR/go.mod" ]; then
info "Using packaged xworkmate-bridge source at $XWORKMATE_BRIDGE_SOURCE_DIR"
elif [ -d "$XWORKMATE_BRIDGE_SOURCE_DIR/.git" ]; then
@ -1022,6 +1157,234 @@ ensure_xworkmate_bridge_source() {
[ -f "$XWORKMATE_BRIDGE_SOURCE_DIR/go.mod" ] || error "xworkmate-bridge source missing: $XWORKMATE_BRIDGE_SOURCE_DIR/go.mod"
}
read_playbook_default() {
local file=$1
local key=$2
sed -n "s/^${key}:[[:space:]]*[\"']\\{0,1\\}\\([^\"']*\\)[\"']\\{0,1\\}[[:space:]]*$/\\1/p" "$file" | head -n 1
}
prefetch_git_repository() {
local label=$1
local repo=$2
local ref=$3
local dest=$4
if [ -d "$dest/.git" ]; then
git -C "$dest" remote set-url origin "$repo"
git -C "$dest" fetch --force --prune origin "$ref"
else
rm -rf "$dest"
mkdir -p "$(dirname "$dest")"
git clone --no-checkout "$repo" "$dest"
git -C "$dest" fetch --force origin "$ref"
fi
git -C "$dest" checkout --force --detach FETCH_HEAD
git -C "$dest" clean -ffd
printf '%s\n' "$(git -C "$dest" rev-parse HEAD)" > "$dest/.ai-workspace-prefetched-commit"
info "Prefetched $label at $(cat "$dest/.ai-workspace-prefetched-commit")"
}
prefetch_postgres_image() {
local image=$1
docker pull "$image"
}
prefetch_independent_sources() {
if [ "$AI_WORKSPACE_PREFETCH_ENABLED" != "true" ]; then
info "Phase 2 prefetch disabled by AI_WORKSPACE_PREFETCH_ENABLED."
return
fi
if [ "${AI_WORKSPACE_OFFLINE_ACTIVE:-false}" = "true" ]; then
info "Offline package is active; skipping online Phase 2 prefetch."
return
fi
if [ "$(detect_os)" != "linux" ]; then
return
fi
validate_parallel_job_limit
local console_dir="$AI_WORKSPACE_PREFETCH_DIR/xworkspace-console"
local qmd_dir="$AI_WORKSPACE_PREFETCH_DIR/qmd"
local litellm_dir="$AI_WORKSPACE_PREFETCH_DIR/litellm"
local qmd_repo qmd_ref litellm_repo litellm_ref postgres_image
qmd_repo="${QMD_SOURCE_REPO:-$(read_playbook_default roles/vhosts/qmd/defaults/main.yml qmd_source_repo)}"
qmd_ref="${QMD_VERSION:-$(read_playbook_default roles/vhosts/qmd/defaults/main.yml qmd_version)}"
litellm_repo="${LITELLM_SOURCE_REPO:-$(read_playbook_default roles/vhosts/litellm/defaults/main.yml litellm_source_repo)}"
litellm_ref="${LITELLM_VERSION:-$(read_playbook_default roles/vhosts/litellm/defaults/main.yml litellm_version)}"
postgres_image="${POSTGRESQL_IMAGE:-$(read_playbook_default roles/vhosts/postgres/defaults/main.yml postgresql_image)}"
[ -n "$qmd_repo" ] && [ -n "$qmd_ref" ] || error "Unable to resolve pinned QMD source."
[ -n "$litellm_repo" ] && [ -n "$litellm_ref" ] || error "Unable to resolve pinned LiteLLM source."
info "Starting load-adaptive Phase 2 source prefetch (current limit $(dynamic_parallel_job_limit), hard limit $(( $(online_cpu_count) * 2 )))..."
reset_bounded_jobs
run_bounded "repo:console" prefetch_git_repository \
"xworkspace-console" "$XWORKSPACE_CONSOLE_REPO_URL" "${XWORKSPACE_CONSOLE_SOURCE_VERSION:-main}" "$console_dir"
run_bounded "repo:core-skills" prefetch_git_repository \
"xworkspace-core-skills" "$XWORKSPACE_CORE_SKILLS_REPO_URL" "main" "$XWORKSPACE_CORE_SKILLS_DIR"
run_bounded "repo:bridge" prefetch_git_repository \
"xworkmate-bridge" "$XWORKMATE_BRIDGE_REPO_URL" "$XWORKMATE_BRIDGE_BRANCH" "$XWORKMATE_BRIDGE_SOURCE_DIR"
run_bounded "repo:qmd" prefetch_git_repository \
"qmd" "$qmd_repo" "$qmd_ref" "$qmd_dir"
run_bounded "repo:litellm" prefetch_git_repository \
"litellm" "$litellm_repo" "$litellm_ref" "$litellm_dir"
if command -v docker >/dev/null 2>&1 &&
printf ',%s,' "${AI_WORKSPACE_RUNTIME_MODES:-docker,systemd}" | grep -q ',docker,'; then
[ -n "$postgres_image" ] || error "Unable to resolve pinned PostgreSQL image."
run_bounded "image:postgres" prefetch_postgres_image "$postgres_image"
fi
if ! wait_for_bounded_jobs; then
warn "Phase 2 source prefetch failed; continuing with the standard Ansible source tasks."
return
fi
export XWORKSPACE_CONSOLE_SOURCE_REPO="file://$console_dir"
export XWORKSPACE_CONSOLE_SOURCE_VERSION
XWORKSPACE_CONSOLE_SOURCE_VERSION="$(cat "$console_dir/.ai-workspace-prefetched-commit")"
export QMD_SOURCE_REPO="file://$qmd_dir"
export QMD_VERSION="$qmd_ref"
export LITELLM_SOURCE_REPO="file://$litellm_dir"
export LITELLM_VERSION="$litellm_ref"
export AI_WORKSPACE_PREFETCH_COMPLETED=true
success "Phase 2 source prefetch completed."
}
ensure_runtime_build_user() {
local user=$1
local home=$2
if id "$user" >/dev/null 2>&1; then
return
fi
if [ "$(id -u)" -ne 0 ]; then
warn "Cannot create runtime build user $user without root privileges."
return 1
fi
getent group "$user" >/dev/null 2>&1 || groupadd "$user"
useradd --create-home --home-dir "$home" --gid "$user" --shell /bin/bash "$user"
}
run_as_runtime_user() {
local user=$1
local home=$2
shift 2
if [ "$(id -u)" -eq 0 ]; then
runuser -u "$user" -- env HOME="$home" "$@"
else
env HOME="$home" "$@"
fi
}
prepare_runtime_checkout() {
local user=$1
local home=$2
local repo=$3
local ref=$4
local dest=$5
if [ -d "$dest/.git" ]; then
run_as_runtime_user "$user" "$home" git -C "$dest" remote set-url origin "$repo"
run_as_runtime_user "$user" "$home" git -C "$dest" fetch --force --prune origin "$ref"
else
rm -rf "$dest"
install -d -o "$user" -g "$user" "$(dirname "$dest")"
run_as_runtime_user "$user" "$home" git clone --no-checkout "$repo" "$dest"
run_as_runtime_user "$user" "$home" git -C "$dest" fetch --force origin "$ref"
fi
run_as_runtime_user "$user" "$home" git -C "$dest" checkout --force --detach FETCH_HEAD
run_as_runtime_user "$user" "$home" git -C "$dest" clean -ffd
}
prebuild_console_dashboard() {
local user=$1
local home=$2
local repo=$3
local ref=$4
local dest=$5
local cache_dir=$6
prepare_runtime_checkout "$user" "$home" "$repo" "$ref" "$dest"
install -d -o "$user" -g "$user" "$cache_dir"
run_as_runtime_user "$user" "$home" env npm_config_cache="$cache_dir" \
npm install --no-audit --no-fund --prefix "$dest/dashboard"
run_as_runtime_user "$user" "$home" env npm_config_cache="$cache_dir" \
npm run build --prefix "$dest/dashboard"
run_as_runtime_user "$user" "$home" git -C "$dest" rev-parse HEAD \
> "$dest/dashboard/.ai-workspace-build-commit"
}
prebuild_qmd_runtime() {
local user=$1
local home=$2
local repo=$3
local ref=$4
local dest=$5
local cache_dir=$6
prepare_runtime_checkout "$user" "$home" "$repo" "$ref" "$dest"
install -d -o "$user" -g "$user" "$cache_dir" "$home/.bun/bin"
run_as_runtime_user "$user" "$home" env npm_config_cache="$cache_dir" \
npm install --no-audit --no-fund --prefix "$dest"
run_as_runtime_user "$user" "$home" env npm_config_cache="$cache_dir" \
npm run build --prefix "$dest"
run_as_runtime_user "$user" "$home" ln -sfn "$dest/bin/qmd" "$home/.bun/bin/qmd"
}
preinstall_openclaw_runtime() {
local user=$1
local home=$2
local version=$3
local cache_dir=$4
install -d -o "$user" -g "$user" "$cache_dir" "$home/.local"
run_as_runtime_user "$user" "$home" env npm_config_cache="$cache_dir" \
npm install --global --omit=dev --no-audit --no-fund \
--prefix "$home/.local" "openclaw@$version"
}
prebuild_independent_runtimes() {
if [ "$AI_WORKSPACE_RUNTIME_PREBUILD_ENABLED" != "true" ]; then
info "Runtime prebuild disabled by AI_WORKSPACE_RUNTIME_PREBUILD_ENABLED."
return
fi
if ! command -v npm >/dev/null 2>&1; then
warn "npm is unavailable after the Node.js phase; continuing without runtime prebuild."
return
fi
local user="${AI_WORKSPACE_RUNTIME_USER:-ubuntu}"
local home="${AI_WORKSPACE_RUNTIME_HOME:-/home/$user}"
local console_repo="${XWORKSPACE_CONSOLE_SOURCE_REPO:-$XWORKSPACE_CONSOLE_REPO_URL}"
local console_ref="${XWORKSPACE_CONSOLE_SOURCE_VERSION:-main}"
local qmd_repo qmd_ref openclaw_version
qmd_repo="${QMD_SOURCE_REPO:-$(read_playbook_default roles/vhosts/qmd/defaults/main.yml qmd_source_repo)}"
qmd_ref="${QMD_VERSION:-$(read_playbook_default roles/vhosts/qmd/defaults/main.yml qmd_version)}"
openclaw_version="$(read_playbook_default roles/vhosts/gateway_openclaw/defaults/main.yml gateway_openclaw_required_version)"
if ! ensure_runtime_build_user "$user" "$home"; then
return
fi
info "Starting load-adaptive runtime prebuild (current limit $(dynamic_parallel_job_limit))..."
reset_bounded_jobs
run_bounded "build:console" prebuild_console_dashboard \
"$user" "$home" "$console_repo" "$console_ref" "$home/xworkspace-console" \
"$AI_WORKSPACE_PREFETCH_DIR/npm-cache/console"
run_bounded "build:qmd" prebuild_qmd_runtime \
"$user" "$home" "$qmd_repo" "$qmd_ref" "$home/.local/src/qmd" \
"$AI_WORKSPACE_PREFETCH_DIR/npm-cache/qmd"
if [ -n "$openclaw_version" ]; then
run_bounded "package:openclaw" preinstall_openclaw_runtime \
"$user" "$home" "$openclaw_version" "$AI_WORKSPACE_PREFETCH_DIR/npm-cache/openclaw"
fi
if ! wait_for_bounded_jobs; then
warn "One or more runtime prebuild jobs failed; the standard Ansible tasks will retry them serially."
return
fi
success "Runtime prebuild completed."
}
wait_for_url() {
local url=$1
local header=${2:-}
@ -1059,8 +1422,10 @@ service_status_line() {
local label=$1
local unit_patterns=$2
local port=${3:-}
local health_url=${4:-}
local detail="not detected"
local state="inactive"
local http_status=""
if command -v systemctl >/dev/null 2>&1; then
local unit
@ -1088,9 +1453,81 @@ service_status_line() {
fi
fi
if [ -n "$health_url" ] && command -v curl >/dev/null 2>&1; then
http_status="$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 2 --max-time 5 "$health_url" 2>/dev/null || true)"
case "$http_status" in
2*|3*|401)
state="active"
detail="${detail};http:${http_status}"
;;
'') detail="${detail};http:unreachable" ;;
*) detail="${detail};http:${http_status}" ;;
esac
fi
printf ' %-28s : %-8s (%s)\n' "$label" "$state" "$detail"
}
write_service_status() {
local output_file=$1
shift
service_status_line "$@" > "$output_file"
}
print_parallel_service_statuses() {
local status_dir
local labels=(
"Portal / Console"
"XWorkMate Bridge"
"OpenClaw"
"QMD"
"Hermes"
"PostgreSQL"
"Vault"
"LiteLLM"
"Runtime desktop/browser"
)
local units=(
"xworkspace-console.service xworkspace-api.service"
"xworkmate-bridge.service xworkspace-bridge.service"
"xworkspace-openclaw.service openclaw-gateway.service openclaw.service"
"qmd-mcp.service xworkspace-qmd.service qmd.service qdrant.service"
"acp-hermes.service xworkspace-hermes.service hermes.service"
"postgresql.service postgresql@17-main.service postgresql@16-main.service postgresql@15-main.service xworkspace-postgres.service"
"xworkspace-vault.service vault.service"
"xworkspace-litellm.service litellm-proxy.service litellm.service"
"xworkspace-shell.service display-manager.service gdm.service lightdm.service"
)
local ports=("17000" "8787" "18789" "8181" "3920" "5432" "8200" "4000" "")
local urls=(
"http://127.0.0.1:17000/"
"http://127.0.0.1:8787/"
"http://127.0.0.1:18789/channels"
"http://127.0.0.1:8181/"
"http://127.0.0.1:3920/"
""
"http://127.0.0.1:8200/v1/sys/health"
"http://127.0.0.1:4000/health"
""
)
local index
status_dir="$(mktemp -d)"
reset_bounded_jobs
for index in "${!labels[@]}"; do
run_bounded "status:$index" write_service_status "$status_dir/$index" \
"${labels[$index]}" "${units[$index]}" "${ports[$index]}" "${urls[$index]}"
done
if ! wait_for_bounded_jobs; then
rm -rf "$status_dir"
error "Parallel service status collection failed."
fi
for index in "${!labels[@]}"; do
cat "$status_dir/$index"
done
rm -rf "$status_dir"
}
cli_status_line() {
local label=$1
local bin=$2
@ -1133,15 +1570,7 @@ print_deployment_summary() {
[服务状态]
EOF
service_status_line "Portal / Console" "xworkspace-console.service xworkspace-api.service" "17000"
service_status_line "XWorkMate Bridge" "xworkmate-bridge.service xworkspace-bridge.service" "8787"
service_status_line "OpenClaw" "xworkspace-openclaw.service openclaw-gateway.service openclaw.service" "18789"
service_status_line "QMD" "qmd-mcp.service xworkspace-qmd.service qmd.service qdrant.service" "8181"
service_status_line "Hermes" "acp-hermes.service xworkspace-hermes.service hermes.service" "3920"
service_status_line "PostgreSQL" "postgresql.service postgresql@17-main.service postgresql@16-main.service postgresql@15-main.service xworkspace-postgres.service" "5432"
service_status_line "Vault" "xworkspace-vault.service vault.service" "8200"
service_status_line "LiteLLM" "xworkspace-litellm.service litellm-proxy.service litellm.service" "4000"
service_status_line "Runtime desktop/browser" "xworkspace-shell.service display-manager.service gdm.service lightdm.service" ""
print_parallel_service_statuses
cat <<'EOF'
@ -1390,6 +1819,13 @@ deploy_macos_local() {
info "Logs: $api_log, $api_err, $dashboard_log, and $dashboard_err"
}
if [ "${AI_WORKSPACE_LIBRARY_MODE:-false}" = "true" ]; then
if (return 0 2>/dev/null); then
return 0
fi
exit 0
fi
info "Starting AI Workspace All-in-One Bootstrap..."
# 1. Install prerequisites (git, curl, ansible) if missing
@ -1457,6 +1893,7 @@ else
fi
patch_playbook_user_systemd
prefetch_independent_sources
ensure_core_skills_source
ensure_xworkmate_bridge_source
@ -1499,8 +1936,10 @@ append_var "POSTGRESQL_DEPLOY_MODE" "postgresql_deploy_mode"
append_var "AI_WORKSPACE_APT_LOCK_TIMEOUT" "ai_workspace_apt_lock_timeout"
append_var "XWORKSPACE_CONSOLE_SOURCE_REPO" "xworkspace_console_source_repo"
append_var "XWORKSPACE_CONSOLE_SOURCE_VERSION" "xworkspace_console_source_version"
append_var "XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE" "xworkspace_console_runtime_archive"
append_var "QMD_SOURCE_REPO" "qmd_source_repo"
append_var "QMD_VERSION" "qmd_version"
append_var "QMD_RUNTIME_ARCHIVE" "qmd_runtime_archive"
append_var "LITELLM_SOURCE_REPO" "litellm_source_repo"
append_var "LITELLM_VERSION" "litellm_version"
@ -1552,11 +1991,30 @@ chmod 600 "$VAULT_FILE"
# 6. Run Ansible Playbook locally
wait_for_apt_locks
info "Running Ansible Playbook locally..."
ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-all-in-one.yml \
--vault-password-file "$VAULT_FILE" \
"${ANSIBLE_EXTRA_VARS[@]}"
RET=$?
RET=0
if [ "$AI_WORKSPACE_SPLIT_PHASES" = "true" ]; then
info "Running AI Workspace preflight..."
ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-preflight.yml \
--vault-password-file "$VAULT_FILE" \
"${ANSIBLE_EXTRA_VARS[@]}" || RET=$?
if [ "$RET" -eq 0 ]; then
info "Running serialized Node.js foundation phase..."
ansible-playbook -i '127.0.0.1,' -c local setup-nodejs.yml \
--vault-password-file "$VAULT_FILE" \
"${ANSIBLE_EXTRA_VARS[@]}" || RET=$?
fi
if [ "$RET" -eq 0 ]; then
info "Running remaining AI Workspace runtime phases..."
ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-runtime.yml \
--vault-password-file "$VAULT_FILE" \
"${ANSIBLE_EXTRA_VARS[@]}" || RET=$?
fi
else
info "Running monolithic AI Workspace Playbook..."
ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-all-in-one.yml \
--vault-password-file "$VAULT_FILE" \
"${ANSIBLE_EXTRA_VARS[@]}" || RET=$?
fi
if [ $RET -eq 0 ]; then
success "AI Workspace deployed successfully!"