Merge pull request #1493 from trheyi/main
feat(sandbox): enhance lifecycle management and box status handling
This commit is contained in:
commit
d5818be11f
34 changed files with 2569 additions and 281 deletions
88
.github/workflows/notarize-macos.yml
vendored
Normal file
88
.github/workflows/notarize-macos.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
name: Notarize macOS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_id:
|
||||
description: "Release macOS workflow run ID (to download artifacts from)"
|
||||
required: true
|
||||
version:
|
||||
description: "Version used in the release build (e.g. 1.0.0 or 1.0.0-alpha)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ===================================================================
|
||||
# Notarize Yao binaries (arm64 + amd64)
|
||||
# ===================================================================
|
||||
notarize:
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [arm64, amd64]
|
||||
steps:
|
||||
- name: Download Yao Binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-${{ matrix.arch }}
|
||||
path: bin
|
||||
run-id: ${{ github.event.inputs.run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Certificates
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
mkdir -p certs
|
||||
echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer
|
||||
echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12
|
||||
security verify-cert -c certs/DeveloperIDG2CA.cer
|
||||
security verify-cert -c certs/distribution.cer
|
||||
|
||||
- name: Import Certificates
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
- name: Verify Signature
|
||||
run: codesign --verify --deep --strict --verbose=2 bin/yao
|
||||
|
||||
- name: Notarize Yao ${{ matrix.arch }}
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAME_ID: ${{ secrets.APPLE_TEAME_ID }}
|
||||
APPLE_APP_SPEC_PASS: ${{ secrets.APPLE_APP_SPEC_PASS }}
|
||||
run: |
|
||||
zip -j bin/yao.zip bin/yao
|
||||
|
||||
SUBMIT_OUT=$(xcrun notarytool submit bin/yao.zip \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--team-id "$APPLE_TEAME_ID" \
|
||||
--password "$APPLE_APP_SPEC_PASS" \
|
||||
--wait --timeout 10m --output-format json 2>&1) || true
|
||||
echo "$SUBMIT_OUT"
|
||||
|
||||
STATUS=$(echo "$SUBMIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || true)
|
||||
SUB_ID=$(echo "$SUBMIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "::error::Yao ${{ matrix.arch }} notarization failed (status: $STATUS)"
|
||||
[ -n "$SUB_ID" ] && xcrun notarytool log "$SUB_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--team-id "$APPLE_TEAME_ID" \
|
||||
--password "$APPLE_APP_SPEC_PASS" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Yao ${{ matrix.arch }} notarization accepted."
|
||||
4
.github/workflows/pr-test.yml
vendored
4
.github/workflows/pr-test.yml
vendored
|
|
@ -1088,7 +1088,7 @@ jobs:
|
|||
docker run -d --name tai-docker \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \
|
||||
yaoapp/tai:latest server \
|
||||
yaoapp/tai:latest server -direct \
|
||||
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
|
|
@ -1142,7 +1142,7 @@ jobs:
|
|||
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
|
||||
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
||||
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
|
||||
yaoapp/tai:latest server \
|
||||
yaoapp/tai:latest server -direct \
|
||||
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
|
|
|
|||
158
.github/workflows/release-linux.yml
vendored
Normal file
158
.github/workflows/release-linux.yml
vendored
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
name: Release Linux
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 1.0.0 or 1.0.0-alpha). Leave empty to read from share/const.go."
|
||||
required: false
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: yaoapp/yao
|
||||
|
||||
jobs:
|
||||
# ===================================================================
|
||||
# Build Linux Binaries (amd64 + arm64)
|
||||
# ===================================================================
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: yaoapp/yao-build:1.0.0
|
||||
env:
|
||||
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
steps:
|
||||
- name: Configure R2 For Cloudflare
|
||||
run: |
|
||||
aws configure set aws_access_key_id $CF_ACCESS_KEY_ID
|
||||
aws configure set aws_secret_access_key $CF_SECRET_ACCESS_KEY
|
||||
aws configure set default.region us-east-1
|
||||
aws configure set default.s3.signature_version s3v4
|
||||
aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
export PATH=$PATH:/github/home/go/bin
|
||||
/app/build.sh
|
||||
ls -l /data
|
||||
|
||||
- name: Push To R2
|
||||
run: |
|
||||
for file in /data/*; do
|
||||
aws s3 cp "$file" s3://$R2_BUCKET/archives/ \
|
||||
--endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
|
||||
done
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-linux
|
||||
path: /data/*
|
||||
|
||||
# ===================================================================
|
||||
# Docker Images (multi-platform linux/amd64 + linux/arm64)
|
||||
# ===================================================================
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.version }}" ]; then
|
||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g')
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build & Push Development (amd64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker/development
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}-unstable
|
||||
ARCH=amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64-dev
|
||||
|
||||
- name: Build & Push Development (arm64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker/development
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}-unstable
|
||||
ARCH=arm64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64-dev
|
||||
|
||||
- name: Build & Push Production (amd64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}-unstable
|
||||
ARCH=amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64
|
||||
|
||||
- name: Build & Push Production (arm64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}-unstable
|
||||
ARCH=arm64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64
|
||||
|
||||
- name: Build & Push Slim (amd64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker/production-slim
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}-unstable
|
||||
ARCH=amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64-slim
|
||||
|
||||
- name: Build & Push Slim (arm64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker/production-slim
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}-unstable
|
||||
ARCH=arm64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64-slim
|
||||
238
.github/workflows/release-macos.yml
vendored
238
.github/workflows/release-macos.yml
vendored
|
|
@ -1,64 +1,198 @@
|
|||
name: Release MacOS Artifacts
|
||||
name: Release macOS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 1.0.0 or 1.0.0-alpha). Leave empty to read from share/const.go."
|
||||
required: false
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: "macos-12"
|
||||
timeout-minutes: 120
|
||||
|
||||
# ===================================================================
|
||||
# Build Yao macOS binaries (arm64 + amd64) — one job, both arches
|
||||
# ===================================================================
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Download latest artifacts
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Setup Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Checkout Kun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/kun
|
||||
path: kun
|
||||
|
||||
- name: Checkout Xun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/xun
|
||||
path: xun
|
||||
|
||||
- name: Checkout Gou
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/gou
|
||||
path: gou
|
||||
|
||||
- name: Checkout V8Go
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/v8go
|
||||
path: v8go
|
||||
|
||||
- name: Unzip libv8
|
||||
run: |
|
||||
echo "Downloading latest artifacts..."
|
||||
ARTIFACT_URL="https://api.github.com/repos/YaoApp/yao/actions/artifacts"
|
||||
ARTIFACT_CONTENT=$(curl -s -H "Accept: application/vnd.github.v3+json" $ARTIFACT_URL)
|
||||
echo $ARTIFACT_CONTENT
|
||||
ARTIFACTS=$(echo $ARTIFACT_CONTENT | jq -r '.artifacts[] | select(.name | contains("yao-macos")) | .id')
|
||||
for id in $ARTIFACTS; do
|
||||
echo "https://api.github.com/repos/YaoApp/yao/actions/artifacts/$id/zip"
|
||||
curl -L -H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/YaoApp/yao/actions/artifacts/$id/zip" \
|
||||
-o artifact.zip
|
||||
unzip artifact.zip -d ./artifacts
|
||||
rm artifact.zip
|
||||
break
|
||||
files=$(find ./v8go -name "libv8*.zip")
|
||||
for file in $files; do
|
||||
dir=$(dirname "$file")
|
||||
echo "Extracting $file to directory $dir"
|
||||
unzip -o -d $dir $file
|
||||
rm -rf $dir/__MACOSX
|
||||
done
|
||||
ls -l ./artifacts
|
||||
|
||||
# - name: Submit notarization request
|
||||
# run: |
|
||||
# echo "Submitting notarization request..."
|
||||
# UUID=$(xcrun altool --notarize-app --primary-bundle-id "com.example.yourapp" \
|
||||
# --username "your-apple-id" --password "app-specific-password" \
|
||||
# --file ./artifacts/your-binary-file)
|
||||
- name: Checkout CUI v1.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/cui
|
||||
path: cui-v1.0
|
||||
|
||||
# echo "Notarization UUID: $UUID"
|
||||
# echo "$UUID" > notarization_uuid.txt
|
||||
- name: Checkout Yao-Init
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-init
|
||||
path: yao-init
|
||||
|
||||
# - name: Check notarization status
|
||||
# id: check_notarization
|
||||
# timeout-minutes: 120
|
||||
# run: |
|
||||
# UUID=$(cat notarization_uuid.txt)
|
||||
# STATUS="in progress"
|
||||
# while [[ "$STATUS" == "in progress" ]]; do
|
||||
# STATUS=$(xcrun altool --notarization-info "$UUID" \
|
||||
# --username "your-apple-id" --password "app-specific-password")
|
||||
# echo "Notarization status: $STATUS"
|
||||
# if [[ "$STATUS" == *"success"* ]]; then
|
||||
# echo "::set-output name=status::success"
|
||||
# break
|
||||
# elif [[ "$STATUS" == *"invalid"* ]]; then
|
||||
# echo "::set-output name=status::failed"
|
||||
# break
|
||||
# fi
|
||||
# done
|
||||
- name: Move Dependencies
|
||||
run: |
|
||||
mv kun ../
|
||||
mv xun ../
|
||||
mv gou ../
|
||||
mv v8go ../
|
||||
mv cui-v1.0 ../
|
||||
mv yao-init ../
|
||||
rm -f ../cui-v1.0/packages/setup/vite.config.ts.*
|
||||
|
||||
# - name: Create Release
|
||||
# if: steps.check_notarization.outputs.status == 'success'
|
||||
# run: |
|
||||
# echo "Creating a release..."
|
||||
# VERSION=$(git rev-parse --short HEAD)
|
||||
# gh release create "v1.0.0-$VERSION" ./artifacts/* --title "Release v0.10.4-$VERSION" --notes "Notarization succeeded. This is the release for version v1.0.0-$VERSION."
|
||||
- name: Checkout Yao
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Setup Go Tools
|
||||
run: make tools
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.version }}" ]; then
|
||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g')
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Make Artifacts macOS
|
||||
run: make artifacts-macos
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: List Build Output
|
||||
run: ls -lh dist/release/
|
||||
|
||||
- name: Install Certificates
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
mkdir -p certs
|
||||
echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer
|
||||
echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12
|
||||
security verify-cert -c certs/DeveloperIDG2CA.cer
|
||||
security verify-cert -c certs/distribution.cer
|
||||
|
||||
- name: Import Certificates
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
- name: Sign Yao Binaries
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
IDENTITY="Developer ID Application: ${{ secrets.APPLE_SIGN }}"
|
||||
for ARCH in arm64 amd64; do
|
||||
BIN="dist/release/yao-${VERSION}-unstable-darwin-${ARCH}"
|
||||
codesign --force --verbose --timestamp --options runtime --sign "$IDENTITY" "$BIN"
|
||||
codesign --verify --deep --strict --verbose=2 "$BIN"
|
||||
done
|
||||
|
||||
- name: Prepare Output and Checksums
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
for ARCH in arm64 amd64; do
|
||||
mkdir -p /tmp/yao-output-${ARCH}
|
||||
cp "dist/release/yao-${VERSION}-unstable-darwin-${ARCH}" "/tmp/yao-output-${ARCH}/yao"
|
||||
chmod +x "/tmp/yao-output-${ARCH}/yao"
|
||||
done
|
||||
|
||||
mkdir -p /tmp/checksums
|
||||
shasum -a 256 /tmp/yao-output-arm64/yao | awk '{print $1" yao"}' > /tmp/checksums/yao-darwin-arm64.sha256
|
||||
shasum -a 256 /tmp/yao-output-amd64/yao | awk '{print $1" yao"}' > /tmp/checksums/yao-darwin-amd64.sha256
|
||||
cat /tmp/checksums/*.sha256
|
||||
|
||||
- name: Upload arm64 Binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-arm64
|
||||
path: /tmp/yao-output-arm64/yao
|
||||
|
||||
- name: Upload amd64 Binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-amd64
|
||||
path: /tmp/yao-output-amd64/yao
|
||||
|
||||
- name: Upload arm64 Checksum
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-arm64-sha256
|
||||
path: /tmp/checksums/yao-darwin-arm64.sha256
|
||||
|
||||
- name: Upload amd64 Checksum
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-amd64-sha256
|
||||
path: /tmp/checksums/yao-darwin-amd64.sha256
|
||||
|
|
|
|||
110
.github/workflows/release.yml
vendored
Normal file
110
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release Linux", "Release macOS"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ===================================================================
|
||||
# Wait for both workflows to succeed, then create a unified release
|
||||
# ===================================================================
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
TAG="${{ github.event.workflow_run.head_branch }}"
|
||||
echo "version=${TAG#v}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Wait for Both Workflows
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
echo "Waiting for both Release Linux and Release macOS to complete for $TAG..."
|
||||
|
||||
for i in $(seq 1 60); do
|
||||
LINUX_STATUS=$(gh run list --workflow="Release Linux" --branch="$TAG" --limit=1 --json conclusion --jq '.[0].conclusion // "pending"')
|
||||
MACOS_STATUS=$(gh run list --workflow="Release macOS" --branch="$TAG" --limit=1 --json conclusion --jq '.[0].conclusion // "pending"')
|
||||
|
||||
echo "Attempt $i: Linux=$LINUX_STATUS macOS=$MACOS_STATUS"
|
||||
|
||||
if [ "$LINUX_STATUS" = "success" ] && [ "$MACOS_STATUS" = "success" ]; then
|
||||
echo "Both workflows completed successfully."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$LINUX_STATUS" = "failure" ] || [ "$MACOS_STATUS" = "failure" ]; then
|
||||
echo "::error::One or both workflows failed (Linux=$LINUX_STATUS macOS=$MACOS_STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 60
|
||||
done
|
||||
|
||||
echo "::error::Timed out waiting for workflows"
|
||||
exit 1
|
||||
|
||||
- name: Download Linux Artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
LINUX_RUN_ID=$(gh run list --workflow="Release Linux" --branch="$TAG" --limit=1 --json databaseId --jq '.[0].databaseId')
|
||||
mkdir -p dist/linux
|
||||
gh run download "$LINUX_RUN_ID" --name yao-linux --dir dist/linux
|
||||
|
||||
- name: Download macOS Artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
MACOS_RUN_ID=$(gh run list --workflow="Release macOS" --branch="$TAG" --limit=1 --json databaseId --jq '.[0].databaseId')
|
||||
mkdir -p dist/macos
|
||||
gh run download "$MACOS_RUN_ID" --name yao-darwin-arm64 --dir dist/macos
|
||||
gh run download "$MACOS_RUN_ID" --name yao-darwin-amd64 --dir dist/macos
|
||||
gh run download "$MACOS_RUN_ID" --pattern "yao-darwin-*-sha256" --dir dist/macos
|
||||
|
||||
- name: Prepare Release Files
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
mkdir -p release
|
||||
|
||||
cp dist/linux/* release/ 2>/dev/null || true
|
||||
|
||||
if [ -f dist/macos/yao-darwin-arm64/yao ]; then
|
||||
cp dist/macos/yao-darwin-arm64/yao "release/yao-${VERSION}-darwin-arm64"
|
||||
elif [ -f dist/macos/yao ]; then
|
||||
cp dist/macos/yao "release/yao-${VERSION}-darwin-arm64"
|
||||
fi
|
||||
|
||||
if [ -f dist/macos/yao-darwin-amd64/yao ]; then
|
||||
cp dist/macos/yao-darwin-amd64/yao "release/yao-${VERSION}-darwin-amd64"
|
||||
fi
|
||||
|
||||
find dist/macos -name "*.sha256" -exec cp {} release/ \;
|
||||
|
||||
chmod +x release/yao-* 2>/dev/null || true
|
||||
echo "=== Release files ==="
|
||||
ls -lh release/
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: Yao v${{ steps.version.outputs.version }}
|
||||
files: release/*
|
||||
generate_release_notes: true
|
||||
4
.github/workflows/unit-test.yml
vendored
4
.github/workflows/unit-test.yml
vendored
|
|
@ -799,7 +799,7 @@ jobs:
|
|||
docker run -d --name tai-docker \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \
|
||||
yaoapp/tai:latest server \
|
||||
yaoapp/tai:latest server -direct \
|
||||
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
|
|
@ -853,7 +853,7 @@ jobs:
|
|||
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
|
||||
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
||||
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
|
||||
yaoapp/tai:latest server \
|
||||
yaoapp/tai:latest server -direct \
|
||||
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
|
|
|
|||
|
|
@ -224,16 +224,17 @@ func (s *streamState) handleToolCall(data []byte) int {
|
|||
var deltaPath string
|
||||
|
||||
if len(toolCallArray) == 1 {
|
||||
// Single tool call - flatten to props root level
|
||||
tc := toolCallArray[0]
|
||||
props = map[string]interface{}{}
|
||||
|
||||
// Static fields (only in first chunk): use merge
|
||||
hasIdentity := false
|
||||
if id, ok := tc["id"].(string); ok {
|
||||
props["id"] = id
|
||||
hasIdentity = true
|
||||
}
|
||||
if typ, ok := tc["type"].(string); ok {
|
||||
props["type"] = typ
|
||||
hasIdentity = true
|
||||
}
|
||||
if index, ok := tc["index"].(float64); ok {
|
||||
props["index"] = int(index)
|
||||
|
|
@ -241,18 +242,21 @@ func (s *streamState) handleToolCall(data []byte) int {
|
|||
if fn, ok := tc["function"].(map[string]interface{}); ok {
|
||||
if name, ok := fn["name"].(string); ok {
|
||||
props["name"] = name
|
||||
hasIdentity = true
|
||||
}
|
||||
// Arguments field: use append
|
||||
if args, ok := fn["arguments"].(string); ok {
|
||||
props["arguments"] = args
|
||||
// If this chunk has arguments, use append action for arguments field
|
||||
deltaAction = "append"
|
||||
deltaPath = "arguments"
|
||||
}
|
||||
}
|
||||
|
||||
// If no arguments in this chunk, use merge for other fields
|
||||
if deltaAction == "" {
|
||||
if hasIdentity {
|
||||
// First chunk with id/name/type: merge so all fields are applied.
|
||||
deltaAction = "merge"
|
||||
} else if _, ok := props["arguments"]; ok {
|
||||
// Subsequent chunk with only arguments fragment: append to arguments.
|
||||
deltaAction = "append"
|
||||
deltaPath = "arguments"
|
||||
} else {
|
||||
deltaAction = "merge"
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,34 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
|||
// 2. Build human-readable DisplayName from real Agent name + Workspace name.
|
||||
cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name)
|
||||
|
||||
// 2.5. Image existence check + pull (for box mode).
|
||||
if cfg.Computer.Image != "" && manager != nil {
|
||||
nodeID, kind, _ := sandboxv2.ResolveNodeID(ctx, cfg, manager)
|
||||
if kind == "box" && nodeID != "" {
|
||||
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
|
||||
exists, existsErr := manager.ImageExists(stdCtx, nodeID, cfg.Computer.Image)
|
||||
if existsErr != nil {
|
||||
log.Printf("[sandbox/v2] image exists check failed on node %s: %v", nodeID, existsErr)
|
||||
}
|
||||
if existsErr == nil && !exists {
|
||||
updateLoadingV2(ctx, loadingMsgID, "sandbox.pulling_image")
|
||||
ch, pullErr := manager.PullImage(stdCtx, nodeID, cfg.Computer.Image, infraV2.ImagePullOptions{})
|
||||
if pullErr != nil {
|
||||
log.Printf("[sandbox/v2] image pull failed on node %s: %v (will retry in Create)", nodeID, pullErr)
|
||||
} else if ch != nil {
|
||||
for p := range ch {
|
||||
if p.Error != "" {
|
||||
log.Printf("[sandbox/v2] image pull progress error: %s", p.Error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
|
||||
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
|
||||
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn)
|
||||
if err != nil {
|
||||
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
||||
|
|
@ -133,11 +160,6 @@ func (ast *Assistant) executeSandboxV2Stream(
|
|||
cfg := ast.SandboxV2
|
||||
manager := infraV2.M()
|
||||
|
||||
// Close the "preparing" loading on first output.
|
||||
if loadingMsgID != "" {
|
||||
closeLoadingV2(ctx, loadingMsgID, "")
|
||||
}
|
||||
|
||||
// Build system prompt.
|
||||
var systemPrompt string
|
||||
if len(ast.Prompts) > 0 {
|
||||
|
|
@ -172,11 +194,12 @@ func (ast *Assistant) executeSandboxV2Stream(
|
|||
}
|
||||
|
||||
execReq := &sandboxv2.ExecuteRequest{
|
||||
Computer: computer,
|
||||
Runner: runner,
|
||||
Config: cfg,
|
||||
StreamReq: streamReq,
|
||||
Manager: manager,
|
||||
Computer: computer,
|
||||
Runner: runner,
|
||||
Config: cfg,
|
||||
StreamReq: streamReq,
|
||||
Manager: manager,
|
||||
LoadingMsgID: loadingMsgID,
|
||||
}
|
||||
|
||||
return sandboxv2.ExecuteSandboxStream(ctx, execReq, streamHandler)
|
||||
|
|
@ -230,6 +253,22 @@ func buildBoxDisplayName(ctx *context.Context, assistantID, rawName string) stri
|
|||
return ""
|
||||
}
|
||||
|
||||
func updateLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
|
||||
if loadingMsgID == "" || ctx == nil || msgKey == "" {
|
||||
return
|
||||
}
|
||||
msg := &message.Message{
|
||||
MessageID: loadingMsgID,
|
||||
Delta: true,
|
||||
DeltaAction: message.DeltaReplace,
|
||||
Type: message.TypeLoading,
|
||||
Props: map[string]any{
|
||||
"message": i18n.T(ctx.Locale, msgKey),
|
||||
},
|
||||
}
|
||||
ctx.Send(msg)
|
||||
}
|
||||
|
||||
func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
|
||||
if loadingMsgID == "" || ctx == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -100,11 +100,14 @@ func init() {
|
|||
"kb.chat.description": "Auto-created knowledge base collection for chat sessions",
|
||||
|
||||
// Sandbox: assistant/sandbox.go - Sandbox status messages
|
||||
"sandbox.preparing": "Preparing sandbox environment",
|
||||
"sandbox.ready": "Sandbox ready",
|
||||
"sandbox.working": "Working on your request",
|
||||
"sandbox.completed": "Completed",
|
||||
"sandbox.failed": "Execution failed",
|
||||
"sandbox.preparing": "Getting things ready...",
|
||||
"sandbox.ready": "Sandbox ready",
|
||||
"sandbox.working": "Working on your request",
|
||||
"sandbox.completed": "Completed",
|
||||
"sandbox.failed": "Execution failed",
|
||||
"sandbox.starting": "Setting up workspace...",
|
||||
"sandbox.pulling_image": "Preparing environment (first time may take a moment)",
|
||||
"sandbox.waiting_response": "Waiting for AI response...",
|
||||
|
||||
// Sandbox: claude/executor.go - Tool execution messages
|
||||
"sandbox.tool.read": "Reading file",
|
||||
|
|
@ -224,11 +227,14 @@ func init() {
|
|||
"kb.chat.description": "自动为聊天会话创建的知识库集合",
|
||||
|
||||
// Sandbox: assistant/sandbox.go - Sandbox status messages
|
||||
"sandbox.preparing": "正在准备沙箱环境",
|
||||
"sandbox.ready": "沙箱环境就绪",
|
||||
"sandbox.working": "正在处理您的请求",
|
||||
"sandbox.completed": "处理完成",
|
||||
"sandbox.failed": "执行失败",
|
||||
"sandbox.preparing": "正在准备...",
|
||||
"sandbox.ready": "就绪",
|
||||
"sandbox.working": "正在处理您的请求",
|
||||
"sandbox.completed": "处理完成",
|
||||
"sandbox.failed": "执行失败",
|
||||
"sandbox.starting": "正在启动工作区...",
|
||||
"sandbox.pulling_image": "正在准备运行环境(首次可能需要一点时间)",
|
||||
"sandbox.waiting_response": "等待 AI 响应...",
|
||||
|
||||
// Sandbox: claude/executor.go - Tool execution messages
|
||||
"sandbox.tool.read": "正在读取文件",
|
||||
|
|
@ -376,11 +382,14 @@ func init() {
|
|||
"kb.chat.description": "自动为聊天会话创建的知识库集合",
|
||||
|
||||
// Sandbox: assistant/sandbox.go - Sandbox status messages
|
||||
"sandbox.preparing": "正在准备沙箱环境",
|
||||
"sandbox.ready": "沙箱环境就绪",
|
||||
"sandbox.working": "正在处理您的请求",
|
||||
"sandbox.completed": "处理完成",
|
||||
"sandbox.failed": "执行失败",
|
||||
"sandbox.preparing": "正在准备...",
|
||||
"sandbox.ready": "就绪",
|
||||
"sandbox.working": "正在处理您的请求",
|
||||
"sandbox.completed": "处理完成",
|
||||
"sandbox.failed": "执行失败",
|
||||
"sandbox.starting": "正在启动工作区...",
|
||||
"sandbox.pulling_image": "正在准备运行环境(首次可能需要一点时间)",
|
||||
"sandbox.waiting_response": "等待 AI 响应...",
|
||||
|
||||
// Sandbox: claude/executor.go - Tool execution messages
|
||||
"sandbox.tool.read": "正在读取文件",
|
||||
|
|
|
|||
|
|
@ -23,9 +23,13 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St
|
|||
scanner.Buffer(buf, 1024*1024)
|
||||
|
||||
messageStarted := false
|
||||
toolBlockActive := false
|
||||
toolIndex := 0
|
||||
|
||||
type toolState struct {
|
||||
id string
|
||||
name string
|
||||
index int
|
||||
inputJSON strings.Builder
|
||||
}
|
||||
var currentTool *toolState
|
||||
|
|
@ -66,10 +70,41 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St
|
|||
blockType, _ := cb["type"].(string)
|
||||
if blockType == "tool_use" {
|
||||
toolName, _ := cb["name"].(string)
|
||||
currentTool = &toolState{name: toolName}
|
||||
toolID, _ := cb["id"].(string)
|
||||
if toolID == "" {
|
||||
toolID = fmt.Sprintf("tool_%d_%d", toolIndex, time.Now().UnixNano())
|
||||
}
|
||||
currentTool = &toolState{id: toolID, name: toolName, index: toolIndex}
|
||||
toolIndex++
|
||||
|
||||
if handler != nil {
|
||||
data, _ := json.Marshal(map[string]any{"tool": toolName})
|
||||
if handler(message.ChunkToolCall, data) != 0 {
|
||||
if messageStarted {
|
||||
handler(message.ChunkMessageEnd, nil)
|
||||
messageStarted = false
|
||||
}
|
||||
if !toolBlockActive {
|
||||
startData := message.EventMessageStartData{
|
||||
MessageID: fmt.Sprintf("sandbox-tool-%d", time.Now().UnixNano()),
|
||||
Type: "tool_call",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
sd, _ := json.Marshal(startData)
|
||||
if handler(message.ChunkMessageStart, sd) != 0 {
|
||||
stopped = true
|
||||
break
|
||||
}
|
||||
toolBlockActive = true
|
||||
}
|
||||
tcData, _ := json.Marshal([]map[string]any{{
|
||||
"index": currentTool.index,
|
||||
"id": currentTool.id,
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": toolName,
|
||||
"arguments": "",
|
||||
},
|
||||
}})
|
||||
if handler(message.ChunkToolCall, tcData) != 0 {
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
|
|
@ -82,7 +117,14 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St
|
|||
switch deltaType {
|
||||
case "text_delta":
|
||||
if text, ok := delta["text"].(string); ok && text != "" {
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\r", "\n")
|
||||
if handler != nil {
|
||||
if toolBlockActive {
|
||||
handler(message.ChunkMessageEnd, nil)
|
||||
toolBlockActive = false
|
||||
messageStarted = false
|
||||
}
|
||||
if !messageStarted {
|
||||
startData := message.EventMessageStartData{
|
||||
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
||||
|
|
@ -105,6 +147,17 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St
|
|||
if currentTool != nil {
|
||||
if partial, ok := delta["partial_json"].(string); ok {
|
||||
currentTool.inputJSON.WriteString(partial)
|
||||
if handler != nil {
|
||||
tcData, _ := json.Marshal([]map[string]any{{
|
||||
"index": currentTool.index,
|
||||
"function": map[string]any{
|
||||
"arguments": partial,
|
||||
},
|
||||
}})
|
||||
if handler(message.ChunkToolCall, tcData) != 0 {
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,8 +178,53 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St
|
|||
continue
|
||||
}
|
||||
itemType, _ := ci["type"].(string)
|
||||
|
||||
if itemType == "tool_use" && handler != nil {
|
||||
toolName, _ := ci["name"].(string)
|
||||
toolID, _ := ci["id"].(string)
|
||||
if toolID == "" {
|
||||
toolID = fmt.Sprintf("tool_%d_%d", toolIndex, time.Now().UnixNano())
|
||||
}
|
||||
inputRaw, _ := json.Marshal(ci["input"])
|
||||
idx := toolIndex
|
||||
toolIndex++
|
||||
|
||||
if !toolBlockActive {
|
||||
startData := message.EventMessageStartData{
|
||||
MessageID: fmt.Sprintf("sandbox-tool-%d", time.Now().UnixNano()),
|
||||
Type: "tool_call",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
sd, _ := json.Marshal(startData)
|
||||
if handler(message.ChunkMessageStart, sd) != 0 {
|
||||
stopped = true
|
||||
break
|
||||
}
|
||||
toolBlockActive = true
|
||||
}
|
||||
tcData, _ := json.Marshal([]map[string]any{{
|
||||
"index": idx,
|
||||
"id": toolID,
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": toolName,
|
||||
"arguments": string(inputRaw),
|
||||
},
|
||||
}})
|
||||
if handler(message.ChunkToolCall, tcData) != 0 {
|
||||
stopped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if itemType == "text" {
|
||||
if text, ok := ci["text"].(string); ok && text != "" && handler != nil && !messageStarted {
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\r", "\n")
|
||||
if toolBlockActive {
|
||||
handler(message.ChunkMessageEnd, nil)
|
||||
toolBlockActive = false
|
||||
}
|
||||
startData := message.EventMessageStartData{
|
||||
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
||||
Type: "text",
|
||||
|
|
@ -159,8 +257,14 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St
|
|||
return fmt.Errorf("Claude CLI error: %s", result)
|
||||
}
|
||||
}
|
||||
if handler != nil && messageStarted {
|
||||
handler(message.ChunkMessageEnd, nil)
|
||||
if handler != nil {
|
||||
if toolBlockActive {
|
||||
handler(message.ChunkMessageEnd, nil)
|
||||
toolBlockActive = false
|
||||
}
|
||||
if messageStarted {
|
||||
handler(message.ChunkMessageEnd, nil)
|
||||
}
|
||||
}
|
||||
|
||||
case "error":
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -41,17 +42,17 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
|
|||
r.mode = "cli"
|
||||
}
|
||||
|
||||
env := resolveOSEnv(req.Computer, req.Config)
|
||||
|
||||
steps := append([]types.PrepareStep{}, req.Config.Prepare...)
|
||||
|
||||
if req.SkillsDir != "" {
|
||||
claudeDir := env.pathJoin(env.WorkDir, ".claude")
|
||||
steps = append(steps, types.PrepareStep{
|
||||
Action: "exec",
|
||||
Cmd: env.mkdirCmd(claudeDir),
|
||||
Once: true,
|
||||
})
|
||||
ws := req.Computer.Workplace()
|
||||
if ws != nil {
|
||||
src := "local:///" + req.SkillsDir
|
||||
dst := ".claude/skills"
|
||||
if _, err := ws.Copy(src, dst); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[claude] warn: copy skills %s -> %s: %v\n", src, dst, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.MCPServers) > 0 {
|
||||
|
|
@ -60,7 +61,7 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
|
|||
mcpJSON := buildMCPConfig(req.MCPServers)
|
||||
steps = append(steps, types.PrepareStep{
|
||||
Action: "file",
|
||||
Path: env.pathJoin(env.WorkDir, ".mcp.json"),
|
||||
Path: ".claude/mcp.json",
|
||||
Content: mcpJSON,
|
||||
})
|
||||
}
|
||||
|
|
@ -103,6 +104,8 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
|
|||
streamOpts = append(streamOpts, infra.WithStdin(stdin))
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[claude] Stream cmd=%v hasMCP=%v isContinuation=%v stdinLen=%d\n", cmd, r.hasMCP, isContinuation, len(stdin))
|
||||
|
||||
execStream, err := computer.Stream(ctx, cmd, streamOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("computer.Stream: %w", err)
|
||||
|
|
@ -157,6 +160,7 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
|
|||
return waitErr
|
||||
}
|
||||
if exitCode != 0 {
|
||||
fmt.Fprintf(os.Stderr, "[claude] exit code=%d parseErr=%v waitErr=%v stderr=%q\n", exitCode, parseErr, waitErr, stderrStr)
|
||||
if stderrStr != "" {
|
||||
return fmt.Errorf("claude CLI exited with code %d: %s", exitCode, stderrStr)
|
||||
}
|
||||
|
|
@ -227,6 +231,18 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, oe *osEnv, isCo
|
|||
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
|
||||
env["CLAUDE_CODE_SUBAGENT_MODEL"] = model
|
||||
}
|
||||
|
||||
if thinking, ok := setting["thinking"].(map[string]interface{}); ok {
|
||||
thinkType, _ := thinking["type"].(string)
|
||||
switch thinkType {
|
||||
case "disabled":
|
||||
env["MAX_THINKING_TOKENS"] = "0"
|
||||
case "enabled":
|
||||
if budget, ok := thinking["budget_tokens"].(float64); ok && budget > 0 {
|
||||
env["MAX_THINKING_TOKENS"] = fmt.Sprintf("%d", int(budget))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Config != nil && len(req.Config.Secrets) > 0 {
|
||||
|
|
@ -290,7 +306,7 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, oe *osEnv, isCo
|
|||
}
|
||||
|
||||
if r.hasMCP {
|
||||
mcpPath := oe.pathJoin(oe.WorkDir, ".mcp.json")
|
||||
mcpPath := oe.pathJoin(oe.WorkDir, ".claude", "mcp.json")
|
||||
args = append(args, "--mcp-config", mcpPath)
|
||||
if r.mcpToolPattern != "" {
|
||||
args = append(args, "--allowedTools", r.mcpToolPattern)
|
||||
|
|
@ -315,7 +331,7 @@ func buildMCPConfig(servers []types.MCPServer) []byte {
|
|||
}
|
||||
mcpServers[name] = map[string]any{
|
||||
"command": "tai",
|
||||
"args": []string{"mcp"},
|
||||
"args": []string{"mcp", name},
|
||||
}
|
||||
}
|
||||
if len(mcpServers) == 0 {
|
||||
|
|
|
|||
|
|
@ -22,16 +22,9 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID, wor
|
|||
return ""
|
||||
}
|
||||
|
||||
// Custom identifier from metadata takes precedence.
|
||||
if metadata != nil {
|
||||
if cid, ok := metadata["computer_id"].(string); ok && cid != "" {
|
||||
return fmt.Sprintf("%s-%s.%s", ownerID, cid, workspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
switch cfg.Lifecycle {
|
||||
case "session":
|
||||
return fmt.Sprintf("%s-%s", ownerID, chatID)
|
||||
return fmt.Sprintf("%s-%s-%s", ownerID, assistantID, chatID)
|
||||
case "longrunning", "persistent":
|
||||
return fmt.Sprintf("%s-%s.%s", ownerID, assistantID, workspaceID)
|
||||
default:
|
||||
|
|
@ -39,6 +32,61 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID, wor
|
|||
}
|
||||
}
|
||||
|
||||
// ResolveNodeID determines the target nodeID and computer kind based on
|
||||
// metadata and DSL configuration, without creating or acquiring a container.
|
||||
// Returns (nodeID, kind, error). kind is "box" or "host".
|
||||
func ResolveNodeID(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager) (string, string, error) {
|
||||
computerID := ""
|
||||
if ctx.Metadata != nil {
|
||||
if cid, ok := ctx.Metadata["computer_id"].(string); ok && cid != "" {
|
||||
computerID = cid
|
||||
}
|
||||
}
|
||||
|
||||
workspaceID := ""
|
||||
if ctx.Metadata != nil {
|
||||
if ws, ok := ctx.Metadata["workspace_id"].(string); ok && ws != "" {
|
||||
workspaceID = ws
|
||||
}
|
||||
}
|
||||
ownerID := resolveOwnerID(ctx)
|
||||
if workspaceID == "" {
|
||||
workspaceID = ownerID
|
||||
}
|
||||
|
||||
if workspaceID != "" && workspaceID != ownerID {
|
||||
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
|
||||
if err == nil && wsNode != "" {
|
||||
computerID = wsNode
|
||||
}
|
||||
}
|
||||
|
||||
if computerID != "" {
|
||||
if node, ok := tai.GetNodeMeta(computerID); ok {
|
||||
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
|
||||
if node.Capabilities.HostExec && !hasContainerRuntime {
|
||||
return computerID, "host", nil
|
||||
}
|
||||
if node.Capabilities.HostExec && hasContainerRuntime && cfg.Computer.Image == "" {
|
||||
return computerID, "host", nil
|
||||
}
|
||||
if !hasContainerRuntime {
|
||||
return "", "", fmt.Errorf("node %q has no container runtime and no host_exec capability", computerID)
|
||||
}
|
||||
return computerID, "box", nil
|
||||
}
|
||||
return computerID, "box", nil
|
||||
}
|
||||
|
||||
if cfg.Computer.Image == "" {
|
||||
nodeID := cfg.NodeID
|
||||
return nodeID, "host", nil
|
||||
}
|
||||
|
||||
nodeID := cfg.NodeID
|
||||
return nodeID, "box", nil
|
||||
}
|
||||
|
||||
// GetComputer obtains or creates a Computer for the current request.
|
||||
// An optional connector may be passed to inject OPENAI_PROXY_* env vars.
|
||||
// Returns the Computer, the resolved identifier, and any error.
|
||||
|
|
@ -184,8 +232,17 @@ func resolveBox(
|
|||
if identifier != "" {
|
||||
box, err := manager.Get(context.Background(), identifier)
|
||||
if err == nil && box != nil {
|
||||
box.BindWorkplace(workspaceID)
|
||||
return box, identifier, nil
|
||||
if box.IsStopped() {
|
||||
if startErr := manager.StartBox(context.Background(), identifier); startErr != nil {
|
||||
log.Printf("[sandbox/v2] auto-start stopped box %s failed: %v, creating new", identifier, startErr)
|
||||
} else {
|
||||
box.BindWorkplace(workspaceID)
|
||||
return box, identifier, nil
|
||||
}
|
||||
} else {
|
||||
box.BindWorkplace(workspaceID)
|
||||
return box, identifier, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ func TestBuildIdentifier_Oneshot(t *testing.T) {
|
|||
func TestBuildIdentifier_Session(t *testing.T) {
|
||||
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", "ws1", nil)
|
||||
if id != "owner1-chat42" {
|
||||
t.Errorf("session: got %q, want %q", id, "owner1-chat42")
|
||||
if id != "owner1-ast1-chat42" {
|
||||
t.Errorf("session: got %q, want %q", id, "owner1-ast1-chat42")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,8 +54,9 @@ func TestBuildIdentifier_MetadataOverride(t *testing.T) {
|
|||
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
||||
meta := map[string]any{"computer_id": "custom-box"}
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", meta)
|
||||
if id != "owner1-custom-box.ws1" {
|
||||
t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box.ws1")
|
||||
// computer_id is used for routing only, not for identifier generation.
|
||||
if id != "owner1-ast1-chat1" {
|
||||
t.Errorf("metadata override: got %q, want %q", id, "owner1-ast1-chat1")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) {
|
|||
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
||||
meta := map[string]any{"computer_id": ""}
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", "ws1", meta)
|
||||
if id != "owner1-chat42" {
|
||||
if id != "owner1-ast1-chat42" {
|
||||
t.Errorf("empty metadata should fall through to session, got %q", id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
|
|||
}
|
||||
opts.IdleTimeout = d
|
||||
}
|
||||
if opts.IdleTimeout == 0 {
|
||||
switch opts.Policy {
|
||||
case infra.Session:
|
||||
opts.IdleTimeout = infra.DefaultSessionIdleTimeout
|
||||
case infra.LongRunning:
|
||||
opts.IdleTimeout = infra.DefaultLongRunningIdleTimeout
|
||||
}
|
||||
}
|
||||
if cfg.MaxLifetime != "" {
|
||||
d, err := time.ParseDuration(cfg.MaxLifetime)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||
infra "github.com/yaoapp/yao/sandbox/v2"
|
||||
|
|
@ -15,11 +16,12 @@ import (
|
|||
|
||||
// ExecuteRequest consolidates all parameters for ExecuteSandboxStream.
|
||||
type ExecuteRequest struct {
|
||||
Computer infra.Computer
|
||||
Runner types.Runner
|
||||
Config *types.SandboxConfig
|
||||
StreamReq *types.StreamRequest
|
||||
Manager *infra.Manager
|
||||
Computer infra.Computer
|
||||
Runner types.Runner
|
||||
Config *types.SandboxConfig
|
||||
StreamReq *types.StreamRequest
|
||||
Manager *infra.Manager
|
||||
LoadingMsgID string
|
||||
}
|
||||
|
||||
// ExecuteSandboxStream is the V2 replacement for executeSandboxStream.
|
||||
|
|
@ -105,8 +107,28 @@ func ExecuteSandboxStream(
|
|||
}
|
||||
}()
|
||||
|
||||
if req.LoadingMsgID != "" {
|
||||
waitMsg := &message.Message{
|
||||
MessageID: req.LoadingMsgID,
|
||||
Delta: true,
|
||||
DeltaAction: message.DeltaReplace,
|
||||
Type: message.TypeLoading,
|
||||
Props: map[string]any{
|
||||
"message": i18n.T(ctx.Locale, "sandbox.waiting_response"),
|
||||
},
|
||||
}
|
||||
ctx.Send(waitMsg)
|
||||
}
|
||||
|
||||
var textContent []byte
|
||||
loadingClosed := false
|
||||
wrappedHandler := func(chunkType message.StreamChunkType, data []byte) int {
|
||||
if !loadingClosed && req.LoadingMsgID != "" {
|
||||
if chunkType == message.ChunkText || chunkType == message.ChunkToolCall || chunkType == message.ChunkMessageStart {
|
||||
closeLoading(ctx, req.LoadingMsgID)
|
||||
loadingClosed = true
|
||||
}
|
||||
}
|
||||
if chunkType == message.ChunkText {
|
||||
textContent = append(textContent, data...)
|
||||
}
|
||||
|
|
@ -118,6 +140,10 @@ func ExecuteSandboxStream(
|
|||
|
||||
err := req.Runner.Stream(runnerCtx, req.StreamReq, wrappedHandler)
|
||||
|
||||
if !loadingClosed && req.LoadingMsgID != "" {
|
||||
closeLoading(ctx, req.LoadingMsgID)
|
||||
}
|
||||
|
||||
panicked = false // Normal exit reached.
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -136,3 +162,20 @@ func ExecuteSandboxStream(
|
|||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func closeLoading(ctx *agentContext.Context, loadingMsgID string) {
|
||||
if loadingMsgID == "" || ctx == nil {
|
||||
return
|
||||
}
|
||||
msg := &message.Message{
|
||||
MessageID: loadingMsgID,
|
||||
Delta: true,
|
||||
DeltaAction: message.DeltaReplace,
|
||||
Type: message.TypeLoading,
|
||||
Props: map[string]any{
|
||||
"done": true,
|
||||
"message": "",
|
||||
},
|
||||
}
|
||||
ctx.Send(msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ func IssueSandboxToken(teamID, userID string) (*types.SandboxToken, error) {
|
|||
extraClaims["team_id"] = teamID
|
||||
}
|
||||
|
||||
tokenStr, err := svc.MakeAccessToken("__yao.sandbox", "sandbox:mcp", subject,
|
||||
tokenStr, err := svc.MakeAccessToken("__yao.sandbox", "grpc:mcp", subject,
|
||||
int(accessTokenTTL.Seconds()), extraClaims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sandbox token: issue access token: %w", err)
|
||||
|
|
@ -78,7 +78,7 @@ func IssueSandboxToken(teamID, userID string) (*types.SandboxToken, error) {
|
|||
|
||||
tok := &types.SandboxToken{Token: tokenStr}
|
||||
|
||||
refreshStr, err := svc.MakeRefreshToken("__yao.sandbox", "sandbox:mcp", subject,
|
||||
refreshStr, err := svc.MakeRefreshToken("__yao.sandbox", "grpc:mcp", subject,
|
||||
int(refreshTokenTTL.Seconds()), extraClaims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sandbox token: issue refresh token: %w", err)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import (
|
|||
"github.com/yaoapp/yao/messenger"
|
||||
"github.com/yaoapp/yao/moapi"
|
||||
"github.com/yaoapp/yao/model"
|
||||
"github.com/yaoapp/yao/monitor"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
"github.com/yaoapp/yao/pack"
|
||||
"github.com/yaoapp/yao/pipe"
|
||||
|
|
@ -242,6 +243,14 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
|
|||
warnings = append(warnings, Warning{Widget: "Event", Error: err})
|
||||
}
|
||||
|
||||
// Start Monitor Service (watchers registered via init())
|
||||
err = loadStep("Monitor", func() error {
|
||||
return monitor.Start(context.Background())
|
||||
}, callback)
|
||||
if err != nil {
|
||||
warnings = append(warnings, Warning{Widget: "Monitor", Error: err})
|
||||
}
|
||||
|
||||
// Load Uploaders
|
||||
err = loadStep("Uploader", func() error {
|
||||
return attachment.Load(cfg)
|
||||
|
|
@ -455,6 +464,9 @@ func Unload() (err error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Stop Monitor Service (before event, so watchers can still push events)
|
||||
monitor.Stop()
|
||||
|
||||
// Stop Event Service (before runtime, so in-flight handlers can still use V8)
|
||||
event.Stop(context.Background())
|
||||
|
||||
|
|
|
|||
153
monitor/README.md
Normal file
153
monitor/README.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Yao Monitor
|
||||
|
||||
A process-level inspection service for Yao. Monitor schedules periodic health checks (watchers) and records anomalies. It knows nothing about business logic — the business layer defines what to check, how to judge, and what action to take.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Implement a Watcher
|
||||
|
||||
```go
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/monitor"
|
||||
)
|
||||
|
||||
type sandboxWatcher struct{}
|
||||
|
||||
func (w *sandboxWatcher) Name() string { return "sandbox" }
|
||||
func (w *sandboxWatcher) Interval() time.Duration { return 30 * time.Second }
|
||||
|
||||
func (w *sandboxWatcher) Check(ctx context.Context) []monitor.Alert {
|
||||
// Inspect containers, compare states, detect idle timeouts, etc.
|
||||
// Return an empty slice if everything is normal.
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register via init()
|
||||
|
||||
```go
|
||||
func init() {
|
||||
monitor.Register(&sandboxWatcher{})
|
||||
}
|
||||
```
|
||||
|
||||
Registration happens before `monitor.Start()` is called. The watcher will be picked up automatically when the engine boots.
|
||||
|
||||
### 3. That's It
|
||||
|
||||
The engine calls `monitor.Start()` / `monitor.Stop()` during load/unload. Your watcher's `Check()` will be called at the interval you specified, in its own goroutine.
|
||||
|
||||
## Alert Levels
|
||||
|
||||
| Level | Constant | Use Case |
|
||||
|-------|----------|----------|
|
||||
| Trace | `monitor.Trace` | Heartbeat, periodic status sync, routine checks |
|
||||
| Info | `monitor.Info` | Notable events: state changes, service registrations |
|
||||
| Warn | `monitor.Warn` | Needs attention: idle timeout, degraded state |
|
||||
| Error | `monitor.Error` | Needs immediate action: crash, unreachable |
|
||||
|
||||
Which level to use is entirely up to the business watcher — the monitor just records what it's told.
|
||||
|
||||
All alert levels are delivered to subscribers via `Subscribe()`.
|
||||
|
||||
### Log Level by Mode
|
||||
|
||||
The minimum level written to `monitor.log` depends on Yao's run mode (`YAO_ENV`):
|
||||
|
||||
| Mode | Min Level | Effect |
|
||||
|------|-----------|--------|
|
||||
| `production` | Info | Trace alerts are **not** written to the log file |
|
||||
| `development` | Trace | **Everything** is written |
|
||||
|
||||
This keeps production logs lean while giving full visibility during development.
|
||||
|
||||
## Alert Actions
|
||||
|
||||
An alert can carry an `Action` — a function the monitor executes synchronously within the tick:
|
||||
|
||||
```go
|
||||
monitor.Alert{
|
||||
Level: monitor.Warn,
|
||||
Target: "box:abc123",
|
||||
Message: "idle timeout exceeded, stopping",
|
||||
Action: func(ctx context.Context) { box.Stop(ctx) },
|
||||
}
|
||||
```
|
||||
|
||||
- Actions run synchronously in the watcher's goroutine.
|
||||
- A panicking action is recovered and logged; subsequent alerts in the same tick continue.
|
||||
- A long-running action blocks the next tick of *this* watcher only, not others.
|
||||
|
||||
## API
|
||||
|
||||
```go
|
||||
// Register a watcher (call before Start, typically in init).
|
||||
monitor.Register(w Watcher)
|
||||
|
||||
// Start the monitor (called by engine).
|
||||
monitor.Start(ctx context.Context) error
|
||||
|
||||
// Stop the monitor (called by engine).
|
||||
monitor.Stop() error
|
||||
|
||||
// Subscribe to alert notifications. Returns a subscription ID.
|
||||
// Non-blocking: full channels are skipped.
|
||||
monitor.Subscribe(ch chan<- *monitor.Alert) string
|
||||
|
||||
// Unsubscribe by ID.
|
||||
monitor.Unsubscribe(id string)
|
||||
|
||||
// Health returns runtime status of the monitor and all watchers.
|
||||
monitor.Health() HealthStatus
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```go
|
||||
status := monitor.Health()
|
||||
// status.Running — is the monitor running?
|
||||
// status.Watchers — per-watcher stats:
|
||||
// .Name — watcher name
|
||||
// .Interval — check frequency
|
||||
// .LastTick — when the last tick completed
|
||||
// .LastAlerts — alert count from the last tick
|
||||
// .TotalTicks — total ticks since start
|
||||
// .Panics — total panics caught
|
||||
```
|
||||
|
||||
A watcher is considered healthy if `LastTick` is within `Interval × 3` of the current time.
|
||||
|
||||
## Logging
|
||||
|
||||
Monitor writes to `logs/monitor.log` (independent from `application.log`):
|
||||
|
||||
- **Lifecycle events** (Info): monitor started/stopped, watcher started/stopped
|
||||
- **Warn/Error alerts**: always written with watcher name, target, and message
|
||||
- **Info alerts**: written in both production and development
|
||||
- **Trace alerts**: written only in development mode (skipped in production)
|
||||
- **Panics**: always written at Error level
|
||||
|
||||
Log rotation uses lumberjack (50 MB, 3 backups, 7 days). Format follows `YAO_LOG_MODE` (TEXT or JSON).
|
||||
|
||||
## Panic Safety
|
||||
|
||||
- If `Check()` panics, the watcher recovers and continues on the next tick.
|
||||
- If `Action()` panics, the watcher recovers and processes remaining alerts.
|
||||
- Panic counts are tracked in `Health().Watchers[].Panics`.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
monitor/
|
||||
├── DESIGN.md — Architecture and design decisions
|
||||
├── README.md — This file
|
||||
├── types.go — Level, Alert, Watcher interface
|
||||
├── logger.go — Independent slog.Logger → monitor.log
|
||||
├── service.go — Register, Start, Stop, Subscribe, Health
|
||||
└── service_test.go
|
||||
```
|
||||
60
monitor/logger.go
Normal file
60
monitor/logger.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package monitor
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
const slogLevelTrace = slog.Level(-8)
|
||||
|
||||
var logger *slog.Logger
|
||||
|
||||
// initLogger creates the monitor logger.
|
||||
// appMode controls the minimum log level:
|
||||
// - "production" → Info (Trace alerts are not written)
|
||||
// - "development" → Trace (everything is written)
|
||||
func initLogger(root string, logMode string, appMode string) {
|
||||
logDir := filepath.Join(root, "logs")
|
||||
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(logDir, 0755)
|
||||
}
|
||||
|
||||
w := &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, "monitor.log"),
|
||||
MaxSize: 50,
|
||||
MaxBackups: 3,
|
||||
MaxAge: 7,
|
||||
LocalTime: true,
|
||||
}
|
||||
|
||||
minLevel := slog.LevelInfo
|
||||
if appMode == "development" {
|
||||
minLevel = slogLevelTrace
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{Level: minLevel}
|
||||
var handler slog.Handler
|
||||
if logMode == "JSON" {
|
||||
handler = slog.NewJSONHandler(w, opts)
|
||||
} else {
|
||||
handler = slog.NewTextHandler(w, opts)
|
||||
}
|
||||
|
||||
logger = slog.New(handler)
|
||||
}
|
||||
|
||||
func levelToSlog(l Level) slog.Level {
|
||||
switch l {
|
||||
case Trace:
|
||||
return slogLevelTrace
|
||||
case Warn:
|
||||
return slog.LevelWarn
|
||||
case Error:
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
250
monitor/service.go
Normal file
250
monitor/service.go
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/config"
|
||||
)
|
||||
|
||||
var svc = &monitorService{
|
||||
watchers: make(map[string]*watcherEntry),
|
||||
subs: make(map[string]chan<- *Alert),
|
||||
}
|
||||
|
||||
type watcherEntry struct {
|
||||
watcher Watcher
|
||||
cancel context.CancelFunc
|
||||
lastTick atomic.Int64 // unix timestamp of last tick completion
|
||||
lastAlerts atomic.Int64 // alert count from last tick
|
||||
totalTicks atomic.Int64 // total ticks since start
|
||||
panics atomic.Int64 // total panics caught
|
||||
}
|
||||
|
||||
type monitorService struct {
|
||||
mu sync.Mutex
|
||||
watchers map[string]*watcherEntry
|
||||
subs map[string]chan<- *Alert
|
||||
subSeq int
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
started bool
|
||||
}
|
||||
|
||||
// Register adds a watcher. Call before Start (typically in init).
|
||||
// Registering a watcher with the same name replaces the previous one.
|
||||
func Register(w Watcher) {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
name := w.Name()
|
||||
if old, ok := svc.watchers[name]; ok && old.cancel != nil {
|
||||
old.cancel()
|
||||
}
|
||||
svc.watchers[name] = &watcherEntry{watcher: w}
|
||||
|
||||
if svc.started {
|
||||
svc.startWatcher(svc.watchers[name])
|
||||
}
|
||||
}
|
||||
|
||||
// Start initializes the logger and launches a goroutine per registered watcher.
|
||||
func Start(ctx context.Context) error {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
if svc.started {
|
||||
return fmt.Errorf("monitor: already started")
|
||||
}
|
||||
|
||||
initLogger(config.Conf.Root, config.Conf.LogMode, config.Conf.Mode)
|
||||
|
||||
svc.ctx, svc.cancel = context.WithCancel(ctx)
|
||||
for _, entry := range svc.watchers {
|
||||
svc.startWatcher(entry)
|
||||
}
|
||||
svc.started = true
|
||||
|
||||
if logger != nil {
|
||||
logger.Info("monitor started", "watchers", len(svc.watchers))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels all watcher goroutines and waits for them to finish.
|
||||
func Stop() error {
|
||||
svc.mu.Lock()
|
||||
if !svc.started {
|
||||
svc.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
svc.cancel()
|
||||
svc.started = false
|
||||
svc.mu.Unlock()
|
||||
|
||||
svc.wg.Wait()
|
||||
|
||||
if logger != nil {
|
||||
logger.Info("monitor stopped")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe registers a channel to receive alert notifications.
|
||||
// Returns a subscription ID for unsubscribing.
|
||||
// Non-blocking: if the channel is full, alerts are dropped for that subscriber.
|
||||
func Subscribe(ch chan<- *Alert) string {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
svc.subSeq++
|
||||
id := fmt.Sprintf("sub-%d", svc.subSeq)
|
||||
svc.subs[id] = ch
|
||||
return id
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscription by ID.
|
||||
func Unsubscribe(id string) {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
delete(svc.subs, id)
|
||||
}
|
||||
|
||||
// WatcherHealth describes the runtime status of a single watcher.
|
||||
type WatcherHealth struct {
|
||||
Name string `json:"name"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
LastTick time.Time `json:"last_tick"` // zero if never ticked
|
||||
LastAlerts int64 `json:"last_alerts"` // alert count from most recent tick
|
||||
TotalTicks int64 `json:"total_ticks"`
|
||||
Panics int64 `json:"panics"`
|
||||
}
|
||||
|
||||
// HealthStatus describes the overall monitor health.
|
||||
type HealthStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Watchers []WatcherHealth `json:"watchers"`
|
||||
}
|
||||
|
||||
// Health returns the current health status of the monitor service.
|
||||
func Health() HealthStatus {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
status := HealthStatus{Running: svc.started}
|
||||
for _, entry := range svc.watchers {
|
||||
wh := WatcherHealth{
|
||||
Name: entry.watcher.Name(),
|
||||
Interval: entry.watcher.Interval(),
|
||||
LastAlerts: entry.lastAlerts.Load(),
|
||||
TotalTicks: entry.totalTicks.Load(),
|
||||
Panics: entry.panics.Load(),
|
||||
}
|
||||
if ts := entry.lastTick.Load(); ts > 0 {
|
||||
wh.LastTick = time.Unix(ts, 0)
|
||||
}
|
||||
status.Watchers = append(status.Watchers, wh)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (s *monitorService) startWatcher(entry *watcherEntry) {
|
||||
ctx, cancel := context.WithCancel(s.ctx)
|
||||
entry.cancel = cancel
|
||||
s.wg.Add(1)
|
||||
go s.runLoop(ctx, entry)
|
||||
}
|
||||
|
||||
func (s *monitorService) runLoop(ctx context.Context, entry *watcherEntry) {
|
||||
defer s.wg.Done()
|
||||
|
||||
w := entry.watcher
|
||||
name := w.Name()
|
||||
interval := w.Interval()
|
||||
|
||||
if logger != nil {
|
||||
logger.Info("watcher started", "watcher", name, "interval", interval)
|
||||
}
|
||||
|
||||
// Run first check immediately, then on ticker.
|
||||
s.tick(ctx, entry)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.tick(ctx, entry)
|
||||
case <-ctx.Done():
|
||||
if logger != nil {
|
||||
logger.Info("watcher stopped", "watcher", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *monitorService) tick(ctx context.Context, entry *watcherEntry) {
|
||||
name := entry.watcher.Name()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
entry.panics.Add(1)
|
||||
if logger != nil {
|
||||
logger.Error("watcher panic", "watcher", name, "recover", fmt.Sprintf("%v", r))
|
||||
}
|
||||
}
|
||||
entry.totalTicks.Add(1)
|
||||
entry.lastTick.Store(time.Now().Unix())
|
||||
}()
|
||||
|
||||
alerts := entry.watcher.Check(ctx)
|
||||
entry.lastAlerts.Store(int64(len(alerts)))
|
||||
|
||||
for i := range alerts {
|
||||
a := &alerts[i]
|
||||
a.Watcher = name
|
||||
|
||||
// Log level filtering is handled by slog handler:
|
||||
// production → Info and above (Trace skipped)
|
||||
// development → Trace and above (everything)
|
||||
if logger != nil {
|
||||
logger.Log(ctx, levelToSlog(a.Level), a.Message,
|
||||
"watcher", name, "target", a.Target)
|
||||
}
|
||||
|
||||
if a.Action != nil {
|
||||
s.execAction(ctx, name, a)
|
||||
}
|
||||
|
||||
s.notify(a)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *monitorService) execAction(ctx context.Context, watcherName string, a *Alert) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if logger != nil {
|
||||
logger.Error("action panic", "watcher", watcherName, "target", a.Target, "recover", fmt.Sprintf("%v", r))
|
||||
}
|
||||
}
|
||||
}()
|
||||
a.Action(ctx)
|
||||
}
|
||||
|
||||
func (s *monitorService) notify(a *Alert) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, ch := range s.subs {
|
||||
select {
|
||||
case ch <- a:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
507
monitor/service_test.go
Normal file
507
monitor/service_test.go
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// resetService resets the global service for test isolation.
|
||||
func resetService() {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
if svc.started && svc.cancel != nil {
|
||||
svc.cancel()
|
||||
svc.wg.Wait()
|
||||
}
|
||||
|
||||
svc.watchers = make(map[string]*watcherEntry)
|
||||
svc.subs = make(map[string]chan<- *Alert)
|
||||
svc.subSeq = 0
|
||||
svc.started = false
|
||||
svc.ctx = nil
|
||||
svc.cancel = nil
|
||||
|
||||
// Use a discard logger for tests
|
||||
logger = nil
|
||||
}
|
||||
|
||||
// testWatcher is a simple watcher for testing.
|
||||
type testWatcher struct {
|
||||
name string
|
||||
interval time.Duration
|
||||
checkFn func(ctx context.Context) []Alert
|
||||
}
|
||||
|
||||
func (w *testWatcher) Name() string { return w.name }
|
||||
func (w *testWatcher) Interval() time.Duration { return w.interval }
|
||||
func (w *testWatcher) Check(ctx context.Context) []Alert {
|
||||
if w.checkFn != nil {
|
||||
return w.checkFn(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRegisterAndStart(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
var count atomic.Int32
|
||||
Register(&testWatcher{
|
||||
name: "test-basic",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
count.Add(1)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
err := Start(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
// Wait for a few ticks (first immediate + ticker)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if err := Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
c := count.Load()
|
||||
if c < 2 {
|
||||
t.Errorf("expected at least 2 checks (immediate + ticker), got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoubleStartError(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
Register(&testWatcher{name: "dummy", interval: time.Second})
|
||||
|
||||
if err := Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Start(context.Background()); err == nil {
|
||||
t.Error("expected error on double Start")
|
||||
}
|
||||
|
||||
Stop()
|
||||
}
|
||||
|
||||
func TestStopWithoutStart(t *testing.T) {
|
||||
resetService()
|
||||
if err := Stop(); err != nil {
|
||||
t.Errorf("Stop without Start should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertWatcherName(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
var got string
|
||||
var mu sync.Mutex
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "namer",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if got != "" {
|
||||
return nil
|
||||
}
|
||||
return []Alert{{
|
||||
Level: Info,
|
||||
Target: "test:1",
|
||||
Message: "hello",
|
||||
}}
|
||||
},
|
||||
})
|
||||
|
||||
ch := make(chan *Alert, 8)
|
||||
subID := Subscribe(ch)
|
||||
defer Unsubscribe(subID)
|
||||
|
||||
Start(context.Background())
|
||||
defer Stop()
|
||||
|
||||
select {
|
||||
case a := <-ch:
|
||||
if a.Watcher != "namer" {
|
||||
t.Errorf("expected Watcher='namer', got %q", a.Watcher)
|
||||
}
|
||||
mu.Lock()
|
||||
got = a.Watcher
|
||||
mu.Unlock()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for alert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertAction(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
var acted atomic.Bool
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "actor",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
if acted.Load() {
|
||||
return nil
|
||||
}
|
||||
return []Alert{{
|
||||
Level: Warn,
|
||||
Target: "test:action",
|
||||
Message: "do something",
|
||||
Action: func(ctx context.Context) {
|
||||
acted.Store(true)
|
||||
},
|
||||
}}
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
defer Stop()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if !acted.Load() {
|
||||
t.Error("action was not executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicRecovery_Check(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
var count atomic.Int32
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "panicker",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
n := count.Add(1)
|
||||
if n == 1 {
|
||||
panic("boom")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
Stop()
|
||||
|
||||
c := count.Load()
|
||||
if c < 2 {
|
||||
t.Errorf("expected watcher to continue after panic, got %d checks", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicRecovery_Action(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
var postPanic atomic.Bool
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "action-panicker",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
return []Alert{
|
||||
{
|
||||
Level: Error,
|
||||
Target: "test:panic-action",
|
||||
Message: "will panic",
|
||||
Action: func(ctx context.Context) {
|
||||
if !postPanic.Load() {
|
||||
panic("action boom")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
postPanic.Store(true)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
Stop()
|
||||
}
|
||||
|
||||
func TestSubscribeUnsubscribe(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
ch := make(chan *Alert, 16)
|
||||
id := Subscribe(ch)
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "sub-test",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
return []Alert{{Level: Info, Target: "t", Message: "msg"}}
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
Unsubscribe(id)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
Stop()
|
||||
|
||||
// Drain and count
|
||||
close(ch)
|
||||
count := 0
|
||||
for range ch {
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
t.Error("expected at least one alert before unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeFullChanDrops(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
ch := make(chan *Alert, 1) // tiny buffer
|
||||
Subscribe(ch)
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "flood",
|
||||
interval: 10 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
return []Alert{{Level: Info, Target: "t", Message: "flood"}}
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
Stop()
|
||||
// No deadlock = pass
|
||||
}
|
||||
|
||||
func TestRegisterOverwrite(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
var first, second atomic.Int32
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "dup",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
first.Add(1)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "dup",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
second.Add(1)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
Stop()
|
||||
|
||||
if first.Load() > 0 {
|
||||
t.Error("first watcher should have been replaced")
|
||||
}
|
||||
if second.Load() == 0 {
|
||||
t.Error("second watcher should have run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAfterStart(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
Start(context.Background())
|
||||
defer Stop()
|
||||
|
||||
var count atomic.Int32
|
||||
Register(&testWatcher{
|
||||
name: "late",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
count.Add(1)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if count.Load() == 0 {
|
||||
t.Error("watcher registered after Start should still run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelString(t *testing.T) {
|
||||
tests := []struct {
|
||||
level Level
|
||||
want string
|
||||
}{
|
||||
{Trace, "trace"},
|
||||
{Info, "info"},
|
||||
{Warn, "warn"},
|
||||
{Error, "error"},
|
||||
{Level(99), "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := tt.level.String()
|
||||
if got != tt.want {
|
||||
t.Errorf("Level(%d).String() = %q, want %q", tt.level, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancelledDuringCheck(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
var checkDone atomic.Bool
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "slow",
|
||||
interval: time.Hour, // only immediate check runs
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
checkDone.Store(true)
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
|
||||
// Give the immediate check time to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Stop should cancel the context
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
Stop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if !checkDone.Load() {
|
||||
// The check might have not started yet, that's ok
|
||||
fmt.Println("note: check may not have started before stop")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Stop timed out — watcher may not respect context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth_NotStarted(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
Register(&testWatcher{name: "idle", interval: time.Second})
|
||||
|
||||
h := Health()
|
||||
if h.Running {
|
||||
t.Error("expected Running=false before Start")
|
||||
}
|
||||
if len(h.Watchers) != 1 {
|
||||
t.Errorf("expected 1 watcher, got %d", len(h.Watchers))
|
||||
}
|
||||
if h.Watchers[0].TotalTicks != 0 {
|
||||
t.Errorf("expected 0 ticks before Start, got %d", h.Watchers[0].TotalTicks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth_Running(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
Register(&testWatcher{
|
||||
name: "healthy",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
return []Alert{{Level: Info, Target: "t:1", Message: "ok"}}
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
h := Health()
|
||||
if !h.Running {
|
||||
t.Error("expected Running=true")
|
||||
}
|
||||
if len(h.Watchers) != 1 {
|
||||
t.Fatalf("expected 1 watcher, got %d", len(h.Watchers))
|
||||
}
|
||||
|
||||
wh := h.Watchers[0]
|
||||
if wh.Name != "healthy" {
|
||||
t.Errorf("expected name 'healthy', got %q", wh.Name)
|
||||
}
|
||||
if wh.TotalTicks < 2 {
|
||||
t.Errorf("expected at least 2 ticks, got %d", wh.TotalTicks)
|
||||
}
|
||||
if wh.LastTick.IsZero() {
|
||||
t.Error("expected non-zero LastTick")
|
||||
}
|
||||
if wh.LastAlerts != 1 {
|
||||
t.Errorf("expected 1 alert per tick, got %d", wh.LastAlerts)
|
||||
}
|
||||
if wh.Panics != 0 {
|
||||
t.Errorf("expected 0 panics, got %d", wh.Panics)
|
||||
}
|
||||
|
||||
Stop()
|
||||
}
|
||||
|
||||
func TestHealth_PanicCount(t *testing.T) {
|
||||
resetService()
|
||||
defer resetService()
|
||||
|
||||
var n atomic.Int32
|
||||
Register(&testWatcher{
|
||||
name: "crasher",
|
||||
interval: 50 * time.Millisecond,
|
||||
checkFn: func(ctx context.Context) []Alert {
|
||||
if n.Add(1) <= 2 {
|
||||
panic("crash")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
Start(context.Background())
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
h := Health()
|
||||
wh := h.Watchers[0]
|
||||
if wh.Panics < 2 {
|
||||
t.Errorf("expected at least 2 panics, got %d", wh.Panics)
|
||||
}
|
||||
if wh.TotalTicks <= wh.Panics {
|
||||
t.Errorf("expected some successful ticks after panics: total=%d, panics=%d", wh.TotalTicks, wh.Panics)
|
||||
}
|
||||
|
||||
Stop()
|
||||
}
|
||||
55
monitor/types.go
Normal file
55
monitor/types.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level represents alert severity.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
Trace Level = iota // Heartbeat, periodic status sync — not logged
|
||||
Info // Notable events: state changes, registrations
|
||||
Warn // Needs attention: idle timeout, degraded state
|
||||
Error // Needs immediate action: crash, unreachable
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case Trace:
|
||||
return "trace"
|
||||
case Info:
|
||||
return "info"
|
||||
case Warn:
|
||||
return "warn"
|
||||
case Error:
|
||||
return "error"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Alert represents a single finding from a watcher check.
|
||||
type Alert struct {
|
||||
Watcher string // Source watcher name (set by monitor)
|
||||
Level Level // Severity
|
||||
Target string // Target identifier, e.g. "box:abc123", "robot:member-456"
|
||||
Message string // Human-readable description
|
||||
Action func(ctx context.Context) // Business-layer action; nil means notification only
|
||||
}
|
||||
|
||||
// Watcher is the interface that business modules implement and register
|
||||
// with the monitor service.
|
||||
type Watcher interface {
|
||||
// Name returns a globally unique watcher name, used for logging and dedup.
|
||||
Name() string
|
||||
|
||||
// Interval returns the check frequency.
|
||||
Interval() time.Duration
|
||||
|
||||
// Check performs a single inspection and returns any alerts found.
|
||||
// An empty slice means everything is normal.
|
||||
// ctx is cancelled when the monitor stops.
|
||||
Check(ctx context.Context) []Alert
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ type Box struct {
|
|||
lastCall atomic.Int64
|
||||
lastHeartbeat atomic.Int64
|
||||
processCount atomic.Int32
|
||||
status atomic.Value // string: "running", "exited", "stopped", "created", "unknown"
|
||||
idleTimeoutD time.Duration
|
||||
maxLifetimeD time.Duration
|
||||
stopTimeoutD time.Duration
|
||||
|
|
@ -210,14 +211,18 @@ func (b *Box) GetWorkDir() string {
|
|||
func (b *Box) WorkspaceID() string { return b.workspaceID }
|
||||
|
||||
// Snapshot returns a local-only BoxInfo snapshot without any remote calls.
|
||||
// Status is inferred from local state (not from the container runtime).
|
||||
// Status is maintained by the sandbox watcher (see watcher.go).
|
||||
func (b *Box) Snapshot() BoxInfo {
|
||||
s, _ := b.status.Load().(string)
|
||||
if s == "" {
|
||||
s = "unknown"
|
||||
}
|
||||
return BoxInfo{
|
||||
ID: b.id,
|
||||
ContainerID: b.containerID,
|
||||
NodeID: b.nodeID,
|
||||
Owner: b.owner,
|
||||
Status: "running",
|
||||
Status: s,
|
||||
Policy: b.policy,
|
||||
Labels: b.labels,
|
||||
Image: b.image,
|
||||
|
|
@ -313,6 +318,12 @@ func (b *Box) lastActiveTime() time.Time {
|
|||
return time.UnixMilli(ts)
|
||||
}
|
||||
|
||||
// idleSince returns the timestamp of the last business call (Exec/Stream/VNC/etc).
|
||||
// Unlike lastActiveTime, heartbeats do NOT reset this — only real user activity does.
|
||||
func (b *Box) idleSince() time.Time {
|
||||
return time.UnixMilli(b.lastCall.Load())
|
||||
}
|
||||
|
||||
func (b *Box) idleTimeout() time.Duration {
|
||||
return b.idleTimeoutD
|
||||
}
|
||||
|
|
@ -327,3 +338,22 @@ func (b *Box) stopTimeout() time.Duration {
|
|||
}
|
||||
return DefaultStopTimeout
|
||||
}
|
||||
|
||||
// IsStopped reports whether the box's last known status indicates a non-running container.
|
||||
func (b *Box) IsStopped() bool {
|
||||
s, _ := b.status.Load().(string)
|
||||
return s == "exited" || s == "stopped"
|
||||
}
|
||||
|
||||
// inspectStatus queries the container runtime for the real container state.
|
||||
func (b *Box) inspectStatus(ctx context.Context) string {
|
||||
res, err := b.manager.getNode(b.nodeID)
|
||||
if err != nil || res.Runtime == nil {
|
||||
return "unknown"
|
||||
}
|
||||
info, err := res.Runtime.Inspect(ctx, b.containerID)
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return info.Status
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,53 +2,47 @@ package sandbox
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/yaoapp/yao/config"
|
||||
)
|
||||
|
||||
// BuildGRPCEnv builds the gRPC environment variables for a sandbox container
|
||||
// based on the Tai node's mode and address from the registry.
|
||||
//
|
||||
// mode is the TaiNode.Mode ("local", "direct", "tunnel").
|
||||
// addr is the TaiNode.Addr (e.g. "tai://host:port" for direct mode).
|
||||
// sandboxID is the container's sandbox identifier.
|
||||
//
|
||||
// The Yao gRPC port is read from config.Conf.GRPC.Port.
|
||||
func BuildGRPCEnv(mode, addr, sandboxID string) map[string]string {
|
||||
grpcPort := config.Conf.GRPC.Port
|
||||
if grpcPort == 0 {
|
||||
grpcPort = 9099
|
||||
}
|
||||
portStr := strconv.Itoa(grpcPort)
|
||||
const taiHost = "host.tai.internal"
|
||||
|
||||
// BuildGRPCEnv builds the gRPC environment variables for a sandbox container.
|
||||
//
|
||||
// All containers reach the host via "host.tai.internal" (injected by Tai at
|
||||
// container creation). The port depends on the mode:
|
||||
//
|
||||
// - local: Yao gRPC port (Tai and Yao on the same machine)
|
||||
// - tunnel/direct: Tai gRPC port (Tai Gateway forwards to Yao)
|
||||
//
|
||||
// taiGRPCPort is the Tai node's gRPC port from registration (Ports.GRPC).
|
||||
func BuildGRPCEnv(mode string, taiGRPCPort int, sandboxID string) map[string]string {
|
||||
env := map[string]string{
|
||||
"YAO_SANDBOX_ID": sandboxID,
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "local":
|
||||
env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr)
|
||||
|
||||
case "tunnel":
|
||||
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr)
|
||||
|
||||
case "direct":
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr)
|
||||
return env
|
||||
port := config.Conf.GRPC.Port
|
||||
if port == 0 {
|
||||
port = 9099
|
||||
}
|
||||
taiHost := u.Hostname()
|
||||
taiPort := u.Port()
|
||||
if taiPort == "" {
|
||||
taiPort = "19100"
|
||||
env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%d", taiHost, port)
|
||||
|
||||
case "tunnel", "direct":
|
||||
port := taiGRPCPort
|
||||
if port == 0 {
|
||||
port = 19100
|
||||
}
|
||||
env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort)
|
||||
env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%d", taiHost, port)
|
||||
|
||||
default:
|
||||
env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr)
|
||||
port := config.Conf.GRPC.Port
|
||||
if port == 0 {
|
||||
port = 9099
|
||||
}
|
||||
env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%d", taiHost, port)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
func TestBuildGRPCEnvLocal(t *testing.T) {
|
||||
config.Conf.GRPC.Port = 9099
|
||||
env := sandbox.BuildGRPCEnv("local", "", "sb-001")
|
||||
env := sandbox.BuildGRPCEnv("local", 19100, "sb-001")
|
||||
|
||||
if env["YAO_SANDBOX_ID"] != "sb-001" {
|
||||
t.Errorf("YAO_SANDBOX_ID = %q", env["YAO_SANDBOX_ID"])
|
||||
|
|
@ -17,25 +17,48 @@ func TestBuildGRPCEnvLocal(t *testing.T) {
|
|||
if _, ok := env["YAO_TOKEN"]; ok {
|
||||
t.Error("YAO_TOKEN should not be set by BuildGRPCEnv")
|
||||
}
|
||||
if env["YAO_GRPC_ADDR"] != "host.docker.internal:9099" {
|
||||
t.Errorf("YAO_GRPC_ADDR = %q, want host.docker.internal:9099", env["YAO_GRPC_ADDR"])
|
||||
want := "host.tai.internal:9099"
|
||||
if env["YAO_GRPC_ADDR"] != want {
|
||||
t.Errorf("YAO_GRPC_ADDR = %q, want %q", env["YAO_GRPC_ADDR"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGRPCEnvDirect(t *testing.T) {
|
||||
config.Conf.GRPC.Port = 9099
|
||||
env := sandbox.BuildGRPCEnv("direct", "tai://gpu-server", "sb-002")
|
||||
env := sandbox.BuildGRPCEnv("direct", 19100, "sb-002")
|
||||
|
||||
if env["YAO_GRPC_ADDR"] != "gpu-server:19100" {
|
||||
t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:19100", env["YAO_GRPC_ADDR"])
|
||||
want := "host.tai.internal:19100"
|
||||
if env["YAO_GRPC_ADDR"] != want {
|
||||
t.Errorf("YAO_GRPC_ADDR = %q, want %q", env["YAO_GRPC_ADDR"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGRPCEnvDirectDefaultPort(t *testing.T) {
|
||||
config.Conf.GRPC.Port = 9099
|
||||
env := sandbox.BuildGRPCEnv("direct", 0, "sb-002")
|
||||
|
||||
want := "host.tai.internal:19100"
|
||||
if env["YAO_GRPC_ADDR"] != want {
|
||||
t.Errorf("YAO_GRPC_ADDR = %q, want %q (default tai port)", env["YAO_GRPC_ADDR"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGRPCEnvTunnel(t *testing.T) {
|
||||
config.Conf.GRPC.Port = 9099
|
||||
env := sandbox.BuildGRPCEnv("tunnel", "tunnel://relay.example.com", "sb-003")
|
||||
env := sandbox.BuildGRPCEnv("tunnel", 19200, "sb-003")
|
||||
|
||||
if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" {
|
||||
t.Errorf("YAO_GRPC_ADDR = %q, want 127.0.0.1:9099", env["YAO_GRPC_ADDR"])
|
||||
want := "host.tai.internal:19200"
|
||||
if env["YAO_GRPC_ADDR"] != want {
|
||||
t.Errorf("YAO_GRPC_ADDR = %q, want %q", env["YAO_GRPC_ADDR"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGRPCEnvUnknownMode(t *testing.T) {
|
||||
config.Conf.GRPC.Port = 8888
|
||||
env := sandbox.BuildGRPCEnv("unknown", 19100, "sb-004")
|
||||
|
||||
want := "host.tai.internal:8888"
|
||||
if env["YAO_GRPC_ADDR"] != want {
|
||||
t.Errorf("YAO_GRPC_ADDR = %q, want %q (fallback to yao port)", env["YAO_GRPC_ADDR"], want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,20 +19,18 @@ import (
|
|||
)
|
||||
|
||||
// Manager manages sandbox lifecycle. Node connections are delegated to tai/registry.
|
||||
// Idle-timeout and lifecycle enforcement is handled by the sandbox watcher (watcher.go).
|
||||
type Manager struct {
|
||||
boxes sync.Map
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
boxes sync.Map
|
||||
}
|
||||
|
||||
func newManager() *Manager {
|
||||
return &Manager{}
|
||||
}
|
||||
|
||||
// Start discovers existing containers from all registered nodes, rebuilds
|
||||
// the boxes map, and starts the cleanup loop.
|
||||
// If no "local" node is registered yet, it probes the local Docker environment
|
||||
// and auto-registers one when available.
|
||||
// Start discovers existing containers from all registered nodes and rebuilds
|
||||
// the boxes map. If no "local" node is registered yet, it probes the local
|
||||
// Docker environment and auto-registers one when available.
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
reg := registry.Global()
|
||||
if reg == nil {
|
||||
|
|
@ -49,9 +47,6 @@ func (m *Manager) Start(ctx context.Context) error {
|
|||
m.recoverBoxes(ctx, snap.TaiID, res)
|
||||
}
|
||||
|
||||
loopCtx, cancel := context.WithCancel(ctx)
|
||||
m.cancel = cancel
|
||||
go m.cleanupLoop(loopCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -218,6 +213,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
|||
displayName: opts.DisplayName,
|
||||
system: sys,
|
||||
}
|
||||
box.status.Store("running")
|
||||
box.lastCall.Store(time.Now().UnixMilli())
|
||||
|
||||
m.boxes.Store(id, box)
|
||||
|
|
@ -267,6 +263,31 @@ func (m *Manager) List(_ context.Context, opts ListOptions) ([]*Box, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// StartBox starts a stopped sandbox and updates its lastCall timestamp.
|
||||
func (m *Manager) StartBox(ctx context.Context, id string) error {
|
||||
v, ok := m.boxes.Load(id)
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
b := v.(*Box)
|
||||
|
||||
res, err := m.getNode(b.nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Runtime == nil {
|
||||
return fmt.Errorf("sandbox: node %q has no container runtime", b.nodeID)
|
||||
}
|
||||
|
||||
if err := res.Runtime.Start(ctx, b.containerID); err != nil {
|
||||
return fmt.Errorf("sandbox: start container %s: %w", b.containerID, err)
|
||||
}
|
||||
|
||||
b.status.Store("running")
|
||||
b.touch()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove force-removes a sandbox (SIGKILL + delete).
|
||||
func (m *Manager) Remove(ctx context.Context, id string) error {
|
||||
v, ok := m.boxes.Load(id)
|
||||
|
|
@ -284,58 +305,11 @@ func (m *Manager) Remove(ctx context.Context, id string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Cleanup removes idle/expired sandboxes.
|
||||
func (m *Manager) Cleanup(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
m.boxes.Range(func(key, value any) bool {
|
||||
b := value.(*Box)
|
||||
idle := now.Sub(b.lastActiveTime())
|
||||
|
||||
switch b.policy {
|
||||
case OneShot:
|
||||
// handled after Exec
|
||||
case Session:
|
||||
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
|
||||
m.Remove(ctx, b.id)
|
||||
}
|
||||
case LongRunning:
|
||||
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
|
||||
if res, err := m.getNode(b.nodeID); err == nil && res.Runtime != nil {
|
||||
res.Runtime.Stop(ctx, b.containerID, b.stopTimeout())
|
||||
}
|
||||
}
|
||||
if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime {
|
||||
m.Remove(ctx, b.id)
|
||||
}
|
||||
case Persistent:
|
||||
// never auto-cleaned
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops the cleanup loop. Node connections are managed by the registry.
|
||||
// Close is a no-op; lifecycle management is handled by the sandbox watcher.
|
||||
func (m *Manager) Close() error {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cleanupLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.Cleanup(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) getNode(name string) (*tai.ConnResources, error) {
|
||||
res, ok := tai.GetResources(name)
|
||||
if !ok {
|
||||
|
|
@ -350,7 +324,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
|
|||
reg := registry.Global()
|
||||
if reg != nil {
|
||||
if snap, ok := reg.Get(nodeID); ok {
|
||||
grpcEnv := BuildGRPCEnv(snap.Mode, snap.Addr, sandboxID)
|
||||
grpcEnv := BuildGRPCEnv(snap.Mode, snap.Ports.GRPC, sandboxID)
|
||||
for k, v := range grpcEnv {
|
||||
env[k] = v
|
||||
}
|
||||
|
|
@ -488,12 +462,13 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
|||
if sys.OS == "" {
|
||||
sys = inferSystemInfo(ctx, res, c.Image)
|
||||
}
|
||||
policy := LifecyclePolicy(c.Labels["sandbox-policy"])
|
||||
box := &Box{
|
||||
id: sandboxID,
|
||||
containerID: cid,
|
||||
nodeID: c.Labels["sandbox-node-id"],
|
||||
owner: c.Labels["sandbox-owner"],
|
||||
policy: LifecyclePolicy(c.Labels["sandbox-policy"]),
|
||||
policy: policy,
|
||||
labels: c.Labels,
|
||||
createdAt: time.Now(),
|
||||
image: c.Image,
|
||||
|
|
@ -504,6 +479,12 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
|||
system: sys,
|
||||
manager: m,
|
||||
}
|
||||
switch policy {
|
||||
case Session:
|
||||
box.idleTimeoutD = DefaultSessionIdleTimeout
|
||||
case LongRunning:
|
||||
box.idleTimeoutD = DefaultLongRunningIdleTimeout
|
||||
}
|
||||
box.lastCall.Store(time.Now().UnixMilli())
|
||||
m.boxes.Store(sandboxID, box)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,42 +46,6 @@ func TestHeartbeatUnknownBox(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIdleCleanup(t *testing.T) {
|
||||
skipIfNoDocker(t)
|
||||
|
||||
for _, pc := range testNodes() {
|
||||
pc := pc
|
||||
t.Run(pc.Name, func(t *testing.T) {
|
||||
m := setupManagerForNode(t, &pc)
|
||||
ensureTestImage(t, m, pc.TaiID)
|
||||
|
||||
ctx := context.Background()
|
||||
box, err := m.Create(ctx, sandbox.CreateOptions{
|
||||
Image: testImage(),
|
||||
Owner: "test-user",
|
||||
NodeID: pc.TaiID,
|
||||
Policy: sandbox.Session,
|
||||
IdleTimeout: 1 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
boxID := box.ID()
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
if err := m.Cleanup(ctx); err != nil {
|
||||
t.Fatalf("Cleanup: %v", err)
|
||||
}
|
||||
|
||||
_, err = m.Get(ctx, boxID)
|
||||
if err != sandbox.ErrNotFound {
|
||||
t.Errorf("after idle cleanup, Get err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRecovery(t *testing.T) {
|
||||
skipIfNoDocker(t)
|
||||
|
||||
|
|
@ -114,27 +78,50 @@ func TestStartRecovery(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPersistentNotCleaned(t *testing.T) {
|
||||
func TestStartBox(t *testing.T) {
|
||||
skipIfNoDocker(t)
|
||||
|
||||
for _, pc := range testNodes() {
|
||||
pc := pc
|
||||
t.Run(pc.Name, func(t *testing.T) {
|
||||
m := setupManagerForNode(t, &pc)
|
||||
|
||||
box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
|
||||
co.Policy = sandbox.Persistent
|
||||
co.IdleTimeout = 1 * time.Second
|
||||
})
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
box := createTestBox(t, m, pc)
|
||||
boxID := box.ID()
|
||||
ctx := context.Background()
|
||||
m.Cleanup(ctx)
|
||||
|
||||
_, err := m.Get(ctx, box.ID())
|
||||
if err := box.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if err := m.StartBox(ctx, boxID); err != nil {
|
||||
t.Fatalf("StartBox: %v", err)
|
||||
}
|
||||
|
||||
info, err := box.Info(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("persistent box should not be cleaned: %v", err)
|
||||
t.Fatalf("Info after StartBox: %v", err)
|
||||
}
|
||||
if info.Status != "running" {
|
||||
t.Errorf("status = %q after StartBox, want running", info.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReadsStatus(t *testing.T) {
|
||||
skipIfNoDocker(t)
|
||||
|
||||
for _, pc := range testNodes() {
|
||||
pc := pc
|
||||
t.Run(pc.Name, func(t *testing.T) {
|
||||
m := setupManagerForNode(t, &pc)
|
||||
box := createTestBox(t, m, pc)
|
||||
|
||||
snap := box.Snapshot()
|
||||
if snap.Status != "running" {
|
||||
t.Errorf("initial snapshot status = %q, want running", snap.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,11 @@ const (
|
|||
Persistent LifecyclePolicy = "persistent"
|
||||
)
|
||||
|
||||
const DefaultStopTimeout = 2 * time.Second
|
||||
const (
|
||||
DefaultStopTimeout = 2 * time.Second
|
||||
DefaultSessionIdleTimeout = 30 * time.Minute
|
||||
DefaultLongRunningIdleTimeout = 2 * time.Hour
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create / List options
|
||||
|
|
|
|||
87
sandbox/v2/watcher.go
Normal file
87
sandbox/v2/watcher.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/monitor"
|
||||
)
|
||||
|
||||
func init() {
|
||||
monitor.Register(&sandboxWatcher{})
|
||||
}
|
||||
|
||||
type sandboxWatcher struct{}
|
||||
|
||||
func (w *sandboxWatcher) Name() string { return "sandbox" }
|
||||
func (w *sandboxWatcher) Interval() time.Duration { return 30 * time.Second }
|
||||
|
||||
func (w *sandboxWatcher) Check(ctx context.Context) []monitor.Alert {
|
||||
if mgr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var alerts []monitor.Alert
|
||||
mgr.boxes.Range(func(_, v any) bool {
|
||||
b := v.(*Box)
|
||||
|
||||
status := b.inspectStatus(ctx)
|
||||
old, _ := b.status.Swap(status).(string)
|
||||
if old != "" && old != status {
|
||||
alerts = append(alerts, monitor.Alert{
|
||||
Level: monitor.Info,
|
||||
Target: "box:" + b.id,
|
||||
Message: fmt.Sprintf("status %s → %s", old, status),
|
||||
})
|
||||
}
|
||||
|
||||
if status != "running" {
|
||||
return true
|
||||
}
|
||||
|
||||
idle := time.Since(b.idleSince())
|
||||
timeout := b.idleTimeout()
|
||||
if timeout <= 0 || idle <= timeout {
|
||||
return true
|
||||
}
|
||||
|
||||
switch b.policy {
|
||||
case Session:
|
||||
alerts = append(alerts, monitor.Alert{
|
||||
Level: monitor.Warn,
|
||||
Target: "box:" + b.id,
|
||||
Message: fmt.Sprintf("session idle expired (idle=%s, timeout=%s), removing", idle.Round(time.Second), timeout),
|
||||
Action: func(ctx context.Context) {
|
||||
mgr.Remove(ctx, b.id)
|
||||
},
|
||||
})
|
||||
|
||||
case LongRunning:
|
||||
alerts = append(alerts, monitor.Alert{
|
||||
Level: monitor.Warn,
|
||||
Target: "box:" + b.id,
|
||||
Message: fmt.Sprintf("longrunning idle expired (idle=%s, timeout=%s), stopping", idle.Round(time.Second), timeout),
|
||||
Action: func(ctx context.Context) {
|
||||
b.Stop(ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if b.policy == LongRunning {
|
||||
if lifetime := b.maxLifetime(); lifetime > 0 && time.Since(b.createdAt) > lifetime {
|
||||
alerts = append(alerts, monitor.Alert{
|
||||
Level: monitor.Warn,
|
||||
Target: "box:" + b.id,
|
||||
Message: fmt.Sprintf("lifetime expired (%s), removing", lifetime),
|
||||
Action: func(ctx context.Context) {
|
||||
mgr.Remove(ctx, b.id)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
return alerts
|
||||
}
|
||||
96
service/log/access.go
Normal file
96
service/log/access.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
accessWriter *lumberjack.Logger
|
||||
accessErrorWriter *lumberjack.Logger
|
||||
)
|
||||
|
||||
// InitAccessLog initializes access log and access-error log writers.
|
||||
// Must be called before any HTTP request is served.
|
||||
func InitAccessLog(root string) {
|
||||
logDir := filepath.Join(root, "logs")
|
||||
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(logDir, 0755)
|
||||
}
|
||||
|
||||
accessWriter = &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, "access.log"),
|
||||
MaxSize: 100,
|
||||
MaxBackups: 5,
|
||||
MaxAge: 30,
|
||||
LocalTime: true,
|
||||
}
|
||||
accessErrorWriter = &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, "access-error.log"),
|
||||
MaxSize: 50,
|
||||
MaxBackups: 5,
|
||||
MaxAge: 30,
|
||||
LocalTime: true,
|
||||
}
|
||||
}
|
||||
|
||||
// AccessLog returns a gin middleware that writes NGINX Combined Log Format
|
||||
// to access.log (all requests) and access-error.log (4xx/5xx only).
|
||||
func AccessLog() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
if accessWriter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Writer.Status()
|
||||
size := c.Writer.Size()
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s - %s [%s] \"%s %s %s\" %d %d \"%s\" \"%s\"\n",
|
||||
c.ClientIP(),
|
||||
remoteUser(c),
|
||||
time.Now().Format("02/Jan/2006:15:04:05 -0700"),
|
||||
c.Request.Method,
|
||||
c.Request.RequestURI,
|
||||
c.Request.Proto,
|
||||
status,
|
||||
size,
|
||||
dash(c.Request.Referer()),
|
||||
dash(c.Request.UserAgent()),
|
||||
)
|
||||
|
||||
accessWriter.Write([]byte(line))
|
||||
if status >= 400 {
|
||||
accessErrorWriter.Write([]byte(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remoteUser extracts a user identifier from the gin context.
|
||||
// Tries __username (JWT), __user_id (OAuth), __sid (SUI session) in order.
|
||||
func remoteUser(c *gin.Context) string {
|
||||
for _, key := range []string{"__username", "__user_id", "__sid"} {
|
||||
if v, ok := c.Get(key); ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
func dash(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
214
service/log/access_test.go
Normal file
214
service/log/access_test.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func setupTestLog(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
InitAccessLog(dir)
|
||||
return dir, func() {
|
||||
if accessWriter != nil {
|
||||
accessWriter.Close()
|
||||
}
|
||||
if accessErrorWriter != nil {
|
||||
accessErrorWriter.Close()
|
||||
}
|
||||
accessWriter = nil
|
||||
accessErrorWriter = nil
|
||||
}
|
||||
}
|
||||
|
||||
// NGINX Combined: $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"
|
||||
var nginxCombinedRe = regexp.MustCompile(
|
||||
`^(\S+) - (\S+) \[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] "(\S+) (\S+) (\S+)" (\d{3}) (\d+) "(.*)" "(.*)"$`,
|
||||
)
|
||||
|
||||
func TestAccessLog_NginxFormat(t *testing.T) {
|
||||
dir, cleanup := setupTestLog(t)
|
||||
defer cleanup()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AccessLog())
|
||||
router.GET("/api/test", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.Header.Set("User-Agent", "TestAgent/1.0")
|
||||
req.Header.Set("Referer", "https://example.com")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "logs", "access.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("read access.log: %v", err)
|
||||
}
|
||||
|
||||
line := strings.TrimSpace(string(data))
|
||||
if !nginxCombinedRe.MatchString(line) {
|
||||
t.Errorf("access.log line does not match NGINX Combined format:\n%s", line)
|
||||
}
|
||||
|
||||
if !strings.Contains(line, `"GET /api/test HTTP/1.1"`) {
|
||||
t.Errorf("expected request line in log, got: %s", line)
|
||||
}
|
||||
if !strings.Contains(line, `"https://example.com"`) {
|
||||
t.Errorf("expected referer in log, got: %s", line)
|
||||
}
|
||||
if !strings.Contains(line, `"TestAgent/1.0"`) {
|
||||
t.Errorf("expected user-agent in log, got: %s", line)
|
||||
}
|
||||
|
||||
// access-error.log should be empty for 200
|
||||
errData, err := os.ReadFile(filepath.Join(dir, "logs", "access-error.log"))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("read access-error.log: %v", err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(errData))) > 0 {
|
||||
t.Errorf("access-error.log should be empty for 200, got: %s", string(errData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLog_ErrorDoubleWrite(t *testing.T) {
|
||||
dir, cleanup := setupTestLog(t)
|
||||
defer cleanup()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AccessLog())
|
||||
router.GET("/api/fail", func(c *gin.Context) {
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
})
|
||||
router.GET("/api/notfound", func(c *gin.Context) {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
})
|
||||
|
||||
// 500 request
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/fail", nil))
|
||||
|
||||
// 404 request
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/notfound", nil))
|
||||
|
||||
// 200 request (should NOT appear in error log)
|
||||
router.GET("/api/ok", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/ok", nil))
|
||||
|
||||
accessData, _ := os.ReadFile(filepath.Join(dir, "logs", "access.log"))
|
||||
accessLines := nonEmptyLines(string(accessData))
|
||||
if len(accessLines) != 3 {
|
||||
t.Fatalf("access.log: expected 3 lines, got %d:\n%s", len(accessLines), string(accessData))
|
||||
}
|
||||
|
||||
errData, _ := os.ReadFile(filepath.Join(dir, "logs", "access-error.log"))
|
||||
errLines := nonEmptyLines(string(errData))
|
||||
if len(errLines) != 2 {
|
||||
t.Fatalf("access-error.log: expected 2 lines (500+404), got %d:\n%s", len(errLines), string(errData))
|
||||
}
|
||||
|
||||
if !strings.Contains(errLines[0], "500") {
|
||||
t.Errorf("first error line should contain 500: %s", errLines[0])
|
||||
}
|
||||
if !strings.Contains(errLines[1], "404") {
|
||||
t.Errorf("second error line should contain 404: %s", errLines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLog_RemoteUser(t *testing.T) {
|
||||
dir, cleanup := setupTestLog(t)
|
||||
defer cleanup()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AccessLog())
|
||||
router.GET("/api/user", func(c *gin.Context) {
|
||||
c.Set("__username", "alice")
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
router.GET("/api/userid", func(c *gin.Context) {
|
||||
c.Set("__user_id", "uid-123")
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
router.GET("/api/anon", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
for _, path := range []string{"/api/user", "/api/userid", "/api/anon"} {
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "logs", "access.log"))
|
||||
lines := nonEmptyLines(string(data))
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
// Note: AccessLog middleware runs c.Next() first, then reads context.
|
||||
// The user keys are set inside the handler which runs during c.Next(),
|
||||
// so they should be available when the log line is written.
|
||||
if !strings.Contains(lines[0], " alice ") {
|
||||
t.Errorf("line 1 should have user 'alice': %s", lines[0])
|
||||
}
|
||||
if !strings.Contains(lines[1], " uid-123 ") {
|
||||
t.Errorf("line 2 should have user 'uid-123': %s", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[2], " - ") {
|
||||
t.Errorf("line 3 should have '-' for anonymous: %s", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLog_DashForEmpty(t *testing.T) {
|
||||
dir, cleanup := setupTestLog(t)
|
||||
defer cleanup()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AccessLog())
|
||||
router.GET("/api/test", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
// No Referer, no User-Agent
|
||||
req.Header.Del("User-Agent")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "logs", "access.log"))
|
||||
line := strings.TrimSpace(string(data))
|
||||
|
||||
// Should end with "-" "-" for empty referer and user-agent
|
||||
if !strings.HasSuffix(line, `"-" "-"`) {
|
||||
t.Errorf("expected dash for empty referer/ua, got: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmptyLines(s string) []string {
|
||||
var result []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -11,13 +11,14 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
servicelog "github.com/yaoapp/yao/service/log"
|
||||
"github.com/yaoapp/yao/share"
|
||||
"github.com/yaoapp/yao/sui/api"
|
||||
)
|
||||
|
||||
// Middlewares the middlewares
|
||||
var Middlewares = []gin.HandlerFunc{
|
||||
gin.Logger(),
|
||||
servicelog.AccessLog(),
|
||||
withStaticFileServer,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/yaoapp/gou/server/http"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
servicelog "github.com/yaoapp/yao/service/log"
|
||||
"github.com/yaoapp/yao/share"
|
||||
)
|
||||
|
||||
|
|
@ -148,6 +149,8 @@ func Restart(svc *Service, cfg config.Config) error {
|
|||
}
|
||||
|
||||
func prepare() error {
|
||||
servicelog.InitAccessLog(config.Conf.Root)
|
||||
|
||||
err := share.SessionStart()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ func (d *dockerCore) create(ctx context.Context, opts CreateOptions, addVNCPorts
|
|||
}
|
||||
|
||||
hostCfg := &container.HostConfig{
|
||||
Binds: opts.Binds,
|
||||
Binds: normalizeBinds(opts.Binds),
|
||||
ExtraHosts: []string{"host.tai.internal:host-gateway"},
|
||||
}
|
||||
|
||||
if opts.Memory > 0 {
|
||||
|
|
@ -52,6 +53,12 @@ func (d *dockerCore) create(ctx context.Context, opts CreateOptions, addVNCPorts
|
|||
}}
|
||||
}
|
||||
|
||||
// tai relay daemon port — always mapped so the host Tai server can
|
||||
// forward arbitrary container ports through the relay.
|
||||
relayPort := nat.Port("2099/tcp")
|
||||
exposedPorts[relayPort] = struct{}{}
|
||||
portBindings[relayPort] = []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}}
|
||||
|
||||
if opts.VNC {
|
||||
hostCfg.CapAdd = append(hostCfg.CapAdd, "SYS_ADMIN")
|
||||
shmSize := opts.Memory / 4
|
||||
|
|
@ -275,3 +282,56 @@ func (d *dockerCore) list(ctx context.Context, opts ListOptions) ([]ContainerInf
|
|||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// normalizeBinds converts Windows-style host paths in Docker bind-mount
|
||||
// specifications to WSL2 mount paths that Docker (running in WSL2) accepts.
|
||||
// e.g. "D:\volumes\ws-abc:/workspace:rw" -> "/mnt/d/volumes/ws-abc:/workspace:rw"
|
||||
//
|
||||
// Detection is based on the path content (drive-letter prefix), not runtime.GOOS,
|
||||
// because the path may originate from a remote Tai node (Windows) while Yao
|
||||
// runs on macOS/Linux.
|
||||
func normalizeBinds(binds []string) []string {
|
||||
if len(binds) == 0 {
|
||||
return binds
|
||||
}
|
||||
out := make([]string, len(binds))
|
||||
changed := false
|
||||
for i, b := range binds {
|
||||
out[i] = normalizeWindowsBind(b)
|
||||
if out[i] != b {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return binds
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeWindowsBind handles a single bind spec "hostPath:containerPath[:mode]".
|
||||
// When Yao runs on Windows and Docker runs in WSL2, Windows paths like
|
||||
// "D:\volumes\ws-abc" must be converted to "/mnt/d/volumes/ws-abc" because
|
||||
// WSL2 mounts Windows drives under /mnt/<lowercase-letter>/.
|
||||
func normalizeWindowsBind(bind string) string {
|
||||
if len(bind) < 3 {
|
||||
return bind
|
||||
}
|
||||
|
||||
// Detect drive-letter prefix: "X:\" or "X:/"
|
||||
if bind[1] != ':' || (bind[2] != '\\' && bind[2] != '/') {
|
||||
return bind
|
||||
}
|
||||
|
||||
// Find the next colon after the drive letter colon (the bind separator)
|
||||
idx := strings.Index(bind[2:], ":")
|
||||
if idx < 0 {
|
||||
return bind
|
||||
}
|
||||
hostPath := bind[:2+idx]
|
||||
rest := bind[2+idx:] // starts with ":"
|
||||
|
||||
// Convert "D:\foo\bar" -> "/mnt/d/foo/bar"
|
||||
drive := strings.ToLower(string(hostPath[0]))
|
||||
tail := strings.ReplaceAll(hostPath[2:], `\`, `/`)
|
||||
return "/mnt/" + drive + tail + rest
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue