diff --git a/.github/workflows/offline-package-ai-workspace-installer.yaml b/.github/workflows/offline-package-ai-workspace-installer.yaml index d3fe1b2..514a279 100644 --- a/.github/workflows/offline-package-ai-workspace-installer.yaml +++ b/.github/workflows/offline-package-ai-workspace-installer.yaml @@ -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 diff --git a/.github/workflows/runtime-release.yaml b/.github/workflows/runtime-release.yaml new file mode 100644 index 0000000..802e3f7 --- /dev/null +++ b/.github/workflows/runtime-release.yaml @@ -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" < "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}" diff --git a/docs/ai-workspace-runtime-delivery-plan.md b/docs/ai-workspace-runtime-delivery-plan.md index 4992d9f..eb4435c 100644 --- a/docs/ai-workspace-runtime-delivery-plan.md +++ b/docs/ai-workspace-runtime-delivery-plan.md @@ -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/摘要打印。 diff --git a/scripts/ai-workspace-offline-install.sh b/scripts/ai-workspace-offline-install.sh index a10f8a0..e95e6d5 100755 --- a/scripts/ai-workspace-offline-install.sh +++ b/scripts/ai-workspace-offline-install.sh @@ -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 diff --git a/scripts/create-ai-workspace-offline-package.sh b/scripts/create-ai-workspace-offline-package.sh index afac888..60c145b 100755 --- a/scripts/create-ai-workspace-offline-package.sh +++ b/scripts/create-ai-workspace-offline-package.sh @@ -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/" diff --git a/scripts/setup-ai-workspace-all-in-one.sh b/scripts/setup-ai-workspace-all-in-one.sh index 0527879..be93b39 100755 --- a/scripts/setup-ai-workspace-all-in-one.sh +++ b/scripts/setup-ai-workspace-all-in-one.sh @@ -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!"