Compare commits
No commits in common. "main" and "fix/xworkmate-windows-handler" have entirely different histories.
main
...
fix/xworkm
44
.github/workflows/validate-release-pr.yml
vendored
44
.github/workflows/validate-release-pr.yml
vendored
@ -1,44 +0,0 @@
|
||||
name: Validate Release PR
|
||||
|
||||
# release/* 分支的发布策略门禁:仅接受 hotfix/* 或带 cherry-pick/backport 标签的 PR。
|
||||
# 详见 iac_modules/docs/tldr-github-branch-model.md
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-release-source:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.base_ref, 'release/')
|
||||
steps:
|
||||
- name: Check PR source branch
|
||||
run: |
|
||||
SRC="${{ github.head_ref }}"
|
||||
TGT="${{ github.base_ref }}"
|
||||
LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
|
||||
echo "🔍 Validating PR into release branch"
|
||||
echo " source: $SRC"
|
||||
echo " target: $TGT"
|
||||
echo " labels: $LABELS"
|
||||
|
||||
if [[ "$SRC" =~ ^hotfix/ ]]; then
|
||||
echo "✅ Allowed: hotfix/* branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$LABELS" =~ (^|,)(cherry-pick|backport)(,|$) ]]; then
|
||||
echo "✅ Allowed: cherry-pick/backport labeled PR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "❌ Rejected."
|
||||
echo "release/* 仅接受:"
|
||||
echo " - 来自 hotfix/* 的 PR"
|
||||
echo " - 带 cherry-pick 或 backport 标签的 PR(已验证 feature 的 backport/cherry-pick)"
|
||||
echo "禁止从 main / develop / feature/* 直接合并到 release/*。"
|
||||
exit 1
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,4 @@
|
||||
xfce-secrets.yml
|
||||
inventory/__pycache__/
|
||||
.playwright-mcp/
|
||||
.env
|
||||
.artifacts/
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
<string>-c</string>
|
||||
<string>
|
||||
source "{{ xworkspace_console_config_dir }}/portal.env"
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
exec {{ xworkspace_console_api_exec }}
|
||||
</string>
|
||||
</array>
|
||||
|
||||
@ -10,9 +10,7 @@
|
||||
<string>-c</string>
|
||||
<string>
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
||||
# 预编译 runtime 只发 dashboard/dist(无 package.json),且 dashboard 是
|
||||
# 无客户端路由的单页应用,故用 python3 静态伺服 dist 即可(macOS 无 caddy)。
|
||||
exec /usr/bin/env python3 -m http.server {{ xworkspace_console_port }} --bind 127.0.0.1 --directory "{{ xworkspace_console_dashboard_dir }}/dist"
|
||||
exec /usr/bin/env npm run preview -- --host 127.0.0.1 --port {{ xworkspace_console_port }}
|
||||
</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
@ -20,7 +18,7 @@
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{{ xworkspace_console_dashboard_dir }}/dist</string>
|
||||
<string>{{ xworkspace_console_dashboard_dir }}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/console.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ansible 动态 inventory —— 数据源为 Terraform 导出的 CMDB。
|
||||
|
||||
与 IAC 联动方式:
|
||||
iac_modules/terraform-hcl-standard/vultr-vps/envs/ai-workspace/ 的 generate.py
|
||||
在 `terraform apply` 后,把 YAML 静态字段与 terraform 运行时输出合并写出
|
||||
cmdb.json(结构化主机事实)。本脚本把它翻译成 Ansible 动态 inventory,
|
||||
于是 IaC 一变更、重跑 `generate.py inventory`,inventory 就跟着变。
|
||||
|
||||
取数优先级:
|
||||
1. 环境变量 AI_WORKSPACE_CMDB_JSON 指向的文件
|
||||
2. 环境变量 AI_WORKSPACE_TF_DIR(或默认 env 目录)下的 cmdb.json
|
||||
|
||||
用法:
|
||||
ansible-inventory -i inventory/terraform_cmdb.py --list
|
||||
ansible all -i inventory/terraform_cmdb.py -m ping
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# playbooks/inventory -> 仓库根 -> terraform env
|
||||
REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
DEFAULT_TF_DIR = os.path.join(
|
||||
REPO_ROOT,
|
||||
"iac_modules",
|
||||
"terraform-hcl-standard",
|
||||
"vultr-vps",
|
||||
"envs",
|
||||
"ai-workspace",
|
||||
)
|
||||
|
||||
|
||||
def _from_explicit_file():
|
||||
path = os.environ.get("AI_WORKSPACE_CMDB_JSON")
|
||||
if path and os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
return None
|
||||
|
||||
|
||||
def _from_default_file(tf_dir):
|
||||
path = os.path.join(tf_dir, "cmdb.json")
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
return None
|
||||
|
||||
|
||||
def load_cmdb():
|
||||
tf_dir = os.environ.get("AI_WORKSPACE_TF_DIR", DEFAULT_TF_DIR)
|
||||
for loader in (
|
||||
_from_explicit_file,
|
||||
lambda: _from_default_file(tf_dir),
|
||||
):
|
||||
data = loader()
|
||||
if data:
|
||||
return data
|
||||
return {}
|
||||
|
||||
|
||||
def build_inventory(cmdb):
|
||||
inv = {"_meta": {"hostvars": {}}}
|
||||
groups = {}
|
||||
|
||||
for name, host in cmdb.items():
|
||||
hostvars = {
|
||||
"ansible_host": host.get("ip"),
|
||||
"ansible_user": host.get("ansible_user", "root"),
|
||||
# 云主机 IP 常被回收,放宽 host key 校验避免撞到旧 known_hosts
|
||||
"ansible_ssh_common_args": (
|
||||
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
),
|
||||
}
|
||||
# CMDB 其余字段一并暴露给 playbook 使用
|
||||
hostvars.update(host.get("host_vars", {}))
|
||||
hostvars["cmdb_instance_id"] = host.get("instance_id")
|
||||
hostvars["cmdb_os_id"] = host.get("os_id")
|
||||
hostvars["cmdb_tags"] = host.get("tags", [])
|
||||
inv["_meta"]["hostvars"][name] = hostvars
|
||||
|
||||
for group in host.get("groups", []) or ["ungrouped"]:
|
||||
groups.setdefault(group, {"hosts": []})["hosts"].append(name)
|
||||
|
||||
inv.update(groups)
|
||||
inv["all"] = {"children": sorted(list(groups.keys()) + ["ungrouped"])}
|
||||
return inv
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
cmdb = load_cmdb()
|
||||
|
||||
if "--host" in args:
|
||||
# hostvars 已在 _meta 里,单主机查询返回空对象即可
|
||||
print(json.dumps({}))
|
||||
return
|
||||
|
||||
# 默认与 --list 行为一致
|
||||
print(json.dumps(build_inventory(cmdb), indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -3,19 +3,16 @@ agent_skills_user: "{{ ansible_env.USER | default('ubuntu') }}"
|
||||
agent_skills_group: "{{ 'staff' if ansible_os_family == 'Darwin' else agent_skills_user }}"
|
||||
agent_skills_home: "{{ ansible_env.HOME | default('/home/' + agent_skills_user) }}"
|
||||
|
||||
# 规范化技能落地目录(canonical),始终在目标主机上。installer 直接装到这里,
|
||||
# core 技能 clone 后合并进来。本地/pull 与远程 controller 两种模型行为一致。
|
||||
agent_skills_remote_dir: "{{ agent_skills_home }}/.agents/skills"
|
||||
|
||||
# xworkspace-core-skills 以 git clone 获取(最通用、跨平台、双模型一致),
|
||||
# 在目标主机上 clone,不再依赖 controller 端预置目录。
|
||||
agent_skills_local_source_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.agents/skills"
|
||||
agent_skills_xworkspace_core_enabled: true
|
||||
agent_skills_xworkspace_core_required: true
|
||||
agent_skills_xworkspace_core_repo_url: "https://github.com/ai-workspace-lab/xworkspace-core-skills.git"
|
||||
agent_skills_xworkspace_core_version: "main"
|
||||
agent_skills_xworkspace_core_clone_dir: "{{ agent_skills_home }}/.local/src/xworkspace-core-skills"
|
||||
agent_skills_xworkspace_core_source_dir: "{{ agent_skills_xworkspace_core_clone_dir }}/skills"
|
||||
|
||||
agent_skills_xworkspace_core_source_dir: "{{ playbook_dir | dirname }}/xworkspace-core-skills/skills"
|
||||
agent_skills_remote_dir: "{{ agent_skills_home }}/.agents/skills"
|
||||
agent_skills_local_source_create: true
|
||||
agent_skills_delete_removed: false
|
||||
agent_skills_rsync_compress: false
|
||||
agent_skills_rsync_timeout: 120
|
||||
agent_skills_install_rsync: true
|
||||
agent_skills_replace_existing_target_dirs: false
|
||||
agent_skills_preserve_existing_target_dirs:
|
||||
- "{{ agent_skills_home }}/.codex/skills"
|
||||
@ -35,6 +32,17 @@ agent_skills_quality_gate_commands:
|
||||
argv_prefix:
|
||||
- self-improving
|
||||
- inspect
|
||||
agent_skills_rsync_excludes:
|
||||
- .DS_Store
|
||||
- .venv/
|
||||
- __pycache__/
|
||||
- "*.pyc"
|
||||
- "*/__pycache__/"
|
||||
- "*/.DS_Store"
|
||||
agent_skills_rsync_extra_opts:
|
||||
- "--protocol=29"
|
||||
- "--out-format=<<CHANGED>>%i"
|
||||
|
||||
agent_skills_typical_scenario_skills:
|
||||
- name: pptx
|
||||
scenario_groups: [local-document-artifacts]
|
||||
|
||||
@ -1,25 +1,286 @@
|
||||
---
|
||||
# 设计:全程在「目标主机」上执行——没有任何 delegate_to: localhost。
|
||||
# 因此两种执行模型行为完全一致:
|
||||
# - 本地/pull:curl|bash → ansible-playbook -c local(localhost 即主机)
|
||||
# - 远程 controller:ansible-playbook -i <inventory> over ssh(任务在主机上跑)
|
||||
# 源以 git clone 获取(最通用、跨平台),不再依赖 controller 端预置目录,
|
||||
# 合并用 ansible.builtin.copy(无裸 rsync、无本地钉死)。
|
||||
|
||||
- name: Validate agent skills input
|
||||
- name: Validate agent skills sync input
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- agent_skills_user | length > 0
|
||||
- agent_skills_group | length > 0
|
||||
- agent_skills_home | length > 0
|
||||
- agent_skills_local_source_dir | length > 0
|
||||
- (not agent_skills_xworkspace_core_enabled | bool) or agent_skills_xworkspace_core_source_dir | length > 0
|
||||
- agent_skills_remote_dir | length > 0
|
||||
- agent_skills_targets | length > 0
|
||||
fail_msg: "agent_skills_user/group/home, remote_dir and targets must be set."
|
||||
fail_msg: "agent_skills_user, home, source dirs, remote dir, and targets must be set."
|
||||
|
||||
- name: Build required agent skills list
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_required_entries: "{{ agent_skills_typical_scenario_skills + agent_skills_extra_required_skills }}"
|
||||
|
||||
- name: Ensure local agent skills source directory exists for auto install
|
||||
ansible.builtin.file:
|
||||
path: "{{ agent_skills_local_source_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
check_mode: false
|
||||
when:
|
||||
- agent_skills_local_source_create | bool
|
||||
- agent_skills_auto_install_enabled | bool
|
||||
|
||||
- name: Inspect local agent skills source directory
|
||||
ansible.builtin.stat:
|
||||
path: "{{ agent_skills_local_source_dir }}"
|
||||
register: agent_skills_local_source
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- name: Assert local agent skills source directory exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- agent_skills_local_source.stat.isdir | default(false)
|
||||
fail_msg: "Local skills source directory does not exist: {{ agent_skills_local_source_dir }}"
|
||||
|
||||
- name: Inspect xworkspace core skills source directory
|
||||
ansible.builtin.stat:
|
||||
path: "{{ agent_skills_xworkspace_core_source_dir }}"
|
||||
register: agent_skills_xworkspace_core_source
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when: agent_skills_xworkspace_core_enabled | bool
|
||||
|
||||
- name: Assert xworkspace core skills source directory exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- agent_skills_xworkspace_core_source.stat.isdir | default(false)
|
||||
fail_msg: "xworkspace core skills source directory does not exist: {{ agent_skills_xworkspace_core_source_dir }}"
|
||||
when:
|
||||
- agent_skills_xworkspace_core_enabled | bool
|
||||
- agent_skills_xworkspace_core_required | bool
|
||||
|
||||
- name: Build effective agent skills source directories
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_effective_source_dirs: >-
|
||||
{{
|
||||
[agent_skills_local_source_dir]
|
||||
+ (
|
||||
(
|
||||
agent_skills_xworkspace_core_enabled | bool
|
||||
and agent_skills_xworkspace_core_source.stat.isdir | default(false)
|
||||
)
|
||||
| ternary([agent_skills_xworkspace_core_source_dir], [])
|
||||
)
|
||||
}}
|
||||
|
||||
- name: Inspect required local scenario skills
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
for source_dir in {{ agent_skills_effective_source_dirs | map('quote') | join(' ') }}; do
|
||||
for candidate in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
|
||||
if [ -f "$source_dir/$candidate/SKILL.md" ]; then
|
||||
printf '%s\n' "$source_dir/$candidate"
|
||||
exit 0
|
||||
fi
|
||||
match="$(find "$source_dir" -type f -path "*/$candidate/SKILL.md" -print -quit)"
|
||||
if [ -n "$match" ]; then
|
||||
dirname "$match"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
exit 1
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: agent_skills_local_skill_presence
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
loop: "{{ agent_skills_required_entries }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
check_mode: false
|
||||
|
||||
- name: Build missing scenario skills list
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_missing_entries: >-
|
||||
{{
|
||||
agent_skills_local_skill_presence.results
|
||||
| selectattr('rc', 'ne', 0)
|
||||
| map(attribute='item')
|
||||
| list
|
||||
}}
|
||||
|
||||
- name: Install missing scenario skills from local installer adapters
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
skill_name={{ item.name | quote }}
|
||||
target_dir={{ agent_skills_local_source_dir | quote }}
|
||||
target_parent="$(dirname "$target_dir")"
|
||||
target_base="$(basename "$target_dir")"
|
||||
install_rc=1
|
||||
if command -v clawhub >/dev/null 2>&1; then
|
||||
for install_name in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
|
||||
if clawhub --workdir "$target_parent" --dir "$target_base" --no-input install \
|
||||
{{ (item.install_force | default(false) | bool) | ternary('--force', '') }} "$install_name"; then
|
||||
install_rc=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
exit "$install_rc"
|
||||
elif command -v find-skills >/dev/null 2>&1; then
|
||||
for install_name in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
|
||||
if find-skills install "$install_name" --target "$target_dir"; then
|
||||
install_rc=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
exit "$install_rc"
|
||||
elif [ "{{ agent_skills_auto_install_fail_on_missing_installer | bool | ternary('true', 'false') }}" = "true" ]; then
|
||||
echo "Missing installer for $skill_name. Install clawhub or find-skills, or preseed $target_dir/$skill_name." >&2
|
||||
exit 127
|
||||
else
|
||||
echo "Skipped missing skill $skill_name because no installer adapter is available." >&2
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
loop: "{{ agent_skills_missing_entries }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: agent_skills_install_result
|
||||
changed_when: agent_skills_install_result.rc == 0
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when:
|
||||
- agent_skills_auto_install_enabled | bool
|
||||
- agent_skills_missing_entries | length > 0
|
||||
|
||||
- name: Reinspect required local scenario skills after auto install
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
for source_dir in {{ agent_skills_effective_source_dirs | map('quote') | join(' ') }}; do
|
||||
for candidate in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
|
||||
if [ -f "$source_dir/$candidate/SKILL.md" ]; then
|
||||
printf '%s\n' "$source_dir/$candidate"
|
||||
exit 0
|
||||
fi
|
||||
match="$(find "$source_dir" -type f -path "*/$candidate/SKILL.md" -print -quit)"
|
||||
if [ -n "$match" ]; then
|
||||
dirname "$match"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
exit 1
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: agent_skills_local_skill_presence_after_install
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
loop: "{{ agent_skills_required_entries }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when: agent_skills_auto_install_enabled | bool
|
||||
check_mode: false
|
||||
|
||||
- name: Build unresolved scenario skills list
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_unresolved_entries: >-
|
||||
{{
|
||||
(
|
||||
agent_skills_auto_install_enabled | bool
|
||||
)
|
||||
| ternary(
|
||||
agent_skills_local_skill_presence_after_install.results | default([]),
|
||||
agent_skills_local_skill_presence.results | default([])
|
||||
)
|
||||
| selectattr('rc', 'ne', 0)
|
||||
| map(attribute='item.name')
|
||||
| list
|
||||
}}
|
||||
|
||||
- name: Assert required scenario skills are available locally
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- agent_skills_unresolved_entries | length == 0
|
||||
fail_msg: >-
|
||||
Required scenario skills are still missing from {{ agent_skills_local_source_dir }}:
|
||||
{{ agent_skills_unresolved_entries | join(', ') }}.
|
||||
|
||||
- name: Build resolved local scenario skill paths
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_resolved_local_paths: >-
|
||||
{{
|
||||
(
|
||||
(agent_skills_auto_install_enabled | bool)
|
||||
| ternary(
|
||||
agent_skills_local_skill_presence_after_install.results | default([]),
|
||||
agent_skills_local_skill_presence.results | default([])
|
||||
)
|
||||
)
|
||||
| selectattr('rc', 'eq', 0)
|
||||
| map(attribute='stdout')
|
||||
| list
|
||||
| unique
|
||||
}}
|
||||
|
||||
- name: Run optional scenario skill quality gates
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
skill_path={{ item.0 | quote }}
|
||||
gate_name={{ item.1.name | quote }}
|
||||
if command -v "$gate_name" >/dev/null 2>&1; then
|
||||
{{ item.1.argv_prefix | map('quote') | join(' ') }} "$skill_path"
|
||||
else
|
||||
echo "Skipped missing quality gate: $gate_name"
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: agent_skills_quality_gate_results
|
||||
changed_when: false
|
||||
failed_when: agent_skills_quality_gate_fail_on_error | bool and agent_skills_quality_gate_results.rc != 0
|
||||
loop: "{{ agent_skills_resolved_local_paths | product(agent_skills_quality_gate_commands) | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.1.name }} {{ item.0 | basename }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when:
|
||||
- agent_skills_quality_gate_enabled | bool
|
||||
- agent_skills_resolved_local_paths | length > 0
|
||||
check_mode: false
|
||||
|
||||
- name: Detect local top-level symlink skills
|
||||
ansible.builtin.find:
|
||||
paths: "{{ agent_skills_local_source_dir }}"
|
||||
file_type: link
|
||||
recurse: false
|
||||
register: agent_skills_local_symlinks
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- name: Build rsync excludes for local symlink skills
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_local_symlink_excludes: >-
|
||||
{{
|
||||
agent_skills_local_symlinks.files
|
||||
| map(attribute='path')
|
||||
| map('basename')
|
||||
| list
|
||||
}}
|
||||
|
||||
- name: Install rsync for agent skills sync
|
||||
ansible.builtin.apt:
|
||||
name: rsync
|
||||
state: present
|
||||
update_cache: true
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
APT_LISTCHANGES_FRONTEND: none
|
||||
when:
|
||||
- agent_skills_install_rsync | bool
|
||||
- ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Ensure agent skills owner home exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ agent_skills_home }}"
|
||||
@ -36,187 +297,60 @@
|
||||
group: "{{ agent_skills_group }}"
|
||||
mode: "0755"
|
||||
|
||||
# --- 源获取:在目标主机 git clone(最通用) ---------------------------------
|
||||
- name: Ensure core skills checkout parent exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ agent_skills_xworkspace_core_clone_dir | dirname }}"
|
||||
state: directory
|
||||
owner: "{{ agent_skills_user }}"
|
||||
group: "{{ agent_skills_group }}"
|
||||
mode: "0755"
|
||||
when: agent_skills_xworkspace_core_enabled | bool
|
||||
|
||||
- name: Clone/update xworkspace core skills on the target host
|
||||
ansible.builtin.git:
|
||||
repo: "{{ agent_skills_xworkspace_core_repo_url }}"
|
||||
dest: "{{ agent_skills_xworkspace_core_clone_dir }}"
|
||||
version: "{{ agent_skills_xworkspace_core_version }}"
|
||||
depth: 1
|
||||
force: true
|
||||
become_user: "{{ agent_skills_user }}"
|
||||
register: agent_skills_core_clone
|
||||
when: agent_skills_xworkspace_core_enabled | bool
|
||||
|
||||
- name: Inspect core skills directory
|
||||
ansible.builtin.stat:
|
||||
path: "{{ agent_skills_xworkspace_core_source_dir }}"
|
||||
register: agent_skills_core_skills_stat
|
||||
when: agent_skills_xworkspace_core_enabled | bool
|
||||
|
||||
- name: Require core skills directory when enabled and required
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- agent_skills_core_skills_stat.stat.isdir | default(false)
|
||||
fail_msg: "core skills dir missing after clone: {{ agent_skills_xworkspace_core_source_dir }}"
|
||||
when:
|
||||
- agent_skills_xworkspace_core_enabled | bool
|
||||
- agent_skills_xworkspace_core_required | bool
|
||||
|
||||
- name: Build skill search dirs (canonical + core checkout)
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_search_dirs: >-
|
||||
- name: Sync local agent skills into canonical directory
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{
|
||||
[agent_skills_remote_dir]
|
||||
[
|
||||
'rsync',
|
||||
'-a',
|
||||
'--partial',
|
||||
'--timeout=' ~ (agent_skills_rsync_timeout | string)
|
||||
]
|
||||
+ (['--dry-run'] if ansible_check_mode else [])
|
||||
+ (['-z'] if (agent_skills_rsync_compress | bool) else [])
|
||||
+ (['--delete'] if (agent_skills_delete_removed | bool and agent_skills_source_index == 0) else [])
|
||||
+ (['--delete-excluded'] if (agent_skills_delete_removed | bool and agent_skills_source_index == 0) else [])
|
||||
+ (
|
||||
(
|
||||
agent_skills_xworkspace_core_enabled | bool
|
||||
and agent_skills_core_skills_stat.stat.isdir | default(false)
|
||||
agent_skills_rsync_excludes
|
||||
+ ((agent_skills_source_index == 0) | ternary(agent_skills_local_symlink_excludes, []))
|
||||
)
|
||||
| ternary([agent_skills_xworkspace_core_source_dir], [])
|
||||
| map('regex_replace', '^(.*)$', '--exclude=\1')
|
||||
| list
|
||||
)
|
||||
+ agent_skills_rsync_extra_opts
|
||||
+ (
|
||||
((ansible_connection | default('ssh')) == 'local')
|
||||
| ternary(
|
||||
[],
|
||||
['-e', 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null']
|
||||
)
|
||||
)
|
||||
+ [
|
||||
item ~ '/',
|
||||
(
|
||||
((ansible_connection | default('ssh')) == 'local')
|
||||
)
|
||||
| ternary(
|
||||
agent_skills_remote_dir ~ '/',
|
||||
(
|
||||
ansible_user | default(ansible_ssh_user) | default('root')
|
||||
) ~ '@' ~ (
|
||||
ansible_host | default(inventory_hostname)
|
||||
) ~ ':' ~ agent_skills_remote_dir ~ '/'
|
||||
)
|
||||
]
|
||||
}}
|
||||
|
||||
# --- 缺失场景技能:用 installer 适配器装到 canonical(主机本地) --------------
|
||||
- name: Inspect required scenario skills presence
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
for d in {{ agent_skills_search_dirs | map('quote') | join(' ') }}; do
|
||||
for c in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
|
||||
if [ -f "$d/$c/SKILL.md" ]; then printf '%s\n' "$d/$c"; exit 0; fi
|
||||
m="$(find "$d" -type f -path "*/$c/SKILL.md" -print -quit 2>/dev/null || true)"
|
||||
if [ -n "$m" ]; then dirname "$m"; exit 0; fi
|
||||
done
|
||||
done
|
||||
exit 1
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: agent_skills_presence
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
register: agent_skills_rsync_result
|
||||
changed_when: "'<<CHANGED>>' in agent_skills_rsync_result.stdout"
|
||||
check_mode: false
|
||||
loop: "{{ agent_skills_required_entries }}"
|
||||
loop: "{{ agent_skills_effective_source_dirs }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
|
||||
- name: Build missing scenario skills list
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_missing_entries: >-
|
||||
{{ agent_skills_presence.results | selectattr('rc', 'ne', 0) | map(attribute='item') | list }}
|
||||
|
||||
- name: Install missing scenario skills via installer adapters (clawhub/find-skills)
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
skill={{ item.name | quote }}
|
||||
target_dir={{ agent_skills_remote_dir | quote }}
|
||||
parent="$(dirname "$target_dir")"; base="$(basename "$target_dir")"
|
||||
rc=1
|
||||
if command -v clawhub >/dev/null 2>&1; then
|
||||
for n in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
|
||||
if clawhub --workdir "$parent" --dir "$base" --no-input install {{ (item.install_force | default(false) | bool) | ternary('--force', '') }} "$n"; then rc=0; break; fi
|
||||
done
|
||||
exit "$rc"
|
||||
elif command -v find-skills >/dev/null 2>&1; then
|
||||
for n in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
|
||||
if find-skills install "$n" --target "$target_dir"; then rc=0; break; fi
|
||||
done
|
||||
exit "$rc"
|
||||
elif [ "{{ agent_skills_auto_install_fail_on_missing_installer | bool | ternary('true', 'false') }}" = "true" ]; then
|
||||
echo "No installer (clawhub/find-skills) for $skill; preseed $target_dir/$skill." >&2
|
||||
exit 127
|
||||
else
|
||||
echo "Skipped missing $skill (no installer adapter)." >&2
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
become_user: "{{ agent_skills_user }}"
|
||||
register: agent_skills_install_result
|
||||
changed_when: agent_skills_install_result.rc == 0
|
||||
loop: "{{ agent_skills_missing_entries }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when:
|
||||
- agent_skills_auto_install_enabled | bool
|
||||
- agent_skills_missing_entries | length > 0
|
||||
|
||||
# --- 合并 core 技能到 canonical(主机本地 copy;无 rsync、无 delegate) --------
|
||||
- name: Merge core skills into canonical directory
|
||||
ansible.builtin.copy:
|
||||
src: "{{ agent_skills_xworkspace_core_source_dir }}/"
|
||||
dest: "{{ agent_skills_remote_dir }}/"
|
||||
remote_src: true
|
||||
owner: "{{ agent_skills_user }}"
|
||||
group: "{{ agent_skills_group }}"
|
||||
mode: preserve
|
||||
when:
|
||||
- agent_skills_xworkspace_core_enabled | bool
|
||||
- agent_skills_core_skills_stat.stat.isdir | default(false)
|
||||
|
||||
- name: Re-inspect required scenario skills in canonical dir
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
d={{ agent_skills_remote_dir | quote }}
|
||||
for c in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
|
||||
if [ -f "$d/$c/SKILL.md" ]; then printf '%s\n' "$d/$c"; exit 0; fi
|
||||
m="$(find "$d" -type f -path "*/$c/SKILL.md" -print -quit 2>/dev/null || true)"
|
||||
if [ -n "$m" ]; then dirname "$m"; exit 0; fi
|
||||
done
|
||||
exit 1
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: agent_skills_presence_final
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
loop: "{{ agent_skills_required_entries }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
|
||||
- name: Assert required scenario skills are available
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (agent_skills_presence_final.results | selectattr('rc', 'ne', 0) | list | length) == 0
|
||||
fail_msg: >-
|
||||
Required scenario skills still missing under {{ agent_skills_remote_dir }}:
|
||||
{{ agent_skills_presence_final.results | selectattr('rc', 'ne', 0) | map(attribute='item.name') | join(', ') }}.
|
||||
|
||||
- name: Build resolved skill paths
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_resolved_paths: >-
|
||||
{{ agent_skills_presence_final.results | selectattr('rc', 'eq', 0) | map(attribute='stdout') | list | unique }}
|
||||
|
||||
- name: Run optional scenario skill quality gates
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
skill_path={{ item.0 | quote }}
|
||||
gate_name={{ item.1.name | quote }}
|
||||
if command -v "$gate_name" >/dev/null 2>&1; then
|
||||
{{ item.1.argv_prefix | map('quote') | join(' ') }} "$skill_path"
|
||||
else
|
||||
echo "Skipped missing quality gate: $gate_name"
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
become_user: "{{ agent_skills_user }}"
|
||||
register: agent_skills_quality_gate_results
|
||||
changed_when: false
|
||||
failed_when: agent_skills_quality_gate_fail_on_error | bool and agent_skills_quality_gate_results.rc != 0
|
||||
loop: "{{ agent_skills_resolved_paths | product(agent_skills_quality_gate_commands) | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.1.name }} {{ item.0 | basename }}"
|
||||
when:
|
||||
- agent_skills_quality_gate_enabled | bool
|
||||
- agent_skills_resolved_paths | length > 0
|
||||
check_mode: false
|
||||
index_var: agent_skills_source_index
|
||||
label: "{{ item }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- name: Set canonical agent skills ownership
|
||||
ansible.builtin.file:
|
||||
@ -226,7 +360,6 @@
|
||||
group: "{{ agent_skills_group }}"
|
||||
recurse: true
|
||||
|
||||
# --- 把分类嵌套技能在 canonical 根做扁平 symlink(主机本地) ------------------
|
||||
- name: Link nested categorized skills at canonical root
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
@ -235,9 +368,13 @@
|
||||
skill_dir="$(dirname "$skill_manifest")"
|
||||
skill_name="$(basename "$skill_dir")"
|
||||
link_path={{ agent_skills_remote_dir | quote }}/"$skill_name"
|
||||
if [ -e "$link_path" ] && [ ! -L "$link_path" ]; then continue; fi
|
||||
if [ -e "$link_path" ] && [ ! -L "$link_path" ]; then
|
||||
continue
|
||||
fi
|
||||
current_target=""
|
||||
if [ -L "$link_path" ]; then current_target="$(readlink "$link_path")"; fi
|
||||
if [ -L "$link_path" ]; then
|
||||
current_target="$(readlink "$link_path")"
|
||||
fi
|
||||
if [ "$current_target" != "$skill_dir" ]; then
|
||||
if [ "{{ ansible_check_mode | ternary('true', 'false') }}" != "true" ]; then
|
||||
ln -sfn "$skill_dir" "$link_path"
|
||||
@ -245,7 +382,9 @@
|
||||
changed=1
|
||||
fi
|
||||
done < <(find {{ agent_skills_remote_dir | quote }} -mindepth 3 -name SKILL.md -type f -print)
|
||||
if [ "$changed" = "1" ]; then echo "<<CHANGED>>linked nested skills"; fi
|
||||
if [ "$changed" = "1" ]; then
|
||||
echo "<<CHANGED>>linked nested skills"
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: agent_skills_flatten_result
|
||||
@ -262,7 +401,6 @@
|
||||
recurse: true
|
||||
when: agent_skills_remote_flatten_nested_skills | bool
|
||||
|
||||
# --- 把各 Agent 的 skills 目录 symlink 到 canonical ---------------------------
|
||||
- name: Flatten agent skills target paths
|
||||
ansible.builtin.set_fact:
|
||||
agent_skills_target_paths: "{{ agent_skills_targets | subelements('paths') | map('last') | list }}"
|
||||
@ -277,8 +415,8 @@
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
Agent skills target already exists and is not a symlink: {{ item.item }}.
|
||||
Set agent_skills_replace_existing_target_dirs=true to replace it with a link
|
||||
to {{ agent_skills_remote_dir }}.
|
||||
Set agent_skills_replace_existing_target_dirs=true if this path should be
|
||||
replaced with a link to {{ agent_skills_remote_dir }}.
|
||||
loop: "{{ agent_skills_target_path_stats.results }}"
|
||||
when:
|
||||
- item.stat.exists | default(false)
|
||||
@ -339,10 +477,11 @@
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- agent_skills_manifest_files.matched | int > 0
|
||||
fail_msg: "No SKILL.md files found under {{ agent_skills_remote_dir }}."
|
||||
fail_msg: "No SKILL.md files found under {{ agent_skills_remote_dir }} after sync."
|
||||
|
||||
- name: Report synced agent skills
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ agent_skills_manifest_files.matched }} skill manifests under
|
||||
{{ agent_skills_remote_dir }}; linked {{ agent_skills_target_paths | length }} agent targets.
|
||||
Synced {{ agent_skills_manifest_files.matched }} skill manifests into
|
||||
{{ agent_skills_remote_dir }} and linked {{ agent_skills_target_paths | length }}
|
||||
agent target directories.
|
||||
|
||||
@ -14,16 +14,12 @@
|
||||
google-chrome-stable \
|
||||
/usr/bin/chromium \
|
||||
/snap/bin/chromium; do
|
||||
resolved=""
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
resolved="$(command -v "$candidate")"
|
||||
elif [ -x "$candidate" ]; then
|
||||
resolved="$candidate"
|
||||
command -v "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
# 必须真正可执行:跳过 xfce 安装的 disabled chromium stub(退出码 126),
|
||||
# 否则 resolver 会选中它,后续 --version 校验必失败。
|
||||
if [ -n "$resolved" ] && "$resolved" --version >/dev/null 2>&1; then
|
||||
printf '%s\n' "$resolved"
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
@ -41,8 +37,6 @@
|
||||
state: present
|
||||
update_cache: true
|
||||
install_recommends: false
|
||||
# 等 dpkg 前端锁,避免与 cloud-init/unattended-upgrades 抢锁而立即失败
|
||||
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
APT_LISTCHANGES_FRONTEND: none
|
||||
@ -66,16 +60,12 @@
|
||||
/usr/bin/chromium \
|
||||
/snap/bin/chromium \
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; do
|
||||
resolved=""
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
resolved="$(command -v "$candidate")"
|
||||
elif [ -x "$candidate" ]; then
|
||||
resolved="$candidate"
|
||||
command -v "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
# 必须真正可执行:跳过 xfce 安装的 disabled chromium stub(退出码 126),
|
||||
# 否则 resolver 会选中它,后续 --version 校验必失败。
|
||||
if [ -n "$resolved" ] && "$resolved" --version >/dev/null 2>&1; then
|
||||
printf '%s\n' "$resolved"
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
state: present
|
||||
update_cache: true
|
||||
install_recommends: false
|
||||
# 等 dpkg 前端锁,避免与 cloud-init/unattended-upgrades 抢锁而立即失败
|
||||
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
APT_LISTCHANGES_FRONTEND: none
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
state: present
|
||||
update_cache: true
|
||||
install_recommends: false
|
||||
# 等 dpkg 前端锁,避免与 cloud-init/unattended-upgrades 抢锁而立即失败
|
||||
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
APT_LISTCHANGES_FRONTEND: none
|
||||
|
||||
@ -11,8 +11,6 @@
|
||||
state: present
|
||||
update_cache: true
|
||||
install_recommends: false
|
||||
# 等 dpkg 前端锁,避免与 cloud-init/unattended-upgrades 抢锁而立即失败
|
||||
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
APT_LISTCHANGES_FRONTEND: none
|
||||
|
||||
@ -6,10 +6,10 @@ Reusable Ansible role for creating and updating Cloudflare DNS records in the `s
|
||||
|
||||
- Zone lookup by name, or direct `cloudflare_dns_zone_id`
|
||||
- Create/update/delete of managed DNS records
|
||||
- Token resolution from Ansible extra vars, with the DNS-scoped token preferred:
|
||||
- Token resolution from Ansible extra vars:
|
||||
- `-e CLOUDFLARE_DNS_API_TOKEN=...`
|
||||
- `-e CLOUDFLARE_API_TOKEN=...`
|
||||
- Environment-backed token resolution as fallback, with the DNS-scoped token preferred:
|
||||
- Environment-backed token resolution as fallback:
|
||||
- `CLOUDFLARE_DNS_API_TOKEN`
|
||||
- `CLOUDFLARE_API_TOKEN`
|
||||
|
||||
|
||||
@ -78,7 +78,7 @@
|
||||
- "'#zone:read' in (cloudflare_dns_zone_lookup.json.result[0].permissions | default([]))"
|
||||
- "'#dns_records:edit' in (cloudflare_dns_zone_lookup.json.result[0].permissions | default([]))"
|
||||
fail_msg: >-
|
||||
CLOUDFLARE_DNS_API_TOKEN is valid but lacks DNS edit permission for {{ cloudflare_dns_zone_name }}.
|
||||
CLOUDFLARE_API_TOKEN is valid but lacks DNS edit permission for {{ cloudflare_dns_zone_name }}.
|
||||
Current permissions: {{ cloudflare_dns_zone_lookup.json.result[0].permissions | default([]) }}.
|
||||
Required: Zone read + DNS edit on the svc.plus zone.
|
||||
when:
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
---
|
||||
postgresql_image: "ghcr.io/x-evor/images/postgresql:17"
|
||||
postgresql_compose_project_dir: "{{ '/opt/ai-workspace/postgres' if ansible_os_family != 'Darwin' else lookup('env', 'HOME') + '/ai-workspace-postgres' }}"
|
||||
postgresql_compose_project_name: "ai-workspace-postgres"
|
||||
postgresql_compose_file: "{{ postgresql_compose_project_dir }}/docker-compose.yml"
|
||||
postgresql_compose_env_file: "{{ postgresql_compose_project_dir }}/.env"
|
||||
postgresql_data_dir: "{{ postgresql_compose_project_dir }}/data"
|
||||
|
||||
postgresql_admin_user: postgres
|
||||
postgresql_admin_password: "changeme"
|
||||
postgresql_database: postgres
|
||||
postgresql_port: 5432
|
||||
postgresql_local_port: 15432
|
||||
|
||||
postgresql_container_uid: "999"
|
||||
postgresql_container_gid: "999"
|
||||
@ -1,100 +0,0 @@
|
||||
-- PostgreSQL initialization script
|
||||
-- This script runs automatically on first container startup
|
||||
|
||||
-- Create extensions
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_jieba;
|
||||
CREATE EXTENSION IF NOT EXISTS pgmq;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS hstore;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Create a sample database for testing
|
||||
CREATE DATABASE appdb;
|
||||
|
||||
-- Connect to the new database
|
||||
\c appdb
|
||||
|
||||
-- Recreate extensions in the new database
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_jieba;
|
||||
CREATE EXTENSION IF NOT EXISTS pgmq;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS hstore;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Create a sample schema
|
||||
CREATE SCHEMA IF NOT EXISTS app;
|
||||
|
||||
-- Sample table with vector embeddings
|
||||
CREATE TABLE IF NOT EXISTS app.documents (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding vector(1536), -- OpenAI ada-002 dimension
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_embedding ON app.documents
|
||||
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_metadata ON app.documents
|
||||
USING gin (metadata);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_content ON app.documents
|
||||
USING gin (to_tsvector('english', content));
|
||||
|
||||
-- Sample table for node management
|
||||
CREATE TABLE IF NOT EXISTS app.nodes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
server_name TEXT,
|
||||
protocols JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
available BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for available nodes
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_available ON app.nodes (available);
|
||||
|
||||
-- Sample table with Chinese full-text search
|
||||
CREATE TABLE IF NOT EXISTS app.articles_zh (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tags TEXT[],
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_zh_content ON app.articles_zh
|
||||
USING gin (to_tsvector('jiebacfg', content));
|
||||
|
||||
-- Sample key-value store using hstore
|
||||
CREATE TABLE IF NOT EXISTS app.sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
data hstore NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON app.sessions (expires_at);
|
||||
|
||||
-- Create a message queue
|
||||
SELECT pgmq.create('task_queue');
|
||||
SELECT pgmq.create('notification_queue');
|
||||
|
||||
-- Grant permissions (adjust as needed)
|
||||
-- GRANT ALL PRIVILEGES ON SCHEMA app TO your_app_user;
|
||||
-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA app TO your_app_user;
|
||||
|
||||
COMMENT ON DATABASE appdb IS 'Application database with vector search, full-text search, and message queue capabilities';
|
||||
COMMENT ON SCHEMA app IS 'Main application schema';
|
||||
COMMENT ON TABLE app.documents IS 'Documents with vector embeddings for semantic search';
|
||||
COMMENT ON TABLE app.articles_zh IS 'Chinese articles with jieba tokenization';
|
||||
COMMENT ON TABLE app.sessions IS 'Session storage using hstore';
|
||||
@ -1,88 +0,0 @@
|
||||
# PostgreSQL Configuration
|
||||
# Optimized for application workloads with vector search and full-text search
|
||||
|
||||
# Connection Settings
|
||||
listen_addresses = '*'
|
||||
port = 5432
|
||||
max_connections = 100
|
||||
superuser_reserved_connections = 3
|
||||
|
||||
# Memory Settings
|
||||
shared_buffers = 256MB
|
||||
effective_cache_size = 1GB
|
||||
maintenance_work_mem = 64MB
|
||||
work_mem = 16MB
|
||||
|
||||
# Write-Ahead Log
|
||||
wal_buffers = 16MB
|
||||
min_wal_size = 1GB
|
||||
max_wal_size = 4GB
|
||||
checkpoint_completion_target = 0.9
|
||||
wal_compression = on
|
||||
|
||||
# Query Tuning
|
||||
random_page_cost = 1.1 # Lower for SSD
|
||||
effective_io_concurrency = 200
|
||||
default_statistics_target = 100
|
||||
|
||||
# Logging
|
||||
log_destination = 'stderr'
|
||||
logging_collector = on
|
||||
log_directory = 'log'
|
||||
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
|
||||
log_rotation_age = 1d
|
||||
log_rotation_size = 100MB
|
||||
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
|
||||
log_timezone = 'UTC'
|
||||
|
||||
# What to Log
|
||||
log_checkpoints = on
|
||||
log_connections = on
|
||||
log_disconnections = on
|
||||
log_duration = off
|
||||
log_lock_waits = on
|
||||
log_statement = 'none'
|
||||
log_temp_files = 0
|
||||
|
||||
# Slow Query Logging
|
||||
log_min_duration_statement = 1000 # Log queries slower than 1 second
|
||||
|
||||
# Locale and Formatting
|
||||
datestyle = 'iso, mdy'
|
||||
timezone = 'UTC'
|
||||
lc_messages = 'en_US.utf8'
|
||||
lc_monetary = 'en_US.utf8'
|
||||
lc_numeric = 'en_US.utf8'
|
||||
lc_time = 'en_US.utf8'
|
||||
default_text_search_config = 'pg_catalog.english'
|
||||
|
||||
# Extension-specific settings
|
||||
|
||||
# pgvector settings
|
||||
# No specific configuration needed, but ensure shared_buffers is adequate
|
||||
|
||||
# pg_jieba settings
|
||||
# Default configuration is usually sufficient
|
||||
|
||||
# Full-text search
|
||||
# Increase work_mem if doing complex text searches
|
||||
# work_mem = 32MB # Uncomment if needed
|
||||
|
||||
# Performance for JSONB
|
||||
# GIN indexes benefit from larger maintenance_work_mem during creation
|
||||
|
||||
# Connection Pooling (if using PgBouncer)
|
||||
# Consider lowering max_connections and using PgBouncer
|
||||
|
||||
# Security
|
||||
# ssl = on
|
||||
# ssl_cert_file = '/path/to/server.crt'
|
||||
# ssl_key_file = '/path/to/server.key'
|
||||
# ssl_ca_file = '/path/to/ca.crt'
|
||||
|
||||
# Uncomment for production SSL/TLS
|
||||
# ssl_prefer_server_ciphers = on
|
||||
# ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL'
|
||||
|
||||
# Client Authentication
|
||||
# Edit pg_hba.conf for detailed access control
|
||||
@ -1,93 +1,5 @@
|
||||
---
|
||||
- name: Ensure Homebrew Docker and Colima are installed (macOS)
|
||||
ansible.builtin.command: brew install colima docker docker-compose
|
||||
environment:
|
||||
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH | default('/usr/bin:/bin') }}"
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1"
|
||||
register: brew_install
|
||||
changed_when: >-
|
||||
'already installed' not in (brew_install.stderr | default(''))
|
||||
and 'already installed' not in (brew_install.stdout | default(''))
|
||||
when: ansible_os_family == 'Darwin'
|
||||
|
||||
- name: Ensure Colima is started (macOS)
|
||||
ansible.builtin.command: colima start
|
||||
environment:
|
||||
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH | default('/usr/bin:/bin') }}"
|
||||
register: colima_start
|
||||
changed_when: "'already running' not in colima_start.stdout and 'already running' not in colima_start.stderr"
|
||||
when: ansible_os_family == 'Darwin'
|
||||
|
||||
- name: Ensure PostgreSQL compose project directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ postgresql_compose_project_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Ensure PostgreSQL init-scripts directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ postgresql_compose_project_dir }}/init-scripts"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Ensure PostgreSQL data directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ postgresql_data_dir }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
owner: "{{ postgresql_container_uid }}"
|
||||
group: "{{ postgresql_container_gid }}"
|
||||
# macOS/Colima usually handles volume mounts with current user, but uid 999 is standard for postgres container
|
||||
ignore_errors: "{{ ansible_os_family == 'Darwin' }}"
|
||||
|
||||
- name: Render PostgreSQL compose environment file
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ postgresql_compose_env_file }}"
|
||||
mode: "0600"
|
||||
content: |
|
||||
POSTGRES_DB={{ postgresql_database }}
|
||||
POSTGRES_USER={{ postgresql_admin_user }}
|
||||
POSTGRES_PASSWORD={{ postgresql_admin_password }}
|
||||
no_log: true
|
||||
|
||||
- name: Render PostgreSQL compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ postgresql_compose_file }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Copy postgresql.conf
|
||||
ansible.builtin.copy:
|
||||
src: postgresql.conf
|
||||
dest: "{{ postgresql_compose_project_dir }}/postgresql.conf"
|
||||
mode: "0644"
|
||||
|
||||
- name: Copy init-extensions script
|
||||
ansible.builtin.copy:
|
||||
src: init-scripts/01-init-extensions.sql
|
||||
dest: "{{ postgresql_compose_project_dir }}/init-scripts/01-init-extensions.sql"
|
||||
mode: "0644"
|
||||
|
||||
- name: Start PostgreSQL compose service
|
||||
ansible.builtin.command:
|
||||
cmd: "docker compose -f {{ postgresql_compose_file }} -p {{ postgresql_compose_project_name }} up -d --remove-orphans"
|
||||
chdir: "{{ postgresql_compose_project_dir }}"
|
||||
register: postgresql_compose_up
|
||||
changed_when: >-
|
||||
'Started' in (postgresql_compose_up.stdout | default('')) or
|
||||
'Created' in (postgresql_compose_up.stdout | default('')) or
|
||||
'Recreated' in (postgresql_compose_up.stdout | default('')) or
|
||||
'Pulled' in (postgresql_compose_up.stdout | default(''))
|
||||
environment:
|
||||
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH | default('/usr/bin:/bin') }}"
|
||||
|
||||
- name: Validate PostgreSQL compose service
|
||||
ansible.builtin.command:
|
||||
cmd: "docker exec {{ postgresql_compose_project_name }} pg_isready -U {{ postgresql_admin_user }} -d {{ postgresql_database }}"
|
||||
register: postgresql_compose_ready
|
||||
retries: 12
|
||||
delay: 5
|
||||
until: postgresql_compose_ready.rc == 0
|
||||
changed_when: false
|
||||
environment:
|
||||
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH | default('/usr/bin:/bin') }}"
|
||||
# TODO: implement docker deployment tasks
|
||||
- name: Placeholder task
|
||||
debug:
|
||||
msg: "Role placeholder. Implement docker deployment tasks."
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: "{{ postgresql_image }}"
|
||||
container_name: "{{ postgresql_compose_project_name }}"
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- "{{ postgresql_compose_env_file }}"
|
||||
|
||||
# PostgreSQL 只监听 localhost,通过 stunnel 提供外部访问
|
||||
# 不直接暴露端口,确保所有连接都经过 TLS 加密
|
||||
expose:
|
||||
- "5432"
|
||||
ports:
|
||||
- "{{ postgresql_local_port }}:5432"
|
||||
- "{{ postgresql_port }}:5432"
|
||||
|
||||
volumes:
|
||||
- "{{ postgresql_data_dir }}:/var/lib/postgresql/data"
|
||||
- ./init-scripts:/docker-entrypoint-initdb.d:ro
|
||||
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U {{ postgresql_admin_user }} -h 127.0.0.1" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
networks:
|
||||
- postgres_network
|
||||
|
||||
# Resource limits (adjust based on your needs)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
|
||||
networks:
|
||||
postgres_network:
|
||||
driver: bridge
|
||||
@ -8,9 +8,9 @@ acp_gemini_xdg_config_home: "{{ acp_gemini_home }}/.config"
|
||||
acp_gemini_xdg_state_home: "{{ acp_gemini_home }}/.local/state"
|
||||
acp_gemini_config_dir: "{{ acp_gemini_home }}/.gemini"
|
||||
acp_gemini_npm_global_bin: "{{ acp_gemini_home + '/.local/bin' if ansible_os_family == 'Darwin' else '/usr/bin' }}"
|
||||
acp_gemini_binary_path: "{{ acp_gemini_npm_global_bin }}/antigravity-cli"
|
||||
acp_gemini_binary_path: "{{ acp_gemini_npm_global_bin }}/gemini"
|
||||
acp_gemini_path: "{{ acp_gemini_npm_global_bin }}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin"
|
||||
acp_gemini_args: mcp-app-server
|
||||
acp_gemini_args: --experimental-acp
|
||||
acp_gemini_bridge_local_source_dir: "{{ playbook_dir }}/../xworkmate-bridge"
|
||||
acp_gemini_bridge_local_build_dir: "{{ playbook_dir }}/.artifacts/acp_gemini"
|
||||
acp_gemini_bridge_local_binary_path: "{{ acp_gemini_bridge_local_build_dir }}/xworkmate-go-core"
|
||||
|
||||
@ -69,21 +69,6 @@
|
||||
changed_when: true
|
||||
become: true
|
||||
|
||||
- name: Ensure Gemini ACP runtime directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: "{{ acp_gemini_service_user }}"
|
||||
group: "{{ acp_gemini_service_group }}"
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ acp_gemini_home }}"
|
||||
- "{{ acp_gemini_workdir }}"
|
||||
- "{{ acp_gemini_xdg_config_home }}"
|
||||
- "{{ acp_gemini_xdg_state_home }}"
|
||||
become: true
|
||||
when: ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Deploy Gemini ACP systemd service
|
||||
ansible.builtin.command:
|
||||
cmd: lsattr "/etc/systemd/system/{{ acp_gemini_service_name }}.service"
|
||||
|
||||
@ -19,7 +19,7 @@ Environment=GEMINI_ADAPTER_ALLOWED_ORIGINS={{ acp_gemini_allowed_origins | join(
|
||||
Environment={{ key }}={{ value }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
ExecStart={{ acp_gemini_bridge_binary_path }} adapter gemini -listen {{ acp_gemini_listen_host }}:{{ acp_gemini_listen_port }} -gemini-bin {{ acp_gemini_binary_path }} -gemini-args "{{ acp_gemini_args }}"
|
||||
ExecStart={{ acp_gemini_bridge_binary_path }} adapter gemini --listen {{ acp_gemini_listen_host }}:{{ acp_gemini_listen_port }} --gemini-bin {{ acp_gemini_binary_path }} --gemini-args "{{ acp_gemini_args }}"
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
|
||||
@ -11,10 +11,6 @@ acp_opencode_workdir: "{{ ansible_env.HOME | default('/home/' + acp_opencode_ser
|
||||
# user-level npm global bin lives under ~/.local/bin; include Homebrew + system.
|
||||
acp_opencode_npm_global_bin: "{{ acp_opencode_home + '/.local/bin' if ansible_os_family == 'Darwin' else '/usr/bin' }}"
|
||||
acp_opencode_path: "{{ acp_opencode_npm_global_bin }}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin"
|
||||
# OpenCode CLI binary the adapter spawns lazily (mirrors gemini/codex which use
|
||||
# {{ acp_X_npm_global_bin }}/<cli>). Was hardcoded to /usr/bin/opencode in the
|
||||
# unit template; use the resolved npm global bin so macOS (~/.local/bin) works too.
|
||||
acp_opencode_binary_path: "{{ acp_opencode_npm_global_bin }}/opencode"
|
||||
acp_opencode_listen_host: 127.0.0.1
|
||||
acp_opencode_listen_port: 38992
|
||||
acp_opencode_packages: []
|
||||
|
||||
@ -79,29 +79,6 @@
|
||||
- "{{ acp_opencode_home }}/.local"
|
||||
- "{{ acp_opencode_workdir }}"
|
||||
|
||||
# Resolve the OpenCode CLI at deploy time instead of assuming a fixed path.
|
||||
# npm installs it wherever the active node prefix points (NodeSource -> /usr/bin
|
||||
# on Debian/Ubuntu, ~/.local/bin or Homebrew on macOS), so probe the real path
|
||||
# with the role PATH and fall back to the OS-aware default when not yet present.
|
||||
- name: Resolve OpenCode CLI binary path
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
export PATH="{{ acp_opencode_path }}:${PATH}"
|
||||
command -v opencode || true
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: acp_opencode_resolved_bin
|
||||
changed_when: false
|
||||
|
||||
- name: Use resolved OpenCode CLI binary path when present
|
||||
ansible.builtin.set_fact:
|
||||
acp_opencode_binary_path: "{{ acp_opencode_resolved_bin.stdout_lines[0] | trim }}"
|
||||
when: acp_opencode_resolved_bin.stdout | default('') | trim | length > 0
|
||||
|
||||
- name: Report effective OpenCode CLI binary path
|
||||
ansible.builtin.debug:
|
||||
msg: "OpenCode CLI binary resolved to: {{ acp_opencode_binary_path }}"
|
||||
|
||||
- name: Deploy Caddy main file
|
||||
ansible.builtin.template:
|
||||
src: Caddyfile.j2
|
||||
|
||||
@ -17,71 +17,28 @@
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
# 用 curl 重试循环替代 uri:服务刚 (重)启时 adapter 会先 accept TCP 但短时间内不
|
||||
# 应答(读挂起),而 uri 默认 30s 超时 + retries/until 在连接超时上不可靠循环(实测
|
||||
# 仅试一次即失败)。每次 5s 上限、真重试,给冷启动足够时间(adapter 就绪后 ~4ms 回 200)。
|
||||
# 包在 block/rescue:探针失败时把 systemctl status + journalctl 打到 CI 日志,
|
||||
# 否则 play 在此中止,看不到 adapter 进程真正的崩溃原因(如 last code 000)。
|
||||
- name: Validate OpenCode local ACP endpoint
|
||||
block:
|
||||
- name: Validate OpenCode local ACP endpoint (readiness retry)
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
url="http://{{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }}/acp/rpc"
|
||||
body='{"jsonrpc":"2.0","id":1,"method":"acp.capabilities","params":{}}'
|
||||
code=""
|
||||
for i in $(seq 1 30); do
|
||||
code="$(curl -s -m 5 -o /dev/null -w '%{http_code}' -X POST "$url" \
|
||||
-H 'Content-Type: application/json' -d "$body" 2>/dev/null || true)"
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "OpenCode ACP endpoint ready after ${i} attempt(s)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "OpenCode ACP endpoint ${url} not ready after retries (last code: ${code:-none})" >&2
|
||||
exit 1
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
register: acp_opencode_adapter_probe
|
||||
rescue:
|
||||
- name: Capture OpenCode ACP service status on failure
|
||||
ansible.builtin.command: systemctl status "{{ acp_opencode_service_name }}" --no-pager --full
|
||||
register: acp_opencode_status_fail
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Capture recent OpenCode ACP service logs on failure
|
||||
ansible.builtin.command: journalctl -u "{{ acp_opencode_service_name }}" -n 80 --no-pager
|
||||
register: acp_opencode_journal_fail
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Show OpenCode ACP failure diagnostics
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "Probe stderr: {{ acp_opencode_adapter_probe.stderr | default('N/A') }}"
|
||||
- "Listeners: {{ acp_opencode_ss.stdout | default('N/A') }}"
|
||||
- "Service status: {{ acp_opencode_status_fail.stdout | default('N/A') }}"
|
||||
- "Recent logs: {{ acp_opencode_journal_fail.stdout | default('N/A') }}"
|
||||
|
||||
- name: Fail after emitting OpenCode ACP diagnostics
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
OpenCode ACP endpoint
|
||||
{{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }} did not
|
||||
become ready. See the diagnostics above (service status + journal) for
|
||||
the adapter crash cause.
|
||||
ansible.builtin.uri:
|
||||
url: "http://{{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }}/acp/rpc"
|
||||
method: POST
|
||||
body_format: json
|
||||
body:
|
||||
jsonrpc: "2.0"
|
||||
id: 1
|
||||
method: acp.capabilities
|
||||
params: {}
|
||||
return_content: true
|
||||
status_code: 200
|
||||
register: acp_opencode_adapter_http
|
||||
retries: 30
|
||||
delay: 2
|
||||
until: acp_opencode_adapter_http.status == 200
|
||||
|
||||
- name: Show OpenCode ACP status
|
||||
ansible.builtin.command: systemctl status "{{ acp_opencode_service_name }}" --no-pager
|
||||
register: acp_opencode_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Show OpenCode ACP validation summary
|
||||
ansible.builtin.debug:
|
||||
@ -90,6 +47,6 @@
|
||||
- "Preferred WebSocket endpoint: {{ acp_opencode_public_base_url }}/acp"
|
||||
- "Compatibility HTTP RPC endpoint: {{ acp_opencode_public_base_url }}/acp/rpc"
|
||||
- "OpenCode ACP adapter listener: {{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }}"
|
||||
- "Readiness probe: {{ acp_opencode_adapter_probe.stdout | default('N/A') }}"
|
||||
- "Service: {{ acp_opencode_status.stdout | default('N/A') }}"
|
||||
- "Socket: {{ acp_opencode_ss.stdout | default('N/A') }}"
|
||||
- "Adapter capabilities HTTP: {{ acp_opencode_adapter_http.content | default('N/A') }}"
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=XWorkmate OpenCode ACP bridge server
|
||||
After=network-online.target {{ acp_opencode_service_name }}.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User={{ acp_opencode_service_user }}
|
||||
Group={{ acp_opencode_service_group }}
|
||||
WorkingDirectory={{ acp_opencode_workdir }}
|
||||
Environment=HOME={{ acp_opencode_home }}
|
||||
Environment=TERM=xterm-256color
|
||||
Environment=ACP_LISTEN_ADDR={{ acp_opencode_bridge_listen_host }}:{{ acp_opencode_bridge_listen_port }}
|
||||
Environment=ACP_ALLOWED_ORIGINS={{ acp_opencode_bridge_allowed_origins | join(',') }}
|
||||
{% if acp_opencode_auth_token | trim | length > 0 %}
|
||||
Environment=ACP_AUTH_TOKEN={{ acp_opencode_auth_token }}
|
||||
{% endif %}
|
||||
Environment=ACP_CODEX_BIN={{ acp_opencode_bridge_disabled_binary_path }}
|
||||
Environment=ACP_CLAUDE_BIN={{ acp_opencode_bridge_disabled_binary_path }}
|
||||
Environment=ACP_GEMINI_BIN={{ acp_opencode_bridge_disabled_binary_path }}
|
||||
Environment=ACP_OPENCODE_BIN={{ acp_opencode_bridge_opencode_binary_path }}
|
||||
ExecStart={{ acp_opencode_bridge_binary_path }} serve --listen {{ acp_opencode_bridge_listen_host }}:{{ acp_opencode_bridge_listen_port }}
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -9,10 +9,9 @@ User={{ acp_opencode_service_user }}
|
||||
Group={{ acp_opencode_service_group }}
|
||||
WorkingDirectory={{ acp_opencode_workdir }}
|
||||
Environment=HOME={{ acp_opencode_home }}
|
||||
Environment=PATH={{ acp_opencode_path }}
|
||||
Environment=TERM=xterm-256color
|
||||
Environment=OPENCODE_ADAPTER_ALLOWED_ORIGINS={{ acp_opencode_bridge_allowed_origins | join(',') }}
|
||||
ExecStart={{ acp_opencode_bridge_binary_path }} adapter opencode --listen {{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }} --opencode-bin {{ acp_opencode_binary_path }} --cwd {{ acp_opencode_workdir }}
|
||||
ExecStart={{ acp_opencode_bridge_binary_path }} adapter opencode --listen {{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }} --opencode-bin /usr/bin/opencode --cwd {{ acp_opencode_workdir }}
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
|
||||
exec "{{ acp_opencode_bridge_binary_path }}" adapter opencode \
|
||||
-listen {{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }} \
|
||||
-opencode-bin "{{ acp_opencode_binary_path }}" \
|
||||
-cwd "{{ acp_opencode_workdir }}"
|
||||
</string>
|
||||
</array>
|
||||
|
||||
@ -6,53 +6,6 @@ migrate_litellm_db: "litellm"
|
||||
migrate_litellm_db_user: "litellm"
|
||||
migrate_litellm_db_host: "127.0.0.1"
|
||||
|
||||
# Public bootstrap redirects
|
||||
ai_workspace_caddy_base_dir: "{{ caddy_config_dir | default('/etc/caddy') }}"
|
||||
ai_workspace_caddy_conf_dir: "{{ ai_workspace_caddy_base_dir }}/conf.d"
|
||||
ai_workspace_caddyfile_path: "{{ ai_workspace_caddy_base_dir }}/Caddyfile"
|
||||
ai_workspace_caddy_fragment_path: "{{ ai_workspace_caddy_conf_dir }}/install.svc.plus.caddy"
|
||||
ai_workspace_public_domain: "install.svc.plus"
|
||||
ai_workspace_install_script_url: "https://raw.githubusercontent.com/ai-workspace-lab/xworkspace-console/main/scripts/setup-ai-workspace-all-in-one.sh"
|
||||
ai_workspace_xworkmate_install_script_url: "https://raw.githubusercontent.com/ai-workspace-lab/xworkmate-app/main/scripts/install-xworkmate-app.sh"
|
||||
ai_workspace_manage_caddy: true
|
||||
|
||||
# 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
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
---
|
||||
dependencies:
|
||||
- role: roles/vhosts/caddy
|
||||
- role: roles/agent_skills
|
||||
- role: roles/vhosts/gateway_openclaw
|
||||
- role: roles/vhosts/xworkmate_bridge
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
---
|
||||
# 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
|
||||
@ -1,156 +0,0 @@
|
||||
---
|
||||
- name: Ensure AI Workspace Caddy fragment directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ ai_workspace_caddy_conf_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: ai_workspace_manage_caddy | bool
|
||||
|
||||
- name: Render install.svc.plus redirect fragment
|
||||
ansible.builtin.template:
|
||||
src: Caddyfile.j2
|
||||
dest: "{{ ai_workspace_caddy_fragment_path }}"
|
||||
mode: "0644"
|
||||
register: ai_workspace_caddy_fragment
|
||||
when: ai_workspace_manage_caddy | bool
|
||||
|
||||
- name: Validate Caddy configuration
|
||||
ansible.builtin.command: >-
|
||||
caddy validate --config {{ ai_workspace_caddyfile_path }}
|
||||
changed_when: false
|
||||
when:
|
||||
- ai_workspace_manage_caddy | bool
|
||||
- ai_workspace_caddy_fragment.changed
|
||||
|
||||
- name: Reload Caddy after updating install redirects
|
||||
ansible.builtin.service:
|
||||
name: caddy
|
||||
state: reloaded
|
||||
when:
|
||||
- ai_workspace_manage_caddy | bool
|
||||
- ai_workspace_caddy_fragment.changed
|
||||
|
||||
# =============================================================================
|
||||
# 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'
|
||||
@ -1,6 +0,0 @@
|
||||
{{ ai_workspace_public_domain }} {
|
||||
redir /ai-workspace {{ ai_workspace_install_script_url }} 302
|
||||
redir /ai-workspace/latest {{ ai_workspace_install_script_url }} 302
|
||||
redir /xworkmate-app {{ ai_workspace_xworkmate_install_script_url }} 302
|
||||
redir /xworkmate-app/latest {{ ai_workspace_xworkmate_install_script_url }} 302
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
<?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 }}" ] && . "{{ 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>
|
||||
@ -22,18 +22,8 @@ enable_common: true
|
||||
# macOS (Darwin) baseline: shared Homebrew CLI prerequisites used by helper
|
||||
# scripts across roles (e.g. jq is required by vault's init_vault_admin.sh).
|
||||
# macOS ships curl/base64 already; jq is not present by default.
|
||||
#
|
||||
# docker/docker-compose/colima provide a headless container runtime on macOS
|
||||
# (colima runs the Docker daemon in a lightweight VM; the docker formula is the
|
||||
# CLI only, no Docker Desktop). Needed for container workloads such as the qmd
|
||||
# PostgreSQL memory-bridge integration tests (test/pg-compose.yml). Each formula
|
||||
# installs a /opt/homebrew/bin/<name> binary, so the task's `creates` check stays
|
||||
# idempotent. After install, start the runtime once with `colima start`.
|
||||
common_darwin_brew_packages:
|
||||
- jq
|
||||
- docker
|
||||
- docker-compose
|
||||
- colima
|
||||
|
||||
common_firewall:
|
||||
enabled: true
|
||||
|
||||
@ -1,21 +1,9 @@
|
||||
---
|
||||
# Debian/Ubuntu 走 apt:play 的 module_defaults.apt.lock_timeout(模板值)经
|
||||
# ansible.builtin.package 间接派发到 apt 时不渲染 → lock_timeout 收到字面 "{{ ... }}"
|
||||
# 报 int 转换失败(与 xworkmate_bridge/litellm 同类修复)。
|
||||
- name: Fail2ban | Install Fail2ban package (Debian/Ubuntu via apt)
|
||||
ansible.builtin.apt:
|
||||
name: fail2ban
|
||||
state: present
|
||||
update_cache: true
|
||||
become: true
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
- name: Fail2ban | Install Fail2ban package (non-Debian)
|
||||
- name: Fail2ban | Install Fail2ban package
|
||||
ansible.builtin.package:
|
||||
name: fail2ban
|
||||
state: present
|
||||
become: true
|
||||
when: ansible_os_family != 'Debian'
|
||||
|
||||
- name: Fail2ban | Deploy jail.local configuration
|
||||
ansible.builtin.copy:
|
||||
|
||||
@ -23,11 +23,12 @@ gateway_openclaw_install_dir: "{{ gateway_openclaw_home }}/.local/lib/node_modul
|
||||
gateway_openclaw_required_version: "2026.6.1"
|
||||
gateway_openclaw_npm_package_spec: "openclaw@{{ gateway_openclaw_required_version }}"
|
||||
gateway_openclaw_global_npm_dir: "{{ gateway_openclaw_home }}/.openclaw/npm"
|
||||
gateway_openclaw_multi_session_plugin_archive_url: "https://github.com/ai-workspace-lab/openclaw-multi-session-plugins/releases/download/runtime-latest/openclaw-multi-session-plugins-runtime-all.tar.gz"
|
||||
gateway_openclaw_multi_session_plugin_archive_url: "https://github.com/ai-workspace-lab/openclaw-multi-session-plugins/releases/latest/download/openclaw-multi-session-plugins-runtime-all.tar.gz"
|
||||
gateway_openclaw_required_global_plugins:
|
||||
- name: "@openclaw/codex"
|
||||
version: "{{ gateway_openclaw_required_version }}"
|
||||
gateway_openclaw_removed_global_plugins: []
|
||||
gateway_openclaw_removed_global_plugins:
|
||||
- "@openclaw/acpx"
|
||||
gateway_openclaw_extension_dependency_dirs: []
|
||||
gateway_openclaw_config_path: "{{ gateway_openclaw_home }}/.openclaw/openclaw.json"
|
||||
gateway_openclaw_workspace_path: "{{ gateway_openclaw_home }}/.openclaw/workspace"
|
||||
@ -58,63 +59,22 @@ gateway_openclaw_acp_default_agent: codex
|
||||
gateway_openclaw_codex_app_server_url: ws://127.0.0.1:9001
|
||||
gateway_openclaw_default_model_primary: "deepseek/deepseek-v4-flash"
|
||||
gateway_openclaw_default_model_fallback: "deepseek/deepseek-v4-pro"
|
||||
gateway_openclaw_fallbacks_deepseek:
|
||||
- "{{ gateway_openclaw_default_model_fallback }}"
|
||||
- "deepseek/deepseek-chat"
|
||||
- "deepseek/deepseek-reasoner"
|
||||
gateway_openclaw_fallbacks_nvidia:
|
||||
- "nvidia/deepseek-v4-flash"
|
||||
- "nvidia/deepseek-v4-pro"
|
||||
- "nvidia/glm-5.1"
|
||||
- "nvidia/minimax-m3"
|
||||
- "nvidia/qwen3.5"
|
||||
- "nvidia/kimi-k2.6"
|
||||
gateway_openclaw_fallbacks_ollama:
|
||||
- "ollama/deepseek-v4-flash"
|
||||
- "ollama/deepseek-v4-pro"
|
||||
- "ollama/glm-5.2"
|
||||
- "ollama/minimax-m3"
|
||||
- "ollama/qwen3.5"
|
||||
- "ollama/kimi-k2.7-code"
|
||||
|
||||
gateway_openclaw_default_model:
|
||||
primary: "{{ gateway_openclaw_default_model_primary }}"
|
||||
fallbacks: >-
|
||||
{{
|
||||
([]
|
||||
+ (gateway_openclaw_fallbacks_deepseek if lookup('ansible.builtin.env', 'DEEPSEEK_API_KEY') else [])
|
||||
+ (gateway_openclaw_fallbacks_nvidia if lookup('ansible.builtin.env', 'NVIDIA_API_KEY') else [])
|
||||
+ (gateway_openclaw_fallbacks_ollama if lookup('ansible.builtin.env', 'OLLAMA_API_KEY') else []))
|
||||
| unique | list
|
||||
}}
|
||||
|
||||
gateway_openclaw_default_models_deepseek:
|
||||
fallbacks:
|
||||
- "{{ gateway_openclaw_default_model_fallback }}"
|
||||
- nvidia/nemotron-3-super-120b-a12b
|
||||
- nvidia/minimaxai/minimax-m2.5
|
||||
- nvidia/z-ai/glm5
|
||||
gateway_openclaw_default_models:
|
||||
"{{ gateway_openclaw_default_model_primary }}": {}
|
||||
"{{ gateway_openclaw_default_model_fallback }}": {}
|
||||
"deepseek/deepseek-chat": {}
|
||||
"deepseek/deepseek-reasoner": {}
|
||||
gateway_openclaw_default_models_nvidia:
|
||||
"nvidia/deepseek-v4-flash": {}
|
||||
"nvidia/deepseek-v4-pro": {}
|
||||
"nvidia/glm-5.1": {}
|
||||
"nvidia/minimax-m3": {}
|
||||
"nvidia/qwen3.5": {}
|
||||
"nvidia/kimi-k2.6": {}
|
||||
gateway_openclaw_default_models_ollama:
|
||||
"ollama/deepseek-v4-flash": {}
|
||||
"ollama/deepseek-v4-pro": {}
|
||||
"ollama/glm-5.2": {}
|
||||
"ollama/minimax-m3": {}
|
||||
"ollama/qwen3.5": {}
|
||||
"ollama/kimi-k2.7-code": {}
|
||||
|
||||
gateway_openclaw_default_models: >-
|
||||
{{
|
||||
{ 'openai/gpt-5.5': { 'agentRuntime': { 'id': 'codex' } } }
|
||||
| combine(gateway_openclaw_default_models_deepseek if lookup('ansible.builtin.env', 'DEEPSEEK_API_KEY') else {})
|
||||
| combine(gateway_openclaw_default_models_nvidia if lookup('ansible.builtin.env', 'NVIDIA_API_KEY') else {})
|
||||
| combine(gateway_openclaw_default_models_ollama if lookup('ansible.builtin.env', 'OLLAMA_API_KEY') else {})
|
||||
}}
|
||||
nvidia/nemotron-3-super-120b-a12b: {}
|
||||
nvidia/minimaxai/minimax-m2.5: {}
|
||||
nvidia/z-ai/glm5: {}
|
||||
openai/gpt-5.5:
|
||||
agentRuntime:
|
||||
id: codex
|
||||
gateway_openclaw_main_agent_model: "{{ gateway_openclaw_default_model_primary }}"
|
||||
|
||||
gateway_openclaw_main_agent_skills:
|
||||
@ -162,97 +122,77 @@ gateway_openclaw_mcp_servers:
|
||||
url: http://localhost:8181/mcp
|
||||
transport: streamable-http
|
||||
|
||||
gateway_openclaw_provider_deepseek:
|
||||
api: openai-completions
|
||||
baseUrl: "http://127.0.0.1:{{ litellm_listen_port | default(4000) }}/v1"
|
||||
apiKey: "{{ ai_workspace_auth_token }}"
|
||||
models:
|
||||
- id: "{{ gateway_openclaw_default_model_primary }}"
|
||||
name: DeepSeek V4 Flash
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
maxTokens: 8192
|
||||
reasoning: false
|
||||
- id: "{{ gateway_openclaw_default_model_fallback }}"
|
||||
name: DeepSeek V4 Pro
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
maxTokens: 8192
|
||||
reasoning: true
|
||||
- id: "deepseek/deepseek-chat"
|
||||
name: DeepSeek V3 Chat
|
||||
input: [text]
|
||||
contextWindow: 64000
|
||||
- id: "deepseek/deepseek-reasoner"
|
||||
name: DeepSeek R1 Reasoner
|
||||
input: [text]
|
||||
contextWindow: 64000
|
||||
reasoning: true
|
||||
|
||||
gateway_openclaw_provider_nvidia:
|
||||
api: openai-completions
|
||||
baseUrl: "http://127.0.0.1:{{ litellm_listen_port | default(4000) }}/v1"
|
||||
apiKey: "{{ ai_workspace_auth_token }}"
|
||||
models:
|
||||
- id: "nvidia/deepseek-v4-flash"
|
||||
name: NVIDIA DeepSeek V4 Flash
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "nvidia/deepseek-v4-pro"
|
||||
name: NVIDIA DeepSeek V4 Pro
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "nvidia/glm-5.2"
|
||||
name: NVIDIA GLM 5.2
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "nvidia/minimax-m3"
|
||||
name: NVIDIA MiniMax M3
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "nvidia/qwen3.5"
|
||||
name: NVIDIA Qwen 3.5
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "nvidia/kimi-k2.7-code"
|
||||
name: NVIDIA Kimi K2.7 Code
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
|
||||
gateway_openclaw_provider_ollama:
|
||||
api: openai-completions
|
||||
baseUrl: "http://127.0.0.1:{{ litellm_listen_port | default(4000) }}/v1"
|
||||
apiKey: "{{ ai_workspace_auth_token }}"
|
||||
models:
|
||||
- id: "ollama/deepseek-v4-flash"
|
||||
name: Ollama DeepSeek V4 Flash
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "ollama/deepseek-v4-pro"
|
||||
name: Ollama DeepSeek V4 Pro
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "ollama/glm-5.2"
|
||||
name: Ollama GLM 5.2
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "ollama/minimax-m3"
|
||||
name: Ollama MiniMax M3
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "ollama/qwen3.5"
|
||||
name: Ollama Qwen 3.5
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
- id: "ollama/kimi-k2.7-code"
|
||||
name: Ollama Kimi K2.7 Code
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
|
||||
gateway_openclaw_model_providers: >-
|
||||
{{
|
||||
{}
|
||||
| combine({'deepseek': gateway_openclaw_provider_deepseek} if lookup('ansible.builtin.env', 'DEEPSEEK_API_KEY') else {})
|
||||
| combine({'nvidia': gateway_openclaw_provider_nvidia} if lookup('ansible.builtin.env', 'NVIDIA_API_KEY') else {})
|
||||
| combine({'ollama': gateway_openclaw_provider_ollama} if lookup('ansible.builtin.env', 'OLLAMA_API_KEY') else {})
|
||||
}}
|
||||
gateway_openclaw_model_providers:
|
||||
litellm:
|
||||
api: openai-completions
|
||||
baseUrl: "http://127.0.0.1:{{ litellm_listen_port | default(4000) }}/v1"
|
||||
apiKey: "{{ ai_workspace_auth_token }}"
|
||||
models:
|
||||
- id: "{{ gateway_openclaw_default_model_primary }}"
|
||||
name: DeepSeek V4 Flash
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
maxTokens: 8192
|
||||
reasoning: false
|
||||
- id: "{{ gateway_openclaw_default_model_fallback }}"
|
||||
name: DeepSeek V4 Pro
|
||||
input: [text]
|
||||
contextWindow: 128000
|
||||
maxTokens: 8192
|
||||
reasoning: true
|
||||
nvidia:
|
||||
api: openai-completions
|
||||
baseUrl: https://integrate.api.nvidia.com/v1
|
||||
models:
|
||||
- id: nvidia/nemotron-3-super-120b-a12b
|
||||
name: NVIDIA Nemotron 3 Super 120B
|
||||
input: [text]
|
||||
contextWindow: 262144
|
||||
maxTokens: 8192
|
||||
reasoning: false
|
||||
compat:
|
||||
requiresStringContent: true
|
||||
cost:
|
||||
input: 0
|
||||
output: 0
|
||||
cacheRead: 0
|
||||
cacheWrite: 0
|
||||
- id: moonshotai/kimi-k2.5
|
||||
name: Kimi K2.5
|
||||
input: [text]
|
||||
contextWindow: 262144
|
||||
maxTokens: 8192
|
||||
reasoning: false
|
||||
compat:
|
||||
requiresStringContent: true
|
||||
cost:
|
||||
input: 0
|
||||
output: 0
|
||||
cacheRead: 0
|
||||
cacheWrite: 0
|
||||
- id: minimaxai/minimax-m2.5
|
||||
name: MiniMax M2.5
|
||||
input: [text]
|
||||
contextWindow: 196608
|
||||
maxTokens: 8192
|
||||
reasoning: false
|
||||
compat:
|
||||
requiresStringContent: true
|
||||
cost:
|
||||
input: 0
|
||||
output: 0
|
||||
cacheRead: 0
|
||||
cacheWrite: 0
|
||||
- id: z-ai/glm5
|
||||
name: GLM-5
|
||||
input: [text]
|
||||
contextWindow: 202752
|
||||
maxTokens: 8192
|
||||
reasoning: false
|
||||
compat:
|
||||
requiresStringContent: true
|
||||
cost:
|
||||
input: 0
|
||||
output: 0
|
||||
cacheRead: 0
|
||||
cacheWrite: 0
|
||||
|
||||
@ -1,23 +1,6 @@
|
||||
---
|
||||
- name: Check OpenClaw health (POSIX)
|
||||
ansible.builtin.command: "{{ gateway_openclaw_binary_path }} doctor --lint --severity-min error"
|
||||
environment:
|
||||
HOME: "{{ gateway_openclaw_home }}"
|
||||
PATH: "{{ gateway_openclaw_service_path }}"
|
||||
OPENCLAW_NO_RESPAWN: "1"
|
||||
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
|
||||
become: true
|
||||
become_user: "{{ gateway_openclaw_service_user }}"
|
||||
register: gateway_openclaw_doctor_lint
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family != 'Windows'
|
||||
listen: Run OpenClaw doctor
|
||||
|
||||
- name: Repair OpenClaw health findings (POSIX)
|
||||
ansible.builtin.command: "{{ gateway_openclaw_binary_path }} doctor --repair --non-interactive --yes"
|
||||
- name: Run OpenClaw doctor (POSIX)
|
||||
ansible.builtin.command: "{{ gateway_openclaw_binary_path }} doctor --fix --force --non-interactive --yes"
|
||||
environment:
|
||||
HOME: "{{ gateway_openclaw_home }}"
|
||||
PATH: "{{ gateway_openclaw_service_path }}"
|
||||
@ -28,22 +11,21 @@
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family != 'Windows'
|
||||
- gateway_openclaw_doctor_lint.rc | default(0) != 0
|
||||
listen: Run OpenClaw doctor
|
||||
listen: Restart openclaw
|
||||
|
||||
- name: Run OpenClaw doctor (Windows)
|
||||
ansible.builtin.include_tasks: windows_doctor.yml
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family == 'Windows'
|
||||
listen: Run OpenClaw doctor
|
||||
listen: Restart openclaw
|
||||
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family not in ['Darwin', 'Windows']
|
||||
- ansible_os_family != 'Darwin'
|
||||
listen: Restart openclaw
|
||||
|
||||
- name: Restart openclaw gateway
|
||||
@ -65,14 +47,7 @@
|
||||
become: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family not in ['Darwin', 'Windows']
|
||||
listen: Restart openclaw
|
||||
|
||||
- name: Restart OpenClaw gateway on Windows
|
||||
ansible.builtin.include_tasks: windows_restart.yml
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family == 'Windows'
|
||||
- ansible_os_family != 'Darwin'
|
||||
listen: Restart openclaw
|
||||
|
||||
- name: Unload openclaw on macOS
|
||||
|
||||
@ -1,16 +1,6 @@
|
||||
---
|
||||
- name: Check OpenClaw health on Windows
|
||||
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd doctor --lint --severity-min error"
|
||||
- name: Execute OpenClaw doctor
|
||||
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd doctor --fix --force --non-interactive --yes"
|
||||
environment:
|
||||
OPENCLAW_NO_RESPAWN: "1"
|
||||
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
|
||||
register: gateway_openclaw_doctor_lint_win
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Repair OpenClaw health findings on Windows
|
||||
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd doctor --repair --non-interactive --yes"
|
||||
environment:
|
||||
OPENCLAW_NO_RESPAWN: "1"
|
||||
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
|
||||
when: gateway_openclaw_doctor_lint_win.rc | default(0) != 0
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
- name: Restart OpenClaw gateway on Windows
|
||||
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd gateway restart"
|
||||
environment:
|
||||
OPENCLAW_NO_RESPAWN: "1"
|
||||
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
|
||||
@ -68,13 +68,6 @@
|
||||
group: "{{ gateway_openclaw_service_group }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Inspect installed OpenClaw gateway package version
|
||||
ansible.builtin.slurp:
|
||||
path: "{{ gateway_openclaw_install_dir }}/package.json"
|
||||
register: gateway_openclaw_installed_package_manifest
|
||||
failed_when: false
|
||||
when: ansible_os_family != 'Windows'
|
||||
|
||||
- name: Install required OpenClaw gateway package version
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
@ -96,13 +89,7 @@
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family != 'Windows'
|
||||
- >-
|
||||
(gateway_openclaw_installed_package_manifest.content | default('') | length == 0)
|
||||
or
|
||||
((gateway_openclaw_installed_package_manifest.content | b64decode | from_json).version | default('') != gateway_openclaw_required_version)
|
||||
notify:
|
||||
- Run OpenClaw doctor
|
||||
- Restart openclaw
|
||||
notify: Restart openclaw
|
||||
|
||||
- name: Ensure OpenClaw extensions directory exists
|
||||
ansible.builtin.file:
|
||||
@ -118,10 +105,6 @@
|
||||
url: "{{ gateway_openclaw_multi_session_plugin_archive_url }}"
|
||||
dest: "/tmp/openclaw-multi-session-plugins.tar.gz"
|
||||
mode: "0644"
|
||||
register: gateway_openclaw_multi_session_plugin_download
|
||||
until: gateway_openclaw_multi_session_plugin_download is succeeded
|
||||
retries: 3
|
||||
delay: 5
|
||||
|
||||
- name: Extract OpenClaw Multi-Session Plugins
|
||||
ansible.builtin.unarchive:
|
||||
@ -131,11 +114,8 @@
|
||||
owner: "{{ gateway_openclaw_service_user }}"
|
||||
group: "{{ gateway_openclaw_service_group }}"
|
||||
mode: "0755"
|
||||
creates: "{{ gateway_openclaw_home }}/.openclaw/extensions/openclaw-multi-session-plugins"
|
||||
become: "{{ ansible_os_family != 'Darwin' }}"
|
||||
notify:
|
||||
- Run OpenClaw doctor
|
||||
- Restart openclaw
|
||||
notify: Restart openclaw
|
||||
|
||||
- name: Ensure OpenClaw global plugin npm directory exists
|
||||
ansible.builtin.file:
|
||||
@ -145,21 +125,6 @@
|
||||
group: "{{ gateway_openclaw_service_group }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Inspect installed OpenClaw global plugin versions
|
||||
ansible.builtin.command:
|
||||
cmd: npm list --depth=0 --json --prefix "{{ gateway_openclaw_global_npm_dir }}"
|
||||
environment:
|
||||
HOME: "{{ gateway_openclaw_home }}"
|
||||
PATH: "{{ gateway_openclaw_service_path }}"
|
||||
become: true
|
||||
become_user: "{{ gateway_openclaw_service_user }}"
|
||||
register: gateway_openclaw_installed_global_plugins
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family != 'Windows'
|
||||
|
||||
- name: Install required OpenClaw global plugin versions
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
@ -184,12 +149,7 @@
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family != 'Windows'
|
||||
- >-
|
||||
(((gateway_openclaw_installed_global_plugins.stdout | default('{}') | from_json).dependencies | default({})).get(item.name, {}).get('version', ''))
|
||||
!= item.version
|
||||
notify:
|
||||
- Run OpenClaw doctor
|
||||
- Restart openclaw
|
||||
notify: Restart openclaw
|
||||
|
||||
- name: Remove unsupported OpenClaw global plugin packages
|
||||
ansible.builtin.command:
|
||||
@ -212,9 +172,7 @@
|
||||
when:
|
||||
- gateway_openclaw_removed_global_plugins | length > 0
|
||||
- not ansible_check_mode
|
||||
notify:
|
||||
- Run OpenClaw doctor
|
||||
- Restart openclaw
|
||||
notify: Restart openclaw
|
||||
|
||||
- name: Reset OpenClaw compile cache after package or plugin changes
|
||||
ansible.builtin.file:
|
||||
@ -318,9 +276,7 @@
|
||||
group: "{{ gateway_openclaw_service_group }}"
|
||||
mode: "0600"
|
||||
diff: false
|
||||
notify:
|
||||
- Run OpenClaw doctor
|
||||
- Restart openclaw
|
||||
notify: Restart openclaw
|
||||
|
||||
- name: Inspect OpenClaw package manifest
|
||||
ansible.builtin.stat:
|
||||
@ -347,8 +303,6 @@
|
||||
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
|
||||
become: true
|
||||
become_user: "{{ gateway_openclaw_service_user }}"
|
||||
args:
|
||||
creates: "{{ gateway_openclaw_install_dir }}/node_modules"
|
||||
register: gateway_openclaw_package_deps
|
||||
changed_when: >-
|
||||
'added ' in (gateway_openclaw_package_deps.stdout | default('')) or
|
||||
@ -375,8 +329,6 @@
|
||||
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
|
||||
become: true
|
||||
become_user: "{{ gateway_openclaw_service_user }}"
|
||||
args:
|
||||
creates: "{{ item.item }}/node_modules"
|
||||
register: gateway_openclaw_extension_deps
|
||||
changed_when: >-
|
||||
'added ' in (gateway_openclaw_extension_deps.stdout | default('')) or
|
||||
@ -468,7 +420,6 @@
|
||||
(
|
||||
(gateway_openclaw_plugin_registry.stdout | from_json).plugins
|
||||
| selectattr('id', 'equalto', 'acpx')
|
||||
| rejectattr('version', 'equalto', gateway_openclaw_required_version)
|
||||
| list
|
||||
| length
|
||||
) == 0
|
||||
@ -483,7 +434,7 @@
|
||||
fail_msg: >-
|
||||
OpenClaw must run @openclaw/codex {{ gateway_openclaw_required_version }}
|
||||
plus openclaw-multi-session-plugins {{ gateway_openclaw_required_version }},
|
||||
and any OpenClaw-managed acpx plugin must match the gateway version.
|
||||
and must not keep stale global @openclaw/acpx.
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ansible_os_family != 'Windows'
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
---
|
||||
- name: Inspect installed OpenClaw gateway package version on Windows
|
||||
ansible.windows.win_slurp:
|
||||
src: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\package.json"
|
||||
register: gateway_openclaw_installed_package_manifest_win
|
||||
failed_when: false
|
||||
|
||||
- name: Install required OpenClaw gateway package version on Windows
|
||||
community.windows.win_command:
|
||||
cmd: >-
|
||||
@ -18,15 +12,8 @@
|
||||
changed_when: >-
|
||||
'added ' in (gateway_openclaw_package_install_win.stdout | default('')) or
|
||||
'updated ' in (gateway_openclaw_package_install_win.stdout | default(''))
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- >-
|
||||
(gateway_openclaw_installed_package_manifest_win.content | default('') | length == 0)
|
||||
or
|
||||
((gateway_openclaw_installed_package_manifest_win.content | b64decode | from_json).version | default('') != gateway_openclaw_required_version)
|
||||
notify:
|
||||
- Run OpenClaw doctor
|
||||
- Restart openclaw
|
||||
when: not ansible_check_mode
|
||||
notify: Restart openclaw
|
||||
|
||||
- name: Ensure OpenClaw extensions directory exists on Windows
|
||||
ansible.windows.win_file:
|
||||
@ -39,17 +26,13 @@
|
||||
dest: "{{ gateway_openclaw_home }}\.openclaw\extensions"
|
||||
creates: "{{ gateway_openclaw_home }}\.openclaw\extensions\openclaw-multi-session-plugins"
|
||||
when: not ansible_check_mode
|
||||
notify:
|
||||
- Run OpenClaw doctor
|
||||
- Restart openclaw
|
||||
notify: Restart openclaw
|
||||
|
||||
- name: Deploy OpenClaw gateway JSON config on Windows
|
||||
ansible.windows.win_template:
|
||||
src: openclaw.json.j2
|
||||
dest: "{{ gateway_openclaw_config_path }}"
|
||||
notify:
|
||||
- Run OpenClaw doctor
|
||||
- Restart openclaw
|
||||
notify: Restart openclaw
|
||||
|
||||
- name: Register OpenClaw gateway as a Windows Service (schtasks)
|
||||
community.windows.win_command:
|
||||
|
||||
@ -131,12 +131,7 @@
|
||||
},
|
||||
"memory-wiki": {"enabled": true},
|
||||
"openai": {"enabled": true},
|
||||
"openclaw-multi-session-plugins": {
|
||||
"enabled": true,
|
||||
"hooks": {
|
||||
"allowConversationAccess": true
|
||||
}
|
||||
},
|
||||
"openclaw-multi-session-plugins": {"enabled": true},
|
||||
"device-pair": {"enabled": false},
|
||||
"phone-control": {"enabled": false},
|
||||
"talk-voice": {"enabled": false}
|
||||
|
||||
@ -5,51 +5,18 @@ litellm_service_group: "{{ 'staff' if ansible_os_family == 'Darwin' else (ansibl
|
||||
litellm_service_home: "{{ ansible_env.HOME | default('/home/' + litellm_service_user) }}"
|
||||
litellm_source_repo: "https://github.com/ai-workspace-services/litellm.git"
|
||||
litellm_version: "3ad385a8a46988b6a81fe6c0bc22ef58685baa58"
|
||||
litellm_source_archive_url: "https://github.com/ai-workspace-services/litellm/archive/{{ litellm_version }}.zip"
|
||||
litellm_debian_11_compat_version: "1.74.9"
|
||||
litellm_default_bootstrap_python_executable: >-
|
||||
{{
|
||||
'/opt/homebrew/bin/python3.13'
|
||||
if ansible_os_family == 'Darwin'
|
||||
else ('C:/Python313/python.exe' if ansible_os_family == 'Windows' else '/usr/bin/python3')
|
||||
}}
|
||||
litellm_default_pip_cache_dir: >-
|
||||
{{
|
||||
lookup('ansible.builtin.env', 'LOCALAPPDATA')
|
||||
| default(litellm_service_home ~ '/AppData/Local/pip/Cache', true)
|
||||
if ansible_os_family == 'Windows'
|
||||
else (litellm_service_home ~ '/Library/Caches/pip' if ansible_os_family == 'Darwin' else litellm_service_home ~ '/.cache/pip')
|
||||
}}
|
||||
litellm_package_spec: >-
|
||||
{{
|
||||
lookup('ansible.builtin.env', 'LITELLM_PACKAGE_SPEC')
|
||||
| default(
|
||||
'litellm[proxy]==' ~ litellm_debian_11_compat_version
|
||||
if ansible_facts.distribution == 'Debian' and ansible_facts.distribution_major_version == '11'
|
||||
else 'litellm[proxy] @ ' ~ litellm_source_archive_url,
|
||||
else 'litellm[proxy] @ git+' ~ litellm_source_repo ~ '@' ~ litellm_version,
|
||||
true)
|
||||
}}
|
||||
litellm_bootstrap_python_executable: >-
|
||||
{{
|
||||
lookup('ansible.builtin.env', 'LITELLM_PYTHON_EXECUTABLE')
|
||||
| default(litellm_default_bootstrap_python_executable, true)
|
||||
}}
|
||||
litellm_venv_dir: "{{ litellm_service_home }}/.local/share/litellm/venv"
|
||||
litellm_pip_cache_dir: >-
|
||||
{{
|
||||
lookup('ansible.builtin.env', 'LITELLM_PIP_CACHE_DIR')
|
||||
| default(litellm_default_pip_cache_dir, true)
|
||||
}}
|
||||
litellm_install_marker_file: "{{ litellm_venv_dir }}/.install-spec"
|
||||
litellm_python_executable: "{{ litellm_venv_dir }}/bin/python"
|
||||
litellm_pip_executable: "{{ litellm_venv_dir }}/bin/pip"
|
||||
# Network resilience for the (large) online dependency install. Resume-retries
|
||||
# requires pip >= 25.1, which the role guarantees by upgrading pip in the venv.
|
||||
litellm_pip_retries: 5
|
||||
litellm_pip_resume_retries: 5
|
||||
litellm_pip_timeout: 180
|
||||
litellm_binary_path: "{{ litellm_venv_dir }}/bin/litellm"
|
||||
litellm_prisma_binary_path: "{{ litellm_venv_dir }}/bin/prisma"
|
||||
litellm_python_executable: "{{ lookup('ansible.builtin.env', 'LITELLM_PYTHON_EXECUTABLE') | default('python3', true) }}"
|
||||
litellm_pip_executable: "{{ lookup('ansible.builtin.env', 'LITELLM_PIP_EXECUTABLE') | default('', true) }}"
|
||||
litellm_listen_host: 127.0.0.1
|
||||
litellm_listen_port: 4000
|
||||
litellm_config_dir: /etc/litellm
|
||||
@ -107,22 +74,8 @@ litellm_database_password: "{{ lookup('ansible.builtin.env', 'LITELLM_DATABASE_P
|
||||
litellm_database_admin_user: "{{ lookup('ansible.builtin.env', 'LITELLM_DATABASE_ADMIN_USER') | default('postgres', true) }}"
|
||||
litellm_database_admin_password: "{{ lookup('ansible.builtin.env', 'LITELLM_DATABASE_ADMIN_PASSWORD') | default('', true) }}"
|
||||
|
||||
# Percent-encode the password for use inside the DATABASE_URL userinfo. The
|
||||
# shared auth token is `openssl rand -base64`, which can contain '/', '+' and
|
||||
# '=' — a raw '/' truncates the URL authority and Prisma aborts with
|
||||
# "P1013: invalid port number in database URL". Jinja's `urlencode` leaves '/'
|
||||
# safe, so encode the reserved set explicitly ('%' first to avoid double
|
||||
# encoding). The actual DB user password stays raw (provision-database and
|
||||
# LITELLM_DB_PASSWORD use it verbatim); only the URL form is encoded so the
|
||||
# client decodes back to the same raw secret.
|
||||
litellm_database_password_urlencoded: >-
|
||||
{{ litellm_database_password
|
||||
| replace('%', '%25') | replace('/', '%2F') | replace('+', '%2B')
|
||||
| replace('=', '%3D') | replace('@', '%40') | replace(':', '%3A')
|
||||
| replace('?', '%3F') | replace('#', '%23') | replace(' ', '%20') }}
|
||||
|
||||
# Build DATABASE_URL from components (used in litellm.env)
|
||||
litellm_database_url: "{% if litellm_database_host | trim | length > 0 %}postgresql://{{ litellm_database_user }}:{{ litellm_database_password_urlencoded | trim }}@{{ litellm_database_host }}:{{ litellm_database_port }}/{{ litellm_database_name }}?sslmode={{ litellm_database_sslmode }}{% else %}{% endif %}"
|
||||
litellm_database_url: "{% if litellm_database_host | trim | length > 0 %}postgresql://{{ litellm_database_user }}:{{ litellm_database_password }}@{{ litellm_database_host }}:{{ litellm_database_port }}/{{ litellm_database_name }}?sslmode={{ litellm_database_sslmode }}{% else %}{% endif %}"
|
||||
|
||||
# Models are now dynamically managed via DB/UI or user-provided config
|
||||
|
||||
|
||||
@ -13,31 +13,17 @@ if [ -z "$LITELLM_TOKEN" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${DEEPSEEK_API_KEY:-}" ] && [ -z "${NVIDIA_API_KEY:-}" ] && [ -z "${OLLAMA_API_KEY:-}" ]; then
|
||||
echo "[INFO] DEEPSEEK_API_KEY, NVIDIA_API_KEY, and OLLAMA_API_KEY are empty. Manual configuration mode."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[INFO] Using LiteLLM URL: $LITELLM_URL"
|
||||
|
||||
# Aliases successfully registered, collected for the post-registration probe.
|
||||
REGISTERED=()
|
||||
|
||||
# Function to add a model
|
||||
add_model() {
|
||||
local alias_name="$1"
|
||||
local litellm_provider_model="$2"
|
||||
local api_key_env_var="$3"
|
||||
local api_base="${4:-}"
|
||||
|
||||
# Skip registration when the backing API key was not provided (empty env var).
|
||||
if [ -z "${!api_key_env_var:-}" ]; then
|
||||
echo "[SKIP] $alias_name: $api_key_env_var is empty; not registering."
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
echo "Adding model: $alias_name -> $litellm_provider_model"
|
||||
|
||||
|
||||
local payload
|
||||
if [ -n "$api_base" ]; then
|
||||
payload=$(cat <<EOF
|
||||
@ -45,7 +31,7 @@ add_model() {
|
||||
"model_name": "$alias_name",
|
||||
"litellm_params": {
|
||||
"model": "$litellm_provider_model",
|
||||
"api_key": "${!api_key_env_var}",
|
||||
"api_key": "os.environ/$api_key_env_var",
|
||||
"api_base": "$api_base"
|
||||
},
|
||||
"model_info": {
|
||||
@ -61,7 +47,7 @@ EOF
|
||||
"model_name": "$alias_name",
|
||||
"litellm_params": {
|
||||
"model": "$litellm_provider_model",
|
||||
"api_key": "${!api_key_env_var}"
|
||||
"api_key": "os.environ/$api_key_env_var"
|
||||
},
|
||||
"model_info": {
|
||||
"id": "$alias_name",
|
||||
@ -77,168 +63,68 @@ EOF
|
||||
response=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "$LITELLM_URL/model/new" \
|
||||
-H "Authorization: Bearer $LITELLM_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload") || true
|
||||
-d "$payload")
|
||||
|
||||
http_code=$(echo "$response" | grep -Eo 'HTTP_CODE:[0-9]{3}' | cut -d':' -f2 || echo "000")
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||
echo "[SUCCESS] Model $alias_name added."
|
||||
REGISTERED+=("$alias_name")
|
||||
else
|
||||
echo "[INFO] Model $alias_name failed to add via /model/new (HTTP $http_code), attempting /model/update..."
|
||||
response=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "$LITELLM_URL/model/update" \
|
||||
-H "Authorization: Bearer $LITELLM_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload") || true
|
||||
http_code=$(echo "$response" | grep -Eo 'HTTP_CODE:[0-9]{3}' | cut -d':' -f2 || echo "000")
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||
echo "[SUCCESS] Model $alias_name updated."
|
||||
REGISTERED+=("$alias_name")
|
||||
else
|
||||
echo "[ERROR] Failed to add/update model $alias_name. HTTP Code: $http_code"
|
||||
echo "Response: $response"
|
||||
fi
|
||||
echo "[ERROR] Failed to add model $alias_name. HTTP Code: $http_code"
|
||||
echo "Response: $response"
|
||||
fi
|
||||
}
|
||||
|
||||
# Probe a single registered alias by sending a real 1-token completion through
|
||||
# LiteLLM. Registration (presence in /v1/models) only proves the row exists in
|
||||
# the DB; it does NOT prove the upstream model id / api_base / entitlement are
|
||||
# valid. This is the only check that proves an alias is actually callable.
|
||||
# Echoes "PASS" / "FAIL <http> <reason>" and returns 0 only on PASS.
|
||||
probe_model() {
|
||||
local alias_name="$1"
|
||||
local body http_code msg
|
||||
body=$(curl -s -m 60 -w "\nHTTP_CODE:%{http_code}" \
|
||||
-X POST "$LITELLM_URL/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $LITELLM_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"model\":\"$alias_name\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1}") || true
|
||||
http_code=$(echo "$body" | grep -Eo 'HTTP_CODE:[0-9]{3}' | cut -d':' -f2 || echo "000")
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo "PASS"
|
||||
return 0
|
||||
fi
|
||||
# Pull a short reason out of the error for the report. Prefer the upstream
|
||||
# provider message (e.g. "this model requires a subscription") over
|
||||
# LiteLLM's verbose fallback-wrapper text, then cap the length.
|
||||
local flat
|
||||
flat=$(echo "$body" | sed 's/HTTP_CODE:[0-9]*//' | tr '\n' ' ')
|
||||
msg=$(echo "$flat" | grep -Eo "'error': '[^']*'" | head -1 | sed "s/'error': '//; s/'$//")
|
||||
[ -z "$msg" ] && msg=$(echo "$flat" | grep -Eo '"message":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
[ -z "$msg" ] && msg="$flat"
|
||||
echo "FAIL $http_code $(echo "${msg:-unknown}" | cut -c1-90)"
|
||||
return 1
|
||||
}
|
||||
echo "========================================="
|
||||
echo "Registering DeepSeek Models..."
|
||||
echo "========================================="
|
||||
add_model "deepseek-chat" "deepseek/deepseek-chat" "DEEPSEEK_API_KEY"
|
||||
add_model "deepseek-reasoner" "deepseek/deepseek-reasoner" "DEEPSEEK_API_KEY"
|
||||
add_model "deepseek-v4-flash" "deepseek/deepseek-v4-flash" "DEEPSEEK_API_KEY"
|
||||
add_model "deepseek-v4-pro" "deepseek/deepseek-v4-pro" "DEEPSEEK_API_KEY"
|
||||
|
||||
if [ -n "${DEEPSEEK_API_KEY:-}" ]; then
|
||||
echo "========================================="
|
||||
echo "Registering DeepSeek Models..."
|
||||
echo "========================================="
|
||||
add_model "deepseek/deepseek-v4-flash" "deepseek/deepseek-v4-flash" "DEEPSEEK_API_KEY"
|
||||
add_model "deepseek/deepseek-v4-pro" "deepseek/deepseek-v4-pro" "DEEPSEEK_API_KEY"
|
||||
add_model "deepseek/deepseek-chat" "deepseek/deepseek-chat" "DEEPSEEK_API_KEY"
|
||||
add_model "deepseek/deepseek-reasoner" "deepseek/deepseek-reasoner" "DEEPSEEK_API_KEY"
|
||||
fi
|
||||
|
||||
if [ -n "${NVIDIA_API_KEY:-}" ]; then
|
||||
echo "========================================="
|
||||
echo "Registering NVIDIA Build Models..."
|
||||
echo "========================================="
|
||||
# NVIDIA NIM model ids are vendor-namespaced (deepseek-ai/..., minimaxai/...,
|
||||
# qwen/..., z-ai/..., moonshotai/...); bare names 404 on the upstream router.
|
||||
# Every alias below maps to a model that EXISTS in the live GET /v1/models
|
||||
# catalog. NVIDIA serves glm-5.1 and kimi-k2.6 (no 5.2 / k2.7), so the
|
||||
# aliases are named for the real versions rather than lying about them.
|
||||
NVIDIA_API_BASE="${NVIDIA_API_BASE:-https://integrate.api.nvidia.com/v1}"
|
||||
add_model "nvidia/deepseek-v4-flash" "openai/deepseek-ai/deepseek-v4-flash" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
|
||||
add_model "nvidia/deepseek-v4-pro" "openai/deepseek-ai/deepseek-v4-pro" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
|
||||
add_model "nvidia/glm-5.1" "openai/z-ai/glm-5.1" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
|
||||
add_model "nvidia/minimax-m3" "openai/minimaxai/minimax-m3" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
|
||||
add_model "nvidia/qwen3.5" "openai/qwen/qwen3.5-397b-a17b" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
|
||||
add_model "nvidia/kimi-k2.6" "openai/moonshotai/kimi-k2.6" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
|
||||
fi
|
||||
echo "========================================="
|
||||
echo "Registering NVIDIA Build Models..."
|
||||
echo "========================================="
|
||||
# For NVIDIA NIM models, you can use openai format with custom base, or nvidia_nim/ provider
|
||||
add_model "nvidia/deepseek-r1" "openai/deepseek-ai/deepseek-r1" "NVIDIA_API_KEY" "https://integrate.api.nvidia.com/v1"
|
||||
add_model "nvidia/minimax-text-01" "openai/minimax/minimax-text-01" "NVIDIA_API_KEY" "https://integrate.api.nvidia.com/v1"
|
||||
add_model "nvidia/glm-4" "openai/thudm/glm-4-9b-chat" "NVIDIA_API_KEY" "https://integrate.api.nvidia.com/v1"
|
||||
add_model "nvidia/glm-5" "openai/thudm/glm-5" "NVIDIA_API_KEY" "https://integrate.api.nvidia.com/v1"
|
||||
|
||||
echo "========================================="
|
||||
echo "Registering Gemini Models..."
|
||||
echo "========================================="
|
||||
if [ -n "${GEMINI_API_KEY:-}" ]; then
|
||||
add_model "gemini-2.5-pro" "gemini/gemini-2.5-pro" "GEMINI_API_KEY"
|
||||
add_model "gemini-2.5-flash" "gemini/gemini-2.5-flash" "GEMINI_API_KEY"
|
||||
add_model "gemini-1.5-pro" "gemini/gemini-1.5-pro" "GEMINI_API_KEY"
|
||||
fi
|
||||
add_model "gemini-2.5-pro" "gemini/gemini-2.5-pro" "GEMINI_API_KEY"
|
||||
add_model "gemini-2.5-flash" "gemini/gemini-2.5-flash" "GEMINI_API_KEY"
|
||||
add_model "gemini-1.5-pro" "gemini/gemini-1.5-pro" "GEMINI_API_KEY"
|
||||
|
||||
echo "========================================="
|
||||
echo "Registering GPT Models..."
|
||||
echo "========================================="
|
||||
if [ -n "${OPENAI_API_KEY:-}" ]; then
|
||||
add_model "gpt-5.5" "openai/gpt-5.5" "OPENAI_API_KEY"
|
||||
add_model "gpt-5.4" "openai/gpt-5.4" "OPENAI_API_KEY"
|
||||
add_model "gpt-5.4-mini" "openai/gpt-5.4-mini" "OPENAI_API_KEY"
|
||||
fi
|
||||
add_model "gpt-5.5" "openai/gpt-5.5" "OPENAI_API_KEY"
|
||||
add_model "gpt-5.4" "openai/gpt-5.4" "OPENAI_API_KEY"
|
||||
add_model "gpt-5.4-mini" "openai/gpt-5.4-mini" "OPENAI_API_KEY"
|
||||
|
||||
echo "========================================="
|
||||
echo "Registering Claude Models..."
|
||||
echo "========================================="
|
||||
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
add_model "claude-3.5-sonnet" "anthropic/claude-3-5-sonnet-20241022" "ANTHROPIC_API_KEY"
|
||||
add_model "claude-3.5-haiku" "anthropic/claude-3-5-haiku-20241022" "ANTHROPIC_API_KEY"
|
||||
add_model "claude-3-opus" "anthropic/claude-3-opus-20240229" "ANTHROPIC_API_KEY"
|
||||
fi
|
||||
add_model "claude-3.5-sonnet" "anthropic/claude-3-5-sonnet-20241022" "ANTHROPIC_API_KEY"
|
||||
add_model "claude-3.5-haiku" "anthropic/claude-3-5-haiku-20241022" "ANTHROPIC_API_KEY"
|
||||
add_model "claude-3-opus" "anthropic/claude-3-opus-20240229" "ANTHROPIC_API_KEY"
|
||||
|
||||
if [ -n "${OLLAMA_API_KEY:-}" ]; then
|
||||
echo "========================================="
|
||||
echo "Registering OLLAMA Cloud Models..."
|
||||
echo "========================================="
|
||||
OLLAMA_API_BASE="${OLLAMA_API_BASE:-https://api.ollama.cloud/v1}"
|
||||
# Ollama Cloud model ids carry a tag (":cloud" for the hosted big models),
|
||||
# per https://ollama.com/search. The bare names below resolve to a local
|
||||
# pull that the cloud endpoint does not have -> 404 "model not found".
|
||||
# NOTE: the :cloud models require an Ollama paid subscription; without one
|
||||
# the upstream returns 403. The verification pass at the end will surface
|
||||
# this clearly (a 403/404 here is an upstream entitlement issue, not a
|
||||
# config bug).
|
||||
add_model "ollama/deepseek-v4-flash" "openai/deepseek-v4-flash:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
|
||||
add_model "ollama/deepseek-v4-pro" "openai/deepseek-v4-pro:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
|
||||
add_model "ollama/glm-5.2" "openai/glm-5.2:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
|
||||
add_model "ollama/minimax-m3" "openai/minimax-m3:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
|
||||
add_model "ollama/qwen3.5" "openai/qwen3.5:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
|
||||
add_model "ollama/kimi-k2.7-code" "openai/kimi-k2.7-code:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
|
||||
fi
|
||||
echo "========================================="
|
||||
echo "Registering Zhipu (GLM) using OLLAMA_API_KEY..."
|
||||
echo "========================================="
|
||||
add_model "glm-4" "openai/glm-4" "OLLAMA_API_KEY" "https://open.bigmodel.cn/api/paas/v4"
|
||||
add_model "glm-5" "openai/glm-5" "OLLAMA_API_KEY" "https://open.bigmodel.cn/api/paas/v4"
|
||||
|
||||
echo "========================================="
|
||||
echo "Registering OLLAMA Cloud Models..."
|
||||
echo "========================================="
|
||||
# Assuming OLLAMA API is exposed via a cloud endpoint or an OpenAI proxy
|
||||
OLLAMA_API_BASE="${OLLAMA_API_BASE:-https://api.ollama.cloud/v1}"
|
||||
add_model "ollama-llama3" "openai/llama3" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
|
||||
add_model "ollama-qwen" "openai/qwen" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
|
||||
|
||||
echo "All models requested have been registered."
|
||||
echo "You can check them at $LITELLM_URL/ui/?page=models"
|
||||
|
||||
# =============================================================================
|
||||
# Verification pass: prove callability, not mere presence in /v1/models.
|
||||
# Sends a real 1-token completion through LiteLLM for every registered alias and
|
||||
# prints a PASS/FAIL health table. Controlled by REGISTER_MODELS_VERIFY (default
|
||||
# on); set REGISTER_MODELS_VERIFY=0 to skip. A FAIL here is the real signal that
|
||||
# a fallback link is unhealthy even though it shows up in /v1/models.
|
||||
# =============================================================================
|
||||
if [ "${REGISTER_MODELS_VERIFY:-1}" != "0" ] && [ "${#REGISTERED[@]}" -gt 0 ]; then
|
||||
echo "========================================="
|
||||
echo "Verifying callability (1-token live probe per alias)..."
|
||||
echo "========================================="
|
||||
pass_count=0
|
||||
fail_count=0
|
||||
fail_list=()
|
||||
for alias_name in "${REGISTERED[@]}"; do
|
||||
# `|| true` keeps the non-zero FAIL return from tripping `set -e`.
|
||||
result="$(probe_model "$alias_name" || true)"
|
||||
if [ "$result" = "PASS" ]; then
|
||||
printf ' [PASS] %s\n' "$alias_name"
|
||||
pass_count=$((pass_count + 1))
|
||||
else
|
||||
printf ' [FAIL] %-28s %s\n' "$alias_name" "${result#FAIL }"
|
||||
fail_count=$((fail_count + 1))
|
||||
fail_list+=("$alias_name")
|
||||
fi
|
||||
done
|
||||
echo "-----------------------------------------"
|
||||
echo "Callable: $pass_count Unhealthy: $fail_count (of ${#REGISTERED[@]} registered)"
|
||||
if [ "$fail_count" -gt 0 ]; then
|
||||
echo "Unhealthy aliases (registered but NOT callable): ${fail_list[*]}"
|
||||
echo "These appear in /v1/models but fail a real call — check upstream"
|
||||
echo "model id, api_base, and account entitlement (e.g. 403 = subscription)."
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -1,21 +1,7 @@
|
||||
---
|
||||
# Provision the litellm database and user BEFORE litellm starts.
|
||||
# Only runs when litellm_database_host is set.
|
||||
# Debian/Ubuntu 显式走 apt:play 的 module_defaults.apt.lock_timeout(模板值)只有在
|
||||
# apt 任务上才渲染;经 package 间接派发到 apt 时模板不渲染 → lock_timeout 收到字面
|
||||
# "{{ ... }}" 报 int 转换失败(见 xworkmate_bridge 同类修复)。
|
||||
- name: Install LiteLLM prerequisites (Debian/Ubuntu via apt)
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3
|
||||
- python3-pip
|
||||
- python3-venv
|
||||
- python3-psycopg2
|
||||
state: present
|
||||
update_cache: true
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
- name: Install LiteLLM prerequisites (non-Debian Linux)
|
||||
- name: Install LiteLLM prerequisites (Linux)
|
||||
ansible.builtin.package:
|
||||
name:
|
||||
- python3
|
||||
@ -23,7 +9,7 @@
|
||||
- python3-venv
|
||||
- python3-psycopg2
|
||||
state: present
|
||||
when: ansible_os_family not in ['Darwin', 'Debian', 'Windows']
|
||||
when: ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Install LiteLLM prerequisites (macOS)
|
||||
# Use brew from PATH (Apple Silicon prefix first) instead of the
|
||||
@ -117,160 +103,28 @@
|
||||
mode: "0600"
|
||||
notify: Restart litellm
|
||||
|
||||
# litellm 的 pinned fork 要求 Python <3.14。当系统解释器 >=3.14(如 Ubuntu 26.04 的
|
||||
# 3.14,且 apt 无 3.13/3.12)时,用 uv 装独立 Python 3.13 并改用它建 venv,否则
|
||||
# pip 会报 "requires a different Python: 3.14 not in '<3.14,>=3.10'"。
|
||||
- name: Detect litellm bootstrap python version
|
||||
ansible.builtin.command: >-
|
||||
{{ litellm_bootstrap_python_executable }} -c
|
||||
'import sys; print("%d.%d" % sys.version_info[:2])'
|
||||
register: litellm_bootstrap_py_ver
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Provision compatible Python 3.13 via uv (system python >=3.14, litellm needs <3.14)
|
||||
when:
|
||||
- ansible_os_family not in ['Darwin', 'Windows']
|
||||
- (litellm_bootstrap_py_ver.stdout | default('0.0', true) | trim) is version('3.14', '>=')
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
block:
|
||||
- name: Install uv for litellm python provisioning
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
if [ ! -x "{{ litellm_service_home }}/.local/bin/uv" ] && ! command -v uv >/dev/null 2>&1; then
|
||||
curl -LsSf https://astral.sh/uv/install.sh \
|
||||
| env UV_INSTALL_DIR="{{ litellm_service_home }}/.local/bin" sh
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: true
|
||||
|
||||
- name: Install Python 3.13 via uv
|
||||
ansible.builtin.command: "{{ litellm_service_home }}/.local/bin/uv python install 3.13"
|
||||
changed_when: true
|
||||
|
||||
- name: Resolve uv-managed Python 3.13 path
|
||||
ansible.builtin.command: "{{ litellm_service_home }}/.local/bin/uv python find 3.13"
|
||||
register: litellm_uv_python313
|
||||
changed_when: false
|
||||
|
||||
- name: Use uv Python 3.13 as litellm bootstrap interpreter
|
||||
ansible.builtin.set_fact:
|
||||
litellm_bootstrap_python_executable: "{{ litellm_uv_python313.stdout | trim }}"
|
||||
|
||||
- name: Drop incompatible existing litellm venv (rebuild with Python 3.13)
|
||||
ansible.builtin.file:
|
||||
path: "{{ litellm_venv_dir }}"
|
||||
state: absent
|
||||
|
||||
- name: Create isolated LiteLLM Python environment
|
||||
ansible.builtin.command:
|
||||
cmd: "{{ litellm_bootstrap_python_executable }} -m venv {{ litellm_venv_dir }}"
|
||||
creates: "{{ litellm_python_executable }}"
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
|
||||
# A venv bootstrapped by ensurepip can ship a pip older than 25.1, which lacks
|
||||
# `--resume-retries`. Upgrade pip first so the resilient download flags used by
|
||||
# the dependency install below are always available.
|
||||
- name: Ensure recent pip in the LiteLLM environment
|
||||
ansible.builtin.pip:
|
||||
name: pip
|
||||
state: latest
|
||||
executable: "{{ litellm_pip_executable }}"
|
||||
environment:
|
||||
PIP_CACHE_DIR: "{{ litellm_pip_cache_dir }}"
|
||||
PIP_DEFAULT_TIMEOUT: "120"
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
|
||||
- name: Inspect installed LiteLLM dependency marker
|
||||
ansible.builtin.stat:
|
||||
path: "{{ litellm_install_marker_file }}"
|
||||
register: litellm_install_marker
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
|
||||
- name: Read installed LiteLLM dependency marker
|
||||
ansible.builtin.command:
|
||||
cmd: "cat {{ litellm_install_marker_file }}"
|
||||
register: litellm_install_marker_content
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
when: litellm_install_marker.stat.exists
|
||||
|
||||
# The probe must stay a valid single-line Python program: the `>-` folding
|
||||
# collapses every newline into a space, so a `for ... : try: ... except:` block
|
||||
# would become one illegal logical line, crash with SyntaxError, and (with
|
||||
# failed_when:false) leave stdout empty -> the from_json below then explodes.
|
||||
# Build the version map from importlib.metadata.distributions() with dict/list
|
||||
# comprehensions, which are valid single statements joined by semicolons.
|
||||
- name: Inspect installed LiteLLM dependency versions
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
{{ litellm_python_executable }} -c
|
||||
"import importlib.metadata as m, json;
|
||||
dists = {d.metadata['Name'].lower(): d.version for d in m.distributions()};
|
||||
print(json.dumps({p: dists.get(p, 'missing') for p in ['litellm', 'prisma', 'psycopg2-binary']}))"
|
||||
register: litellm_dependency_versions
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
|
||||
- name: Decide whether LiteLLM dependencies need installation
|
||||
ansible.builtin.set_fact:
|
||||
# default('{}', true) also substitutes when stdout is an empty string (not
|
||||
# just undefined), so a failed/empty probe degrades to "install required"
|
||||
# instead of crashing from_json with "Expecting value: line 1 column 1".
|
||||
litellm_dependency_install_required: >-
|
||||
{{
|
||||
not litellm_install_marker.stat.exists
|
||||
or (litellm_install_marker_content.stdout | default('') | trim != litellm_package_spec)
|
||||
or (litellm_dependency_versions.stdout | default('{}', true) | from_json).litellm == 'missing'
|
||||
or (litellm_dependency_versions.stdout | default('{}', true) | from_json).prisma == 'missing'
|
||||
or (litellm_dependency_versions.stdout | default('{}', true) | from_json)['psycopg2-binary'] == 'missing'
|
||||
}}
|
||||
|
||||
- name: Ensure LiteLLM and DB dependencies are installed
|
||||
ansible.builtin.pip:
|
||||
name:
|
||||
- "{{ litellm_package_spec }}"
|
||||
- "prisma"
|
||||
- "psycopg2-binary"
|
||||
executable: "{{ litellm_pip_executable }}"
|
||||
extra_args: --break-system-packages
|
||||
executable: "{{ litellm_pip_executable if litellm_pip_executable | length > 0 else omit }}"
|
||||
state: present
|
||||
# litellm[proxy] pulls large wheels (e.g. polars-runtime ~46MB) that often
|
||||
# break mid-stream on slow/mirrored links with IncompleteRead. --retries
|
||||
# reconnects and --resume-retries continues a partial download instead of
|
||||
# restarting it, so a flaky connection no longer fails the whole deploy.
|
||||
extra_args: "--retries {{ litellm_pip_retries }} --resume-retries {{ litellm_pip_resume_retries }}"
|
||||
environment:
|
||||
PIP_CACHE_DIR: "{{ litellm_pip_cache_dir }}"
|
||||
PIP_DEFAULT_TIMEOUT: "{{ litellm_pip_timeout }}"
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
when: litellm_dependency_install_required | bool
|
||||
|
||||
- name: Record installed LiteLLM dependency spec
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ litellm_install_marker_file }}"
|
||||
content: "{{ litellm_package_spec }}\n"
|
||||
owner: "{{ litellm_service_user }}"
|
||||
group: "{{ litellm_service_group }}"
|
||||
mode: "0644"
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
when: litellm_dependency_install_required | bool
|
||||
|
||||
- name: Resolve LiteLLM Python site-packages path
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
{{ litellm_python_executable }} -c
|
||||
"import pathlib, litellm.proxy; print(pathlib.Path(litellm.proxy.__file__).parent)"
|
||||
ansible.builtin.shell: |
|
||||
{{ litellm_python_executable | quote }} - <<'PY'
|
||||
import glob
|
||||
import os
|
||||
paths = glob.glob(os.path.expanduser("~/.local/lib/python*/site-packages/litellm/proxy"))
|
||||
if not paths:
|
||||
raise SystemExit("litellm proxy package path not found")
|
||||
print(sorted(paths)[-1])
|
||||
PY
|
||||
register: litellm_proxy_package_path
|
||||
changed_when: false
|
||||
become: true
|
||||
@ -282,17 +136,11 @@
|
||||
litellm_python_site_packages: "{{ (litellm_proxy_package_path.stdout | trim) | dirname | dirname }}"
|
||||
|
||||
- name: Generate Prisma Python Client
|
||||
ansible.builtin.command:
|
||||
cmd: "{{ litellm_prisma_binary_path }} generate"
|
||||
ansible.builtin.shell: |
|
||||
export PATH={{ litellm_service_home }}/.local/bin:$PATH
|
||||
prisma generate
|
||||
args:
|
||||
chdir: "{{ litellm_proxy_dir }}"
|
||||
# `prisma generate` shells out to the `prisma-client-py` generator, which is a
|
||||
# console script installed into the venv's bin dir. The default command PATH
|
||||
# does not include the venv, so the absolute prisma binary still fails with
|
||||
# "prisma-client-py: command not found". Put the venv bin dir on PATH so the
|
||||
# generator subprocess is resolvable.
|
||||
environment:
|
||||
PATH: "{{ litellm_venv_dir }}/bin:/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH }}"
|
||||
become: true
|
||||
become_user: "{{ litellm_service_user }}"
|
||||
changed_when: false
|
||||
|
||||
@ -8,9 +8,10 @@ User={{ litellm_service_user }}
|
||||
Group={{ litellm_service_group }}
|
||||
WorkingDirectory={{ litellm_service_home }}
|
||||
EnvironmentFile={{ litellm_env_file }}
|
||||
Environment=PYTHONPATH={{ litellm_python_site_packages }}
|
||||
Environment=PATH={{ litellm_venv_dir }}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ExecStart={{ litellm_binary_path }} --host {{ litellm_listen_host }} --port {{ litellm_listen_port }} --config {{ litellm_config_file }}
|
||||
Environment=PYTHONPATH={{ litellm_python_site_packages | default(litellm_service_home ~ '/.local/lib/python3.12/site-packages') }}
|
||||
Environment=PYTHONUSERBASE={{ litellm_service_home }}/.local
|
||||
Environment=PATH={{ litellm_service_home }}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ExecStart={{ litellm_service_home }}/.local/bin/litellm --host {{ litellm_listen_host }} --port {{ litellm_listen_port }} --config {{ litellm_config_file }}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<string>/bin/bash</string>
|
||||
<string>-c</string>
|
||||
<string>
|
||||
export PATH="{{ litellm_venv_dir }}/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:{{ litellm_service_home }}/.local/bin:$PATH"
|
||||
export DATABASE_URL="{{ litellm_database_url }}"
|
||||
export LITELLM_MASTER_KEY="{{ litellm_master_key }}"
|
||||
export LITELLM_SALT_KEY="{{ litellm_salt_key }}"
|
||||
@ -22,7 +22,7 @@
|
||||
export GEMINI_API_KEY="{{ litellm_gemini_api_key }}"
|
||||
export ANTHROPIC_API_KEY="{{ litellm_anthropic_api_key }}"
|
||||
|
||||
exec "{{ litellm_binary_path }}" --host {{ litellm_listen_host }} --port {{ litellm_listen_port }} --config "{{ litellm_config_file }}" --use_prisma_db_push
|
||||
exec "{{ litellm_proxy_dir | default(litellm_service_home ~ '/.local/lib/python3.13/site-packages/litellm/proxy') }}/../../../../bin/litellm" --host {{ litellm_listen_host }} --port {{ litellm_listen_port }} --config "{{ litellm_config_file }}" --use_prisma_db_push
|
||||
</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
nodejs_homebrew_formula: "node@{{ nodejs_version_major | default(22) }}"
|
||||
|
||||
- name: Ensure unversioned Homebrew node formula is absent
|
||||
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/brew uninstall --ignore-dependencies --force node"
|
||||
ansible.builtin.command: "brew uninstall --ignore-dependencies --force node"
|
||||
register: nodejs_brew_uninstall
|
||||
changed_when: "'uninstalled' in (nodejs_brew_uninstall.stdout | lower) or 'uninstalled' in (nodejs_brew_uninstall.stderr | lower)"
|
||||
failed_when: nodejs_brew_uninstall.rc != 0 and 'no such keg' not in (nodejs_brew_uninstall.stdout | lower) and 'not installed' not in (nodejs_brew_uninstall.stderr | lower)
|
||||
@ -17,7 +17,7 @@
|
||||
HOMEBREW_BOTTLE_DOMAIN: "{{ lookup('ansible.builtin.env', 'HOMEBREW_BOTTLE_DOMAIN') | default('https://mirrors.ustc.edu.cn/homebrew-bottles', true) }}"
|
||||
|
||||
- name: Ensure Homebrew {{ nodejs_homebrew_formula }} formula is installed
|
||||
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/brew install {{ nodejs_homebrew_formula }}"
|
||||
ansible.builtin.command: "brew install {{ nodejs_homebrew_formula }}"
|
||||
register: nodejs_brew_install
|
||||
changed_when: "'already installed' not in (nodejs_brew_install.stdout | lower) and 'already installed' not in (nodejs_brew_install.stderr | lower)"
|
||||
failed_when: nodejs_brew_install.rc != 0 and 'already installed' not in (nodejs_brew_install.stdout | lower) and 'already installed' not in (nodejs_brew_install.stderr | lower)
|
||||
@ -29,7 +29,7 @@
|
||||
HOMEBREW_BOTTLE_DOMAIN: "{{ lookup('ansible.builtin.env', 'HOMEBREW_BOTTLE_DOMAIN') | default('https://mirrors.ustc.edu.cn/homebrew-bottles', true) }}"
|
||||
|
||||
- name: Ensure {{ nodejs_homebrew_formula }} is linked as the default node
|
||||
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/brew link --force --overwrite {{ nodejs_homebrew_formula }}"
|
||||
ansible.builtin.command: "brew link --force --overwrite {{ nodejs_homebrew_formula }}"
|
||||
register: nodejs_brew_link
|
||||
changed_when: "'linking' in (nodejs_brew_link.stdout | lower)"
|
||||
failed_when: nodejs_brew_link.rc != 0 and 'already linked' not in (nodejs_brew_link.stdout | lower) and 'already linked' not in (nodejs_brew_link.stderr | lower)
|
||||
@ -44,7 +44,7 @@
|
||||
create: true
|
||||
|
||||
- name: Pin {{ nodejs_homebrew_formula }} to prevent automatic upgrades
|
||||
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/brew pin {{ nodejs_homebrew_formula }}"
|
||||
ansible.builtin.command: "brew pin {{ nodejs_homebrew_formula }}"
|
||||
register: nodejs_brew_pin
|
||||
changed_when: "'pinned' in (nodejs_brew_pin.stdout | lower)"
|
||||
failed_when: nodejs_brew_pin.rc != 0 and 'already pinned' not in (nodejs_brew_pin.stdout | lower) and 'already pinned' not in (nodejs_brew_pin.stderr | lower)
|
||||
|
||||
@ -27,12 +27,6 @@ postgresql_compose_project_name: ai-workspace-postgres
|
||||
postgresql_image: "postgres:17.7"
|
||||
postgresql_container_name: ai-workspace-postgres
|
||||
postgresql_data_dir: "{{ postgresql_compose_project_dir }}/data"
|
||||
# 官方 postgres 镜像内 postgres 用户的 uid/gid(17.x 为 999)。数据目录必须归
|
||||
# 该 uid,否则容器内 postgres 进程无法进入自己的 PGDATA(global/pg_filenode.map
|
||||
# Permission denied)。首次 initdb 由 entrypoint chown,但非空 PGDATA 不再 chown,
|
||||
# 故 ansible 须把目录直接建成该 uid,避免重跑时被重置回 root。
|
||||
postgresql_container_uid: "999"
|
||||
postgresql_container_gid: "999"
|
||||
postgresql_database: postgres
|
||||
postgresql_admin_user: postgres
|
||||
postgresql_admin_password_file: /root/.ai_workspace_postgres_password
|
||||
|
||||
@ -33,24 +33,18 @@
|
||||
no_log: true
|
||||
when: postgresql_admin_password_file_status.stat.exists
|
||||
|
||||
- name: Ensure PostgreSQL compose project directory exists
|
||||
- name: Ensure PostgreSQL compose directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ postgresql_compose_project_dir }}"
|
||||
path: "{{ item.path }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
# 数据目录归容器内 postgres uid(默认 999),而非 root:否则重跑时这个 file
|
||||
# 任务会把 PGDATA 顶层重置回 root:root 0700,而非空 PGDATA 不再被 entrypoint
|
||||
# chown,导致 uid 999 无法进入 → "could not open file ... Permission denied"。
|
||||
- name: Ensure PostgreSQL data directory exists (owned by container postgres uid)
|
||||
ansible.builtin.file:
|
||||
path: "{{ postgresql_data_dir }}"
|
||||
state: directory
|
||||
owner: "{{ postgresql_container_uid }}"
|
||||
group: "{{ postgresql_container_gid }}"
|
||||
mode: "0700"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- path: "{{ postgresql_compose_project_dir }}"
|
||||
mode: "0755"
|
||||
- path: "{{ postgresql_data_dir }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Render PostgreSQL compose environment
|
||||
ansible.builtin.copy:
|
||||
|
||||
@ -15,20 +15,6 @@
|
||||
and 'already installed' not in (postgresql_brew_install.stdout | default(''))
|
||||
failed_when: postgresql_brew_install.rc != 0
|
||||
|
||||
- name: Stop conflicting PostgreSQL versions
|
||||
ansible.builtin.shell: |
|
||||
conflicting_services=$(brew services list | awk '/^postgresql(@[0-9]+)?/ && $2 == "started" && $1 != "postgresql@16" {print $1}')
|
||||
if [ -n "$conflicting_services" ]; then
|
||||
for svc in $conflicting_services; do
|
||||
brew services stop "$svc"
|
||||
done
|
||||
echo "Stopped: $conflicting_services"
|
||||
fi
|
||||
environment:
|
||||
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH }}"
|
||||
register: stop_conflicting_pg
|
||||
changed_when: "'Stopped:' in stop_conflicting_pg.stdout"
|
||||
|
||||
- name: Start PostgreSQL via Homebrew Services
|
||||
ansible.builtin.command: brew services start postgresql@16
|
||||
register: brew_services_output
|
||||
|
||||
@ -3,7 +3,7 @@ qmd_user: "{{ ansible_env.USER | default('ubuntu') }}"
|
||||
qmd_group: "{{ 'staff' if ansible_os_family == 'Darwin' else (ansible_env.USER | default('ubuntu')) }}"
|
||||
qmd_home: "{{ ansible_env.HOME | default('/home/' + qmd_user) }}"
|
||||
qmd_source_repo: "https://github.com/ai-workspace-services/qmd.git"
|
||||
qmd_version: "236c83a5f38d860fbf56829ba4e188c1fa2ae52b"
|
||||
qmd_version: "6021ea34ac27ac9b5c9a7d655500544917c801dd"
|
||||
qmd_source_dir: "{{ qmd_home }}/.local/src/qmd"
|
||||
qmd_runtime_archive: "{{ lookup('ansible.builtin.env', 'QMD_RUNTIME_ARCHIVE') | default('', true) }}"
|
||||
qmd_runtime_marker: "{{ qmd_source_dir }}/.runtime-archive-sha256"
|
||||
@ -18,11 +18,6 @@ qmd_index_config_mode: "0664"
|
||||
qmd_env_path: "{{ qmd_config_dir }}/qmd.env"
|
||||
qmd_mcp_service_name: qmd-mcp
|
||||
qmd_mcp_service_unit_path: "{{ qmd_home }}/.config/systemd/user/{{ qmd_mcp_service_name }}.service"
|
||||
qmd_launch_agent_label: plus.svc.xworkspace.qmd
|
||||
qmd_launch_agent_path: "{{ qmd_home }}/Library/LaunchAgents/{{ qmd_launch_agent_label }}.plist"
|
||||
qmd_launch_agent_log_dir: "{{ qmd_home }}/.local/state/xworkspace"
|
||||
qmd_launch_agent_stdout_path: "{{ qmd_launch_agent_log_dir }}/qmd.log"
|
||||
qmd_launch_agent_stderr_path: "{{ qmd_launch_agent_log_dir }}/qmd.err.log"
|
||||
qmd_service_uid: ""
|
||||
qmd_mcp_host: 127.0.0.1
|
||||
qmd_mcp_port: 8181
|
||||
|
||||
@ -10,14 +10,14 @@
|
||||
listen: Restart QMD
|
||||
|
||||
- name: Unload QMD on macOS
|
||||
ansible.builtin.command: "launchctl unload {{ qmd_launch_agent_path }}"
|
||||
ansible.builtin.command: "launchctl unload {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
when: ansible_system == 'Darwin'
|
||||
listen: Restart QMD
|
||||
|
||||
- name: Load QMD on macOS
|
||||
ansible.builtin.command: "launchctl load -w {{ qmd_launch_agent_path }}"
|
||||
ansible.builtin.command: "launchctl load -w {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
|
||||
changed_when: false
|
||||
when: ansible_system == 'Darwin'
|
||||
listen: Restart QMD
|
||||
|
||||
@ -1,26 +1,13 @@
|
||||
---
|
||||
- name: Ensure QMD launchd directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: "{{ qmd_user }}"
|
||||
group: "{{ qmd_group }}"
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ qmd_launch_agent_path | dirname }}"
|
||||
- "{{ qmd_launch_agent_log_dir }}"
|
||||
|
||||
- name: Deploy QMD LaunchAgent
|
||||
- name: Create launchd plist template for QMD
|
||||
ansible.builtin.template:
|
||||
src: qmd.plist.j2
|
||||
dest: "{{ qmd_launch_agent_path }}"
|
||||
owner: "{{ qmd_user }}"
|
||||
group: "{{ qmd_group }}"
|
||||
dest: "{{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
|
||||
mode: "0644"
|
||||
notify: Restart QMD
|
||||
|
||||
- name: Ensure QMD LaunchAgent is loaded
|
||||
ansible.builtin.command: "launchctl load -w {{ qmd_launch_agent_path }}"
|
||||
- name: Reload launchd agent for QMD
|
||||
ansible.builtin.command: "launchctl load -w {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
|
||||
register: launchctl_result
|
||||
changed_when: false
|
||||
failed_when: launchctl_result.rc != 0 and 'already loaded' not in launchctl_result.stderr
|
||||
|
||||
@ -126,13 +126,6 @@
|
||||
ansible.builtin.command:
|
||||
cmd: npm install
|
||||
chdir: "{{ qmd_source_dir }}"
|
||||
# Pin Homebrew node@24 ahead of any nvm/other node on Darwin so the native
|
||||
# better-sqlite3 module is compiled against the same Node ABI that the
|
||||
# launchd service and `qmd status` run with. Otherwise a host with nvm node
|
||||
# first builds the module for the wrong NODE_MODULE_VERSION and qmd aborts
|
||||
# with ERR_DLOPEN_FAILED. Linux PATH is left untouched.
|
||||
environment:
|
||||
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
|
||||
become: true
|
||||
become_user: "{{ qmd_user }}"
|
||||
when:
|
||||
@ -143,8 +136,6 @@
|
||||
ansible.builtin.command:
|
||||
cmd: npm run build
|
||||
chdir: "{{ qmd_source_dir }}"
|
||||
environment:
|
||||
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
|
||||
become: true
|
||||
become_user: "{{ qmd_user }}"
|
||||
when:
|
||||
@ -305,11 +296,7 @@
|
||||
- name: Validate QMD status
|
||||
ansible.builtin.command:
|
||||
cmd: "{{ qmd_binary_path }} status"
|
||||
# qmd's /bin/sh wrapper invokes `node`; on Darwin pin Homebrew node@24 first
|
||||
# so it matches the ABI better-sqlite3 was built with (see npm tasks above),
|
||||
# instead of falling through to an nvm node and failing ERR_DLOPEN_FAILED.
|
||||
environment:
|
||||
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
|
||||
HOME: "{{ qmd_home }}"
|
||||
QMD_EMBED_API_BASE_URL: "{{ qmd_embed_api_base_url }}"
|
||||
QMD_EMBED_MODEL: "{{ qmd_embed_model }}"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{{ qmd_launch_agent_label }}</string>
|
||||
<string>plus.svc.xworkspace.qmd</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
@ -20,15 +20,15 @@
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{{ qmd_home }}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{ qmd_launch_agent_stdout_path }}</string>
|
||||
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/qmd.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{ qmd_launch_agent_stderr_path }}</string>
|
||||
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/qmd.err.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HOME</key>
|
||||
<string>{{ qmd_home }}</string>
|
||||
<key>PATH</key>
|
||||
<string>{{ qmd_home }}/.bun/bin:{{ qmd_home }}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:{{ ansible_env.HOME }}/.nvm/versions/node/{{ nodejs_version }}/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -1,36 +1,10 @@
|
||||
---
|
||||
- name: Check required ACP and gateway service status (Linux)
|
||||
- name: Check required ACP and gateway service status
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
loop: "{{ xworkmate_bridge_required_services | default(['acp-codex.service', 'acp-opencode.service', 'acp-gemini.service', 'acp-hermes.service']) }}"
|
||||
register: xworkmate_bridge_dependency_status_linux
|
||||
until: xworkmate_bridge_dependency_status_linux.status.ActiveState | default('') == "active"
|
||||
register: xworkmate_bridge_dependency_status
|
||||
until: xworkmate_bridge_dependency_status.status.ActiveState | default('') == "active"
|
||||
retries: 12
|
||||
delay: 5
|
||||
ignore_errors: true
|
||||
when: ansible_os_family not in ['Darwin', 'Windows']
|
||||
|
||||
- name: Check required ACP and gateway service status (macOS)
|
||||
ansible.builtin.command: "launchctl list plus.svc.xworkspace.{{ item | regex_replace('\\.service$', '') | replace('-', '.') }}"
|
||||
loop: "{{ xworkmate_bridge_required_services | default(['acp-codex.service', 'acp-opencode.service', 'acp-gemini.service', 'acp-hermes.service']) }}"
|
||||
register: xworkmate_bridge_dependency_status_macos
|
||||
until: xworkmate_bridge_dependency_status_macos.rc == 0 and ('\"PID\"' in xworkmate_bridge_dependency_status_macos.stdout)
|
||||
retries: 12
|
||||
delay: 5
|
||||
ignore_errors: true
|
||||
changed_when: false
|
||||
when: ansible_os_family == 'Darwin'
|
||||
|
||||
- name: Check required ACP and gateway service status (Windows)
|
||||
ansible.windows.win_service_info:
|
||||
name: "{{ item | regex_replace('\\.service$', '') }}"
|
||||
loop: "{{ xworkmate_bridge_required_services | default(['acp-codex.service', 'acp-opencode.service', 'acp-gemini.service', 'acp-hermes.service']) }}"
|
||||
register: xworkmate_bridge_dependency_status_windows
|
||||
until: >
|
||||
xworkmate_bridge_dependency_status_windows.exists and
|
||||
(xworkmate_bridge_dependency_status_windows.services | length > 0) and
|
||||
(xworkmate_bridge_dependency_status_windows.services[0].state == 'running')
|
||||
retries: 12
|
||||
delay: 5
|
||||
ignore_errors: true
|
||||
when: ansible_os_family == 'Windows'
|
||||
|
||||
@ -1,14 +1,4 @@
|
||||
---
|
||||
- name: Ensure macOS Vault directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ vault_config_dir }}"
|
||||
- "{{ vault_data_dir }}"
|
||||
- "{{ ansible_env.HOME }}/.local/state/xworkspace"
|
||||
|
||||
- name: Install HashiCorp Tap
|
||||
ansible.builtin.command: brew tap hashicorp/tap
|
||||
changed_when: false
|
||||
@ -19,12 +9,6 @@
|
||||
creates: /opt/homebrew/bin/vault
|
||||
changed_when: true
|
||||
|
||||
- name: Install jq via Homebrew (required by Vault admin bootstrap)
|
||||
ansible.builtin.command: brew install jq
|
||||
args:
|
||||
creates: /opt/homebrew/bin/jq
|
||||
changed_when: true
|
||||
|
||||
- name: Create symlink for Vault binary to match Linux path
|
||||
ansible.builtin.file:
|
||||
src: /opt/homebrew/bin/vault
|
||||
|
||||
@ -58,7 +58,6 @@
|
||||
- "{{ vault_data_dir }}"
|
||||
when:
|
||||
- vault_deploy_mode == "standalone"
|
||||
- ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Deploy standalone Vault systemd service
|
||||
ansible.builtin.copy:
|
||||
@ -130,8 +129,6 @@
|
||||
--root-token {{ vault_server_root_access_token | quote }}
|
||||
--output-dir {{ vault_admin_output_dir | quote }}
|
||||
--ui-url {{ vault_admin_ui_url | quote }}
|
||||
environment:
|
||||
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH }}"
|
||||
no_log: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
|
||||
@ -6,9 +6,9 @@ vault_deploy_mode: "{{ lookup('ansible.builtin.env', 'VAULT_DEPLOY_MODE') | defa
|
||||
vault_version: "{{ lookup('ansible.builtin.env', 'VAULT_VERSION') | default('1.21.4', true) }}"
|
||||
vault_listen_addr: 127.0.0.1:8200
|
||||
vault_service_name: vault
|
||||
vault_binary_path: "{{ '/opt/homebrew/bin/vault' if ansible_os_family == 'Darwin' else '/usr/local/bin/vault' }}"
|
||||
vault_config_dir: "{{ (ansible_env.HOME ~ '/Library/Application Support/vault') if ansible_os_family == 'Darwin' else '/etc/vault.d' }}"
|
||||
vault_data_dir: "{{ (ansible_env.HOME ~ '/Library/Application Support/vault/data') if ansible_os_family == 'Darwin' else '/opt/vault/data' }}"
|
||||
vault_binary_path: /usr/local/bin/vault
|
||||
vault_config_dir: /etc/vault.d
|
||||
vault_data_dir: /opt/vault/data
|
||||
ai_workspace_auth_token: "{{ lookup('ansible.builtin.env', 'AI_WORKSPACE_AUTH_TOKEN') | default('', true) }}"
|
||||
vault_server_root_access_token: "{{ lookup('ansible.builtin.env', 'VAULT_SERVER_ROOT_ACCESS_TOKEN') | default(lookup('ansible.builtin.env', 'VAULT_TOKEN') | default(ai_workspace_auth_token, true), true) }}"
|
||||
vault_admin_init_enabled: "{{ (vault_server_root_access_token | trim | length > 0) and (vault_admin_password | trim | length > 0) }}"
|
||||
|
||||
@ -11,10 +11,7 @@ xfce_packages:
|
||||
- fonts-noto-cjk
|
||||
- xserver-xorg-core
|
||||
|
||||
# 不钉具体构建号:Google apt 源只保留当前 stable,任何固定版本几周后即消失
|
||||
# 导致 "no available installation candidate"。留空 = 装当前可用的 google-chrome-stable。
|
||||
# 如确需复现某版本,可设为该 madison 中仍存在的版本串(缺失时自动回退最新)。
|
||||
xfce_google_chrome_version: ""
|
||||
xfce_google_chrome_version: "149.0.7827.114-1"
|
||||
xfce_google_chrome_apt_key_url: "https://dl.google.com/linux/linux_signing_key.pub"
|
||||
xfce_google_chrome_apt_keyring: "/etc/apt/keyrings/google-linux-signing-key.gpg"
|
||||
xfce_google_chrome_apt_source: "deb [arch=amd64 signed-by={{ xfce_google_chrome_apt_keyring }}] https://dl.google.com/linux/chrome/deb/ stable main"
|
||||
|
||||
@ -110,37 +110,18 @@
|
||||
when:
|
||||
- xfce_browser_package == 'google-chrome-stable'
|
||||
- not xfce_offline_active
|
||||
|
||||
- name: Inspect available Google Chrome apt versions
|
||||
ansible.builtin.command: apt-cache madison google-chrome-stable
|
||||
changed_when: false
|
||||
register: xfce_google_chrome_versions
|
||||
when:
|
||||
- xfce_browser_package == 'google-chrome-stable'
|
||||
- xfce_google_chrome_version | length > 0
|
||||
|
||||
- name: Select Google Chrome package spec
|
||||
ansible.builtin.set_fact:
|
||||
xfce_browser_package_spec: >-
|
||||
{{
|
||||
'google-chrome-stable=' ~ xfce_google_chrome_version
|
||||
if (
|
||||
xfce_browser_package == 'google-chrome-stable'
|
||||
and (xfce_google_chrome_version | length) > 0
|
||||
and (xfce_google_chrome_versions.stdout | default('') is search('\\|\\s*' ~ (xfce_google_chrome_version | regex_escape) ~ '\\s*\\|'))
|
||||
)
|
||||
else xfce_browser_package
|
||||
}}
|
||||
when: xfce_browser_package | length > 0
|
||||
- xfce_google_chrome_repo.changed
|
||||
|
||||
- name: Install apt-managed workspace browser
|
||||
ansible.builtin.apt:
|
||||
name: "{{ xfce_browser_package_spec | default(xfce_browser_package) }}"
|
||||
name: >-
|
||||
{{
|
||||
'google-chrome-stable=' ~ xfce_google_chrome_version
|
||||
if xfce_browser_package == 'google-chrome-stable'
|
||||
else xfce_browser_package
|
||||
}}
|
||||
state: present
|
||||
install_recommends: false
|
||||
# Chrome 源只留当前 stable;若显式钉到一个比候选更旧(但仍在源内)的版本,
|
||||
# 安装会被 apt 视为降级而拒绝。允许降级,避免该路径硬失败。
|
||||
allow_downgrade: true
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
APT_LISTCHANGES_FRONTEND: none
|
||||
|
||||
@ -3,12 +3,7 @@
|
||||
ansible.builtin.include_role:
|
||||
name: roles/vhosts/nodejs
|
||||
vars:
|
||||
# 不可写成 default(nodejs_version):传入名为 nodejs_version 的变量里再引用
|
||||
# nodejs_version 形成自引用,Ansible 2.19+ 惰性模板判定递归 (Recursive loop
|
||||
# detected) 而失败。也不可用 default(omit):include_role 的 vars 里 omit 不会
|
||||
# 回退到角色默认,而是把 omit 占位符字面塞进去,导致 node_<<Omit>>.x 仓库地址。
|
||||
# 用显式回退到 nodejs 角色的文档默认值。
|
||||
nodejs_version: "{{ ai_agent_runtime_nodejs_version | default('22.22.3', true) }}"
|
||||
nodejs_version: "{{ ai_agent_runtime_nodejs_version | default(nodejs_version) }}"
|
||||
when: xfce_desktop_install_nodejs_runtime | bool
|
||||
|
||||
- name: Install Playwright browser runtime
|
||||
|
||||
@ -9,7 +9,7 @@ xworkmate_bridge_review_auth_token: "{{ lookup('ansible.builtin.env', 'BRIDGE_RE
|
||||
xworkmate_bridge_listen_host: 127.0.0.1
|
||||
xworkmate_bridge_listen_port: 8787
|
||||
xworkmate_bridge_listen_addr: "{{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}"
|
||||
xworkmate_bridge_base_dir: "{{ (ansible_env.HOME ~ '/Library/Application Support/cloud-neutral/xworkmate-bridge') if ansible_os_family == 'Darwin' else '/opt/cloud-neutral/xworkmate-bridge' }}"
|
||||
xworkmate_bridge_base_dir: /opt/cloud-neutral/xworkmate-bridge
|
||||
xworkmate_bridge_config_file: "{{ xworkmate_bridge_base_dir }}/config.yaml"
|
||||
xworkmate_bridge_binary_path: /usr/local/bin/xworkmate-go-core
|
||||
xworkmate_bridge_systemd_unit_path: "/etc/systemd/system/{{ xworkmate_bridge_service_name }}.service"
|
||||
@ -23,19 +23,9 @@ xworkmate_bridge_service_environment:
|
||||
BRIDGE_AUTH_TOKEN: "{{ xworkmate_bridge_effective_auth_token | default(xworkmate_bridge_auth_token) }}"
|
||||
BRIDGE_REVIEW_AUTH_TOKEN: "{{ xworkmate_bridge_effective_review_auth_token | default(xworkmate_bridge_review_auth_token) }}"
|
||||
BRIDGE_CONFIG_PATH: "{{ xworkmate_bridge_config_file }}"
|
||||
XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_MAX_ACTIVE: "{{ xworkmate_bridge_openclaw_gateway_max_active }}"
|
||||
XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_MAX_QUEUED: "{{ xworkmate_bridge_openclaw_gateway_max_queued }}"
|
||||
XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_QUEUE_TIMEOUT: "{{ xworkmate_bridge_openclaw_gateway_queue_timeout }}"
|
||||
xworkmate_bridge_openclaw_gateway_max_active: 2
|
||||
xworkmate_bridge_openclaw_gateway_max_active: 5
|
||||
xworkmate_bridge_openclaw_gateway_max_queued: 20
|
||||
xworkmate_bridge_openclaw_gateway_queue_timeout: 10m
|
||||
# Caddy reverse_proxy 流式(SSE)超时。必须 >= bridge openClawAgentWaitMaxTimeout(60min)
|
||||
# 才不会在长任务执行到一半时把 SSE 从入口掐断(表现为 ACP_HTTP_CONNECTION_CLOSED,
|
||||
# 而 OpenClaw gateway 仍在后台跑)。来源常量见 xworkmate-bridge/internal/acp/orchestrator.go:32。
|
||||
# 取 70m = 60min 上限 + 10min 余量(HTTP margin + keepalive 抖动)。改这里即同时驱动 read/write_timeout。
|
||||
xworkmate_bridge_acp_stream_timeout: 70m
|
||||
xworkmate_bridge_acp_dial_timeout: 10s
|
||||
xworkmate_bridge_acp_upstream_keepalive: 5m
|
||||
xworkmate_bridge_distributed_topology: ""
|
||||
xworkmate_bridge_distributed_local_node_id: ""
|
||||
xworkmate_bridge_distributed_task_forward_peer_id: ""
|
||||
@ -56,14 +46,7 @@ deploy_acp_hermes: true
|
||||
|
||||
# Unified domain settings
|
||||
ai_workspace_public_domain: "{{ lookup('ansible.builtin.env', 'SERVER_DOMAIN') | default(lookup('ansible.builtin.env', 'ACP_BRIDGE_DOMAIN') | default(lookup('ansible.builtin.env', 'BRIDGE_DOMAIN') | default('xworkmate-bridge.svc.plus', true), true), true) }}"
|
||||
# 域名优先级:XWORKMATE_BRIDGE_DOMAIN(env,operator 指定) > CMDB service_domains
|
||||
# 首个域名(inventory hostvar;on-host 模型由流水线作为该 env 传入) > ai_workspace_public_domain。
|
||||
# 用作 xworkmate-bridge.caddy 站点名与 /etc/hostname,绝不为空/127.0.0.1。
|
||||
xworkmate_bridge_domain: >-
|
||||
{{ lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_DOMAIN')
|
||||
| default((service_domains | default('', true) | string | split(',')
|
||||
| map('trim') | reject('equalto', '') | list | first | default('', true)), true)
|
||||
| default(ai_workspace_public_domain, true) }}
|
||||
xworkmate_bridge_domain: "{{ lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_DOMAIN') | default(ai_workspace_public_domain, true) }}"
|
||||
|
||||
# When false, disables public Caddy access to XWorkmate Bridge.
|
||||
xworkmate_bridge_public_access: true
|
||||
@ -76,7 +59,6 @@ xworkmate_bridge_validation_validate_certs: true
|
||||
xworkmate_bridge_validation_origin: https://xworkmate.svc.plus
|
||||
|
||||
# Caddy configuration paths
|
||||
xworkmate_bridge_caddy_base_dir: "{{ caddy_config_dir }}"
|
||||
xworkmate_bridge_caddyfile_path: "{{ caddy_config_dir }}/Caddyfile"
|
||||
xworkmate_bridge_caddy_conf_dir: "{{ caddy_config_dir }}/conf.d"
|
||||
xworkmate_bridge_service_caddy_fragment_path: "{{ caddy_config_dir }}/conf.d/xworkmate-bridge.caddy"
|
||||
|
||||
@ -1,46 +1,9 @@
|
||||
---
|
||||
# Debian/Ubuntu 显式走 apt:play 的 module_defaults.apt.lock_timeout(模板值)只有
|
||||
# 在 ansible.builtin.apt 任务上才会被正确渲染;经 ansible.builtin.package 间接派发到
|
||||
# apt 时该模板不渲染,会把字面 "{{ ... }}" 当成 lock_timeout 传入而报 int 转换失败。
|
||||
- name: Install xworkmate-bridge prerequisites (Debian/Ubuntu via apt)
|
||||
ansible.builtin.apt:
|
||||
name: "{{ xworkmate_bridge_packages }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
# 非 Debian Linux 仍用通用 package(派发到 yum/dnf,不继承 apt 的 lock_timeout 默认)
|
||||
- name: Install xworkmate-bridge prerequisites (non-Debian Linux)
|
||||
- name: Install xworkmate-bridge prerequisites
|
||||
ansible.builtin.package:
|
||||
name: "{{ xworkmate_bridge_packages }}"
|
||||
state: present
|
||||
when: ansible_os_family not in ['Darwin', 'Debian', 'Windows']
|
||||
|
||||
# 非空传递检查:bridge 域名喂给 /etc/hostname 与 caddy 站点名;空/非 FQDN/127.0.0.1
|
||||
# 会渲染出无效 Caddyfile。公网暴露(caddy_enabled)时必须是合法 FQDN,缺失即抛错。
|
||||
- name: Assert bridge domain is a non-empty FQDN when exposed via Caddy
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- xworkmate_bridge_domain | default('') | trim | length > 0
|
||||
- "'.' in xworkmate_bridge_domain"
|
||||
- xworkmate_bridge_domain not in ['127.0.0.1', 'localhost']
|
||||
fail_msg: >-
|
||||
xworkmate_bridge_domain 必须是非空 FQDN(用于 /etc/hostname 与
|
||||
/etc/caddy/conf.d/xworkmate-bridge 站点名)。请设置 XWORKMATE_BRIDGE_DOMAIN,
|
||||
或在 CMDB/inventory 提供 service_domains(当前解析为
|
||||
"{{ xworkmate_bridge_domain | default('') }}")。
|
||||
when: caddy_enabled | default(true) | bool
|
||||
|
||||
# 把目标主机 hostname 设为 bridge 域名(= XWORKMATE_BRIDGE_DOMAIN,否则 CMDB
|
||||
# service_domains)。仅 Linux、且为合法 FQDN 时设置;绝不取 127.0.0.1/localhost。
|
||||
- name: Set host FQDN from xworkmate-bridge domain
|
||||
ansible.builtin.hostname:
|
||||
name: "{{ xworkmate_bridge_domain }}"
|
||||
when:
|
||||
- ansible_os_family not in ['Darwin', 'Windows']
|
||||
- xworkmate_bridge_domain | default('') | trim | length > 0
|
||||
- "'.' in xworkmate_bridge_domain"
|
||||
- xworkmate_bridge_domain not in ['127.0.0.1', 'localhost']
|
||||
when: ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Ensure xworkmate-bridge service group exists
|
||||
ansible.builtin.group:
|
||||
|
||||
@ -32,14 +32,11 @@
|
||||
- "'handle /acp*' in xworkmate_bridge_fragment.stdout"
|
||||
- "'handle /api*' in xworkmate_bridge_fragment.stdout"
|
||||
- "'handle /artifacts/*' in xworkmate_bridge_fragment.stdout"
|
||||
- >-
|
||||
'reverse_proxy ' ~ xworkmate_bridge_listen_host ~ ':' ~ xworkmate_bridge_listen_port
|
||||
in xworkmate_bridge_fragment.stdout
|
||||
- "'reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}' in xworkmate_bridge_fragment.stdout"
|
||||
- "'flush_interval -1' in xworkmate_bridge_fragment.stdout"
|
||||
# 流式超时与 bridge openClawAgentWaitMaxTimeout(60min) 对齐,由 xworkmate_bridge_acp_stream_timeout 驱动(T1/T2)。
|
||||
- "'read_timeout ' ~ xworkmate_bridge_acp_stream_timeout in xworkmate_bridge_fragment.stdout"
|
||||
- "'write_timeout ' ~ xworkmate_bridge_acp_stream_timeout in xworkmate_bridge_fragment.stdout"
|
||||
- "'keepalive ' ~ xworkmate_bridge_acp_upstream_keepalive in xworkmate_bridge_fragment.stdout"
|
||||
- "'read_timeout 30m' in xworkmate_bridge_fragment.stdout"
|
||||
- "'write_timeout 30m' in xworkmate_bridge_fragment.stdout"
|
||||
- "'keepalive 5m' in xworkmate_bridge_fragment.stdout"
|
||||
- "'/gateway/openclaw' not in xworkmate_bridge_fragment.stdout"
|
||||
- "'/acp-server' not in xworkmate_bridge_fragment.stdout"
|
||||
- "'127.0.0.1:18789' not in xworkmate_bridge_fragment.stdout"
|
||||
|
||||
@ -11,55 +11,28 @@
|
||||
respond `{"jsonrpc":"2.0","error":{"code":-32001,"message":"unauthorized"},"type":"res","ok":false}` 401
|
||||
}
|
||||
|
||||
# /api* 承载 tasks.get 轮询与(部分)流式响应:与 /acp* 用同样的流式 + 长超时配置,
|
||||
# 避免落到 Caddy 默认短超时把轮询/流式打断(T2)。
|
||||
handle /api* {
|
||||
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
dial_timeout {{ xworkmate_bridge_acp_dial_timeout }}
|
||||
read_timeout {{ xworkmate_bridge_acp_stream_timeout }}
|
||||
write_timeout {{ xworkmate_bridge_acp_stream_timeout }}
|
||||
keepalive {{ xworkmate_bridge_acp_upstream_keepalive }}
|
||||
}
|
||||
}
|
||||
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}
|
||||
}
|
||||
|
||||
handle /artifacts/* {
|
||||
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
dial_timeout {{ xworkmate_bridge_acp_dial_timeout }}
|
||||
read_timeout {{ xworkmate_bridge_acp_stream_timeout }}
|
||||
write_timeout {{ xworkmate_bridge_acp_stream_timeout }}
|
||||
keepalive {{ xworkmate_bridge_acp_upstream_keepalive }}
|
||||
}
|
||||
}
|
||||
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}
|
||||
}
|
||||
|
||||
# /acp* 流式超时必须 >= bridge openClawAgentWaitMaxTimeout(60min),否则长任务在入口被掐断(T1)。
|
||||
handle /acp* {
|
||||
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
dial_timeout {{ xworkmate_bridge_acp_dial_timeout }}
|
||||
read_timeout {{ xworkmate_bridge_acp_stream_timeout }}
|
||||
write_timeout {{ xworkmate_bridge_acp_stream_timeout }}
|
||||
keepalive {{ xworkmate_bridge_acp_upstream_keepalive }}
|
||||
dial_timeout 10s
|
||||
read_timeout 30m
|
||||
write_timeout 30m
|
||||
keepalive 5m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle / {
|
||||
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
dial_timeout {{ xworkmate_bridge_acp_dial_timeout }}
|
||||
read_timeout {{ xworkmate_bridge_acp_stream_timeout }}
|
||||
write_timeout {{ xworkmate_bridge_acp_stream_timeout }}
|
||||
keepalive {{ xworkmate_bridge_acp_upstream_keepalive }}
|
||||
}
|
||||
}
|
||||
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}
|
||||
}
|
||||
|
||||
log {
|
||||
|
||||
@ -9,13 +9,9 @@
|
||||
vars:
|
||||
xworkspace_console_enable_xrdp: false
|
||||
tasks:
|
||||
# XFCE/XRDP is a Linux remote-desktop stack (apt-based, systemd) and is not
|
||||
# applicable to macOS, which already has a native GUI. Skip the whole stack
|
||||
# on Darwin so the apt-driven tasks never run there.
|
||||
- name: Include XFCE desktop runtime role
|
||||
ansible.builtin.include_role:
|
||||
name: roles/vhosts/xfce_desktop_minimal_runtime
|
||||
when: ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Include XRDP server role when enabled
|
||||
ansible.builtin.include_role:
|
||||
@ -27,6 +23,4 @@
|
||||
xfce_user_groups:
|
||||
- sudo
|
||||
- docker
|
||||
when:
|
||||
- ansible_os_family != 'Darwin'
|
||||
- xworkspace_console_enable_xrdp | bool
|
||||
when: xworkspace_console_enable_xrdp | bool
|
||||
|
||||
@ -7,10 +7,7 @@
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
|
||||
vars:
|
||||
# 跟随连接用户,与 xworkspace_console_home(ansible_env.HOME) 保持一致:
|
||||
# 以 root 连接时 user=root/home=/root,避免 become_user=ubuntu 去 link /root
|
||||
# 下的 unit 文件而报 "src does not exist"(root 家目录 700,ubuntu 无法进入)。
|
||||
xworkspace_console_user: "{{ ansible_env.USER | default('ubuntu') }}"
|
||||
xworkspace_console_user: ubuntu
|
||||
xworkspace_console_public_access: false
|
||||
xworkspace_console_domain: workspace.svc.plus
|
||||
xworkspace_console_home: "{{ ansible_env.HOME | default('/home/ubuntu') }}"
|
||||
@ -19,10 +16,8 @@
|
||||
xworkspace_console_runtime_archive: "{{ lookup('ansible.builtin.env', 'XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE') | default('', true) }}"
|
||||
ai_workspace_prebuilt_components_required: "{{ lookup('ansible.builtin.env', 'AI_WORKSPACE_PREBUILT_COMPONENTS_REQUIRED') | default('false', true) | bool }}"
|
||||
xworkspace_console_dashboard_dir: "{{ xworkspace_console_repo_dir }}/dashboard"
|
||||
# 预编译 runtime tar 的 manifest.json 记 apiBinary: bin/xworkspace-api,
|
||||
# 二进制落在 bin/(非源码布局的 api/)。对齐之,否则服务 203/EXEC 崩溃重启。
|
||||
xworkspace_console_api_dir: "{{ xworkspace_console_repo_dir }}/bin"
|
||||
xworkspace_console_api_binary: "{{ xworkspace_console_api_dir }}/xworkspace-api"
|
||||
xworkspace_console_api_dir: "{{ xworkspace_console_repo_dir }}/api"
|
||||
xworkspace_console_api_binary: "{{ xworkspace_console_repo_dir }}/bin/xworkspace-api"
|
||||
xworkspace_console_runtime_marker: "{{ xworkspace_console_repo_dir }}/.runtime-archive-sha256"
|
||||
xworkspace_console_api_working_dir: "{{ xworkspace_console_repo_dir }}"
|
||||
xworkspace_console_api_exec: "{{ xworkspace_console_api_binary }}"
|
||||
@ -158,16 +153,10 @@
|
||||
update_cache: true
|
||||
name: >-
|
||||
{{
|
||||
['xfce4', 'python3', 'golang-go']
|
||||
+ (['caddy'] if caddy_enabled | default(true) | bool else [])
|
||||
['caddy', 'xfce4', 'python3', 'golang-go']
|
||||
+ ([xworkspace_console_browser_package] if xworkspace_console_browser_package | length > 0 else [])
|
||||
}}
|
||||
state: present
|
||||
# xfce4 元包会拉入整套桌面,安装期间偶发重置网络/拖长,导致前台 SSH 会话
|
||||
# 掉线 → ansible 误判 UNREACHABLE(实际包已在主机装完)。改异步执行 + 轮询,
|
||||
# 让安装在主机后台跑、ansible 重连轮询,掉线也不影响。
|
||||
async: "{{ ai_workspace_runtime_apt_async | default(1800) | int }}"
|
||||
poll: 15
|
||||
when: ansible_os_family != 'Darwin'
|
||||
|
||||
- name: Ensure ttyd binary target directory exists
|
||||
@ -501,14 +490,10 @@
|
||||
|
||||
- name: Download XWorkspace Console runtime release
|
||||
ansible.builtin.get_url:
|
||||
url: "https://github.com/ai-workspace-lab/xworkspace-console/releases/download/latest-runtime/xworkspace-console-runtime-{{ ansible_system | lower }}-{{ 'amd64' if ansible_architecture in ['x86_64', 'amd64'] else 'arm64' }}.tar.gz"
|
||||
url: "https://github.com/ai-workspace-lab/xworkspace-console/releases/latest/download/xworkspace-console-runtime-{{ ansible_system | lower }}-{{ 'amd64' if ansible_architecture in ['x86_64', 'amd64'] else 'arm64' }}.tar.gz"
|
||||
dest: "/tmp/xworkspace-console-runtime.tar.gz"
|
||||
mode: "0644"
|
||||
force: true
|
||||
register: xworkspace_console_runtime_download
|
||||
until: xworkspace_console_runtime_download is succeeded
|
||||
retries: 3
|
||||
delay: 5
|
||||
when: xworkspace_console_runtime_archive | length == 0
|
||||
|
||||
- name: Set runtime archive path
|
||||
@ -624,11 +609,8 @@
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory={{ xworkspace_console_dashboard_dir }}/dist
|
||||
# console 只是 17000 上的静态后端(dashboard 为无路由单页 dist),由系统
|
||||
# caddy 经 /etc/caddy/conf.d/ 反代对外。用 python3 静态伺服即可,跨 Linux/
|
||||
# macOS 统一、不再起第二个 caddy(避免与系统 caddy 抢 :80)。
|
||||
ExecStart=/usr/bin/env python3 -m http.server {{ xworkspace_console_port }} --bind 127.0.0.1 --directory {{ xworkspace_console_dashboard_dir }}/dist
|
||||
WorkingDirectory={{ xworkspace_console_dashboard_dir }}
|
||||
ExecStart=/usr/bin/npm run preview -- --host 127.0.0.1 --port {{ xworkspace_console_port }}
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
|
||||
10
test.yml
10
test.yml
@ -1,10 +0,0 @@
|
||||
- hosts: localhost
|
||||
tasks:
|
||||
- name: test
|
||||
command: npm -v
|
||||
environment:
|
||||
PATH: "/Users/shenlan/.local/bin:/Users/shenlan/.npm-global/bin:/Users/shenlan/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
become: true
|
||||
become_user: shenlan
|
||||
register: out
|
||||
- debug: var=out.stdout
|
||||
Loading…
Reference in New Issue
Block a user